diff --git a/package.json b/package.json index ecf58ab..a56eb10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@insforge/cli", - "version": "0.1.76", + "version": "0.1.77", "description": "InsForge CLI - Command line tool for InsForge platform", "type": "module", "bin": { diff --git a/src/commands/config/apply.test.ts b/src/commands/config/apply.test.ts index 27f406f..b87eb63 100644 --- a/src/commands/config/apply.test.ts +++ b/src/commands/config/apply.test.ts @@ -8,6 +8,8 @@ import type * as ErrorsModule from '../../lib/errors.js'; // Per-test we override what /api/metadata returns by reassigning this. let nextMetadataResponse: unknown = {}; +// Stash secret values for env() resolution. Map secret name → value. +const secretsStore: Map = new Map(); const ossFetchMock = vi.fn(async (path: string, init?: RequestInit) => { if (path === '/api/metadata' && (!init || init.method === undefined || init.method === 'GET')) { return new Response(JSON.stringify(nextMetadataResponse), { @@ -15,6 +17,21 @@ const ossFetchMock = vi.fn(async (path: string, init?: RequestInit) => { headers: { 'content-type': 'application/json' }, }); } + const secretMatch = path.match(/^\/api\/secrets\/(.+)$/); + if (secretMatch && (!init || init.method === undefined || init.method === 'GET')) { + const key = decodeURIComponent(secretMatch[1]); + const value = secretsStore.get(key); + if (value === undefined) { + // Real ossFetch throws on any non-2xx instead of returning the Response. + // Mirror that: the resolver recovers the "missing" signal from the error + // message because the underlying status is unreachable. + throw new Error(`Secret not found: ${key}`); + } + return new Response(JSON.stringify({ key, value }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -75,6 +92,7 @@ let tmp: string; beforeEach(() => { vi.clearAllMocks(); + secretsStore.clear(); tmp = mkdtempSync(join(tmpdir(), 'insforge-apply-test-')); }); @@ -166,3 +184,326 @@ describe('config apply (capability probe)', () => { rmSync(tmp, { recursive: true, force: true }); }); }); + +describe('config apply — auth.smtp', () => { + const EMPTY_SMTP_METADATA = { + enabled: false, + host: '', + port: 587, + username: '', + hasPassword: false, + senderEmail: '', + senderName: '', + minIntervalSeconds: 60, + }; + + it('resolves env() ref and PUTs /api/auth/smtp-config with the actual password', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [], smtpConfig: EMPTY_SMTP_METADATA }, + }; + secretsStore.set('SMTP_PASSWORD', 'real-secret-from-store'); + + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync( + tomlPath, + `[auth.smtp] +enabled = true +host = "smtp.gmail.com" +port = 587 +username = "user@gmail.com" +password = "env(SMTP_PASSWORD)" +sender_email = "noreply@app.com" +sender_name = "App" +min_interval_seconds = 60 +`, + ); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + + const secretLookups = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/secrets/SMTP_PASSWORD', + ); + expect(secretLookups).toHaveLength(1); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/auth/smtp-config' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + const body = JSON.parse(putCalls[0][1]!.body as string) as Record; + expect(body.password).toBe('real-secret-from-store'); + expect(body.host).toBe('smtp.gmail.com'); + expect(body.senderEmail).toBe('noreply@app.com'); + expect(body.senderName).toBe('App'); + expect(body.minIntervalSeconds).toBe(60); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('fails fast (no PUT) when env() ref points at a missing secret', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [], smtpConfig: EMPTY_SMTP_METADATA }, + }; + + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync( + tomlPath, + `[auth.smtp] +enabled = true +host = "smtp.gmail.com" +port = 587 +username = "u@g.com" +password = "env(MISSING_SECRET)" +sender_email = "noreply@app.com" +sender_name = "App" +`, + ); + + const program = makeProgram(); + await expect( + runJson(program, ['--json', '--yes', 'config', 'apply', '--file', tomlPath]), + ).rejects.toMatchObject({ code: 'SECRET_NOT_FOUND' }); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/auth/smtp-config' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it("omits password from PUT body when TOML doesn't carry it (default-keep)", async () => { + nextMetadataResponse = { + auth: { + allowedRedirectUrls: [], + smtpConfig: { + ...EMPTY_SMTP_METADATA, + enabled: true, + host: 'old.smtp.com', + hasPassword: true, + }, + }, + }; + + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync( + tomlPath, + `[auth.smtp] +enabled = true +host = "new.smtp.com" +port = 587 +username = "" +sender_email = "" +sender_name = "" +min_interval_seconds = 60 +`, + ); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/auth/smtp-config' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + const body = JSON.parse(putCalls[0][1]!.body as string) as Record; + expect(body.host).toBe('new.smtp.com'); + expect('password' in body).toBe(false); + + const secretLookups = ossFetchMock.mock.calls.filter((c) => + (c[0] as string).startsWith('/api/secrets/'), + ); + expect(secretLookups).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('skips SMTP changes when the backend predates the smtpConfig metadata field', async () => { + nextMetadataResponse = { auth: { allowedRedirectUrls: [] } }; + + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync( + tomlPath, + `[auth.smtp] +enabled = true +host = "smtp.gmail.com" +port = 587 +username = "u@g.com" +sender_email = "noreply@app.com" +sender_name = "App" +`, + ); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: Array<{ key: string }> }; + expect(result.applied).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].key).toBe('auth.smtp'); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/auth/smtp-config' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); +}); + +describe('config apply — deployments.subdomain', () => { + it('PUTs /api/deployments/slug with the new slug when cloud backend exposes the slice', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: null }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + expect(JSON.parse(putCalls[0][1]!.body as string)).toEqual({ slug: 'my-app' }); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('clears the slug when TOML carries an empty subdomain (PUT slug: null)', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'existing-slug' }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = ""\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { applied: unknown[]; skipped: unknown[] }; + expect(result.applied).toHaveLength(1); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(1); + expect(JSON.parse(putCalls[0][1]!.body as string)).toEqual({ slug: null }); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('no-op when TOML matches live slug (default-keep)', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'my-app' }, + }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + // When the diff is empty, the apply path emits { applied: false } via + // the no-changes shortcut — the assertion that matters is that no PUT + // is issued. + const result = docs[0] as { applied: false | unknown[] }; + expect(result.applied).toBe(false); + + const putCalls = ossFetchMock.mock.calls.filter((c) => c[1]?.method === 'PUT'); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('skips with named warning when backend predates the deployments slice (self-host or pre-#1259)', async () => { + // Critical version-skew case: a backend without the deployments metadata + // field must NOT receive a PUT to /api/deployments/slug — on self-host + // that endpoint 503s ("Custom slugs are only available in cloud + // environment"), and on a pre-#1259 backend the metadata round-trip + // wouldn't have detected our intent at all. + nextMetadataResponse = { auth: { allowedRedirectUrls: [] } }; + const tomlPath = join(tmp, 'insforge.toml'); + writeFileSync(tomlPath, '[deployments]\nsubdomain = "my-app"\n'); + + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + '--yes', + 'config', + 'apply', + '--file', + tomlPath, + ]); + + const result = docs[0] as { + applied: unknown[]; + skipped: Array<{ key: string; reason: string }>; + }; + expect(result.applied).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].key).toBe('deployments.subdomain'); + + const putCalls = ossFetchMock.mock.calls.filter( + (c) => c[0] === '/api/deployments/slug' && c[1]?.method === 'PUT', + ); + expect(putCalls).toHaveLength(0); + + rmSync(tmp, { recursive: true, force: true }); + }); +}); diff --git a/src/commands/config/apply.ts b/src/commands/config/apply.ts index 02f3f22..9f9df9c 100644 --- a/src/commands/config/apply.ts +++ b/src/commands/config/apply.ts @@ -8,12 +8,36 @@ import { ossFetch } from '../../lib/api/oss.js'; import { requireAuth } from '../../lib/credentials.js'; import { handleError, getRootOpts, CLIError } from '../../lib/errors.js'; import { parseConfigToml } from '../../lib/config-toml.js'; -import { diffConfig, type DiffChange } from '../../lib/config-diff.js'; +import { diffConfig, type DiffChange, type LiveConfig } from '../../lib/config-diff.js'; import { formatPlan } from '../../lib/config-format.js'; import { metadataSupports, changePath } from '../../lib/config-capabilities.js'; -import type { InsforgeConfig } from '../../lib/config-schema.js'; +import { resolveEnvRef } from '../../lib/config-secrets.js'; import { reportCliUsage } from '../../lib/skills.js'; +interface RawAuthMetadata { + allowedRedirectUrls?: string[]; + smtpConfig?: { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + hasPassword?: boolean; + senderEmail?: string; + senderName?: string; + minIntervalSeconds?: number; + }; +} + +interface RawMetadataResponse { + auth?: RawAuthMetadata; + // Cloud-only slice (InsForge#1259). Self-host or pre-#1259 backends omit + // the key entirely; the capability gate uses presence/absence to decide + // whether [deployments] writes are honored. + deployments?: { + customSlug?: string | null; + }; +} + export function registerConfigApplyCommand(cfg: Command): void { cfg .command('apply') @@ -31,12 +55,8 @@ export function registerConfigApplyCommand(cfg: Command): void { const file = parseConfigToml(tomlSource); const res = await ossFetch('/api/metadata'); - const raw = (await res.json()) as { - auth?: { allowedRedirectUrls?: string[] }; - }; - const live: InsforgeConfig = { - auth: { allowed_redirect_urls: raw.auth?.allowedRedirectUrls ?? [] }, - }; + const raw = (await res.json()) as RawMetadataResponse; + const live = liveFromMetadata(raw); const result = diffConfig({ live, file }); const approved = opts.autoApprove || yes; @@ -80,9 +100,9 @@ export function registerConfigApplyCommand(cfg: Command): void { } // Per-change capability gate. Each change is independent: a backend - // that supports `auth.allowed_redirect_urls` but not (future) - // `email.smtp` should apply the first and skip the second with a - // named warning. Better than failing the whole batch. + // that supports `auth.allowed_redirect_urls` but not `auth.smtp` + // should apply the first and skip the second with a named warning. + // Better than failing the whole batch. const applied: DiffChange[] = []; const skipped: Array<{ key: string; reason: string }> = []; for (const change of result.changes) { @@ -126,6 +146,30 @@ export function registerConfigApplyCommand(cfg: Command): void { }); } +function liveFromMetadata(raw: RawMetadataResponse): LiveConfig { + const live: LiveConfig = { auth: {} }; + if (raw.auth?.allowedRedirectUrls !== undefined) { + live.auth!.allowed_redirect_urls = raw.auth.allowedRedirectUrls; + } + if (raw.auth?.smtpConfig) { + const s = raw.auth.smtpConfig; + live.auth!.smtp = { + enabled: s.enabled ?? false, + host: s.host ?? '', + port: s.port ?? 587, + username: s.username ?? '', + hasPassword: s.hasPassword ?? false, + sender_email: s.senderEmail ?? '', + sender_name: s.senderName ?? '', + min_interval_seconds: s.minIntervalSeconds ?? 60, + }; + } + if (raw.deployments) { + live.deployments = { subdomain: raw.deployments.customSlug ?? null }; + } + return live; +} + async function applyChange(change: DiffChange): Promise { if (change.section === 'auth' && change.key === 'allowed_redirect_urls') { await ossFetch('/api/auth/config', { @@ -134,5 +178,50 @@ async function applyChange(change: DiffChange): Promise { }); return; } - throw new Error(`Unsupported change type: ${change.section}.${change.key}`); + if (change.section === 'auth.smtp') { + // Build the upsert body from the file's resolved view. Force-resend the + // password every time when an env() ref is present — see config-diff.ts + // for the rationale. + const to = change.to; + const body: Record = { + enabled: to.enabled, + host: to.host, + port: to.port, + username: to.username, + senderEmail: to.sender_email, + senderName: to.sender_name, + minIntervalSeconds: to.min_interval_seconds, + }; + if (change.passwordEnvRef) { + // Pre-flight resolves the secret; failure here aborts BEFORE we PUT + // anything, so a missing secret doesn't leave the backend half-updated. + const value = await resolveEnvRef( + `env(${change.passwordEnvRef})`, + 'auth.smtp.password', + ); + body.password = value; + } + // Omitting `password` from the body tells the backend's upsert to + // preserve the existing encrypted value — matches our "absent = preserve" + // semantics. Force-resend only fires when the TOML carries an env() ref. + await ossFetch('/api/auth/smtp-config', { + method: 'PUT', + body: JSON.stringify(body), + }); + return; + } + if (change.section === 'deployments' && change.key === 'subdomain') { + // Backend (updateSlugRequestSchema) accepts string | null; the diff + // layer already normalized empty-string to null. A conflict on a + // taken slug returns 409 — ossFetch surfaces that as a CLIError with + // the backend's "Slug is already taken" message. + await ossFetch('/api/deployments/slug', { + method: 'PUT', + body: JSON.stringify({ slug: change.to }), + }); + return; + } + // Exhaustiveness check — TS will error if we miss a discriminated variant. + const _exhaustive: never = change; + throw new Error(`Unsupported change: ${JSON.stringify(_exhaustive)}`); } diff --git a/src/commands/config/export.test.ts b/src/commands/config/export.test.ts index 9e35e4b..237cf48 100644 --- a/src/commands/config/export.test.ts +++ b/src/commands/config/export.test.ts @@ -71,9 +71,21 @@ beforeEach(() => { }); describe('config export (capability probe)', () => { - it('emits the auth section when the backend exposes the field', async () => { + it('emits both auth sections when the backend exposes both fields', async () => { nextMetadataResponse = { - auth: { allowedRedirectUrls: ['https://a.com', 'https://b.com'] }, + auth: { + allowedRedirectUrls: ['https://a.com', 'https://b.com'], + smtpConfig: { + enabled: false, + host: '', + port: 587, + username: '', + hasPassword: false, + senderEmail: '', + senderName: '', + minIntervalSeconds: 60, + }, + }, }; const target = join(tmp, 'insforge.toml'); const program = makeProgram(); @@ -86,20 +98,105 @@ describe('config export (capability probe)', () => { '--force', ]); - const result = docs[0] as { config: { auth?: unknown }; skipped: string[] }; - expect(result.config.auth).toEqual({ - allowed_redirect_urls: ['https://a.com', 'https://b.com'], - }); - expect(result.skipped).toEqual([]); + const result = docs[0] as { + config: { auth?: { smtp?: unknown; allowed_redirect_urls?: unknown } }; + skipped: string[]; + }; + expect(result.config.auth?.allowed_redirect_urls).toEqual([ + 'https://a.com', + 'https://b.com', + ]); + expect(result.config.auth?.smtp).toBeDefined(); + // No deployments slice in this fixture — that section gets skipped, but + // the auth slice still produces a valid TOML. + expect(result.skipped).toEqual(['deployments.subdomain']); const written = readFileSync(target, 'utf8'); expect(written).toContain('allowed_redirect_urls'); + expect(written).toContain('[auth.smtp]'); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('emits env(SMTP_PASSWORD) placeholder when hasPassword is true', async () => { + nextMetadataResponse = { + auth: { + allowedRedirectUrls: [], + smtpConfig: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + hasPassword: true, + senderEmail: 'noreply@app.com', + senderName: 'App', + minIntervalSeconds: 60, + }, + }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { auth?: { smtp?: { password?: string } } }; + skipped: string[]; + }; + expect(result.config.auth?.smtp?.password).toBe('env(SMTP_PASSWORD)'); + + const written = readFileSync(target, 'utf8'); + expect(written).toContain('password = "env(SMTP_PASSWORD)"'); + expect(written).toContain('insforge secrets add SMTP_PASSWORD'); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('omits the password field when hasPassword is false', async () => { + nextMetadataResponse = { + auth: { + allowedRedirectUrls: [], + smtpConfig: { + enabled: false, + host: '', + port: 587, + username: '', + hasPassword: false, + senderEmail: '', + senderName: '', + minIntervalSeconds: 60, + }, + }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { auth?: { smtp?: { password?: string } } }; + }; + expect(result.config.auth?.smtp?.password).toBeUndefined(); + + const written = readFileSync(target, 'utf8'); + expect(written).not.toContain('password'); rmSync(tmp, { recursive: true, force: true }); }); - it('omits the auth section and reports skipped when the field is absent', async () => { - // Older backend — auth metadata returns, but no allowedRedirectUrls key. + it('omits sections and reports skipped when fields are absent (older backend)', async () => { nextMetadataResponse = { auth: { someOtherField: 'x' } }; const target = join(tmp, 'insforge.toml'); const program = makeProgram(); @@ -114,10 +211,78 @@ describe('config export (capability probe)', () => { const result = docs[0] as { config: { auth?: unknown }; skipped: string[] }; expect(result.config.auth).toBeUndefined(); - expect(result.skipped).toEqual(['auth.allowed_redirect_urls']); + expect(result.skipped.sort()).toEqual([ + 'auth.allowed_redirect_urls', + 'auth.smtp', + 'deployments.subdomain', + ]); // File is still written so future apply cycles work — just empty. expect(existsSync(target)).toBe(true); rmSync(tmp, { recursive: true, force: true }); }); + + it('emits [deployments] when cloud backend exposes a custom slug', async () => { + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: 'my-app' }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { deployments?: { subdomain?: string } }; + skipped: string[]; + }; + expect(result.config.deployments).toEqual({ subdomain: 'my-app' }); + // No smtpConfig in fixture → that section gets skipped, but the + // deployments section still emits cleanly. + expect(result.skipped).toEqual(['auth.smtp']); + + const written = readFileSync(target, 'utf8'); + expect(written).toContain('[deployments]'); + expect(written).toContain('subdomain = "my-app"'); + + rmSync(tmp, { recursive: true, force: true }); + }); + + it('omits [deployments] when cloud backend has no slug set', async () => { + // Slice present but customSlug: null — the project is on its default + // URL. Emitting subdomain = "" would mean "clear on apply" (which fails + // the 3-char min), so we leave the section out entirely. + nextMetadataResponse = { + auth: { allowedRedirectUrls: [] }, + deployments: { customSlug: null }, + }; + const target = join(tmp, 'insforge.toml'); + const program = makeProgram(); + const docs = await runJson(program, [ + '--json', + 'config', + 'export', + '--out', + target, + '--force', + ]); + + const result = docs[0] as { + config: { deployments?: unknown }; + skipped: string[]; + }; + expect(result.config.deployments).toBeUndefined(); + expect(result.skipped).toEqual(['auth.smtp']); + + const written = readFileSync(target, 'utf8'); + expect(written).not.toContain('[deployments]'); + + rmSync(tmp, { recursive: true, force: true }); + }); }); diff --git a/src/commands/config/export.ts b/src/commands/config/export.ts index 1c06d3f..6b09faa 100644 --- a/src/commands/config/export.ts +++ b/src/commands/config/export.ts @@ -11,6 +11,30 @@ import { stringifyConfigToml } from '../../lib/config-toml.js'; import type { InsforgeConfig } from '../../lib/config-schema.js'; import { reportCliUsage } from '../../lib/skills.js'; +interface RawAuthMetadata { + allowedRedirectUrls?: string[]; + smtpConfig?: { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + hasPassword?: boolean; + senderEmail?: string; + senderName?: string; + minIntervalSeconds?: number; + }; +} + +interface RawMetadataResponse { + auth?: RawAuthMetadata; + // Cloud-only slice. Self-host or pre-#1259 backends omit the key + // entirely; presence is the signal the CLI uses to decide whether to + // emit a [deployments] section. + deployments?: { + customSlug?: string | null; + }; +} + export function registerConfigExportCommand(cfg: Command): void { cfg .command('export') @@ -44,9 +68,7 @@ export function registerConfigExportCommand(cfg: Command): void { } const res = await ossFetch('/api/metadata'); - const raw = (await res.json()) as { - auth?: { allowedRedirectUrls?: string[] }; - }; + const raw = (await res.json()) as RawMetadataResponse; // Only emit sections the backend actually exposes. The TOML file // should describe what THIS backend can do — not aspirational fields @@ -58,13 +80,53 @@ export function registerConfigExportCommand(cfg: Command): void { const authSlice = raw?.auth; if (authSlice && typeof authSlice === 'object' && 'allowedRedirectUrls' in authSlice) { - config.auth = { - allowed_redirect_urls: authSlice.allowedRedirectUrls ?? [], - }; + config.auth = config.auth ?? {}; + config.auth.allowed_redirect_urls = authSlice.allowedRedirectUrls ?? []; } else { skipped.push('auth.allowed_redirect_urls'); } + if ( + authSlice && + typeof authSlice === 'object' && + 'smtpConfig' in authSlice && + authSlice.smtpConfig + ) { + const s = authSlice.smtpConfig; + config.auth = config.auth ?? {}; + config.auth.smtp = { + enabled: s.enabled ?? false, + host: s.host ?? '', + port: s.port ?? 587, + username: s.username ?? '', + // When backend has a password set, emit a deterministic env() + // placeholder so the user knows which secret to define. We do + // NOT round-trip the value (it never leaves the backend). + // Re-applying this TOML force-resends from the secrets store + // — see config-diff.ts for the force-resend rationale. + ...(s.hasPassword ? { password: 'env(SMTP_PASSWORD)' } : {}), + sender_email: s.senderEmail ?? '', + sender_name: s.senderName ?? '', + min_interval_seconds: s.minIntervalSeconds ?? 60, + }; + } else { + skipped.push('auth.smtp'); + } + + const deploymentsSlice = raw?.deployments; + if (deploymentsSlice && typeof deploymentsSlice === 'object') { + // Cloud backend exposes the slice. Only emit a value when a slug + // is actually set — an unset slug means the project is on its + // default URL, and surfacing subdomain = "" in the TOML would + // imply "clear on apply" (and fail the backend's 3-char min). + if (typeof deploymentsSlice.customSlug === 'string' && deploymentsSlice.customSlug) { + config.deployments = { subdomain: deploymentsSlice.customSlug }; + } + } else { + // Self-host or pre-#1259 backend — slice missing entirely. + skipped.push('deployments.subdomain'); + } + const toml = stringifyConfigToml(config); writeFileSync(target, toml, 'utf8'); diff --git a/src/lib/config-capabilities.test.ts b/src/lib/config-capabilities.test.ts index 3b7d9bc..997e8c6 100644 --- a/src/lib/config-capabilities.test.ts +++ b/src/lib/config-capabilities.test.ts @@ -61,6 +61,32 @@ describe('metadataSupports', () => { }); }); +describe('metadataSupports — deployments.subdomain', () => { + const change: DiffChange = { + section: 'deployments', + op: 'modify', + key: 'subdomain', + from: null, + to: 'my-app', + }; + + it('returns true when the deployments slice is present (cloud backend)', () => { + expect(metadataSupports({ deployments: { customSlug: null } }, change)).toBe(true); + }); + + it('returns true when the slice carries a non-null slug', () => { + expect(metadataSupports({ deployments: { customSlug: 'set' } }, change)).toBe(true); + }); + + it('returns false when the slice is omitted (self-host or pre-#1259 backend)', () => { + // Critical version-skew guard: a backend that doesn't expose + // deployments must not receive a slug PUT — self-host's slug endpoint + // 503s, and a pre-#1259 cloud backend would have no metadata round-trip + // to detect the field at all. + expect(metadataSupports({ auth: { allowedRedirectUrls: [] } }, change)).toBe(false); + }); +}); + describe('changePath', () => { it('joins section and key with a dot', () => { expect(changePath(change)).toBe('auth.allowed_redirect_urls'); diff --git a/src/lib/config-capabilities.ts b/src/lib/config-capabilities.ts index ba3eb3d..cd95d2c 100644 --- a/src/lib/config-capabilities.ts +++ b/src/lib/config-capabilities.ts @@ -27,6 +27,9 @@ import type { DiffChange } from './config-diff.js'; type RawMetadata = { auth?: Record; + // Cloud-only slice. Self-host backends omit the key entirely — that's the + // signal we use to gate [deployments] writes (self-host can't honor them). + deployments?: Record; }; /** @@ -43,6 +46,30 @@ export function metadataSupports(raw: RawMetadata, change: DiffChange): boolean 'allowedRedirectUrls' in raw.auth ); } + if (change.section === 'auth.smtp') { + // SMTP is whole-object: a backend either exposes `smtpConfig` in + // /api/metadata (and accepts PUT /api/auth/smtp-config) or doesn't. + return ( + raw?.auth !== undefined && + raw.auth !== null && + typeof raw.auth === 'object' && + 'smtpConfig' in raw.auth + ); + } + if (change.section === 'deployments' && change.key === 'subdomain') { + // Presence-only probe: cloud backends always carry `customSlug` in the + // slice (null when unset); self-host omits the whole `deployments` key. + return ( + raw?.deployments !== undefined && + raw.deployments !== null && + typeof raw.deployments === 'object' + ); + } + // Exhaustiveness check — if a new DiffChange variant lands without a + // matching probe, TS errors at compile time instead of silently dumping + // every apply of that section into skipped[] forever. + const _exhaustive: never = change; + void _exhaustive; return false; } @@ -50,5 +77,6 @@ export function metadataSupports(raw: RawMetadata, change: DiffChange): boolean * Human-readable path for a change, used in skipped/applied summaries. */ export function changePath(change: DiffChange): string { + if (change.section === 'auth.smtp') return 'auth.smtp'; return `${change.section}.${change.key}`; } diff --git a/src/lib/config-diff.test.ts b/src/lib/config-diff.test.ts index 9b83fc0..52c1174 100644 --- a/src/lib/config-diff.test.ts +++ b/src/lib/config-diff.test.ts @@ -78,3 +78,148 @@ describe('diffConfig', () => { ]); }); }); + +const LIVE_SMTP_EMPTY = { + enabled: false, + host: '', + port: 587, + username: '', + hasPassword: false, + sender_email: '', + sender_name: '', + min_interval_seconds: 60, +}; + +const LIVE_SMTP_CONFIGURED = { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + hasPassword: true, + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, +}; + +describe('diffConfig — auth.smtp', () => { + it('emits a single auth.smtp change with all field updates', () => { + const live = { auth: { smtp: LIVE_SMTP_EMPTY } }; + const file = { + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + password: 'env(SMTP_PASSWORD)', + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }; + const result = diffConfig({ live, file }); + expect(result.changes).toHaveLength(1); + const change = result.changes[0]; + expect(change.section).toBe('auth.smtp'); + if (change.section === 'auth.smtp') { + expect(change.from.host).toBe(''); + expect(change.to.host).toBe('smtp.gmail.com'); + expect(change.from.password).toBe('(unset)'); + expect(change.to.password).toBe('env(SMTP_PASSWORD)'); + expect(change.passwordEnvRef).toBe('SMTP_PASSWORD'); + } + }); + + it('treats absent [auth.smtp] section as no-op (preserve live)', () => { + const live = { auth: { smtp: LIVE_SMTP_CONFIGURED } }; + const file = { auth: {} }; + expect(diffConfig({ live, file }).changes).toEqual([]); + }); + + it('force-resends password when env() ref is present even if other fields match', () => { + const live = { auth: { smtp: LIVE_SMTP_CONFIGURED } }; + const file = { + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + password: 'env(SMTP_PASSWORD)', + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }; + const result = diffConfig({ live, file }); + expect(result.changes).toHaveLength(1); + const change = result.changes[0]; + if (change.section === 'auth.smtp') { + expect(change.passwordEnvRef).toBe('SMTP_PASSWORD'); + expect(change.from.host).toBe(change.to.host); + } + }); + + it('is a true no-op when password is omitted and non-password fields match', () => { + const live = { auth: { smtp: LIVE_SMTP_CONFIGURED } }; + const file = { + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }; + expect(diffConfig({ live, file }).changes).toEqual([]); + }); + + it('renders password slot as "(set)" for live and "(unchanged)" for file omission', () => { + const live = { auth: { smtp: LIVE_SMTP_CONFIGURED } }; + const file = { + auth: { + smtp: { + enabled: false, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }; + const result = diffConfig({ live, file }); + const change = result.changes[0]; + if (change.section === 'auth.smtp') { + expect(change.from.password).toBe('(set)'); + expect(change.to.password).toBe('(unchanged)'); + expect(change.passwordEnvRef).toBeUndefined(); + } + }); + + it('diffs SMTP and redirect URLs independently in one apply batch', () => { + const live = { + auth: { + allowed_redirect_urls: ['https://old.com'], + smtp: LIVE_SMTP_EMPTY, + }, + }; + const file = { + auth: { + allowed_redirect_urls: ['https://new.com'], + smtp: { enabled: true, host: 'smtp.gmail.com' }, + }, + }; + const result = diffConfig({ live, file }); + expect(result.changes).toHaveLength(2); + const sections = result.changes.map((c) => c.section).sort(); + expect(sections).toEqual(['auth', 'auth.smtp']); + }); +}); diff --git a/src/lib/config-diff.ts b/src/lib/config-diff.ts index f24e6e8..15f1a8e 100644 --- a/src/lib/config-diff.ts +++ b/src/lib/config-diff.ts @@ -1,12 +1,77 @@ -import type { InsforgeConfig } from './config-schema.js'; - -export type DiffChange = { - section: 'auth'; - op: 'modify'; - key: 'allowed_redirect_urls'; - from: string[]; - to: string[]; -}; +import type { InsforgeConfig, SmtpConfig } from './config-schema.js'; +import { parseEnvRef } from './config-secrets.js'; + +/** + * A single declarative change the file would impose on live state. Discriminated + * union: each variant maps to one backend endpoint at apply time. + */ +export type DiffChange = + | { + section: 'auth'; + op: 'modify'; + key: 'allowed_redirect_urls'; + from: string[]; + to: string[]; + } + | { + section: 'auth.smtp'; + op: 'modify'; + key: 'config'; + from: SmtpDiffView; + to: SmtpDiffView; + /** + * env() reference name (e.g. "SMTP_PASSWORD") when the TOML's password + * field is present. Carried separately from the rendered from/to so the + * apply layer can resolve the secret at PUT time without re-parsing. + * When set, the password is force-resent even if nothing else changed. + */ + passwordEnvRef?: string; + } + | { + section: 'deployments'; + op: 'modify'; + key: 'subdomain'; + from: string | null; + to: string | null; + }; + +/** + * Renderable view of SMTP state for plan/diff display. The `password` slot is + * always an opaque marker — actual values never appear in plan output. + */ +export interface SmtpDiffView { + enabled: boolean; + host: string; + port: number; + username: string; + /** + * Opaque marker: + * "(set)" — live state with hasPassword: true + * "(unset)" — live state with hasPassword: false + * "env(NAME)" — TOML side referencing a secret (force re-send) + * "(unchanged)" — TOML side omitting the field (preserve) + */ + password: string; + sender_email: string; + sender_name: string; + min_interval_seconds: number; +} + +/** + * Live SMTP state pulled from /api/metadata auth.smtpConfig slice. The + * backend never returns the actual password — `hasPassword` is the only + * signal we get about credential presence. + */ +export interface LiveSmtpState { + enabled: boolean; + host: string; + port: number; + username: string; + hasPassword: boolean; + sender_email: string; + sender_name: string; + min_interval_seconds: number; +} export interface DiffSummary { add: number; @@ -21,14 +86,29 @@ export interface DiffResult { } export interface DiffInput { - live: InsforgeConfig; + live: LiveConfig; file: InsforgeConfig; } +/** + * Live state shape used as input to diff. Mirrors InsforgeConfig but the SMTP + * slice includes hasPassword (which we get from the backend but never emit + * back into TOML). + */ +export interface LiveConfig { + auth?: { + allowed_redirect_urls?: string[]; + smtp?: LiveSmtpState; + }; + deployments?: { + subdomain?: string | null; + }; +} + /** * Compute the changes the file would impose on the live state. - * v1 scope: auth.allowed_redirect_urls only. Default-keep for absent fields - * — if the file omits a section, live state is left alone. + * Default-keep semantics: if the file omits a section, live state is + * untouched. Each section diffs independently. */ export function diffConfig({ live, file }: DiffInput): DiffResult { const changes: DiffChange[] = []; @@ -38,9 +118,7 @@ export function diffConfig({ live, file }: DiffInput): DiffResult { if (fileAuth && 'allowed_redirect_urls' in fileAuth) { // Treat the redirect allowlist as a set: order and duplicates in the TOML - // shouldn't produce a diff. Reorder/dedupe both sides before comparing, - // and emit the normalized values so the change rendered to the user - // (and the request body sent on apply) matches what's actually different. + // shouldn't produce a diff. Reorder/dedupe both sides before comparing. const fromV = normalizeUrlList(liveAuth.allowed_redirect_urls); const toV = normalizeUrlList(fileAuth.allowed_redirect_urls); if (!arrayEquals(fromV, toV)) { @@ -54,9 +132,133 @@ export function diffConfig({ live, file }: DiffInput): DiffResult { } } + if (fileAuth?.smtp !== undefined) { + const smtpChange = diffSmtp(liveAuth.smtp, fileAuth.smtp); + if (smtpChange) changes.push(smtpChange); + } + + const fileDeployments = file.deployments; + const liveDeployments = live.deployments ?? {}; + if (fileDeployments && 'subdomain' in fileDeployments) { + const fromV = liveDeployments.subdomain ?? null; + // Empty-string in TOML means "clear the slug" — TOML has no null literal, + // so this is the only way the user can express "unset" without deleting + // the line. The PUT body sends slug: null which the backend interprets + // as clear. + const rawTo = fileDeployments.subdomain; + const toV = rawTo === null || rawTo === '' ? null : rawTo; + if (fromV !== toV) { + changes.push({ + section: 'deployments', + op: 'modify', + key: 'subdomain', + from: fromV, + to: toV, + }); + } + } + return { changes, summary: summarize(changes) }; } +/** + * Diff a single SMTP section. Whole-object semantics: any field difference + * (including a force-resend of the password) emits one change targeting the + * upsert endpoint. Returns null if the TOML matches live state and no + * password env ref is present (the only no-op case). + */ +function diffSmtp( + live: LiveSmtpState | undefined, + fileSmtp: SmtpConfig, +): DiffChange | null { + const livedView = renderLiveSmtp(live); + const tomlView = renderFileSmtp(fileSmtp); + const envRef = fileSmtp.password ? parseEnvRef(fileSmtp.password) : null; + + const nonPasswordFieldsChanged = + livedView.enabled !== tomlView.enabled || + livedView.host !== tomlView.host || + livedView.port !== tomlView.port || + livedView.username !== tomlView.username || + livedView.sender_email !== tomlView.sender_email || + livedView.sender_name !== tomlView.sender_name || + livedView.min_interval_seconds !== tomlView.min_interval_seconds; + + // Force-resend semantics: if the TOML carries a password env() ref, + // we always re-send it (we can't tell whether the secrets-store value + // changed without resolving + comparing, which would expose the value + // through the diff). Re-sending is safer if the user rotated the secret + // but forgot to re-apply. + if (!nonPasswordFieldsChanged && envRef === null) { + return null; + } + + return { + section: 'auth.smtp', + op: 'modify', + key: 'config', + from: livedView, + to: tomlView, + passwordEnvRef: envRef ?? undefined, + }; +} + +/** + * Map live backend state to the diff view. Password slot reflects only + * hasPassword — the actual value is never available client-side. + */ +function renderLiveSmtp(live: LiveSmtpState | undefined): SmtpDiffView { + const empty = EMPTY_SMTP_VIEW; + if (!live) return empty; + return { + enabled: live.enabled, + host: live.host, + port: live.port, + username: live.username, + password: live.hasPassword ? '(set)' : '(unset)', + sender_email: live.sender_email, + sender_name: live.sender_name, + min_interval_seconds: live.min_interval_seconds, + }; +} + +/** + * Map TOML file state to the diff view. Missing fields fall back to the + * empty-config shape — the backend's upsert handles partials with its own + * defaults, so we render what the file says (not aspirational defaults). + */ +function renderFileSmtp(file: SmtpConfig): SmtpDiffView { + return { + enabled: file.enabled ?? false, + host: file.host ?? '', + port: file.port ?? 587, + username: file.username ?? '', + password: renderFilePassword(file.password), + sender_email: file.sender_email ?? '', + sender_name: file.sender_name ?? '', + min_interval_seconds: file.min_interval_seconds ?? 60, + }; +} + +function renderFilePassword(value: string | undefined): string { + if (value === undefined) return '(unchanged)'; + const ref = parseEnvRef(value); + // Validator already rejected literals; if ref is null here something + // upstream is broken. Fall back to opaque marker. + return ref ? `env(${ref})` : '(invalid)'; +} + +const EMPTY_SMTP_VIEW: SmtpDiffView = { + enabled: false, + host: '', + port: 587, + username: '', + password: '(unset)', + sender_email: '', + sender_name: '', + min_interval_seconds: 60, +}; + function summarize(changes: DiffChange[]): DiffSummary { const s: DiffSummary = { add: 0, modify: 0, remove: 0, kept: 0 }; for (const c of changes) { diff --git a/src/lib/config-format.ts b/src/lib/config-format.ts index b06dc19..3ad013f 100644 --- a/src/lib/config-format.ts +++ b/src/lib/config-format.ts @@ -30,5 +30,33 @@ export function formatPlan(result: DiffResult): string { } function formatChange(c: DiffChange): string { + if (c.section === 'auth.smtp') { + const lines = [`~ smtp config:`]; + const from = c.from; + const to = c.to; + for (const key of [ + 'enabled', + 'host', + 'port', + 'username', + 'password', + 'sender_email', + 'sender_name', + 'min_interval_seconds', + ] as const) { + if (from[key] !== to[key]) { + lines.push(` ${key}: ${JSON.stringify(from[key])} → ${JSON.stringify(to[key])}`); + } + } + if (c.passwordEnvRef) { + lines.push(` (password force-resent from env(${c.passwordEnvRef}))`); + } + return lines.join('\n '); + } + if (c.section === 'deployments' && c.key === 'subdomain') { + const fromLabel = c.from === null ? '(unset)' : JSON.stringify(c.from); + const toLabel = c.to === null ? '(unset)' : JSON.stringify(c.to); + return `~ ${c.key}: ${fromLabel} → ${toLabel}`; + } return `~ ${c.key}: ${JSON.stringify(c.from)} → ${JSON.stringify(c.to)}`; } diff --git a/src/lib/config-schema.ts b/src/lib/config-schema.ts index 4224803..538e9fc 100644 --- a/src/lib/config-schema.ts +++ b/src/lib/config-schema.ts @@ -1,17 +1,45 @@ // CLI/src/lib/config-schema.ts +import { validateSensitiveString } from './config-secrets.js'; + /** - * The shape of insforge.toml after parsing. v1 MVP scope: only the - * [auth] allowed_redirect_urls field is wired. Every future section - * (SMTP, OAuth providers, deployments, etc.) extends this type. + * The shape of insforge.toml after parsing. Sections cover declarative + * project settings ("dashboard knobs"). Each section maps to a single + * backend admin endpoint and is gated independently by the capability + * probe — adding a section here does NOT silently break old backends. */ export interface InsforgeConfig { project_id?: string; auth?: AuthConfig; + deployments?: DeploymentsConfig; } export interface AuthConfig { allowed_redirect_urls?: string[]; + smtp?: SmtpConfig; +} + +/** + * SMTP configuration. Mirrors backend `smtpConfigSchema` minus the row + * metadata (id/createdAt/updatedAt) — TOML is desired state, not the + * persisted row. The `password` field is required to be an env() ref + * when present; literal values are rejected at parse time. + */ +export interface SmtpConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + /** env(NAME) reference; never a literal value. Omit to preserve existing. */ + password?: string; + sender_email?: string; + sender_name?: string; + min_interval_seconds?: number; +} + +export interface DeploymentsConfig { + // null clears the slug; absent in TOML means default-keep. + subdomain?: string | null; } export class ConfigValidationError extends Error { @@ -22,8 +50,8 @@ export class ConfigValidationError extends Error { } /** - * Validates a parsed TOML object against the v1 schema. - * Throws ConfigValidationError with the path of the first violation. + * Validates a parsed TOML object against the schema. Throws + * ConfigValidationError with the path of the first violation. */ export function validateConfig(input: unknown): InsforgeConfig { if (input === null || typeof input !== 'object' || Array.isArray(input)) { @@ -40,6 +68,31 @@ export function validateConfig(input: unknown): InsforgeConfig { } if ('auth' in obj) out.auth = validateAuth(obj.auth); + if ('deployments' in obj) out.deployments = validateDeployments(obj.deployments); + + return out; +} + +function validateDeployments(input: unknown): DeploymentsConfig { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigValidationError('deployments', 'must be an object'); + } + const obj = input as Record; + const out: DeploymentsConfig = {}; + + if ('subdomain' in obj) { + const v = obj.subdomain; + // Accept null (clear slug) or string. Slug format validation lives on + // the backend (single source of truth: updateSlugRequestSchema) so the + // CLI doesn't drift from server rules. + if (v !== null && typeof v !== 'string') { + throw new ConfigValidationError( + 'deployments.subdomain', + 'must be a string or null', + ); + } + out.subdomain = v; + } return out; } @@ -62,5 +115,91 @@ function validateAuth(input: unknown): AuthConfig { out.allowed_redirect_urls = v; } + if ('smtp' in obj) out.smtp = validateSmtp(obj.smtp); + + return out; +} + +function validateSmtp(input: unknown): SmtpConfig { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new ConfigValidationError('auth.smtp', 'must be a table'); + } + const obj = input as Record; + const out: SmtpConfig = {}; + + if ('enabled' in obj) { + if (typeof obj.enabled !== 'boolean') { + throw new ConfigValidationError('auth.smtp.enabled', 'must be a boolean'); + } + out.enabled = obj.enabled; + } + + if ('host' in obj) { + if (typeof obj.host !== 'string') { + throw new ConfigValidationError('auth.smtp.host', 'must be a string'); + } + out.host = obj.host; + } + + if ('port' in obj) { + if ( + typeof obj.port !== 'number' || + !Number.isInteger(obj.port) || + obj.port < 1 || + obj.port > 65535 + ) { + throw new ConfigValidationError( + 'auth.smtp.port', + 'must be an integer between 1 and 65535', + ); + } + out.port = obj.port; + } + + if ('username' in obj) { + if (typeof obj.username !== 'string') { + throw new ConfigValidationError('auth.smtp.username', 'must be a string'); + } + out.username = obj.username; + } + + if ('password' in obj) { + // env() ref only — literal passwords are rejected at parse time so the + // TOML stays git-safe even if a developer pastes one in by mistake. + out.password = validateSensitiveString( + 'auth.smtp.password', + obj.password, + 'SMTP_PASSWORD', + ); + } + + if ('sender_email' in obj) { + if (typeof obj.sender_email !== 'string') { + throw new ConfigValidationError('auth.smtp.sender_email', 'must be a string'); + } + out.sender_email = obj.sender_email; + } + + if ('sender_name' in obj) { + if (typeof obj.sender_name !== 'string') { + throw new ConfigValidationError('auth.smtp.sender_name', 'must be a string'); + } + out.sender_name = obj.sender_name; + } + + if ('min_interval_seconds' in obj) { + if ( + typeof obj.min_interval_seconds !== 'number' || + !Number.isInteger(obj.min_interval_seconds) || + obj.min_interval_seconds < 0 + ) { + throw new ConfigValidationError( + 'auth.smtp.min_interval_seconds', + 'must be a non-negative integer', + ); + } + out.min_interval_seconds = obj.min_interval_seconds; + } + return out; } diff --git a/src/lib/config-secrets.test.ts b/src/lib/config-secrets.test.ts index 5c985b6..3060c55 100644 --- a/src/lib/config-secrets.test.ts +++ b/src/lib/config-secrets.test.ts @@ -1,7 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { parseEnvRef, validateSensitiveString } from './config-secrets.js'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { parseEnvRef, validateSensitiveString, resolveEnvRef } from './config-secrets.js'; import { ConfigValidationError } from './config-schema.js'; +const ossFetchMock = vi.fn(); +vi.mock('./api/oss.js', () => ({ + ossFetch: (...args: unknown[]) => ossFetchMock(...args), +})); + +beforeEach(() => { + ossFetchMock.mockReset(); +}); + describe('parseEnvRef', () => { it('extracts the secret name from a well-formed env() reference', () => { expect(parseEnvRef('env(GOOGLE_CLIENT_SECRET)')).toBe('GOOGLE_CLIENT_SECRET'); @@ -70,3 +79,60 @@ describe('validateSensitiveString', () => { ).toThrow(/must be a string/); }); }); + +describe('resolveEnvRef', () => { + it('returns the secret value on a successful lookup', async () => { + ossFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ key: 'SMTP_PASSWORD', value: 'real-secret' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const value = await resolveEnvRef('env(SMTP_PASSWORD)', 'auth.smtp.password'); + expect(value).toBe('real-secret'); + expect(ossFetchMock).toHaveBeenCalledWith('/api/secrets/SMTP_PASSWORD'); + }); + + it('throws SECRET_NOT_FOUND when secret is missing (ossFetch throws "not found")', async () => { + // ossFetch throws on any non-2xx — recover the missing-secret signal from + // the error message rather than inspecting status, since the underlying + // Response is unreachable from the caller side. + ossFetchMock.mockRejectedValueOnce(new Error('Secret not found: MISSING')); + await expect( + resolveEnvRef('env(MISSING)', 'auth.smtp.password'), + ).rejects.toMatchObject({ + code: 'SECRET_NOT_FOUND', + message: expect.stringContaining('insforge secrets add MISSING'), + }); + }); + + it('throws SECRET_EMPTY when the secret resolves to an empty string', async () => { + ossFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ key: 'INACTIVE', value: '' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + await expect( + resolveEnvRef('env(INACTIVE)', 'auth.smtp.password'), + ).rejects.toMatchObject({ + code: 'SECRET_EMPTY', + message: expect.stringContaining('insforge secrets update INACTIVE --active true'), + }); + }); + + it('throws SECRET_LOOKUP_FAILED on a non-404 HTTP error', async () => { + ossFetchMock.mockResolvedValueOnce( + new Response('boom', { status: 500 }), + ); + await expect( + resolveEnvRef('env(WHATEVER)', 'auth.smtp.password'), + ).rejects.toMatchObject({ code: 'SECRET_LOOKUP_FAILED' }); + }); + + it('throws ConfigValidationError if called with a non-env() string (defensive)', async () => { + await expect( + resolveEnvRef('plain-literal', 'auth.smtp.password'), + ).rejects.toBeInstanceOf(ConfigValidationError); + }); +}); diff --git a/src/lib/config-secrets.ts b/src/lib/config-secrets.ts index 51b8518..24f5114 100644 --- a/src/lib/config-secrets.ts +++ b/src/lib/config-secrets.ts @@ -19,6 +19,8 @@ // section to use it will be [email.smtp] or [auth.providers.]. import { ConfigValidationError } from './config-schema.js'; +import { ossFetch } from './api/oss.js'; +import { CLIError } from './errors.js'; /** Matches `env(NAME)` where NAME is upper-snake-case. */ const ENV_REF_PATTERN = /^env\(([A-Z_][A-Z0-9_]*)\)$/; @@ -70,3 +72,73 @@ export function validateSensitiveString( ` 3. insforge config apply`, ); } + +/** + * Resolve an env() ref against the project's InsForge secrets store. Returns + * the decrypted value. Pre-flight check before `apply` PUTs anything — if + * the named secret doesn't exist or is inactive, fail fast with an + * actionable error rather than letting the backend emit a generic 400. + * + * Why the secrets store (not local env vars): secrets are shared per-project + * across teammates, CI deploys, and dashboards. A `process.env.SMTP_PASSWORD` + * from a developer's shell would create silent skew for everyone else. + * + * @param envRef The full env() reference (e.g. "env(SMTP_PASSWORD)"). + * @param fieldPath Dotted path of the field for error messages. + */ +export async function resolveEnvRef(envRef: string, fieldPath: string): Promise { + const secretName = parseEnvRef(envRef); + if (!secretName) { + // Defensive — callers should have already validated. If we reach here, + // it means schema validation was bypassed somewhere upstream. + throw new ConfigValidationError( + fieldPath, + `expected env() reference, got "${envRef}"`, + ); + } + + let res: Response; + try { + res = await ossFetch(`/api/secrets/${encodeURIComponent(secretName)}`); + } catch (err) { + // ossFetch throws on any non-2xx, swallowing the status. Recover the + // "missing secret" case from the error message — the backend's NOT_FOUND + // path is the most common failure here and deserves the named code + + // actionable hint, not a generic network error. + const message = (err as Error).message ?? ''; + if (/not found/i.test(message)) { + throw new CLIError( + `${fieldPath} references env(${secretName}) but no such secret exists.\n` + + ` fix: insforge secrets add ${secretName} ""`, + 1, + 'SECRET_NOT_FOUND', + ); + } + // Other failures: re-wrap with the path context so users see what we + // were trying to resolve when the lookup blew up. + throw new CLIError( + `failed to resolve env(${secretName}) for ${fieldPath}: ${message}`, + 1, + 'SECRET_LOOKUP_FAILED', + ); + } + + if (!res.ok) { + throw new CLIError( + `failed to resolve env(${secretName}) for ${fieldPath}: HTTP ${res.status}`, + 1, + 'SECRET_LOOKUP_FAILED', + ); + } + + const body = (await res.json()) as { value?: string }; + if (typeof body.value !== 'string' || body.value.length === 0) { + throw new CLIError( + `env(${secretName}) resolved to an empty value (secret may be inactive).\n` + + ` fix: insforge secrets update ${secretName} --active true`, + 1, + 'SECRET_EMPTY', + ); + } + return body.value; +} diff --git a/src/lib/config-toml.test.ts b/src/lib/config-toml.test.ts index 21ff312..baf0294 100644 --- a/src/lib/config-toml.test.ts +++ b/src/lib/config-toml.test.ts @@ -47,3 +47,192 @@ describe('stringifyConfigToml', () => { expect(out).not.toContain('[auth]'); }); }); + +describe('parseConfigToml — auth.smtp', () => { + it('parses a full SMTP section with env() password ref', () => { + const toml = ` +[auth.smtp] +enabled = true +host = "smtp.gmail.com" +port = 587 +username = "user@gmail.com" +password = "env(SMTP_PASSWORD)" +sender_email = "noreply@app.com" +sender_name = "App" +min_interval_seconds = 60 +`; + expect(parseConfigToml(toml)).toEqual({ + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'user@gmail.com', + password: 'env(SMTP_PASSWORD)', + sender_email: 'noreply@app.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }); + }); + + it('rejects a literal password (must be env() ref)', () => { + const toml = ` +[auth.smtp] +password = "plaintext-secret-do-not-commit" +`; + expect(() => parseConfigToml(toml)).toThrow(/sensitive field must be an env\(\) reference/); + }); + + it('accepts SMTP section with only some fields (partial)', () => { + const toml = ` +[auth.smtp] +enabled = false +`; + expect(parseConfigToml(toml)).toEqual({ + auth: { smtp: { enabled: false } }, + }); + }); + + it('rejects invalid SMTP port (non-integer)', () => { + const toml = ` +[auth.smtp] +port = 587.5 +`; + expect(() => parseConfigToml(toml)).toThrow(/auth\.smtp\.port.*integer/); + }); + + it('rejects negative min_interval_seconds', () => { + const toml = ` +[auth.smtp] +min_interval_seconds = -1 +`; + expect(() => parseConfigToml(toml)).toThrow(/min_interval_seconds.*non-negative/); + }); + + it('rejects port outside 1-65535', () => { + expect(() => parseConfigToml('[auth.smtp]\nport = 0\n')).toThrow( + /port.*1 and 65535/, + ); + expect(() => parseConfigToml('[auth.smtp]\nport = -1\n')).toThrow( + /port.*1 and 65535/, + ); + expect(() => parseConfigToml('[auth.smtp]\nport = 70000\n')).toThrow( + /port.*1 and 65535/, + ); + }); +}); + +describe('stringifyConfigToml — auth.smtp', () => { + it('emits SMTP fields under [auth.smtp] with discovery comment for password', () => { + const out = stringifyConfigToml({ + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'u@g.com', + password: 'env(SMTP_PASSWORD)', + sender_email: 'noreply@a.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }); + expect(out).toContain('[auth.smtp]'); + expect(out).toContain('password = "env(SMTP_PASSWORD)"'); + expect(out).toContain('insforge secrets add SMTP_PASSWORD'); + }); + + it('discovery comment names the actual env ref, not the SMTP_PASSWORD default', () => { + // When the user names their secret PROD_SMTP_PASS, the hint that tells + // them how to provision it must point at PROD_SMTP_PASS — pointing at + // SMTP_PASSWORD would have them create the wrong secret. + const out = stringifyConfigToml({ + auth: { + smtp: { + password: 'env(PROD_SMTP_PASS)', + }, + }, + }); + expect(out).toContain('insforge secrets add PROD_SMTP_PASS'); + expect(out).not.toContain('insforge secrets add SMTP_PASSWORD'); + }); + + it('omits password line entirely when password is undefined', () => { + const out = stringifyConfigToml({ + auth: { + smtp: { + enabled: false, + host: '', + port: 587, + username: '', + sender_email: '', + sender_name: '', + min_interval_seconds: 60, + }, + }, + }); + expect(out).toContain('[auth.smtp]'); + expect(out).not.toContain('password'); + }); + + it('round-trips a full SMTP config through stringify → parse', () => { + const original = { + auth: { + smtp: { + enabled: true, + host: 'smtp.gmail.com', + port: 587, + username: 'u@g.com', + password: 'env(SMTP_PASSWORD)', + sender_email: 'noreply@a.com', + sender_name: 'App', + min_interval_seconds: 60, + }, + }, + }; + expect(parseConfigToml(stringifyConfigToml(original))).toEqual(original); + }); +}); + +describe('parseConfigToml — [deployments]', () => { + it('parses subdomain as a string', () => { + expect(parseConfigToml('[deployments]\nsubdomain = "my-app"\n')).toEqual({ + deployments: { subdomain: 'my-app' }, + }); + }); + + it('parses empty subdomain (the clear-slug signal)', () => { + // TOML has no null literal, so "" is the convention for "unset on apply". + // The diff layer normalizes this to null before sending. + expect(parseConfigToml('[deployments]\nsubdomain = ""\n')).toEqual({ + deployments: { subdomain: '' }, + }); + }); + + it('rejects non-string subdomain', () => { + expect(() => parseConfigToml('[deployments]\nsubdomain = 42\n')).toThrow( + /subdomain.*string or null/, + ); + }); +}); + +describe('stringifyConfigToml — [deployments]', () => { + it('emits [deployments] section when subdomain is a non-empty string', () => { + const out = stringifyConfigToml({ deployments: { subdomain: 'my-app' } }); + expect(out).toContain('[deployments]'); + expect(out).toContain('subdomain = "my-app"'); + }); + + it('omits the section when subdomain is null', () => { + const out = stringifyConfigToml({ deployments: { subdomain: null } }); + expect(out).not.toContain('[deployments]'); + }); + + it('omits the section when subdomain is empty string (avoid emitting clear-signal in export)', () => { + const out = stringifyConfigToml({ deployments: { subdomain: '' } }); + expect(out).not.toContain('[deployments]'); + }); +}); diff --git a/src/lib/config-toml.ts b/src/lib/config-toml.ts index f1067ac..4075dcc 100644 --- a/src/lib/config-toml.ts +++ b/src/lib/config-toml.ts @@ -1,5 +1,6 @@ import * as smolToml from 'smol-toml'; -import { validateConfig, type InsforgeConfig } from './config-schema.js'; +import { validateConfig, type InsforgeConfig, type SmtpConfig } from './config-schema.js'; +import { parseEnvRef } from './config-secrets.js'; export function parseConfigToml(input: string): InsforgeConfig { let parsed: unknown; @@ -13,7 +14,12 @@ export function parseConfigToml(input: string): InsforgeConfig { /** * Render a normalized config back to TOML. Section ordering is deterministic - * (project_id → auth) so diffs are stable across runs of `insforge config export`. + * (project_id → auth → auth.smtp) so diffs are stable across runs of + * `insforge config export`. + * + * The renderer is intentionally hand-rolled rather than using smol-toml's + * stringify: smol-toml doesn't preserve field order, and we want a stable + * lexical layout that survives git diff/code review. */ export function stringifyConfigToml(config: InsforgeConfig): string { const lines: string[] = []; @@ -32,7 +38,52 @@ export function stringifyConfigToml(config: InsforgeConfig): string { lines.push(`allowed_redirect_urls = [${urls}]`); } lines.push(''); + + if (config.auth.smtp !== undefined) { + lines.push('[auth.smtp]'); + renderSmtpFields(config.auth.smtp, lines); + lines.push(''); + } + } + + if (config.deployments) { + // TOML has no null literal, and "" would be ambiguous (clear vs unset). + // Convention: omit the section entirely when subdomain is null/undefined. + // To clear an existing slug via apply, the user writes subdomain = "" — + // the diff/apply layer normalizes empty string to null. + if (typeof config.deployments.subdomain === 'string' && config.deployments.subdomain !== '') { + lines.push('[deployments]'); + lines.push(`subdomain = ${JSON.stringify(config.deployments.subdomain)}`); + lines.push(''); + } } return lines.join('\n').replace(/\n+$/, '\n'); } + +function renderSmtpFields(smtp: SmtpConfig, lines: string[]): void { + if (smtp.enabled !== undefined) lines.push(`enabled = ${smtp.enabled}`); + if (smtp.host !== undefined) lines.push(`host = ${JSON.stringify(smtp.host)}`); + if (smtp.port !== undefined) lines.push(`port = ${smtp.port}`); + if (smtp.username !== undefined) lines.push(`username = ${JSON.stringify(smtp.username)}`); + if (smtp.password !== undefined) { + // password is always an env() ref at this point (schema validator rejects + // literals at parse time). Emit a comment naming the *actual* secret — + // hardcoding SMTP_PASSWORD here would mislead anyone who named their + // ref differently (e.g. env(PROD_SMTP_PASS)). + const secretName = parseEnvRef(smtp.password) ?? 'SMTP_PASSWORD'; + lines.push( + `# password is managed via secrets — run \`insforge secrets add ${secretName} ""\``, + ); + lines.push(`password = ${JSON.stringify(smtp.password)}`); + } + if (smtp.sender_email !== undefined) { + lines.push(`sender_email = ${JSON.stringify(smtp.sender_email)}`); + } + if (smtp.sender_name !== undefined) { + lines.push(`sender_name = ${JSON.stringify(smtp.sender_name)}`); + } + if (smtp.min_interval_seconds !== undefined) { + lines.push(`min_interval_seconds = ${smtp.min_interval_seconds}`); + } +}