diff --git a/.changeset/stash-env-mint-credentials.md b/.changeset/stash-env-mint-credentials.md new file mode 100644 index 000000000..99e1004a7 --- /dev/null +++ b/.changeset/stash-env-mint-credentials.md @@ -0,0 +1,28 @@ +--- +'stash': minor +--- + +`stash env` now works: it mints deployment credentials from your device-code +session and prints them as env vars — no dashboard copy-paste. The command +creates a fresh ZeroKMS client and a member-role CipherStash access key (named +via `--name`; the role is pinned in the request and verified on the response — +the CLI deliberately cannot mint admin keys), then emits `CS_WORKSPACE_CRN`, +`CS_CLIENT_ID`, `CS_CLIENT_KEY`, and `CS_CLIENT_ACCESS_KEY`. + +Output goes to stdout by default — and stdout is pipe-clean (progress UI is on +stderr), so `stash env --name x > prod.env` and pipes into secret stores are +safe. `--write [path]` writes a file instead (default `.env.production.local`, +enforced mode 0600 even when overwriting), confirming before overwriting and +refusing non-interactively — always *before* anything is minted, so a refusal +never discards the shown-exactly-once access key. `--json` emits NDJSON; with +`--write` the confirmation event is deliberately secret-free. API responses +are schema-validated so a service change can never print `undefined` into a +credentials file. Creating access keys requires the admin role in the +workspace. + +This is also the supported credential path for WASM/edge local development +(Supabase Edge Functions, Cloudflare Workers, Deno), where the runtime cannot +read the `~/.cipherstash` device profile: mint a key and feed it via +`supabase functions serve --env-file` or the platform's secret store. + +The `STASH_EXPERIMENTAL_ENV_CMD` gate is removed. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 515423778..b7703df74 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -125,7 +125,7 @@ Commands: encrypt cutover Rename swap encrypted → primary column (EQL v2 only) encrypt drop Generate a migration to drop the plaintext column - env (experimental) Print production env vars for deployment + env Mint deployment credentials and print them as env vars Options: --help, -h Show help @@ -532,7 +532,19 @@ async function dispatch( await runSchemaCommand(subcommand, flags, values) break case 'env': - await envCommand({ write: flags.write }) + await envCommand({ + // parseArgs puts `--write path/x` in values and bare `--write` in + // flags — accept both so a path after --write targets that file + // instead of silently printing secrets to stdout. + write: values.write ?? flags.write, + json: flags.json, + name: values.name, + // `--name` followed by another flag (or nothing) is booleanised by + // parseArgs; surface it as its own error instead of missing_name. + nameMissingValue: flags.name === true, + // `stash env my-app` would otherwise vanish into `subcommand`. + unexpectedArg: subcommand ?? commandArgs[0], + }) break case 'manifest': // Pure metadata (no native code) — safe to run anywhere, including when diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 3c1fb8122..ce64a201b 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -530,15 +530,49 @@ export const registry: CommandGroup[] = [ ], }, { - title: 'Experimental', + title: 'Deployment', commands: [ { name: 'env', - summary: '(experimental) Print production env vars for deployment', + summary: 'Mint deployment credentials and print them as env vars', + long: [ + 'Mints a fresh ZeroKMS client and a CipherStash access key from your', + 'device-code session (`stash auth login`), then prints the four env', + 'vars a deployed app needs: CS_WORKSPACE_CRN, CS_CLIENT_ID,', + 'CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY.', + '', + 'The access key is created with the member role (the CLI never mints', + 'admin keys) and is shown exactly once — pipe the output into your', + 'deployment secret store. Creating access keys requires your user to', + 'have the admin role in the workspace.', + '', + 'Stdout carries only the dotenv block (or the --json events);', + 'progress UI goes to stderr, so `stash env --name x > prod.env`', + 'and pipes into secret stores are safe.', + ].join('\n'), + examples: [ + 'env --name my-app-prod', + 'env --name my-app-prod --write', + 'env --name staging --write .env.staging.local', + 'env --name edge-dev --json', + ], flags: [ + { + name: '--name', + value: '', + description: + 'Name for the minted access key and ZeroKMS client. Prompted for interactively; required in non-interactive runs.', + }, { name: '--write', - description: 'Write the vars to a file instead of printing them.', + value: '[path]', + description: + 'Write the vars to a file (default .env.production.local, mode 0600) instead of printing them. An existing file prompts before overwriting — and is refused non-interactively — before anything is minted.', + }, + { + name: '--json', + description: + 'Emit machine-readable NDJSON (a { status: "minted" } object, or { status: "written" } with --write — deliberately secret-free since the secrets are in the file; failures are { status: "error" }). Implies no prompts.', }, ], }, diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts new file mode 100644 index 000000000..01bf8acde --- /dev/null +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -0,0 +1,588 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CliExit } from '../../../cli/exit.js' +import { messages } from '../../../messages.js' + +// env/index.ts imports the native `@cipherstash/auth` binary — replace it +// before load so the unit suite stays native-free (same pattern as +// `auth/__tests__/login.test.ts`). 0.41+ Result envelopes throughout. +const authMock = vi.hoisted(() => ({ + DeviceSessionStrategy: { fromProfile: vi.fn() }, +})) +vi.mock('@cipherstash/auth', () => ({ default: authMock })) + +const clack = vi.hoisted(() => { + const spinnerInstance = { start: vi.fn(), stop: vi.fn() } + return { + spinnerInstance, + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + spinner: vi.fn(() => spinnerInstance), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + text: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + } +}) +vi.mock('@clack/prompts', () => clack) + +// Interactivity is env-dependent; pin it per test. +const tty = vi.hoisted(() => ({ isInteractive: vi.fn(() => false) })) +vi.mock('../../../config/tty.js', () => tty) + +// Keep the runner deterministic (`npx stash`) regardless of the dev machine. +vi.mock('../../init/utils.js', () => ({ + detectPackageManager: () => 'npm', + runnerCommand: () => 'npx ', +})) + +const { envCommand } = await import('../index.js') + +/** Run envCommand and return the CliExit it threw (fails if it didn't). */ +async function expectExit( + promise: Promise, + code: number, +): Promise { + try { + await promise + } catch (err) { + expect(err).toBeInstanceOf(CliExit) + expect((err as CliExit).code).toBe(code) + return err as CliExit + } + throw new Error(`expected CliExit(${code}), but the command returned`) +} + +/** A happy-path device session: fromProfile → getToken → token data. */ +function stubSession(over: Record = {}) { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + data: { + getToken: vi.fn(async () => ({ + data: { + token: 'test-bearer-token', + subject: 'CS|user', + workspaceId: 'WS123', + issuer: 'https://cts.test/', + services: { zerokms: 'https://zkms.test/' }, + ...over, + }, + })), + }, + }) +} + +/** + * Route the three HTTP calls by URL. Returns the fetch spy. Bodies mirror the + * real cts-web / zerokms-protocol wire shapes. + */ +function stubFetch( + overrides: Partial< + Record<'workspaces' | 'client' | 'accessKey', Response> + > = {}, +) { + const routes: Array<{ match: string; response: () => Response }> = [ + { + match: '/api/workspaces', + response: () => + overrides.workspaces ?? + Response.json([ + { + id: 'OTHER', + name: 'other', + role: 'member', + region: 'us-east-1.aws', + org_id: 'o', + }, + { + id: 'WS123', + name: 'mine', + role: 'admin', + region: 'ap-southeast-2.aws', + org_id: 'o', + }, + ]), + }, + { + match: '/create-client', + response: () => + overrides.client ?? + // client_key: base64 of bytes 00 01 02 03 → hex "00010203" + Response.json({ + id: 'client-uuid-1', + dataset_id: 'ks-uuid', + name: 'my-app-prod', + description: 'd', + client_key: 'AAECAw==', + }), + }, + { + match: '/api/access-keys', + response: () => + overrides.accessKey ?? + Response.json( + { accessKey: 'CSAKTkeyid.keysecret', role: 'member' }, + { status: 201 }, + ), + }, + ] + const fetchSpy = vi.fn(async (url: string | URL) => { + const href = String(url) + const route = routes.find((r) => href.includes(r.match)) + if (!route) throw new Error(`unexpected fetch: ${href}`) + return route.response() + }) + vi.stubGlobal('fetch', fetchSpy) + return fetchSpy +} + +let logSpy: ReturnType + +beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + authMock.DeviceSessionStrategy.fromProfile.mockReset() + tty.isInteractive.mockReturnValue(false) +}) + +/** Every console.log line joined — the command's full stdout. */ +function stdout(): string { + return logSpy.mock.calls.map((c) => c.join(' ')).join('\n') +} + +/** The most recent p.log.error message (first arg; second is the stderr chrome opts). */ +function lastError(): string { + return String(clack.log.error.mock.calls.at(-1)?.[0]) +} + +describe('envCommand — pre-mint argv failures (all credential-free)', () => { + it('fails non-interactively without --name, before touching the profile', async () => { + await expectExit(envCommand({}), 1) + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining(messages.env.missingName), + expect.anything(), + ) + // The failure must be reachable with no profile and no network. + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + + it('emits the error envelope on the --json stream', async () => { + await expectExit(envCommand({ json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'missing_name' }) + }) + + it('rejects a valueless --name with its own error, not missing_name', async () => { + await expectExit(envCommand({ nameMissingValue: true, json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'name_requires_value', + }) + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + + it('rejects a name containing control characters before touching the profile', async () => { + await expectExit(envCommand({ name: 'bad\nCS_INJECTED=1', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'invalid_name' }) + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) + + it('rejects a stray positional with did-you-mean guidance', async () => { + await expectExit(envCommand({ unexpectedArg: 'my-app-prod' }), 1) + const message = lastError() + expect(message).toContain(messages.env.unexpectedArgument) + expect(message).toContain('--name my-app-prod') + expect(authMock.DeviceSessionStrategy.fromProfile).not.toHaveBeenCalled() + }) +}) + +describe('envCommand — happy path', () => { + it('mints credentials and prints the four env vars', async () => { + stubSession() + const fetchSpy = stubFetch() + + await envCommand({ name: 'my-app-prod' }) + + const block = stdout() + expect(block).toContain('CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:WS123') + expect(block).toContain('CS_CLIENT_ID=client-uuid-1') + // Hex-transcoded from base64 AAECAw== + expect(block).toContain('CS_CLIENT_KEY=00010203') + expect(block).toContain('CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret') + expect(block).toContain('Do not commit') + // The bearer token must never reach stdout. + expect(block).not.toContain('test-bearer-token') + // Stdout is pipe-clean: exactly one console.log — the block itself. + // (All chrome goes through the mocked clack, which routes to stderr.) + expect(logSpy).toHaveBeenCalledTimes(1) + + // Ordering contract: client BEFORE access key (partial failure leaves an + // inert client, never an unaccounted-for live credential). + const urls = fetchSpy.mock.calls.map((c) => String(c[0])) + expect(urls.findIndex((u) => u.includes('/create-client'))).toBeLessThan( + urls.findIndex((u) => u.includes('/api/access-keys')), + ) + + // Wire details: bearer auth everywhere; the member role is pinned in the + // request; trailing slashes normalised (no `//` in paths). + for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) { + expect((init.headers as Record).authorization).toBe( + 'Bearer test-bearer-token', + ) + expect(String(url)).not.toMatch(/[^:]\/\//) + } + const accessKeyCall = fetchSpy.mock.calls.find((c) => + String(c[0]).includes('/api/access-keys'), + ) as [string, RequestInit] + const body = JSON.parse(String(accessKeyCall[1].body)) + expect(body).toEqual({ + keyName: 'my-app-prod', + workspaceId: 'WS123', + role: 'member', + }) + }) + + it('routes all chrome to stderr', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'my-app-prod' }) + + const stderrOpts = { output: process.stderr } + expect(clack.intro).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining(stderrOpts), + ) + expect(clack.spinner).toHaveBeenCalledWith( + expect.objectContaining(stderrOpts), + ) + expect(clack.outro).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining(stderrOpts), + ) + }) + + it('emits a single minted object in --json mode', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'edge-dev', json: true }) + + expect(logSpy).toHaveBeenCalledTimes(1) + const event = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(event).toEqual({ + status: 'minted', + keyName: 'edge-dev', + workspaceCrn: 'crn:ap-southeast-2.aws:WS123', + clientId: 'client-uuid-1', + clientKey: '00010203', + accessKey: 'CSAKTkeyid.keysecret', + }) + }) +}) + +describe('envCommand — failure modes', () => { + it('reports a missing login with an auth hint', async () => { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + failure: { type: 'not_authenticated', error: new Error('no auth.json') }, + }) + stubFetch() + + await expectExit(envCommand({ name: 'x' }), 1) + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('Not logged in'), + expect.anything(), + ) + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('npx stash auth login'), + expect.anything(), + ) + }) + + it('maps a 403 on access-key creation to the admin-role message, naming the leftover client', async () => { + stubSession() + stubFetch({ accessKey: new Response('forbidden', { status: 403 }) }) + + await expectExit(envCommand({ name: 'my-app-prod' }), 1) + const message = lastError() + expect(message).toContain('admin') + expect(message).toContain("ZeroKMS client 'my-app-prod'") + }) + + it('suggests --name on a duplicate-name rejection', async () => { + stubSession() + stubFetch({ + accessKey: new Response('{"error":"Duplicate key error"}', { + status: 400, + }), + }) + + await expectExit(envCommand({ name: 'taken' }), 1) + const message = lastError() + expect(message).toContain('Duplicate key error') + expect(message).toContain('--name') + }) + + it('does NOT append the --name hint to a 500 (only 400/409 are duplicates)', async () => { + stubSession() + stubFetch({ + accessKey: new Response('internal error', { status: 500 }), + }) + + await expectExit(envCommand({ name: 'x' }), 1) + const message = lastError() + expect(message).not.toContain('already taken') + // The leftover-client note still appears — a client was minted. + expect(message).toContain("ZeroKMS client 'x'") + }) + + it('maps a request timeout to a clear request_timeout error', async () => { + stubSession() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new DOMException('The operation timed out.', 'TimeoutError') + }), + ) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'request_timeout' }) + expect(String(event.message)).toContain('cts.test') + }) + + it('maps a connection failure to a network_error naming the host', async () => { + stubSession() + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed', { + cause: new Error('getaddrinfo ENOTFOUND cts.test'), + }) + }), + ) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ status: 'error', code: 'network_error' }) + expect(String(event.message)).toContain('ENOTFOUND') + }) + + it('fails when the session workspace is missing from the workspace list', async () => { + stubSession({ workspaceId: 'GONE' }) + stubFetch() + + await expectExit(envCommand({ name: 'x' }), 1) + expect(lastError()).toContain('GONE') + }) +}) + +describe('envCommand — response validation (nothing minted may print undefined)', () => { + it('rejects a workspace entry with no region instead of emitting crn:undefined', async () => { + stubSession() + stubFetch({ + workspaces: Response.json([{ id: 'WS123', name: 'mine' }]), + }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + expect(String(event.message)).toContain('region') + }) + + it('rejects an access-key response with no accessKey field', async () => { + stubSession() + stubFetch({ accessKey: Response.json({ ok: true }, { status: 201 }) }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + }) + + it('refuses to transcode non-base64 client key material', async () => { + stubSession() + stubFetch({ + client: Response.json({ id: 'c1', client_key: 'not!!valid==' }), + }) + + await expectExit(envCommand({ name: 'x' }), 1) + expect(lastError()).toContain('unexpected encoding') + }) + + it('refuses to emit an access key whose returned role is not member', async () => { + stubSession() + stubFetch({ + accessKey: Response.json( + { accessKey: 'CSAKTid.secret', role: 'admin' }, + { status: 201 }, + ), + }) + + await expectExit(envCommand({ name: 'privileged' }), 1) + const message = lastError() + expect(message).toContain("'admin'") + expect(message).toContain("Revoke 'privileged'") + // The over-privileged secret itself must not be printed anywhere. + expect(stdout()).not.toContain('CSAKTid.secret') + }) + + it('treats an absent role as member (server default) and succeeds', async () => { + stubSession() + stubFetch({ + // No `role` field — the check must assume member, not skip. + accessKey: Response.json( + { accessKey: 'CSAKTid.secret' }, + { status: 201 }, + ), + }) + + await envCommand({ name: 'x' }) + expect(stdout()).toContain('CS_CLIENT_ACCESS_KEY=CSAKTid.secret') + }) + + it('refuses to emit a server value containing a control character', async () => { + stubSession() + stubFetch({ + // A newline in the region would break out of the CRN's dotenv line. + workspaces: Response.json([ + { id: 'WS123', region: 'us-east-1.aws\nCS_INJECTED=evil' }, + ]), + }) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + const event = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) + expect(event).toMatchObject({ + status: 'error', + code: 'unexpected_response', + }) + expect(String(event.message)).toContain('workspaceCrn') + expect(stdout()).not.toContain('CS_INJECTED') + }) +}) + +describe('envCommand — --write', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'stash-env-test-')) + vi.spyOn(process, 'cwd').mockReturnValue(dir) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes .env.production.local with mode 0600', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'my-app-prod', write: true }) + + const target = join(dir, '.env.production.local') + const content = readFileSync(target, 'utf-8') + expect(content).toContain('CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret') + expect(statSync(target).mode & 0o777).toBe(0o600) + // Nothing printed to stdout on the write path. + expect(stdout()).not.toContain('CS_CLIENT_ACCESS_KEY') + }) + + it('honours a custom path passed as the --write value', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'staging', write: '.env.staging.local' }) + + const target = join(dir, '.env.staging.local') + expect(readFileSync(target, 'utf-8')).toContain('CS_CLIENT_ACCESS_KEY=') + expect(statSync(target).mode & 0o777).toBe(0o600) + }) + + it('re-applies 0600 when overwriting an existing permissive file', async () => { + stubSession() + stubFetch() + const target = join(dir, '.env.production.local') + writeFileSync(target, 'OLD=1') + chmodSync(target, 0o644) + tty.isInteractive.mockReturnValue(true) + clack.confirm.mockResolvedValue(true) + + await envCommand({ name: 'x', write: true }) + + expect(readFileSync(target, 'utf-8')).toContain('CS_CLIENT_ACCESS_KEY=') + expect(statSync(target).mode & 0o777).toBe(0o600) + }) + + it('refuses an existing file non-interactively BEFORE minting anything', async () => { + stubSession() + const fetchSpy = stubFetch() + writeFileSync(join(dir, '.env.production.local'), 'existing') + + await expectExit(envCommand({ name: 'x', write: true }), 1) + expect(readFileSync(join(dir, '.env.production.local'), 'utf-8')).toBe( + 'existing', + ) + expect(lastError()).toContain('refusing to overwrite') + // The load-bearing bit: the refusal happened with ZERO server state + // created — no fetch, no client, no orphaned shown-once access key. + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('declining the interactive overwrite aborts BEFORE minting, exit 0', async () => { + stubSession() + const fetchSpy = stubFetch() + writeFileSync(join(dir, '.env.production.local'), 'existing') + tty.isInteractive.mockReturnValue(true) + clack.confirm.mockResolvedValue(false) + + await expectExit(envCommand({ name: 'x', write: true }), 0) + expect(fetchSpy).not.toHaveBeenCalled() + expect(clack.cancel).toHaveBeenCalledWith( + expect.stringContaining('nothing was minted'), + expect.anything(), + ) + }) + + it('--json --write writes the file and emits a secret-free confirmation', async () => { + stubSession() + stubFetch() + + await envCommand({ name: 'edge-dev', write: true, json: true }) + + const target = join(dir, '.env.production.local') + expect(readFileSync(target, 'utf-8')).toContain( + 'CS_CLIENT_ACCESS_KEY=CSAKTkeyid.keysecret', + ) + expect(logSpy).toHaveBeenCalledTimes(1) + const event = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(event).toEqual({ + status: 'written', + path: target, + keyName: 'edge-dev', + workspaceCrn: 'crn:ap-southeast-2.aws:WS123', + clientId: 'client-uuid-1', + }) + // The secrets live in the 0600 file only — never on the JSON stream. + const raw = logSpy.mock.calls[0][0] as string + expect(raw).not.toContain('CSAKTkeyid.keysecret') + expect(raw).not.toContain('00010203') + }) +}) diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index e7881318c..4087f9ae1 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -1,95 +1,564 @@ -import { existsSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { chmodSync, existsSync, writeFileSync } from 'node:fs' +import { basename, resolve } from 'node:path' +import auth from '@cipherstash/auth' import * as p from '@clack/prompts' +import { z } from 'zod' +import { CliExit } from '../../cli/exit.js' +import { isInteractive } from '../../config/tty.js' +import { messages } from '../../messages.js' +import { emitJsonError, emitJsonEvent } from '../auth/events.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' +const { DeviceSessionStrategy } = auth + +/** + * All human-facing chrome (intro, spinner, prompts, logs) is routed to + * STDERR. Stdout carries exactly one thing: the dotenv block (or, with + * `--json`, the NDJSON events) — so `stash env > prod.env` and pipes into + * dotenv consumers stay clean, and prompts remain visible on the terminal + * even when stdout is redirected. + */ +const CHROME = { output: process.stderr } as const + export interface EnvOptions { - /** Write the emitted block to `.env.production.local` instead of stdout. */ - write?: boolean + /** + * Write the emitted block to a file instead of stdout. `true` uses the + * default `.env.production.local`; a string names the target path. + */ + write?: boolean | string + /** Name for the minted access key + ZeroKMS client. Required non-interactively. */ + name?: string + /** True when `--name` was passed with no value (argv put it in the boolean flags). */ + nameMissingValue?: boolean + /** A stray positional argument (e.g. `stash env my-app`) — rejected with guidance. */ + unexpectedArg?: string + /** Emit NDJSON events instead of a dotenv block. Implies no prompts. */ + json?: boolean } /** - * Generate production env vars for CipherStash using the local device-code - * auth. Today this is a scaffold (CIP-2997): the CTS endpoint that mints a - * production access key isn't wired up yet, so the command is gated behind - * the `STASH_EXPERIMENTAL_ENV_CMD` env flag to keep it out of the way until - * the backend piece lands. + * Mint deployment credentials from the local device-code session (CIP-2997, + * stack#663) and print them as env vars. + * + * Uses the CTS + ZeroKMS APIs the dashboard itself uses, authenticated with + * the device-session token from `stash auth login`: + * + * 1. `GET {cts}/api/workspaces` → the workspace's region → `CS_WORKSPACE_CRN` + * 2. `POST {zerokms}/create-client` → `CS_CLIENT_ID` / `CS_CLIENT_KEY` + * 3. `POST {cts}/api/access-keys` → `CS_CLIENT_ACCESS_KEY` + * + * The access key is minted with the **member** role — pinned in the request + * AND asserted on the response; the CLI deliberately has no `--role` (admin + * keys belong in the dashboard, where they're visible). The key is returned + * exactly once by CTS; we print it and never persist it (except via an + * explicit `--write`). * - * Once CTS exposes the mint endpoint, fill in `fetchProdCredentials` below. - * The emitted format matches what apps in the onboarding flow consume via - * `process.env.*`, so `--write` produces a file the user can commit into a - * deployment secrets store. + * Ordering is deliberate, and everything that can refuse without server + * state does so BEFORE minting: argv problems, the key name, and the + * `--write` overwrite decision are all resolved first, so a refusal never + * discards a minted credential. Within the mint, the ZeroKMS client is + * created before the access key, so the only partial-failure leftover is an + * inert client record — never an unaccounted-for live credential. + * + * Exits via {@link CliExit} (never deep `process.exit`) so run() records the + * outcome for telemetry. */ export async function envCommand(options: EnvOptions = {}): Promise { - if (!process.env.STASH_EXPERIMENTAL_ENV_CMD) { - p.log.warn('`env` is experimental and not ready for production use yet.') - p.log.info( - 'Set STASH_EXPERIMENTAL_ENV_CMD=1 in your environment to try it.', - ) - return - } - + const json = options.json ?? false const runner = runnerCommand(detectPackageManager(), '').trim() const cliRef = `${runner} stash` - p.intro(`${cliRef} env`) + if (!json) p.intro(`${cliRef} env`, CHROME) + + try { + await runEnv(options, json, cliRef) + } catch (err) { + // CliExit(0) from a cancel path — already rendered; just unwind. + if (err instanceof CliExit) throw err + const failure = + err instanceof MintError + ? err + : new MintError( + 'mint_failed', + err instanceof Error ? err.message : String(err), + ) + if (json) { + emitJsonError(failure.code, failure.message) + } else { + p.log.error(failure.message, CHROME) + if (failure.hint) { + p.log.info(failure.hint.replaceAll('{cli}', cliRef), CHROME) + } + } + throw new CliExit(1) + } +} - const creds = await fetchProdCredentials() - if (!creds) { - p.log.error( - `Could not mint production credentials. Make sure you are logged in: ${cliRef} auth login`, +async function runEnv( + options: EnvOptions, + json: boolean, + cliRef: string, +): Promise { + // Everything refusable without server state fails BEFORE minting: argv + // shape, the key name, and the --write overwrite decision. This ordering + // is load-bearing — the access key is shown exactly once, so no local + // refusal may run after it exists (and it keeps the failure paths + // credential-free, which is what the e2e suite exercises). + if (options.unexpectedArg) { + throw new MintError( + 'unexpected_argument', + `${messages.env.unexpectedArgument} '${options.unexpectedArg}' — pass the credential name with --name (e.g. \`${cliRef} env --name ${options.unexpectedArg}\`).`, + ) + } + if (options.nameMissingValue) { + throw new MintError( + 'name_requires_value', + `${messages.env.nameRequiresValue} — e.g. \`${cliRef} env --name my-app-prod\`.`, ) - process.exit(1) } + const keyName = await resolveKeyName(options, json, cliRef) + // The name lands in the emitted dotenv comment line, so a control + // character (esp. \n) could break out of the comment and inject a line + // into a credentials file — reject before it reaches anything. + if (CONTROL_CHARS.test(keyName)) { + throw new MintError( + 'invalid_name', + 'The credential name must not contain newlines or control characters.', + ) + } + const writeTarget = await resolveWriteTarget(options, json) + + const s = json ? null : p.spinner(CHROME) + s?.start('Minting deployment credentials...') + let creds: MintedCredentials + try { + creds = await mintCredentials(keyName) + } catch (err) { + s?.stop('Could not mint deployment credentials.') + throw err + } + s?.stop('Deployment credentials minted.') + const block = formatEnvBlock(creds, cliRef) - if (options.write) { - const target = resolve(process.cwd(), '.env.production.local') - if (existsSync(target)) { - const overwrite = await p.confirm({ - message: `${target} already exists. Overwrite?`, - initialValue: false, + if (writeTarget) { + writeEnvFile(writeTarget, block) + if (json) { + // Deliberately secret-free: the secrets are in the 0600 file, so the + // machine-readable confirmation never lands them in a captured log. + emitJsonEvent({ + status: 'written', + path: writeTarget, + keyName: creds.keyName, + workspaceCrn: creds.workspaceCrn, + clientId: creds.clientId, }) - if (p.isCancel(overwrite) || !overwrite) { - p.cancel('Aborted.') - return - } + return } + p.log.success(`Wrote ${writeTarget}`, CHROME) + p.log.warn( + 'This file contains live secrets — keep it out of version control.', + CHROME, + ) + p.outro('Done!', CHROME) + return + } - writeFileSync(target, block, 'utf-8') - p.log.success(`Wrote ${target}`) - p.outro('Done!') + if (json) { + emitJsonEvent({ status: 'minted', ...creds }) return } - // Default: print to stdout so users can pipe into secret stores / CI env. - // Use `console.log` (not `p.*`) so the output is clean for redirection. + // Stdout carries the block and nothing else (chrome is on stderr), so + // users can redirect or pipe it straight into a secret store. console.log(block) - p.outro('Done!') + p.outro('Done!', CHROME) } -interface ProdCredentials { +// --------------------------------------------------------------------------- +// Pre-mint resolution: key name and --write target +// --------------------------------------------------------------------------- + +async function resolveKeyName( + options: EnvOptions, + json: boolean, + cliRef: string, +): Promise { + const explicit = options.name?.trim() + if (explicit) return explicit + + if (json || !isInteractive()) { + throw new MintError( + 'missing_name', + `${messages.env.missingName} — pass --name (e.g. \`${cliRef} env --name my-app-prod\`).`, + ) + } + + const answer = await p.text({ + message: 'Name for this deployment credential', + initialValue: suggestKeyName(), + validate: (value) => { + if (value.trim().length === 0) return 'A name is required.' + if (CONTROL_CHARS.test(value)) return 'No control characters.' + return undefined + }, + ...CHROME, + }) + if (p.isCancel(answer)) { + p.cancel('Cancelled.', CHROME) + throw new CliExit(0) + } + return answer.trim() +} + +/** + * Resolve and PREFLIGHT the `--write` target before anything is minted: a + * refused or declined overwrite must never discard a shown-exactly-once + * credential. + */ +async function resolveWriteTarget( + options: EnvOptions, + json: boolean, +): Promise { + if (!options.write) return null + const target = resolve( + process.cwd(), + typeof options.write === 'string' ? options.write : '.env.production.local', + ) + if (!existsSync(target)) return target + + if (json || !isInteractive()) { + throw new MintError( + 'write_conflict', + `${target} already exists — refusing to overwrite non-interactively. Remove it first, or pass a different path to --write.`, + ) + } + const overwrite = await p.confirm({ + message: `${target} already exists. Overwrite?`, + initialValue: false, + ...CHROME, + }) + if (p.isCancel(overwrite) || !overwrite) { + p.cancel('Aborted — nothing was minted.', CHROME) + throw new CliExit(0) + } + return target +} + +/** Default key name: the project directory, sanitised, e.g. `my-app-prod`. */ +function suggestKeyName(): string { + const dir = basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + return `${dir || 'app'}-prod` +} + +/** + * Write the dotenv block with owner-only permissions. `writeFileSync`'s + * `mode` only applies when the file is CREATED, so an overwrite of an + * existing (possibly 0644) file must be followed by an explicit chmod for + * the documented 0600 guarantee to hold. + */ +function writeEnvFile(target: string, block: string): void { + writeFileSync(target, block, { encoding: 'utf-8', mode: 0o600 }) + chmodSync(target, 0o600) +} + +// --------------------------------------------------------------------------- +// Credential minting +// --------------------------------------------------------------------------- + +interface MintedCredentials { + keyName: string + workspaceCrn: string clientId: string + /** Hex-encoded — the historical `CS_CLIENT_KEY` format every SDK accepts. */ clientKey: string - workspaceId: string + accessKey: string } -async function fetchProdCredentials(): Promise { - // TODO(CIP-2997): call the CTS mint endpoint once it's available. The - // endpoint shape, auth header, and error codes are TBD — coordinate with - // the platform team before wiring this up. Until then, return undefined - // so the experimental command fails loudly rather than silently emitting - // placeholder credentials. - return undefined +/** + * Error with a machine-readable code (surfaced on the `--json` stream) and an + * optional human hint (printed as a follow-up log line; `{cli}` is replaced + * with the detected runner, e.g. `npx stash`). + */ +class MintError extends Error { + constructor( + readonly code: string, + message: string, + readonly hint?: string, + ) { + super(message) + this.name = 'MintError' + } } -function formatEnvBlock(creds: ProdCredentials, cliRef: string): string { +const LOGIN_HINT = 'Run `{cli} auth login` and try again.' + +// The API responses cross an ownership boundary — CTS/ZeroKMS version these +// shapes, not this repo — so each one is validated before any field reaches +// the emitted env block. An `as`-cast here would print `undefined` into a +// credentials file after a live, shown-exactly-once key was already minted. +const workspaceListSchema = z.array(z.object({ id: z.string() }).passthrough()) +const workspaceRegionSchema = z.object({ region: z.string().min(1) }) +const createClientSchema = z + .object({ id: z.string().min(1), client_key: z.string().min(1) }) + .passthrough() +const accessKeySchema = z + .object({ accessKey: z.string().min(1), role: z.string().optional() }) + .passthrough() + +/** Standard padded base64 — rejected BEFORE Node's lenient decoder can turn + * a hex/enveloped/garbled key into plausible-looking wrong bytes. */ +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ + +/** C0 controls + DEL — anything that could corrupt the emitted dotenv block. */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control chars is the point +const CONTROL_CHARS = /[\x00-\x1f\x7f]/ + +function parsed(schema: z.ZodType, data: unknown, what: string): T { + const result = schema.safeParse(data) + if (!result.success) { + const issue = result.error.issues[0] + throw new MintError( + 'unexpected_response', + `Unexpected ${what} response shape (${issue ? `${issue.path.join('.') || ''}: ${issue.message}` : 'invalid'}) — the service API may have changed; check for a newer CLI release.`, + ) + } + return result.data +} + +async function mintCredentials(keyName: string): Promise { + // 1. Device session from ~/.cipherstash (written by `stash auth login`). + const strategyResult = DeviceSessionStrategy.fromProfile() + if (strategyResult.failure) { + throw new MintError( + 'not_logged_in', + `Not logged in: ${strategyResult.failure.error.message}`, + LOGIN_HINT, + ) + } + const tokenResult = await strategyResult.data.getToken() + if (tokenResult.failure) { + throw new MintError( + 'session_invalid', + `Could not refresh your session: ${tokenResult.failure.error.message}`, + LOGIN_HINT, + ) + } + const { token, workspaceId, issuer, services } = tokenResult.data + + const ctsBase = trimTrailingSlash(issuer) + const zerokmsUrl = services?.zerokms + if (!zerokmsUrl) { + throw new MintError( + 'no_zerokms_service', + 'Your session token carries no ZeroKMS service URL — re-authenticate and try again.', + LOGIN_HINT, + ) + } + const zerokmsBase = trimTrailingSlash(zerokmsUrl) + + // 2. Workspace region → CRN. The region in the workspace listing is + // server-authoritative (CTS derives it from the workspace's host), so + // this works for self-hosted CTS too — no issuer-hostname parsing. + // The list is validated loosely (only `id`), then OUR workspace + // strictly — an unrelated malformed entry must not block the mint. + const wsResponse = await apiFetch(`${ctsBase}/api/workspaces`, token) + if (!wsResponse.ok) { + throw await httpError( + 'list_workspaces_failed', + 'list workspaces', + wsResponse, + ) + } + const workspaces = parsed( + workspaceListSchema, + await wsResponse.json(), + 'workspace list', + ) + const workspace = workspaces.find((w) => w.id === workspaceId) + if (!workspace) { + throw new MintError( + 'workspace_not_found', + `Workspace ${workspaceId} is not in your workspace list — your session may be stale.`, + LOGIN_HINT, + ) + } + const { region } = parsed(workspaceRegionSchema, workspace, 'workspace') + const workspaceCrn = `crn:${region}:${workspaceId}` + + // 3. ZeroKMS client — CS_CLIENT_ID / CS_CLIENT_KEY. Created BEFORE the + // access key (see the ordering note in the command doc). + const clientResponse = await apiFetch(`${zerokmsBase}/create-client`, token, { + method: 'POST', + body: JSON.stringify({ + name: keyName, + description: `Created by \`stash env\` for deployment`, + }), + }) + if (!clientResponse.ok) { + throw await httpError( + 'create_client_failed', + 'create the ZeroKMS client', + clientResponse, + ) + } + const client = parsed( + createClientSchema, + await clientResponse.json(), + 'create-client', + ) + // ZeroKMS returns the key material base64-encoded; emit hex, the historical + // CS_CLIENT_KEY format that every released SDK accepts (base64 tolerance + // only landed in cipherstash-client 0.40). Node's base64 decoder is + // lenient, so the format is checked first — silently transcoding a + // non-base64 value would emit a plausible-looking but corrupt key. + if (!BASE64.test(client.client_key) || client.client_key.length % 4 !== 0) { + throw new MintError( + 'unexpected_response', + 'ZeroKMS returned client key material in an unexpected encoding — refusing to emit a possibly-corrupt CS_CLIENT_KEY. Check for a newer CLI release.', + ) + } + const clientKey = Buffer.from(client.client_key, 'base64').toString('hex') + + // 4. Access key — CS_CLIENT_ACCESS_KEY. The member role is pinned in the + // request (CTS also defaults to member) AND asserted on the response: + // the docs promise this command cannot mint anything stronger, so a + // server that returns a different role fails the command rather than + // silently handing out an over-privileged credential. + const keyResponse = await apiFetch(`${ctsBase}/api/access-keys`, token, { + method: 'POST', + body: JSON.stringify({ keyName, workspaceId, role: 'member' }), + }) + if (!keyResponse.ok) { + const leftover = ` (Note: the ZeroKMS client '${keyName}' was already created — it is inert without an access key, but you may want to remove it in the dashboard.)` + if (keyResponse.status === 403) { + throw new MintError( + 'not_admin', + `Creating access keys requires the admin role in this workspace — ask a workspace admin to mint the key in the dashboard, or to grant you admin.${leftover}`, + ) + } + const base = await httpError( + 'create_access_key_failed', + 'create the access key', + keyResponse, + ) + // A duplicate name comes back as 400 (BadRequest) or 409 (Conflict); only + // then is "pick a different name" the right suggestion — a 500 shouldn't + // carry it. + const dupHint = + keyResponse.status === 400 || keyResponse.status === 409 + ? ' If the name is already taken, rerun with a different --name.' + : '' + throw new MintError(base.code, `${base.message}${dupHint}${leftover}`) + } + const accessKeyBody = parsed( + accessKeySchema, + await keyResponse.json(), + 'access key', + ) + // The request pinned `member` and the server owns the default, so an ABSENT + // role means "server default (member)" — treat it as member rather than + // skipping the check, so the documented "verified on the response" guarantee + // doesn't quietly depend on the field always being present. + const returnedRole = (accessKeyBody.role ?? 'member').toLowerCase() + if (returnedRole !== 'member') { + throw new MintError( + 'unexpected_role', + `CTS returned a '${accessKeyBody.role}' access key where member was requested — refusing to emit it. Revoke '${keyName}' in the dashboard.`, + ) + } + + const creds: MintedCredentials = { + keyName, + workspaceCrn, + clientId: client.id, + clientKey, + accessKey: accessKeyBody.accessKey, + } + // Defense-in-depth: the name is already control-char-guarded, but the + // server-provided values (CRN region, client id, access key) land on their + // own dotenv lines too. A control char — especially a newline — could break + // out of its line and inject another. Very low risk (trusted server), fully + // closed here. clientKey is hex by construction, so it can't carry one. + for (const [field, value] of Object.entries(creds)) { + if (CONTROL_CHARS.test(value)) { + throw new MintError( + 'unexpected_response', + `The service returned a ${field} containing a control character — refusing to emit it. Check for a newer CLI release.`, + ) + } + } + return creds +} + +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, '') +} + +/** Per-request deadline — a stalled CTS/ZeroKMS endpoint must not hang the CLI. */ +const REQUEST_TIMEOUT_MS = 30_000 + +async function apiFetch( + url: string, + token: string, + init?: { method?: string; body?: string }, +): Promise { + try { + return await fetch(url, { + method: init?.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': 'stash-cli', + }, + body: init?.body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch (err) { + const host = new URL(url).host + if (err instanceof DOMException && err.name === 'TimeoutError') { + throw new MintError( + 'request_timeout', + `Request to ${host} timed out after ${REQUEST_TIMEOUT_MS / 1000}s — check your network and try again.`, + ) + } + throw new MintError( + 'network_error', + `Could not reach ${host}: ${err instanceof Error ? (err.cause instanceof Error ? err.cause.message : err.message) : String(err)}`, + ) + } +} + +/** Build a MintError from a non-2xx response, including the body (which CTS + * keeps free of secrets) but never the request's bearer token. */ +async function httpError( + code: string, + action: string, + response: Response, +): Promise { + const body = (await response.text().catch(() => '')).slice(0, 500) + return new MintError( + code, + `Could not ${action}: HTTP ${response.status}${body ? ` — ${body}` : ''}`, + ) +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function formatEnvBlock(creds: MintedCredentials, cliRef: string): string { return [ - `# Generated by \`${cliRef} env\` — production credentials`, + `# Generated by \`${cliRef} env\` — CipherStash deployment credentials ('${creds.keyName}')`, + '# CS_CLIENT_KEY and CS_CLIENT_ACCESS_KEY are secrets. Do not commit them.', + `CS_WORKSPACE_CRN=${creds.workspaceCrn}`, `CS_CLIENT_ID=${creds.clientId}`, `CS_CLIENT_KEY=${creds.clientKey}`, - `CS_WORKSPACE_ID=${creds.workspaceId}`, + `CS_CLIENT_ACCESS_KEY=${creds.accessKey}`, '', ].join('\n') } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 9e671825e..b97c40904 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -109,6 +109,16 @@ export const messages = { ) => `\`${pkg}\` is not installed in this project.\n\nInstall the CipherStash packages, then re-run:\n ${installCommands}\n\nOr run \`${stash} init\` to set everything up.`, }, + env: { + /** + * Stable leaders of the pre-mint argv errors. The e2e suite asserts on + * these; the runner-aware hints are appended at the call sites. All + * three fire BEFORE any profile or network access. + */ + missingName: 'A credential name is required in non-interactive mode', + nameRequiresValue: '--name requires a value', + unexpectedArgument: 'Unexpected argument', + }, telemetry: { /** * The one-time first-run notice. Printed to stderr so it never pollutes diff --git a/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts new file mode 100644 index 000000000..733f94d22 --- /dev/null +++ b/packages/cli/tests/e2e/env-non-interactive.e2e.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * Non-interactive `stash env`. Only the pre-mint argv failures are exercised: + * the command resolves the credential name (and any argv problems) BEFORE + * loading the device profile or touching the network, so these cases are + * deterministic, credential-free, and — critically — can never mint real + * keys on a developer machine that happens to have a live `~/.cipherstash` + * session. + * + * The happy path (real CTS + ZeroKMS calls) is covered by unit tests with a + * stubbed fetch; minting live credentials from CI is deliberately not done. + */ + +/** Find the first stdout line that parses as a JSON object, or undefined. */ +function firstJsonLine(stdout: string): Record | undefined { + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) continue + try { + return JSON.parse(trimmed) as Record + } catch { + // not JSON — keep scanning + } + } + return undefined +} + +describe('stash env — non-interactive argv resolution', () => { + it('exits 1 (no hang, no mint) in a non-TTY context with no --name', async () => { + const r = await runPiped(['env'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stderr).toContain(messages.env.missingName) + // The actionable fix is named. + expect(r.stderr).toContain('--name') + // Stdout is reserved for the dotenv block / JSON events — human error + // chrome must land on stderr so `stash env > file` can't capture it. + expect(r.stdout).not.toContain(messages.env.missingName) + }) + + it('--json with no --name emits a JSON missing_name error and exits 1', async () => { + const r = await runPiped(['env', '--json'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ status: 'error', code: 'missing_name' }) + }) + + it('a valueless --name gets its own diagnostic, not missing_name', async () => { + const r = await runPiped(['env', '--name', '--json'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ + status: 'error', + code: 'name_requires_value', + }) + }) + + it('a stray positional is rejected with did-you-mean guidance', async () => { + const r = await runPiped(['env', 'my-app-prod'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stderr).toContain(messages.env.unexpectedArgument) + expect(r.stderr).toContain('--name my-app-prod') + }) +}) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index de04c8d37..595c3db2c 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -82,6 +82,22 @@ npx stash auth login --json --region us-east-1 | `{ status: "device_bound" }` | Device bound to the default keyset. Done. | | `{ status: "error", code, message }` | Failure. Exit code 1. | +Operationally: after printing `authorization_required` the command **blocks, +polling, until the human approves or the code expires** (`expiresIn` is +~900 s). So run it as a background/async task with a generous timeout — +a short-timeout synchronous run kills the poll and the login never lands. +The working loop is: + +1. Start `npx stash auth login --json --region ` in the background. +2. Read the first stdout line; relay `verificationUriComplete` to the human + (include `userCode` so they can cross-check what they're approving, and + mention the ~15-minute expiry). +3. Leave the process running. Success is **exit 0 with `device_bound` as the + final event** — the session and development key are then in the profile + and every later command authenticates silently. +4. To confirm, trust the event stream or run any authenticated command — + never inspect `~/.cipherstash` (see "Never read these"). + **Authenticate before `stash init`.** Init's authenticate step uses the interactive path, so an agent running `init` unauthenticated makes the CLI try to open a browser on the agent's machine — and in a non-TTY it exits with `region_required` unless `--region` or `STASH_REGION` is set. Once a valid token exists, init logs `Using workspace X (region)` and moves on silently. Flags: `--region ` (env `STASH_REGION`), `--json`, `--no-open`, `--supabase` / `--drizzle` (referrer tracking only). @@ -484,15 +500,59 @@ Version-aware. For **EQL v2** columns in the `cut-over` phase it emits `ALTER TA Flags: `--table`, `--column`, `--migrations-dir `. -### Experimental +### Deployment #### `env` ```bash -stash env +stash env --name my-app-prod # print the four CS_* vars to stdout +stash env --name my-app-prod --write # write .env.production.local (mode 0600) +stash env --name staging --write .env.staging.local # custom target path +stash env --name edge-dev --json # NDJSON events, no prompts +``` + +Mints deployment credentials from the local device-code session (`stash auth +login`) — no dashboard copy-paste. It creates a fresh ZeroKMS client and a +CipherStash access key (both named `--name`), then emits the four env vars a +deployed app needs: + +```dotenv +CS_WORKSPACE_CRN=crn:: +CS_CLIENT_ID= +CS_CLIENT_KEY= +CS_CLIENT_ACCESS_KEY=CSAK… ``` -**A stub — it does not work yet.** Gated behind `STASH_EXPERIMENTAL_ENV_CMD`, and even then the credential mint endpoint is not wired up, so it emits nothing. Intended to print the `CS_*` variables needed to deploy. Don't build on it. +Things to know: + +- **The access key is shown exactly once** — CTS cannot re-reveal it. Pipe the + output straight into your secret store (`supabase secrets set --env-file`, + `vercel env add`, `wrangler secret put`, …). `CS_CLIENT_KEY` and + `CS_CLIENT_ACCESS_KEY` are secrets; never commit them. +- **Stdout is pipe-clean.** Only the dotenv block (or the `--json` events) + goes to stdout; progress UI and prompts go to stderr. `stash env --name x + > prod.env` and pipes into dotenv consumers are safe. +- **The key is member-role, always** — pinned in the request and verified on + the response. The CLI deliberately cannot mint admin keys — use the + dashboard for those. *Creating* a key does, however, require your own user + to have the admin role in the workspace (403 otherwise). +- **Non-interactive runs require `--name`** — without it the command exits 1 + with an actionable message before touching the network, and `--write` + refuses to overwrite an existing file (also before anything is minted). + In `--json` mode failures arrive as `{ status: "error", code, message }` + on stdout. +- **`--json` + `--write` compose**: the file is written and the JSON + confirmation (`{ status: "written", path, … }`) is deliberately + secret-free, so captured CI logs never contain the key. +- Each run mints a **new** credential; a duplicate name is rejected by the + server — rerun with a different `--name`. +- This is also the local-dev path for runtimes that can't reach + `~/.cipherstash` (Supabase Edge Functions run in a container; Workers have + no filesystem): mint a key, feed it via `supabase functions serve + --env-file` or the platform's secret store, and use + `@cipherstash/stack/wasm-inline` with explicit config. + +Flags: `--name `, `--write [path]`, `--json`. ## Programmatic API diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index e6ea83813..5cfe91462 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -111,6 +111,12 @@ already-initialized project, `npx stash auth login` alone authenticates the machine; the SDK and CLI pick up the saved profile automatically. Sign up at [cipherstash.com/signup](https://cipherstash.com/signup) first. +Agents can drive the login too: `npx stash auth login --json --region ` +emits the verification URL as data — run it in the background, relay the URL +to the human, and the flow completes when they approve in a browser. The +event contract and the operational loop are in the `stash-cli` skill's +Authentication section. + ### CI and Production (environment variables) Deployed environments and CI use machine credentials via environment variables @@ -123,6 +129,16 @@ CS_CLIENT_KEY=your-client-key CS_CLIENT_ACCESS_KEY=your-access-key ``` +Mint all four from your device session with `npx stash env --name ` — +no dashboard copy-paste. It creates a fresh ZeroKMS client plus a +**member-role** access key (shown exactly once; the CLI cannot mint admin +keys) and prints the block above, ready to pipe into your platform's secret +store. `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY` are secrets — never commit +them. Also the path for runtimes that can't read `~/.cipherstash` — e.g. +`@cipherstash/stack/wasm-inline` on Supabase Edge Functions (containerised +even in local dev) or Cloudflare Workers. See the `stash-cli` skill for flags +(`--write`, `--json`). + When both are present, the `CS_*` variables take precedence over the saved profile. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 24292d260..7eb38bd5f 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -47,7 +47,13 @@ The Supabase integration ships as its own first-party package, agent-assisted flow — auth, schema, and database end to end) or `npx stash auth login` (device code flow; no environment variables needed). CI and production use the `CS_*` machine-credential environment variables — -see the `stash-encryption` skill's Configuration section. +see the `stash-encryption` skill's Configuration section. Mint them from your +device session with `npx stash env --name ` (no dashboard copy-paste); +this is also how **Supabase Edge Functions** get credentials in local dev — +`supabase functions serve` runs in a container that cannot see +`~/.cipherstash`, so write the vars to a file with +`stash env --name edge-dev --write` and pass `--env-file`, or +`supabase secrets set` them for deploys. ### 1. Install EQL v3 on the database