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..0993d41fb8 --- /dev/null +++ b/client/packages/cli/src/auth.ts @@ -0,0 +1,181 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { getAuthPaths } from './util/getAuthPaths.ts'; + +type AuthTokens = Record; + +const productionApiUrl = 'https://api.instantdb.com'; + +type AuthConfig = + | { type: 'map'; tokens: AuthTokens } + | { type: 'legacy'; token: string } + | { type: 'invalid' }; + +type AuthPaths = ReturnType; + +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.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return { type: 'invalid' }; + } + return { type: 'legacy', token: trimmed }; + } + + 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'; +} + +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; + } +} + +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, +): Promise { + const paths = getAuthPaths(); + 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; + + const migrationKey = await getLegacyTokenApiUrl(key, config.token); + + if (migrationKey) { + await writeAuthConfigFile(paths, { + [migrationKey]: config.token, + }).catch(() => {}); + } + + // 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, +): Promise { + const paths = getAuthPaths(); + const contents = await readAuthConfigFile(paths); + const config = contents === null ? null : parseAuthConfig(contents); + 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, +): 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') { + 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'; + + 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/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..1e72d57f23 100644 --- a/client/packages/cli/src/util/getAuthPaths.ts +++ b/client/packages/cli/src/util/getAuthPaths.ts @@ -1,9 +1,10 @@ import envPaths from 'env-paths'; import { join } from 'node:path'; -const dev = Boolean(process.env.INSTANT_CLI_DEV); - export function getAuthPaths() { + 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/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'); -}