From 2be345c4b8247597e8ed7e1aee5c6887a853abcb Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Thu, 30 Jul 2026 14:41:52 -0700 Subject: [PATCH 1/5] separate cli auth keys by host --- client/packages/cli/__tests__/auth.test.ts | 157 ++++++++++++++++++ client/packages/cli/package.json | 6 + client/packages/cli/src/auth.ts | 127 ++++++++++++++ client/packages/cli/src/commands/logout.ts | 31 ++-- client/packages/cli/src/context/authToken.ts | 31 +--- client/packages/cli/src/lib/http.ts | 42 +---- client/packages/cli/src/lib/login.ts | 16 +- client/packages/cli/src/old.js | 16 +- client/packages/cli/src/util/apiUrl.ts | 40 +++++ client/packages/cli/src/util/getAuthPaths.ts | 3 +- .../packages/create-instant-app/package.json | 1 - .../packages/create-instant-app/src/login.ts | 31 +--- client/pnpm-lock.yaml | 3 - 13 files changed, 370 insertions(+), 134 deletions(-) create mode 100644 client/packages/cli/__tests__/auth.test.ts create mode 100644 client/packages/cli/src/auth.ts create mode 100644 client/packages/cli/src/util/apiUrl.ts diff --git a/client/packages/cli/__tests__/auth.test.ts b/client/packages/cli/__tests__/auth.test.ts new file mode 100644 index 0000000000..3841412953 --- /dev/null +++ b/client/packages/cli/__tests__/auth.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type AuthPaths, + normalizeApiUrl, + readConfigAuthToken, + removeConfigAuthToken, + saveConfigAuthToken, +} from '../src/auth.ts'; + +describe('auth config', () => { + let tempDir: string; + let paths: AuthPaths; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'instant-cli-auth-')); + paths = { + appConfigDirPath: tempDir, + authConfigFilePath: join(tempDir, 'a'), + }; + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('reads and migrates a legacy raw token', async () => { + await writeFile(paths.authConfigFilePath, 'legacy-token'); + + await expect( + readConfigAuthToken('https://api.instantdb.com/', paths), + ).resolves.toBe('legacy-token'); + await expect(readStoredTokens(paths)).resolves.toEqual({ + 'https://api.instantdb.com': 'legacy-token', + }); + }); + + it('uses a legacy token even when migration cannot be written', async () => { + await writeFile(paths.authConfigFilePath, 'legacy-token'); + const unwritablePaths = { + ...paths, + appConfigDirPath: paths.authConfigFilePath, + }; + + await expect( + readConfigAuthToken('https://api.instantdb.com', unwritablePaths), + ).resolves.toBe('legacy-token'); + await expect(readFile(paths.authConfigFilePath, 'utf8')).resolves.toBe( + 'legacy-token', + ); + }); + + it('selects only the token for the current API URL', async () => { + await writeFile( + paths.authConfigFilePath, + JSON.stringify({ + 'https://api.instantdb.com': 'production-token', + 'https://staging.example.com': 'staging-token', + }), + ); + + await expect( + readConfigAuthToken('https://staging.example.com/', paths), + ).resolves.toBe('staging-token'); + await expect( + readConfigAuthToken('https://missing.example.com', paths), + ).resolves.toBeNull(); + }); + + it('does not treat malformed JSON maps as auth tokens', async () => { + await writeFile(paths.authConfigFilePath, '{"https://api.example.com":'); + + await expect( + readConfigAuthToken('https://api.example.com', paths), + ).resolves.toBeNull(); + }); + + it('does not treat invalid JSON values as auth tokens', async () => { + await writeFile( + paths.authConfigFilePath, + JSON.stringify({ 'https://api.example.com': 123 }), + ); + + await expect( + readConfigAuthToken('https://api.example.com', paths), + ).resolves.toBeNull(); + }); + + it('preserves tokens for other API URLs when saving', async () => { + await saveConfigAuthToken( + 'https://api.instantdb.com', + 'production-token', + paths, + ); + await saveConfigAuthToken( + 'https://staging.example.com/', + 'staging-token', + paths, + ); + + await expect(readStoredTokens(paths)).resolves.toEqual({ + 'https://api.instantdb.com': 'production-token', + 'https://staging.example.com': 'staging-token', + }); + }); + + it('removes only the current API URL token', async () => { + await writeFile( + paths.authConfigFilePath, + JSON.stringify({ + 'https://api.instantdb.com': 'production-token', + 'https://staging.example.com': 'staging-token', + }), + ); + + await expect( + removeConfigAuthToken('https://staging.example.com/', paths), + ).resolves.toBe('removed'); + await expect(readStoredTokens(paths)).resolves.toEqual({ + 'https://api.instantdb.com': 'production-token', + }); + }); + + it('deletes the config file after removing the final token', async () => { + await saveConfigAuthToken('https://api.instantdb.com', 'token', paths); + + await expect( + removeConfigAuthToken('https://api.instantdb.com', paths), + ).resolves.toBe('removed'); + await expect(readFile(paths.authConfigFilePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('removes a legacy raw token when logging out', async () => { + await writeFile(paths.authConfigFilePath, 'legacy-token'); + + await expect( + removeConfigAuthToken('https://api.instantdb.com', paths), + ).resolves.toBe('removed'); + await expect(readFile(paths.authConfigFilePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('normalizes trailing slashes', () => { + expect(normalizeApiUrl('https://api.example.com///')).toBe( + 'https://api.example.com', + ); + }); +}); + +async function readStoredTokens(paths: AuthPaths) { + return JSON.parse(await readFile(paths.authConfigFilePath, 'utf8')); +} diff --git a/client/packages/cli/package.json b/client/packages/cli/package.json index 66b86de781..4fbaadcb9f 100644 --- a/client/packages/cli/package.json +++ b/client/packages/cli/package.json @@ -11,6 +11,12 @@ "directory": "client/packages/cli" }, "exports": { + "./auth": { + "import": { + "types": "./dist/auth.d.ts", + "default": "./dist/auth.js" + } + }, "./ui": { "import": { "types": "./dist/ui/index.d.ts", diff --git a/client/packages/cli/src/auth.ts b/client/packages/cli/src/auth.ts new file mode 100644 index 0000000000..3eab411c5f --- /dev/null +++ b/client/packages/cli/src/auth.ts @@ -0,0 +1,127 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { getAuthPaths } from './util/getAuthPaths.ts'; + +export type AuthTokens = Record; + +type AuthConfig = + | { type: 'map'; tokens: AuthTokens } + | { type: 'legacy'; token: string } + | { type: 'invalid' }; + +export type AuthPaths = ReturnType; + +export function normalizeApiUrl(apiUrl: string): string { + return apiUrl.replace(/\/+$/, ''); +} + +function parseAuthConfig(contents: string): AuthConfig { + if (!contents) return { type: 'invalid' }; + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + const trimmed = contents.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return { type: 'invalid' }; + } + return { type: 'legacy', token: contents }; + } + + if ( + parsed === null || + Array.isArray(parsed) || + typeof parsed !== 'object' || + !Object.values(parsed).every((token) => typeof token === 'string') + ) { + return { type: 'invalid' }; + } + + const tokens: AuthTokens = {}; + for (const [apiUrl, token] of Object.entries(parsed)) { + tokens[normalizeApiUrl(apiUrl)] = token as string; + } + return { type: 'map', tokens }; +} + +function serializeAuthTokens(tokens: AuthTokens): string { + return JSON.stringify(tokens, null, 2) + '\n'; +} + +async function readAuthConfigFile(paths: AuthPaths): Promise { + try { + return await readFile(paths.authConfigFilePath, 'utf8'); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } +} + +async function writeAuthConfigFile(paths: AuthPaths, tokens: AuthTokens) { + await mkdir(paths.appConfigDirPath, { recursive: true }); + await writeFile( + paths.authConfigFilePath, + serializeAuthTokens(tokens), + 'utf8', + ); +} + +function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +export async function readConfigAuthToken( + apiUrl: string, + paths: AuthPaths = getAuthPaths(), +): Promise { + const contents = await readAuthConfigFile(paths); + if (contents === null) return null; + + const config = parseAuthConfig(contents); + const key = normalizeApiUrl(apiUrl); + if (config.type === 'map') return config.tokens[key] || null; + if (config.type === 'invalid') return null; + + // A raw token is the legacy format. Associate it with the API URL that is + // currently using it, but do not block authentication if migration fails. + await writeAuthConfigFile(paths, { [key]: config.token }).catch(() => {}); + return config.token; +} + +export async function saveConfigAuthToken( + apiUrl: string, + authToken: string, + paths: AuthPaths = getAuthPaths(), +): Promise { + const contents = await readAuthConfigFile(paths); + const config = contents === null ? null : parseAuthConfig(contents); + const tokens = config?.type === 'map' ? config.tokens : {}; + tokens[normalizeApiUrl(apiUrl)] = authToken; + await writeAuthConfigFile(paths, tokens); +} + +export async function removeConfigAuthToken( + apiUrl: string, + paths: AuthPaths = getAuthPaths(), +): Promise<'removed' | 'not-found'> { + const contents = await readAuthConfigFile(paths); + if (contents === null) return 'not-found'; + + const config = parseAuthConfig(contents); + if (config.type === 'legacy') { + await rm(paths.authConfigFilePath); + return 'removed'; + } + if (config.type === 'invalid') return 'not-found'; + + const key = normalizeApiUrl(apiUrl); + if (!(key in config.tokens)) return 'not-found'; + + delete config.tokens[key]; + if (Object.keys(config.tokens).length === 0) { + await rm(paths.authConfigFilePath); + } else { + await writeAuthConfigFile(paths, config.tokens); + } + return 'removed'; +} diff --git a/client/packages/cli/src/commands/logout.ts b/client/packages/cli/src/commands/logout.ts index 7d056c7073..3a994ee745 100644 --- a/client/packages/cli/src/commands/logout.ts +++ b/client/packages/cli/src/commands/logout.ts @@ -1,23 +1,20 @@ import { Effect } from 'effect'; -import { getAuthPaths } from '../util/getAuthPaths.ts'; -import { FileSystem } from '@effect/platform'; import chalk from 'chalk'; -import { SystemError } from '@effect/platform/Error'; +import { removeConfigAuthToken } from '../auth.ts'; +import { getBaseUrl } from '../util/apiUrl.ts'; export const logoutCommand = Effect.fn(function* () { - const { authConfigFilePath } = getAuthPaths(); - const fs = yield* FileSystem.FileSystem; + const apiUrl = yield* getBaseUrl; - yield* Effect.matchEffect(fs.remove(authConfigFilePath), { - onFailure: (e) => - Effect.gen(function* () { - if (e instanceof SystemError && e.reason === 'NotFound') { - yield* Effect.log(chalk.green('You were already logged out!')); - } else { - yield* Effect.logError(chalk.red('Failed to logout: ' + e.message)); - } - }), - onSuccess: () => - Effect.log(chalk.green('Successfully logged out from Instant!')), - }); + yield* Effect.matchEffect( + Effect.tryPromise(() => removeConfigAuthToken(apiUrl)), + { + onFailure: (e) => + Effect.logError(chalk.red('Failed to logout: ' + e.message)), + onSuccess: (result) => + result === 'removed' + ? Effect.log(chalk.green('Successfully logged out from Instant!')) + : Effect.log(chalk.green('You were already logged out!')), + }, + ); }); diff --git a/client/packages/cli/src/context/authToken.ts b/client/packages/cli/src/context/authToken.ts index bbbd086be9..9dbc584778 100644 --- a/client/packages/cli/src/context/authToken.ts +++ b/client/packages/cli/src/context/authToken.ts @@ -1,9 +1,8 @@ -import { FileSystem } from '@effect/platform'; import { Config, Context, Effect, Layer, Option, Ref, Schema } from 'effect'; -import envPaths from 'env-paths'; -import { join } from 'node:path'; +import { readConfigAuthToken } from '../auth.ts'; import { loginCommand } from '../commands/login.ts'; import { program } from '../program.ts'; +import { getBaseUrl } from '../util/apiUrl.ts'; type AuthTokenSource = 'admin' | 'env' | 'opt' | 'file'; @@ -64,17 +63,13 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) => }; } - const authPaths = yield* getAuthPaths; - const fs = yield* FileSystem.FileSystem; - const file = yield* fs - .readFileString(authPaths.authConfigFilePath, 'utf8') - .pipe( - // will usually fail if file not found, return null instead - Effect.orElseSucceed(() => null), - ); - if (file) { + const apiUrl = yield* getBaseUrl; + const fileToken = yield* Effect.tryPromise(() => + readConfigAuthToken(apiUrl), + ).pipe(Effect.orElseSucceed(() => null)); + if (fileToken) { return { - authToken: file, + authToken: fileToken, source: 'file' as 'env' | 'opt' | 'file', }; } @@ -137,13 +132,3 @@ export const AuthTokenLive = ({ ), ), ); - -const getAuthPaths = Effect.gen(function* () { - const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( - Config.withDefault(false), - ); - const key = `instantdb-${dev ? 'dev' : 'prod'}`; - const { config: appConfigDirPath } = envPaths(key); - const authConfigFilePath = join(appConfigDirPath, 'a'); - return { authConfigFilePath, appConfigDirPath }; -}); diff --git a/client/packages/cli/src/lib/http.ts b/client/packages/cli/src/lib/http.ts index e83a509b35..a8b8a41ab2 100644 --- a/client/packages/cli/src/lib/http.ts +++ b/client/packages/cli/src/lib/http.ts @@ -4,8 +4,9 @@ import { Config, Context, Data, Effect, Layer, Option, Schema } from 'effect'; import { AuthToken } from '../context/authToken.ts'; import { TimeoutException } from 'effect/Cause'; import { RequestError } from '@effect/platform/HttpClientError'; -import { readInstantConfigFile } from '../util/instantConfig.ts'; -import { BadArgsError } from '../errors.ts'; +import { getBaseUrl } from '../util/apiUrl.ts'; + +export { getBaseUrl } from '../util/apiUrl.ts'; export class InstantHttp extends Context.Tag( 'instant-cli/new/lib/http/InstantHttp', @@ -49,15 +50,6 @@ class InstantTypicalHttpErrorResponse extends Schema.Struct({ ), }) {} -const HttpUrl = Schema.URL.pipe( - Schema.filter( - (url) => - url.protocol === 'http:' || - url.protocol === 'https:' || - 'Expected an HTTP(S) URL', - ), -); - export const InstantHttpLive = Layer.effect( InstantHttp, Effect.gen(function* () { @@ -135,34 +127,6 @@ export const InstantHttpAuthedLive = Layer.effect( }), ); -export const getBaseUrl = Effect.gen(function* () { - const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe( - Config.option, - ); - const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( - Config.withDefault(false), - ); - - if (Option.isSome(setEnv)) { - return setEnv.value; - } - - const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); - if (instantConfig?.apiURI !== undefined) { - yield* Schema.decodeUnknown(HttpUrl)(instantConfig.apiURI).pipe( - Effect.mapError(() => - BadArgsError.make({ - message: - 'Invalid apiURI in instant.config.ts. Expected a valid HTTP(S) URL.', - }), - ), - ); - return instantConfig.apiURI; - } - - return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; -}); - export const getDashUrl = Effect.gen(function* () { const setEnv = yield* Config.string('INSTANT_CLI_DASH_URI').pipe( Config.option, diff --git a/client/packages/cli/src/lib/login.ts b/client/packages/cli/src/lib/login.ts index 6cc37407aa..8220a59402 100644 --- a/client/packages/cli/src/lib/login.ts +++ b/client/packages/cli/src/lib/login.ts @@ -1,11 +1,8 @@ import { Effect, Schedule, Schema } from 'effect'; import { InstantHttp, withCommand } from './http.ts'; -import { - HttpClientRequest, - HttpClientResponse, - FileSystem, -} from '@effect/platform'; -import { getAuthPaths } from '../util/getAuthPaths.ts'; +import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; +import { saveConfigAuthToken as saveAuthTokenForApi } from '../auth.ts'; +import { getBaseUrl } from '../util/apiUrl.ts'; const LoginInfo = Schema.Struct({ secret: Schema.String, @@ -46,9 +43,6 @@ export const waitForAuthToken = Effect.fn(function* (secret: string) { }); export const saveConfigAuthToken = Effect.fn(function* (token: string) { - const authPaths = getAuthPaths(); - - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(authPaths.appConfigDirPath, { recursive: true }); - yield* fs.writeFileString(authPaths.authConfigFilePath, token); + const apiUrl = yield* getBaseUrl; + yield* Effect.tryPromise(() => saveAuthTokenForApi(apiUrl, token)); }); diff --git a/client/packages/cli/src/old.js b/client/packages/cli/src/old.js index b18ce2baa2..93ae800352 100644 --- a/client/packages/cli/src/old.js +++ b/client/packages/cli/src/old.js @@ -1,8 +1,8 @@ import boxen from 'boxen'; import chalk from 'chalk'; import { program } from '@commander-js/extra-typings'; -import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { readConfigAuthToken as readStoredAuthToken } from './auth.ts'; import { UI } from './ui/index.ts'; import { deferred, renderUnwrap } from './ui/lib.ts'; import { @@ -10,7 +10,6 @@ import { getPermsReadCandidates, getSchemaReadCandidates, } from './util/findConfigCandidates.ts'; -import { getAuthPaths } from './util/getAuthPaths.ts'; import { loadConfig } from './util/loadConfig.ts'; import { ResolveRenamePrompt } from './util/renamePrompt.ts'; import version from './version.js'; @@ -331,7 +330,7 @@ async function fetchJson({ let authToken = null; if (withAuth) { authToken = - providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging()); + providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging(apiURI)); if (!authToken) { return { ok: false, data: undefined }; } @@ -436,7 +435,7 @@ export async function readLocalEmailFile(emailPath) { return { path: relativePath, email: res.config }; } -async function readConfigAuthToken(allowAdminToken = true) { +async function readConfigAuthToken(apiURI, allowAdminToken = true) { const options = program.opts(); // @ts-expect-error command opts type is unknown if (typeof options.token === 'string') { @@ -458,10 +457,7 @@ async function readConfigAuthToken(allowAdminToken = true) { } } - const authToken = await readFile( - getAuthPaths().authConfigFilePath, - 'utf-8', - ).catch(() => null); + const authToken = await readStoredAuthToken(apiURI).catch(() => null); if (authToken) { return authToken; @@ -470,8 +466,8 @@ async function readConfigAuthToken(allowAdminToken = true) { return null; } -export async function readConfigAuthTokenWithErrorLogging() { - const token = await readConfigAuthToken(); +export async function readConfigAuthTokenWithErrorLogging(apiURI) { + const token = await readConfigAuthToken(apiURI); if (!token) { error( `Looks like you are not logged in. Please log in with ${chalk.green('`instant-cli login`')}`, diff --git a/client/packages/cli/src/util/apiUrl.ts b/client/packages/cli/src/util/apiUrl.ts new file mode 100644 index 0000000000..b4b5beb546 --- /dev/null +++ b/client/packages/cli/src/util/apiUrl.ts @@ -0,0 +1,40 @@ +import { Config, Effect, Option, Schema } from 'effect'; +import { BadArgsError } from '../errors.ts'; +import { readInstantConfigFile } from './instantConfig.ts'; + +const HttpUrl = Schema.URL.pipe( + Schema.filter( + (url) => + url.protocol === 'http:' || + url.protocol === 'https:' || + 'Expected an HTTP(S) URL', + ), +); + +export const getBaseUrl = Effect.gen(function* () { + const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe( + Config.option, + ); + const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( + Config.withDefault(false), + ); + + if (Option.isSome(setEnv)) { + return setEnv.value; + } + + const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); + if (instantConfig?.apiURI !== undefined) { + yield* Schema.decodeUnknown(HttpUrl)(instantConfig.apiURI).pipe( + Effect.mapError(() => + BadArgsError.make({ + message: + 'Invalid apiURI in instant.config.ts. Expected a valid HTTP(S) URL.', + }), + ), + ); + return instantConfig.apiURI; + } + + return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; +}); diff --git a/client/packages/cli/src/util/getAuthPaths.ts b/client/packages/cli/src/util/getAuthPaths.ts index 154573f938..6a52fcfa5b 100644 --- a/client/packages/cli/src/util/getAuthPaths.ts +++ b/client/packages/cli/src/util/getAuthPaths.ts @@ -1,9 +1,8 @@ import envPaths from 'env-paths'; import { join } from 'node:path'; -const dev = Boolean(process.env.INSTANT_CLI_DEV); - export function getAuthPaths() { + const dev = Boolean(process.env.INSTANT_CLI_DEV); const key = `instantdb-${dev ? 'dev' : 'prod'}`; const { config: appConfigDirPath } = envPaths(key); const authConfigFilePath = join(appConfigDirPath, 'a'); diff --git a/client/packages/create-instant-app/package.json b/client/packages/create-instant-app/package.json index 9326279c7c..1711c4fcfd 100644 --- a/client/packages/create-instant-app/package.json +++ b/client/packages/create-instant-app/package.json @@ -40,7 +40,6 @@ "@instantdb/version": "workspace:*", "chalk": "5.2.0", "commander": "^10.0.1", - "env-paths": "^3.0.0", "execa": "^7.2.0", "fs-extra": "^11.3.1", "gradient-string": "^2.0.2", diff --git a/client/packages/create-instant-app/src/login.ts b/client/packages/create-instant-app/src/login.ts index feb15986a0..70df8d1a45 100644 --- a/client/packages/create-instant-app/src/login.ts +++ b/client/packages/create-instant-app/src/login.ts @@ -1,9 +1,7 @@ -import envPaths from 'env-paths'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; import openInBrowser from 'open'; -import { join } from 'node:path'; import { Project } from './cli.js'; import { randomUUID } from 'node:crypto'; +import { readConfigAuthToken, saveConfigAuthToken } from 'instant-cli/auth'; import { fetchJson, instantBackendOrigin, @@ -13,17 +11,8 @@ import { import { renderUnwrap, UI } from 'instant-cli/ui'; import { toTitleCase } from './utils/titleCase.js'; -const dev = Boolean(process.env.INSTANT_CLI_DEV); const forceEphemeral = Boolean(process.env.INSTANT_CLI_FORCE_EPHEMERAL); -function getAuthPaths() { - const key = `instantdb-${dev ? 'dev' : 'prod'}`; - const { config: appConfigDirPath } = envPaths(key); - const authConfigFilePath = join(appConfigDirPath, 'a'); - - return { authConfigFilePath, appConfigDirPath }; -} - export const createApp = async ( title: string, authToken: string, @@ -173,11 +162,7 @@ const getAuthToken = async (): Promise => { return process.env.INSTANT_CLI_AUTH_TOKEN; } - const authToken = await readFile( - getAuthPaths().authConfigFilePath, - 'utf-8', - ).catch(() => null); - return authToken; + return readConfigAuthToken(instantBackendOrigin).catch(() => null); }; export type AppTokenResponse = { @@ -346,7 +331,7 @@ export const tryConnectApp = async ( }), ); - await saveConfigAuthToken(authInfo.token); + await saveConfigAuthToken(instantBackendOrigin, authInfo.token); authToken = authInfo.token; } @@ -456,13 +441,3 @@ async function waitForAuthToken({ } throw new Error('Timed out waiting for login'); } - -async function saveConfigAuthToken(authToken: string) { - const authPaths = getAuthPaths(); - - await mkdir(authPaths.appConfigDirPath, { - recursive: true, - }); - - return writeFile(authPaths.authConfigFilePath, authToken, 'utf-8'); -} diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 77c13944f8..98dcf19967 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -480,9 +480,6 @@ importers: commander: specifier: ^10.0.1 version: 10.0.1 - env-paths: - specifier: ^3.0.0 - version: 3.0.0 execa: specifier: ^7.2.0 version: 7.2.0 From dbd65e475eaee1d17c8aae98f4dda596c63902b7 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Thu, 30 Jul 2026 15:44:52 -0700 Subject: [PATCH 2/5] check key before saving --- client/packages/cli/__tests__/auth.test.ts | 26 ---------------------- client/packages/cli/src/auth.ts | 21 ++++++++++++++--- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/client/packages/cli/__tests__/auth.test.ts b/client/packages/cli/__tests__/auth.test.ts index 3841412953..2f1af3be03 100644 --- a/client/packages/cli/__tests__/auth.test.ts +++ b/client/packages/cli/__tests__/auth.test.ts @@ -26,32 +26,6 @@ describe('auth config', () => { await rm(tempDir, { recursive: true, force: true }); }); - it('reads and migrates a legacy raw token', async () => { - await writeFile(paths.authConfigFilePath, 'legacy-token'); - - await expect( - readConfigAuthToken('https://api.instantdb.com/', paths), - ).resolves.toBe('legacy-token'); - await expect(readStoredTokens(paths)).resolves.toEqual({ - 'https://api.instantdb.com': 'legacy-token', - }); - }); - - it('uses a legacy token even when migration cannot be written', async () => { - await writeFile(paths.authConfigFilePath, 'legacy-token'); - const unwritablePaths = { - ...paths, - appConfigDirPath: paths.authConfigFilePath, - }; - - await expect( - readConfigAuthToken('https://api.instantdb.com', unwritablePaths), - ).resolves.toBe('legacy-token'); - await expect(readFile(paths.authConfigFilePath, 'utf8')).resolves.toBe( - 'legacy-token', - ); - }); - it('selects only the token for the current API URL', async () => { await writeFile( paths.authConfigFilePath, diff --git a/client/packages/cli/src/auth.ts b/client/packages/cli/src/auth.ts index 3eab411c5f..f00939f5c1 100644 --- a/client/packages/cli/src/auth.ts +++ b/client/packages/cli/src/auth.ts @@ -70,6 +70,18 @@ function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error && error.code === 'ENOENT'; } +async function tokenBelongsToApiUrl(apiUrl: string, authToken: string) { + try { + const response = await fetch(`${apiUrl}/dash/me`, { + headers: { Authorization: `Bearer ${authToken}` }, + signal: AbortSignal.timeout(5_000), + }); + return response.ok; + } catch { + return false; + } +} + export async function readConfigAuthToken( apiUrl: string, paths: AuthPaths = getAuthPaths(), @@ -82,9 +94,12 @@ export async function readConfigAuthToken( if (config.type === 'map') return config.tokens[key] || null; if (config.type === 'invalid') return null; - // A raw token is the legacy format. Associate it with the API URL that is - // currently using it, but do not block authentication if migration fails. - await writeAuthConfigFile(paths, { [key]: config.token }).catch(() => {}); + // Verify a legacy token against this backend before associating the two. + // Validation and migration remain best-effort so existing commands can + // still attempt authentication with the legacy token. + if (await tokenBelongsToApiUrl(key, config.token)) { + await writeAuthConfigFile(paths, { [key]: config.token }).catch(() => {}); + } return config.token; } From 722a903a9acb1cf556bddb0458eca6e6c3d1066f Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Thu, 30 Jul 2026 15:51:12 -0700 Subject: [PATCH 3/5] also check production --- client/packages/cli/src/auth.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/client/packages/cli/src/auth.ts b/client/packages/cli/src/auth.ts index f00939f5c1..5604a81e07 100644 --- a/client/packages/cli/src/auth.ts +++ b/client/packages/cli/src/auth.ts @@ -3,6 +3,8 @@ import { getAuthPaths } from './util/getAuthPaths.ts'; export type AuthTokens = Record; +const productionApiUrl = 'https://api.instantdb.com'; + type AuthConfig = | { type: 'map'; tokens: AuthTokens } | { type: 'legacy'; token: string } @@ -94,11 +96,25 @@ export async function readConfigAuthToken( if (config.type === 'map') return config.tokens[key] || null; if (config.type === 'invalid') return null; - // Verify a legacy token against this backend before associating the two. + // Verify a legacy token before associating it with a backend. Legacy tokens + // usually came from production, so check there if a custom backend rejects + // it, but never make the duplicate request when production is current. + let migrationKey: string | null = null; + if (await tokenBelongsToApiUrl(key, config.token)) { + migrationKey = key; + } else if ( + key !== productionApiUrl && + (await tokenBelongsToApiUrl(productionApiUrl, config.token)) + ) { + migrationKey = productionApiUrl; + } + // Validation and migration remain best-effort so existing commands can // still attempt authentication with the legacy token. - if (await tokenBelongsToApiUrl(key, config.token)) { - await writeAuthConfigFile(paths, { [key]: config.token }).catch(() => {}); + if (migrationKey) { + await writeAuthConfigFile(paths, { + [migrationKey]: config.token, + }).catch(() => {}); } return config.token; } From 6d5129a9a4c833c166e07c543cdd5af1c83868e6 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Thu, 30 Jul 2026 15:58:48 -0700 Subject: [PATCH 4/5] remove unnecessary --- client/packages/cli/__tests__/auth.test.ts | 131 ------------------ client/packages/cli/src/auth.ts | 73 ++++++---- client/packages/cli/src/old.js | 16 ++- client/packages/cli/src/util/getAuthPaths.ts | 4 +- .../packages/create-instant-app/package.json | 1 + client/pnpm-lock.yaml | 3 + 6 files changed, 65 insertions(+), 163 deletions(-) delete mode 100644 client/packages/cli/__tests__/auth.test.ts diff --git a/client/packages/cli/__tests__/auth.test.ts b/client/packages/cli/__tests__/auth.test.ts deleted file mode 100644 index 2f1af3be03..0000000000 --- a/client/packages/cli/__tests__/auth.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - type AuthPaths, - normalizeApiUrl, - readConfigAuthToken, - removeConfigAuthToken, - saveConfigAuthToken, -} from '../src/auth.ts'; - -describe('auth config', () => { - let tempDir: string; - let paths: AuthPaths; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'instant-cli-auth-')); - paths = { - appConfigDirPath: tempDir, - authConfigFilePath: join(tempDir, 'a'), - }; - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('selects only the token for the current API URL', async () => { - await writeFile( - paths.authConfigFilePath, - JSON.stringify({ - 'https://api.instantdb.com': 'production-token', - 'https://staging.example.com': 'staging-token', - }), - ); - - await expect( - readConfigAuthToken('https://staging.example.com/', paths), - ).resolves.toBe('staging-token'); - await expect( - readConfigAuthToken('https://missing.example.com', paths), - ).resolves.toBeNull(); - }); - - it('does not treat malformed JSON maps as auth tokens', async () => { - await writeFile(paths.authConfigFilePath, '{"https://api.example.com":'); - - await expect( - readConfigAuthToken('https://api.example.com', paths), - ).resolves.toBeNull(); - }); - - it('does not treat invalid JSON values as auth tokens', async () => { - await writeFile( - paths.authConfigFilePath, - JSON.stringify({ 'https://api.example.com': 123 }), - ); - - await expect( - readConfigAuthToken('https://api.example.com', paths), - ).resolves.toBeNull(); - }); - - it('preserves tokens for other API URLs when saving', async () => { - await saveConfigAuthToken( - 'https://api.instantdb.com', - 'production-token', - paths, - ); - await saveConfigAuthToken( - 'https://staging.example.com/', - 'staging-token', - paths, - ); - - await expect(readStoredTokens(paths)).resolves.toEqual({ - 'https://api.instantdb.com': 'production-token', - 'https://staging.example.com': 'staging-token', - }); - }); - - it('removes only the current API URL token', async () => { - await writeFile( - paths.authConfigFilePath, - JSON.stringify({ - 'https://api.instantdb.com': 'production-token', - 'https://staging.example.com': 'staging-token', - }), - ); - - await expect( - removeConfigAuthToken('https://staging.example.com/', paths), - ).resolves.toBe('removed'); - await expect(readStoredTokens(paths)).resolves.toEqual({ - 'https://api.instantdb.com': 'production-token', - }); - }); - - it('deletes the config file after removing the final token', async () => { - await saveConfigAuthToken('https://api.instantdb.com', 'token', paths); - - await expect( - removeConfigAuthToken('https://api.instantdb.com', paths), - ).resolves.toBe('removed'); - await expect(readFile(paths.authConfigFilePath)).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('removes a legacy raw token when logging out', async () => { - await writeFile(paths.authConfigFilePath, 'legacy-token'); - - await expect( - removeConfigAuthToken('https://api.instantdb.com', paths), - ).resolves.toBe('removed'); - await expect(readFile(paths.authConfigFilePath)).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('normalizes trailing slashes', () => { - expect(normalizeApiUrl('https://api.example.com///')).toBe( - 'https://api.example.com', - ); - }); -}); - -async function readStoredTokens(paths: AuthPaths) { - return JSON.parse(await readFile(paths.authConfigFilePath, 'utf8')); -} diff --git a/client/packages/cli/src/auth.ts b/client/packages/cli/src/auth.ts index 5604a81e07..ce11c230a7 100644 --- a/client/packages/cli/src/auth.ts +++ b/client/packages/cli/src/auth.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { getAuthPaths } from './util/getAuthPaths.ts'; -export type AuthTokens = Record; +type AuthTokens = Record; const productionApiUrl = 'https://api.instantdb.com'; @@ -10,9 +10,9 @@ type AuthConfig = | { type: 'legacy'; token: string } | { type: 'invalid' }; -export type AuthPaths = ReturnType; +type AuthPaths = ReturnType; -export function normalizeApiUrl(apiUrl: string): string { +function normalizeApiUrl(apiUrl: string): string { return apiUrl.replace(/\/+$/, ''); } @@ -84,10 +84,21 @@ async function tokenBelongsToApiUrl(apiUrl: string, authToken: string) { } } +async function getLegacyTokenApiUrl(apiUrl: string, authToken: string) { + if (await tokenBelongsToApiUrl(apiUrl, authToken)) return apiUrl; + if ( + apiUrl !== productionApiUrl && + (await tokenBelongsToApiUrl(productionApiUrl, authToken)) + ) { + return productionApiUrl; + } + return null; +} + export async function readConfigAuthToken( apiUrl: string, - paths: AuthPaths = getAuthPaths(), ): Promise { + const paths = getAuthPaths(); const contents = await readAuthConfigFile(paths); if (contents === null) return null; @@ -96,52 +107,64 @@ export async function readConfigAuthToken( if (config.type === 'map') return config.tokens[key] || null; if (config.type === 'invalid') return null; - // Verify a legacy token before associating it with a backend. Legacy tokens - // usually came from production, so check there if a custom backend rejects - // it, but never make the duplicate request when production is current. - let migrationKey: string | null = null; - if (await tokenBelongsToApiUrl(key, config.token)) { - migrationKey = key; - } else if ( - key !== productionApiUrl && - (await tokenBelongsToApiUrl(productionApiUrl, config.token)) - ) { - migrationKey = productionApiUrl; - } + const migrationKey = await getLegacyTokenApiUrl(key, config.token); - // Validation and migration remain best-effort so existing commands can - // still attempt authentication with the legacy token. if (migrationKey) { await writeAuthConfigFile(paths, { [migrationKey]: config.token, }).catch(() => {}); } - return config.token; + + // If production accepted the token while another backend is selected, do + // not send a known production credential to that backend. + return migrationKey && migrationKey !== key ? null : config.token; } export async function saveConfigAuthToken( apiUrl: string, authToken: string, - paths: AuthPaths = getAuthPaths(), ): Promise { + const paths = getAuthPaths(); const contents = await readAuthConfigFile(paths); const config = contents === null ? null : parseAuthConfig(contents); - const tokens = config?.type === 'map' ? config.tokens : {}; - tokens[normalizeApiUrl(apiUrl)] = authToken; + const key = normalizeApiUrl(apiUrl); + let tokens: AuthTokens = {}; + if (config?.type === 'map') { + tokens = config.tokens; + } else if (config?.type === 'legacy') { + const legacyKey = await getLegacyTokenApiUrl(key, config.token); + if (legacyKey && legacyKey !== key) { + tokens[legacyKey] = config.token; + } + } + tokens[key] = authToken; await writeAuthConfigFile(paths, tokens); } export async function removeConfigAuthToken( apiUrl: string, - paths: AuthPaths = getAuthPaths(), ): Promise<'removed' | 'not-found'> { + const paths = getAuthPaths(); const contents = await readAuthConfigFile(paths); if (contents === null) return 'not-found'; const config = parseAuthConfig(contents); if (config.type === 'legacy') { - await rm(paths.authConfigFilePath); - return 'removed'; + const key = normalizeApiUrl(apiUrl); + if (key === productionApiUrl) { + await rm(paths.authConfigFilePath); + return 'removed'; + } + + const legacyKey = await getLegacyTokenApiUrl(key, config.token); + if (legacyKey === key) { + await rm(paths.authConfigFilePath); + return 'removed'; + } + if (legacyKey) { + await writeAuthConfigFile(paths, { [legacyKey]: config.token }); + } + return 'not-found'; } if (config.type === 'invalid') return 'not-found'; diff --git a/client/packages/cli/src/old.js b/client/packages/cli/src/old.js index 93ae800352..b18ce2baa2 100644 --- a/client/packages/cli/src/old.js +++ b/client/packages/cli/src/old.js @@ -1,8 +1,8 @@ import boxen from 'boxen'; import chalk from 'chalk'; import { program } from '@commander-js/extra-typings'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { readConfigAuthToken as readStoredAuthToken } from './auth.ts'; import { UI } from './ui/index.ts'; import { deferred, renderUnwrap } from './ui/lib.ts'; import { @@ -10,6 +10,7 @@ import { getPermsReadCandidates, getSchemaReadCandidates, } from './util/findConfigCandidates.ts'; +import { getAuthPaths } from './util/getAuthPaths.ts'; import { loadConfig } from './util/loadConfig.ts'; import { ResolveRenamePrompt } from './util/renamePrompt.ts'; import version from './version.js'; @@ -330,7 +331,7 @@ async function fetchJson({ let authToken = null; if (withAuth) { authToken = - providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging(apiURI)); + providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging()); if (!authToken) { return { ok: false, data: undefined }; } @@ -435,7 +436,7 @@ export async function readLocalEmailFile(emailPath) { return { path: relativePath, email: res.config }; } -async function readConfigAuthToken(apiURI, allowAdminToken = true) { +async function readConfigAuthToken(allowAdminToken = true) { const options = program.opts(); // @ts-expect-error command opts type is unknown if (typeof options.token === 'string') { @@ -457,7 +458,10 @@ async function readConfigAuthToken(apiURI, allowAdminToken = true) { } } - const authToken = await readStoredAuthToken(apiURI).catch(() => null); + const authToken = await readFile( + getAuthPaths().authConfigFilePath, + 'utf-8', + ).catch(() => null); if (authToken) { return authToken; @@ -466,8 +470,8 @@ async function readConfigAuthToken(apiURI, allowAdminToken = true) { return null; } -export async function readConfigAuthTokenWithErrorLogging(apiURI) { - const token = await readConfigAuthToken(apiURI); +export async function readConfigAuthTokenWithErrorLogging() { + const token = await readConfigAuthToken(); if (!token) { error( `Looks like you are not logged in. Please log in with ${chalk.green('`instant-cli login`')}`, diff --git a/client/packages/cli/src/util/getAuthPaths.ts b/client/packages/cli/src/util/getAuthPaths.ts index 6a52fcfa5b..1e72d57f23 100644 --- a/client/packages/cli/src/util/getAuthPaths.ts +++ b/client/packages/cli/src/util/getAuthPaths.ts @@ -2,7 +2,9 @@ import envPaths from 'env-paths'; import { join } from 'node:path'; export function getAuthPaths() { - const dev = Boolean(process.env.INSTANT_CLI_DEV); + const dev = ['true', 'yes', 'on', '1'].includes( + process.env.INSTANT_CLI_DEV ?? '', + ); const key = `instantdb-${dev ? 'dev' : 'prod'}`; const { config: appConfigDirPath } = envPaths(key); const authConfigFilePath = join(appConfigDirPath, 'a'); diff --git a/client/packages/create-instant-app/package.json b/client/packages/create-instant-app/package.json index 1711c4fcfd..9326279c7c 100644 --- a/client/packages/create-instant-app/package.json +++ b/client/packages/create-instant-app/package.json @@ -40,6 +40,7 @@ "@instantdb/version": "workspace:*", "chalk": "5.2.0", "commander": "^10.0.1", + "env-paths": "^3.0.0", "execa": "^7.2.0", "fs-extra": "^11.3.1", "gradient-string": "^2.0.2", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 98dcf19967..77c13944f8 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: commander: specifier: ^10.0.1 version: 10.0.1 + env-paths: + specifier: ^3.0.0 + version: 3.0.0 execa: specifier: ^7.2.0 version: 7.2.0 From 06b7eb650c0b03f365b12477932817e6b4f4b788 Mon Sep 17 00:00:00 2001 From: Drew Harris Date: Thu, 30 Jul 2026 16:49:55 -0700 Subject: [PATCH 5/5] trim keys before use --- client/packages/cli/src/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/packages/cli/src/auth.ts b/client/packages/cli/src/auth.ts index ce11c230a7..0993d41fb8 100644 --- a/client/packages/cli/src/auth.ts +++ b/client/packages/cli/src/auth.ts @@ -23,11 +23,11 @@ function parseAuthConfig(contents: string): AuthConfig { try { parsed = JSON.parse(contents); } catch { - const trimmed = contents.trimStart(); + const trimmed = contents.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return { type: 'invalid' }; } - return { type: 'legacy', token: contents }; + return { type: 'legacy', token: trimmed }; } if (