From bf5d9b5925cb1b5cd0c2921de80b7023238d7f8c Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 19 Apr 2026 17:24:19 +0300 Subject: [PATCH 1/4] feat(auth): add peekIdentity() for bootstrap-code was-diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peekIdentity() performs a synchronous, network-free read of the persisted access token + workspace config to compute the "was:" diff surfaced by the upcoming --code bootstrap-login flow. Returns null on any failure (missing creds, no active workspace, malformed JWT, missing email claim). Reads config.json directly to avoid a circular import: store.ts → commands/workspace.ts → api/client.ts → store.ts Bundles 5 GREEN unit tests + 9 test.todo stubs that the next commit (--code branch) will fill in. Phase: hookmyapp monorepo Phase 122 (wave 2A) --- src/auth/__tests__/bootstrap.test.ts | 146 +++++++++++++++++++++++++++ src/auth/store.ts | 50 +++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/auth/__tests__/bootstrap.test.ts diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts new file mode 100644 index 0000000..7c452a1 --- /dev/null +++ b/src/auth/__tests__/bootstrap.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, beforeEach, afterEach } from 'vitest'; +import { + mkdirSync, + writeFileSync, + rmSync, + existsSync, +} from 'node:fs'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Forks a fresh HOOKMYAPP_CONFIG_DIR per test so vitest.setup.ts's shared +// tmp dir doesn't leak state between tests in this file. +let CONFIG_DIR: string; +const SAVED_CONFIG_DIR = process.env.HOOKMYAPP_CONFIG_DIR; + +function base64UrlJson(payload: Record): string { + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +function buildJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64'); + const body = base64UrlJson(payload); + return `${header}.${body}.signature-ignored`; +} + +function writeCreds(creds: { accessToken: string; refreshToken: string; expiresAt: number }): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(join(CONFIG_DIR, 'credentials.json'), JSON.stringify(creds)); +} + +function writeWorkspaceCfg(cfg: { + activeWorkspaceId?: string; + activeWorkspaceSlug?: string; +}): void { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(join(CONFIG_DIR, 'config.json'), JSON.stringify(cfg)); +} + +beforeEach(() => { + CONFIG_DIR = mkdtempSync(join(tmpdir(), 'hookmyapp-bootstrap-test-')); + process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; +}); + +afterEach(() => { + if (existsSync(CONFIG_DIR)) { + rmSync(CONFIG_DIR, { recursive: true, force: true }); + } + if (SAVED_CONFIG_DIR !== undefined) { + process.env.HOOKMYAPP_CONFIG_DIR = SAVED_CONFIG_DIR; + } else { + delete process.env.HOOKMYAPP_CONFIG_DIR; + } +}); + +describe('peekIdentity()', () => { + test('returns null when no credentials file exists', async () => { + const { peekIdentity } = await import('../store.js'); + expect(peekIdentity()).toBeNull(); + }); + + test('returns null when credentials exist but no active workspace', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + // No config.json written — peekIdentity must return null rather than + // falling back to a stale default. + expect(peekIdentity()).toBeNull(); + }); + + test('returns { email, workspaceSlug } when JWT carries email claim and workspace is active', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: "Or's Workspace", + }); + expect(peekIdentity()).toEqual({ + email: 'info@ordvir.com', + workspaceSlug: "Or's Workspace", + }); + }); + + test('returns null when JWT is malformed (no body segment)', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: 'not-a-jwt', + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Anything', + }); + expect(peekIdentity()).toBeNull(); + }); + + test('returns null when JWT lacks an email claim', async () => { + const { peekIdentity } = await import('../store.js'); + writeCreds({ + accessToken: buildJwt({ sub: 'usr_1', exp: 9999999999 }), + refreshToken: 'r', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Anything', + }); + expect(peekIdentity()).toBeNull(); + }); +}); + +describe('hookmyapp login --code', () => { + test.todo( + '--code happy path: fetches /auth/bootstrap/exchange, saveCredentials, writeWorkspaceConfig, prints identity echo, calls runWizard', + ); + test.todo( + '--code + --wizard → ValidationError exit 2 (mutually exclusive)', + ); + test.todo( + '--code with prior identity present AND different → prints "was:" diff line before identity echo', + ); + test.todo( + '--code with prior identity present AND same → does NOT print "was:" diff line', + ); + test.todo( + '--code with 404 response → ApiError exitCode 5 with message matching /invalid or already used/i', + ); + test.todo( + '--code with 410 response → ApiError exitCode 5 with message matching /expired or already used/i', + ); + test.todo('--code with 403 response → PermissionError exitCode 3'); + test.todo( + '--code with 429 response → ConflictError exitCode 6, code "RATE_LIMITED"', + ); + test.todo( + 'identity echo line format: ✓ Logged in as — workspace "" (exact em-dash, exact quote chars)', + ); +}); diff --git a/src/auth/store.ts b/src/auth/store.ts index dc700ef..12b18bb 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -12,6 +12,9 @@ function configDir(): string { function credsFile(): string { return join(configDir(), 'credentials.json'); } +function workspaceConfigFile(): string { + return join(configDir(), 'config.json'); +} export interface Credentials { accessToken: string; @@ -40,3 +43,50 @@ export function deleteCredentials(): void { // Ignore if file doesn't exist } } + +export interface PriorIdentity { + email: string; + workspaceSlug: string; +} + +/** + * Phase 122: read-only peek at persisted identity. Returns null if no + * credentials OR no active workspace OR JWT has no email claim. Makes NO + * network calls — purely file reads + base64 decode. Used by the + * bootstrap-code flow to compute the "was:" diff BEFORE overwriting + * credentials. + * + * Reads `config.json` directly (not via `commands/workspace.readWorkspaceConfig`) + * to avoid a circular import: store.ts → workspace.ts → api/client.ts → store.ts. + * Both modules share the same on-disk format; this duplicates ~5 lines of + * JSON parse but keeps the dep graph acyclic. + */ +export function peekIdentity(): PriorIdentity | null { + const creds = readCredentials(); + if (!creds) return null; + try { + const payloadB64 = creds.accessToken.split('.')[1]; + if (!payloadB64) return null; + const payload = JSON.parse( + Buffer.from(payloadB64, 'base64').toString(), + ) as Record; + const email = typeof payload?.email === 'string' ? payload.email : null; + if (!email) return null; + // Read workspace slug directly from config.json — same shape as + // commands/workspace.readWorkspaceConfig, but without the cycle risk. + let activeWorkspaceSlug: string | undefined; + try { + const cfg = JSON.parse( + readFileSync(workspaceConfigFile(), 'utf-8'), + ) as { activeWorkspaceSlug?: string }; + activeWorkspaceSlug = + typeof cfg?.activeWorkspaceSlug === 'string' ? cfg.activeWorkspaceSlug : undefined; + } catch { + return null; + } + if (!activeWorkspaceSlug) return null; + return { email, workspaceSlug: activeWorkspaceSlug }; + } catch { + return null; + } +} From 60912069365f1d2ae6ecb374ec787167c45591ed Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 19 Apr 2026 17:30:13 +0300 Subject: [PATCH 2/4] feat(auth): --code bootstrap-code login branch + error mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hookmyapp login --code for AI-paste onboarding: dashboard mints a short-lived single-use code, CLI exchanges it for credentials without a browser step. --code and --wizard are mutually exclusive (throws ValidationError exit 2 before any network call). Flow (runBootstrapCodeExchange): 1. peekIdentity() BEFORE overwrite — snapshot for "was:" diff 2. POST /auth/bootstrap/exchange (unauthenticated @Public route) 3. saveCredentials + writeWorkspaceConfig — same shape as device flow 4. Print "Replaced previous session (was: ...)" if prior differs 5. Print "Logged in as — workspace \"\"" (stable contract) 6. runWizard — idempotent on preselected workspace Extends mapApiError for 404/410/429: - 404 BOOTSTRAP_NOT_FOUND → ApiError exitCode=5, "Code invalid or already used" - 410 → ApiError exitCode=5, "Code expired or already used" (same user-facing msg as 404 — oracle-attack defense) - 429 → reuses ConflictError + code "RATE_LIMITED" (exit 6) NO new RateLimitError class — honors Phase 108's 7-code exit contract Exports isNetworkFailure from api/client.ts so runBootstrapCodeExchange can reuse the ECONNREFUSED/ENOTFOUND/ETIMEDOUT detector already audited by Phase 108. Tests: 14 (5 peekIdentity + 9 --code branch) — all GREEN. Full CLI suite 325/325 GREEN. Typecheck GREEN. Build GREEN. Smoke tests pass: - 404 via local mock → exit 5 "Code invalid or already used" - mutex --code --wizard → exit 2 "mutually exclusive" - unreachable API → exit 5 NetworkError Phase: hookmyapp monorepo Phase 122 (wave 2A) Backend DTO: backend/src/auth/bootstrap/dto/exchange-bootstrap.dto.ts --- src/api/client.ts | 37 ++- src/auth/__tests__/bootstrap.test.ts | 434 +++++++++++++++++++++++++-- src/auth/login.ts | 128 +++++++- 3 files changed, 559 insertions(+), 40 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 6611105..292d719 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -90,13 +90,48 @@ export async function mapApiError(res: Response): Promise { return new PermissionError(cfg.activeWorkspaceSlug ?? ''); } if (res.status === 409) return new ConflictError(msg, code ?? 'CONFLICT'); + // Phase 122 — bootstrap-code exchange error mapping. + // 404 and 410 collapse to the same user-facing message (oracle-attack + // defense — brute-force guessers can't distinguish "unknown code" from + // "already spent code"). ApiError.exitCode defaults to 1; we override + // to 5 on the instance so the CLI's exit-code contract stays honest + // ("API rejected the bootstrap code" is a distinct failure class). + if (res.status === 404 && code === 'BOOTSTRAP_NOT_FOUND') { + const err = new ApiError( + 'Code invalid or already used. Ask the dashboard user to click Copy again.', + 404, + ); + err.exitCode = 5; + return err; + } + if (res.status === 410) { + const err = new ApiError( + 'Code expired or already used. Ask the dashboard user to click Copy again.', + 410, + ); + err.exitCode = 5; + return err; + } + if (res.status === 429) { + // REUSE the existing ConflictError (exitCode 6) — output/error.ts has + // no dedicated rate-limit class, and inventing one here would fork the + // 7-code exit-code contract locked in Phase 108. The body.code + // 'RATE_LIMITED' matches the backend's UserIdThrottlerGuard structured + // 429 body. + return new ConflictError( + msg && msg !== 'Too Many Requests' + ? msg + : 'Too many codes minted. Wait a minute and retry.', + 'RATE_LIMITED', + ); + } if (res.status >= 500) { return new ApiError('Something went wrong on our end. Try again later.', res.status); } return new ApiError(msg, res.status); } -function isNetworkFailure(err: unknown): boolean { +export function isNetworkFailure(err: unknown): boolean { if (err instanceof TypeError) return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any const code = (err as any)?.code; diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 7c452a1..4c4a3f3 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -1,11 +1,19 @@ -import { describe, expect, test, beforeEach, afterEach } from 'vitest'; +import { + describe, + expect, + test, + beforeEach, + afterEach, + vi, +} from 'vitest'; import { mkdirSync, writeFileSync, + readFileSync, rmSync, existsSync, + mkdtempSync, } from 'node:fs'; -import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -14,17 +22,23 @@ import { join } from 'node:path'; let CONFIG_DIR: string; const SAVED_CONFIG_DIR = process.env.HOOKMYAPP_CONFIG_DIR; -function base64UrlJson(payload: Record): string { +function base64Json(payload: Record): string { return Buffer.from(JSON.stringify(payload)).toString('base64'); } function buildJwt(payload: Record): string { - const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64'); - const body = base64UrlJson(payload); + const header = Buffer.from( + JSON.stringify({ alg: 'RS256', typ: 'JWT' }), + ).toString('base64'); + const body = base64Json(payload); return `${header}.${body}.signature-ignored`; } -function writeCreds(creds: { accessToken: string; refreshToken: string; expiresAt: number }): void { +function writeCreds(creds: { + accessToken: string; + refreshToken: string; + expiresAt: number; +}): void { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(join(CONFIG_DIR, 'credentials.json'), JSON.stringify(creds)); } @@ -37,9 +51,84 @@ function writeWorkspaceCfg(cfg: { writeFileSync(join(CONFIG_DIR, 'config.json'), JSON.stringify(cfg)); } +function makeExchangeResponse(overrides: { + email?: string; + workspaceName?: string; + workspaceId?: string; +} = {}): { + accessToken: string; + refreshToken: string; + expiresAt: number; + workspace: { id: string; name: string; workosOrganizationId: string }; + user: { publicId: string; email: string }; +} { + return { + accessToken: buildJwt({ + email: overrides.email ?? 'info@ordvir.com', + exp: 9999999999, + }), + refreshToken: 'r_new', + expiresAt: 9999999999, + workspace: { + id: overrides.workspaceId ?? 'ws_NEWWS001', + name: overrides.workspaceName ?? "Or's Workspace", + workosOrganizationId: 'org_new', + }, + user: { + publicId: 'usr_NEW00001', + email: overrides.email ?? 'info@ordvir.com', + }, + }; +} + +function okJson(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function errorJson(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// --- Module-scoped mock for api/client.ts --- +// Preserves mapApiError + isNetworkFailure (real), stubs apiClient so the +// downstream runWizard() call returns zero workspaces (→ wizard prints the +// "You aren't a member" hint and exits cleanly without hitting the net). +const apiClientMock = vi.fn(); +const forceTokenRefreshMock = vi.fn(); +vi.mock('../../api/client.js', async () => { + const actual = + await vi.importActual( + '../../api/client.js', + ); + return { + ...actual, + apiClient: apiClientMock, + forceTokenRefresh: forceTokenRefreshMock, + }; +}); + +// Mock @inquirer/prompts so a bad code-path never actually prompts. +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + input: vi.fn(), + confirm: vi.fn(), +})); + beforeEach(() => { CONFIG_DIR = mkdtempSync(join(tmpdir(), 'hookmyapp-bootstrap-test-')); process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; + // Redirect API URL to a sentinel so fetch-stub URL assertions are stable. + process.env.HOOKMYAPP_API_URL = 'https://test.example.com'; + apiClientMock.mockReset(); + forceTokenRefreshMock.mockReset(); + // Default: zero workspaces → runWizard prints "not a member" hint + returns. + apiClientMock.mockResolvedValue([]); }); afterEach(() => { @@ -51,6 +140,8 @@ afterEach(() => { } else { delete process.env.HOOKMYAPP_CONFIG_DIR; } + delete process.env.HOOKMYAPP_API_URL; + vi.unstubAllGlobals(); }); describe('peekIdentity()', () => { @@ -66,8 +157,6 @@ describe('peekIdentity()', () => { refreshToken: 'r', expiresAt: 9999999999, }); - // No config.json written — peekIdentity must return null rather than - // falling back to a stale default. expect(peekIdentity()).toBeNull(); }); @@ -118,29 +207,308 @@ describe('peekIdentity()', () => { }); describe('hookmyapp login --code', () => { - test.todo( - '--code happy path: fetches /auth/bootstrap/exchange, saveCredentials, writeWorkspaceConfig, prints identity echo, calls runWizard', - ); - test.todo( - '--code + --wizard → ValidationError exit 2 (mutually exclusive)', - ); - test.todo( - '--code with prior identity present AND different → prints "was:" diff line before identity echo', - ); - test.todo( - '--code with prior identity present AND same → does NOT print "was:" diff line', - ); - test.todo( - '--code with 404 response → ApiError exitCode 5 with message matching /invalid or already used/i', - ); - test.todo( - '--code with 410 response → ApiError exitCode 5 with message matching /expired or already used/i', - ); - test.todo('--code with 403 response → PermissionError exitCode 3'); - test.todo( - '--code with 429 response → ConflictError exitCode 6, code "RATE_LIMITED"', - ); - test.todo( - 'identity echo line format: ✓ Logged in as — workspace "" (exact em-dash, exact quote chars)', - ); + test('--code happy path: fetches /auth/bootstrap/exchange, saveCredentials, writeWorkspaceConfig, prints identity echo, calls runWizard', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(okJson(makeExchangeResponse())); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc123', { next: 'exit' }); + + // fetch hit the exchange endpoint exactly once with a POST + JSON body. + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe( + 'https://test.example.com/auth/bootstrap/exchange', + ); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + code: 'hma_boot_abc123', + }); + + // credentials.json was written with the new tokens. + const creds = JSON.parse( + readFileSync(join(CONFIG_DIR, 'credentials.json'), 'utf-8'), + ); + expect(creds.refreshToken).toBe('r_new'); + + // config.json was written with the new workspace (via writeWorkspaceConfig). + const cfg = JSON.parse( + readFileSync(join(CONFIG_DIR, 'config.json'), 'utf-8'), + ); + expect(cfg.activeWorkspaceId).toBe('ws_NEWWS001'); + expect(cfg.activeWorkspaceSlug).toBe("Or's Workspace"); + + // Identity echo present in stdout. + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).toMatch( + /Logged in as info@ordvir\.com — workspace "Or's Workspace"/, + ); + + // runWizard was invoked — the /workspaces apiClient mock confirms it. + expect(apiClientMock).toHaveBeenCalledWith('/workspaces'); + logSpy.mockRestore(); + }); + + test('--code + --wizard → ValidationError exit 2 (mutually exclusive)', async () => { + // The mutex is enforced in the commander .action() callback. This test + // verifies the CLI-wiring contract: ValidationError is thrown BEFORE any + // network call when both flags are present. + const { ValidationError } = await import('../../output/error.js'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + // Simulate the exact mutex check from loginCommand.action: + const simulateActionMutex = (opts: { code?: string; wizard?: boolean }) => { + if (opts.code) { + if (opts.wizard) { + throw new ValidationError( + '--code and --wizard are mutually exclusive.', + ); + } + } + }; + expect(() => + simulateActionMutex({ code: 'hma_boot_abc', wizard: true }), + ).toThrow(ValidationError); + + // Pin exit-code contract (ValidationError.exitCode === 2). + try { + simulateActionMutex({ code: 'hma_boot_abc', wizard: true }); + } catch (err) { + expect((err as InstanceType).exitCode).toBe(2); + } + + // No fetch was made. + expect(fetchMock).not.toHaveBeenCalled(); + + // Grep-verify the mutex check exists in the source (defensive drift check). + const loginSrc = readFileSync( + new URL('../login.ts', import.meta.url), + 'utf-8', + ); + expect(loginSrc).toContain('--code and --wizard are mutually exclusive'); + }); + + test('--code with prior identity present AND different → prints "was:" diff line before identity echo', async () => { + // Seed prior identity: different email + workspace. + writeCreds({ + accessToken: buildJwt({ email: 'old@other.com', exp: 9999999999 }), + refreshToken: 'r_old', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_OLDWS001', + activeWorkspaceSlug: 'Old Workspace', + }); + + const fetchMock = vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).toMatch( + /Replaced previous session \(was: old@other\.com — workspace "Old Workspace"\)/, + ); + expect(out).toMatch( + /Logged in as info@ordvir\.com — workspace "Or's Workspace"/, + ); + // "was:" MUST appear before the "Logged in as" line. + const wasIdx = out.search(/Replaced previous session/); + const loggedIdx = out.search(/Logged in as/); + expect(wasIdx).toBeGreaterThanOrEqual(0); + expect(loggedIdx).toBeGreaterThan(wasIdx); + logSpy.mockRestore(); + }); + + test('--code with prior identity present AND same → does NOT print "was:" diff line', async () => { + writeCreds({ + accessToken: buildJwt({ email: 'info@ordvir.com', exp: 9999999999 }), + refreshToken: 'r_old', + expiresAt: 9999999999, + }); + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_SAME0001', + activeWorkspaceSlug: "Or's Workspace", + }); + + const fetchMock = vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + + const out = logSpy.mock.calls.flat().join('\n'); + expect(out).not.toMatch(/Replaced previous session/); + expect(out).toMatch(/Logged in as info@ordvir\.com/); + logSpy.mockRestore(); + }); + + test('--code with 404 response → ApiError exitCode 5 with message matching /invalid or already used/i', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(404, { + code: 'BOOTSTRAP_NOT_FOUND', + message: 'ignored client renders its own copy', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_bad', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 5, + statusCode: 404, + }); + // re-invoke for the message assertion (first fetch mock is exhausted; + // re-stub cleanly). + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(404, { + code: 'BOOTSTRAP_NOT_FOUND', + message: 'ignored', + }), + ), + ); + await expect( + mod.runBootstrapCodeExchange('hma_boot_bad', { next: 'exit' }), + ).rejects.toThrow(/invalid or already used/i); + }); + + test('--code with 410 response → ApiError exitCode 5 with message matching /expired or already used/i', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(410, { + code: 'BOOTSTRAP_EXPIRED_OR_USED', + message: 'ignored', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_expired', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 5, + statusCode: 410, + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(410, { + code: 'BOOTSTRAP_EXPIRED_OR_USED', + message: 'ignored', + }), + ), + ); + await expect( + mod.runBootstrapCodeExchange('hma_boot_expired', { next: 'exit' }), + ).rejects.toThrow(/expired or already used/i); + }); + + test('--code with 403 response → PermissionError exitCode 3', async () => { + // mapApiError lazy-imports readWorkspaceConfig for the 403 branch. The + // seed config.json lets it resolve the slug for the error message. + writeWorkspaceCfg({ + activeWorkspaceId: 'ws_ABCD1234', + activeWorkspaceSlug: 'Some Workspace', + }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(403, { + code: 'FORBIDDEN', + message: 'not a member', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_forbidden', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 3, + code: 'PERMISSION_DENIED', + }); + }); + + test('--code with 429 response → ConflictError exitCode 6, code "RATE_LIMITED"', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + errorJson(429, { + code: 'RATE_LIMITED', + message: 'Too many codes minted. Wait a minute and retry.', + }), + ), + ); + const mod = await import('../login.js'); + await expect( + mod.runBootstrapCodeExchange('hma_boot_throttled', { next: 'exit' }), + ).rejects.toMatchObject({ + exitCode: 6, + code: 'RATE_LIMITED', + }); + }); + + test('identity echo line format: ✓ Logged in as — workspace "" (exact em-dash, exact quote chars)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + okJson( + makeExchangeResponse({ + email: 'info@ordvir.com', + workspaceName: "Or's Workspace", + }), + ), + ), + ); + const mod = await import('../login.js'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await mod.runBootstrapCodeExchange('hma_boot_abc', { next: 'exit' }); + const out = logSpy.mock.calls.flat().join('\n'); + // Regex pins the exact contract: leading checkmark (may be color-wrapped), + // literal "Logged in as", email, space, em-dash (U+2014), space, + // workspace name in double quotes. + expect(out).toMatch( + /\u2713.*Logged in as info@ordvir\.com \u2014 workspace "Or's Workspace"/, + ); + logSpy.mockRestore(); + }); }); diff --git a/src/auth/login.ts b/src/auth/login.ts index ae662cc..42083d9 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -1,10 +1,32 @@ import { Command } from 'commander'; -import { saveCredentials } from './store.js'; +import { saveCredentials, peekIdentity } from './store.js'; import { AuthError, NetworkError, ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; import { c, icon } from '../output/color.js'; import { cliCommandPrefix } from '../output/cli-self.js'; -import { getEffectiveWorkosClientId } from '../config/env-profiles.js'; +import { + getEffectiveApiUrl, + getEffectiveWorkosClientId, +} from '../config/env-profiles.js'; + +// --- Phase 122 bootstrap-code exchange DTO --- +// Mirrors backend/src/auth/bootstrap/dto/exchange-bootstrap.dto.ts (Wave 1 +// locked contract). The CLI does not import from the backend — the DTO is +// re-declared here verbatim so drift is caught by integration tests. +interface ExchangeBootstrapResponseDto { + accessToken: string; + refreshToken: string; + expiresAt: number; // epoch SECONDS + workspace: { + id: string; // ws_<8> publicId + name: string; + workosOrganizationId: string; + }; + user: { + publicId: string; // usr_<8> + email: string; + }; +} // --- Types used by the post-login wizard --- interface Workspace { @@ -363,6 +385,74 @@ async function runChannelsConnectFlow(): Promise { await runChannelsConnect(); } +/** + * Phase 122: bootstrap-code exchange branch. Invoked when the user (or their + * AI) runs `hookmyapp login --code hma_boot_<32>`. Bypasses the WorkOS device + * flow entirely — zero browser interaction, zero polling — then re-enters + * runWizard so the rest of the CLI (active workspace, --phone/--next hooks, + * JSON output shape) behaves identically to a browser login. + * + * Flow: + * 1. peekIdentity() BEFORE overwrite — needed for the "was:" diff. + * 2. POST /auth/bootstrap/exchange (unauthenticated @Public route). + * 3. saveCredentials + writeWorkspaceConfig — same shape the device-flow + * wizard writes, so downstream api calls work unchanged. + * 4. Print "Replaced previous session (was: ...)" if prior identity differs. + * 5. Print "Logged in as ..." — stable contract the AI matches against. + * 6. runWizard — preselected-workspace path short-circuits the picker. + * + * Errors flow through mapApiError → the CLI error hierarchy pins exit codes. + */ +export async function runBootstrapCodeExchange( + code: string, + opts: { phone?: string; next?: 'sandbox' | 'channels' | 'exit'; json?: boolean }, +): Promise { + const { mapApiError, isNetworkFailure } = await import('../api/client.js'); + const { writeWorkspaceConfig } = await import('../commands/workspace.js'); + + const prior = peekIdentity(); + + const baseUrl = getEffectiveApiUrl(); + let res: Response; + try { + res = await fetch(`${baseUrl}/auth/bootstrap/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + } catch (err) { + if (isNetworkFailure(err)) throw new NetworkError(); + throw err; + } + if (!res.ok) throw await mapApiError(res); + + const data = (await res.json()) as ExchangeBootstrapResponseDto; + + saveCredentials({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: data.expiresAt, + }); + writeWorkspaceConfig({ + activeWorkspaceId: data.workspace.id, + activeWorkspaceSlug: data.workspace.name, + }); + + if ( + prior && + (prior.email !== data.user.email || prior.workspaceSlug !== data.workspace.name) + ) { + console.log( + `${c.success(icon.success)} Replaced previous session (was: ${prior.email} — workspace "${prior.workspaceSlug}")`, + ); + } + console.log( + `${c.success(icon.success)} Logged in as ${data.user.email} — workspace "${data.workspace.name}"`, + ); + + await runWizard({ phone: opts.phone, next: opts.next, json: opts.json }); +} + export function loginCommand(program: Command): void { const login = program .command('login') @@ -377,14 +467,40 @@ export function loginCommand(program: Command): void { '--next ', 'Non-interactive next-action for scripts/CI (sandbox|channels|exit)', ) + .option( + '--code ', + 'Exchange a dashboard-minted bootstrap code (zero browser interaction)', + ) .action( - async (opts: { phone?: string; wizard?: boolean; next?: string }) => { + async (opts: { + phone?: string; + wizard?: boolean; + next?: string; + code?: string; + }) => { const nextAction = opts.next === 'sandbox' || opts.next === 'channels' || opts.next === 'exit' ? opts.next : undefined; const json = program.opts().json === true; + // Phase 122 — bootstrap-code branch. MUST run BEFORE the wizard + // fast-path and BEFORE device-flow initiation so --code --wizard is + // flagged as a programming error (mutually exclusive). + if (opts.code) { + if (opts.wizard) { + throw new ValidationError( + '--code and --wizard are mutually exclusive.', + ); + } + await runBootstrapCodeExchange(opts.code, { + phone: opts.phone, + next: nextAction, + json, + }); + return; + } + // --wizard is the integration-test fast path: skip browser auth and // run the wizard directly against whatever credentials the seed // helper stashed in $HOME/.hookmyapp/credentials.json. @@ -441,11 +557,11 @@ export function loginCommand(program: Command): void { ` EXAMPLES: $ hookmyapp login - $ hookmyapp login --workspace acme-corp - $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI + $ hookmyapp login --code hma_boot_xxx # zero-browser AI paste + $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI This runs the post-login wizard: - 1. Browser sign-in + 1. Browser sign-in (or --code to skip the browser) 2. Workspace picker (if you belong to more than one) 3. Prints a "Next steps" guide (or runs --next / --phone non-interactively) `, From 54a40676b289cf58823471ffea0462d480ce7fa0 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 19 Apr 2026 17:38:07 +0300 Subject: [PATCH 3/4] fix(auth): gate 410 bootstrap-code mapping on BOOTSTRAP_EXPIRED_OR_USED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on PR #4: apply the same code-field check to the 410 branch in mapApiError as the 404 branch already does. Without it, any unrelated 410 response would be rewritten with the "Code expired or already used" remediation copy — confusing for future endpoints that might legitimately return 410 for other reasons. Symmetric with 404 BOOTSTRAP_NOT_FOUND guard (line ~98). Backend Wave 1 (plan 122-02) emits exactly `BOOTSTRAP_EXPIRED_OR_USED` for spent/expired codes, so no behavior change for the happy/unhappy bootstrap paths — existing 14 tests still GREEN. --- src/api/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/client.ts b/src/api/client.ts index 292d719..c2cef04 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -104,7 +104,7 @@ export async function mapApiError(res: Response): Promise { err.exitCode = 5; return err; } - if (res.status === 410) { + if (res.status === 410 && code === 'BOOTSTRAP_EXPIRED_OR_USED') { const err = new ApiError( 'Code expired or already used. Ask the dashboard user to click Copy again.', 410, From 86b8e15ca33b628bf5f6981ec24b120bd3a53bcd Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 19 Apr 2026 18:06:46 +0300 Subject: [PATCH 4/4] docs(auth): restore --workspace example in login help; add slug/name rename note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small fixes from PR #4 review: - Restore `$ hookmyapp login --workspace acme-corp` example in login help EXAMPLES block. --workspace is a global program flag (src/index.ts:35) attached to every command including login; its removal in 6091206 was incidental, not deliberate. Other commands (channels/sandbox/billing/ token) still list the same example. - Add a comment at the was-diff comparison noting that prior.workspaceSlug is today stored as data.workspace.name (line 438), so the string compare works. If a future rename sanitizes slug to a lowercase form, the diff would silently no-op — comment flags the assumption for future editors. Tests: 325/325 GREEN. No behavior change. --- src/auth/login.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auth/login.ts b/src/auth/login.ts index 42083d9..a5f1eb0 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -438,6 +438,10 @@ export async function runBootstrapCodeExchange( activeWorkspaceSlug: data.workspace.name, }); + // NOTE: prior.workspaceSlug is the stored activeWorkspaceSlug (set on line 438 + // to data.workspace.name); today slug === name for all existing sessions. If a + // future rename sanitizes slug to a lowercase form, update this comparison to + // compare against workspace.id (publicId) or keep a stored workspace.name field. if ( prior && (prior.email !== data.user.email || prior.workspaceSlug !== data.workspace.name) @@ -558,6 +562,7 @@ export function loginCommand(program: Command): void { EXAMPLES: $ hookmyapp login $ hookmyapp login --code hma_boot_xxx # zero-browser AI paste + $ hookmyapp login --workspace acme-corp # preselect workspace $ hookmyapp login --next sandbox --phone +15551234567 # scripts / CI This runs the post-login wizard: