diff --git a/lib/daemon/__tests__/secrets-handler.test.ts b/lib/daemon/__tests__/secrets-handler.test.ts index 583a03fd..e2e50460 100644 --- a/lib/daemon/__tests__/secrets-handler.test.ts +++ b/lib/daemon/__tests__/secrets-handler.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -import { createSecretsHandlers } from "../handlers/secrets.ts"; +import { createSecretsHandlers, loadBoardSecrets, type BoardSecretsData, type ReadSecretFn } from "../handlers/secrets.ts"; import { teamSettingsPath } from "../../rt-paths.ts"; import { setSetting } from "../../settings/write.ts"; import { loadRepoTracking } from "../../repo-tracking.ts"; @@ -118,11 +118,13 @@ describe("secrets:forge-token default tracking reader is machine-only", () => { function readHandler(opts: { extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>; deckSecrets?: () => Promise<{ cfApiToken?: string; cfZoneId?: string }>; + boardSecrets?: () => Promise; apiToken?: string; }) { const h = createSecretsHandlers(fakeCtx, { extensionSecrets: opts.extensionSecrets ?? (async () => ({})), deckSecrets: opts.deckSecrets ?? (async () => ({})), + boardSecrets: opts.boardSecrets ?? (async () => ({})), apiToken: () => opts.apiToken ?? "test-token", }); return h["secrets:read"]; @@ -230,12 +232,14 @@ describe("secrets:read scope", () => { expect(res).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-x" } }); }); - test("bad scope is refused before either reader runs", async () => { + test("bad scope is refused before any reader runs", async () => { let extensionCalled = false; let deckCalled = false; + let boardCalled = false; const h = readHandler({ extensionSecrets: async () => { extensionCalled = true; return {}; }, deckSecrets: async () => { deckCalled = true; return {}; }, + boardSecrets: async () => { boardCalled = true; return {}; }, }); const res = await h({ token: "test-token", scope: "bitbucket" as any }); @@ -243,6 +247,7 @@ describe("secrets:read scope", () => { expect(res).toEqual({ ok: false, error: "bad-scope" }); expect(extensionCalled).toBe(false); expect(deckCalled).toBe(false); + expect(boardCalled).toBe(false); }); test("the token gate applies to the deck scope exactly as it does to extension", async () => { @@ -268,12 +273,24 @@ describe("secrets:read scope", () => { expect(deckCalled).toBe(false); }); + test("a case-variant scope (\"Board\") is refused, not treated as \"board\"", async () => { + let boardCalled = false; + const h = readHandler({ boardSecrets: async () => { boardCalled = true; return {}; } }); + + const res = await h({ token: "test-token", scope: "Board" as any }); + + expect(res).toEqual({ ok: false, error: "bad-scope" }); + expect(boardCalled).toBe(false); + }); + test("a non-string scope (array) is refused, not coerced into a match", async () => { let extensionCalled = false; let deckCalled = false; + let boardCalled = false; const h = readHandler({ extensionSecrets: async () => { extensionCalled = true; return {}; }, deckSecrets: async () => { deckCalled = true; return {}; }, + boardSecrets: async () => { boardCalled = true; return {}; }, }); const res = await h({ token: "test-token", scope: ["deck"] as any }); @@ -281,6 +298,7 @@ describe("secrets:read scope", () => { expect(res).toEqual({ ok: false, error: "bad-scope" }); expect(extensionCalled).toBe(false); expect(deckCalled).toBe(false); + expect(boardCalled).toBe(false); }); test("a deck-reader throw surfaces as a rejected promise (transport error), never a partial ok", async () => { @@ -289,3 +307,192 @@ describe("secrets:read scope", () => { await expect(h({ token: "test-token", scope: "deck" })).rejects.toThrow("sops -d exploded"); }); }); + +// scope: "board" is CROSS-DOMAIN — slackToken/slackClientSecret/ +// slackSigningSecret come from the `board` domain, gitlabToken/ +// switchboardToken/switchboardAdminToken from the `rt` domain. A value +// seeded for extension/deck must never leak into a board read, and a +// board-seeded value must never leak into extension/deck (gitlabToken +// appears in both extension's and board's whitelist, but each scope reads +// its OWN loader — this is what the isolation tests below pin down). +describe("secrets:read scope \"board\"", () => { + test("returns exactly the six whitelisted keys when all are set", async () => { + const h = readHandler({ + boardSecrets: async () => ({ + slackToken: "xoxb-1", + slackClientSecret: "slack-cs", + slackSigningSecret: "slack-ss", + gitlabToken: "glpat-board", + switchboardToken: "sb-tok", + switchboardAdminToken: "sb-admin", + }), + }); + + const res = await h({ token: "test-token", scope: "board" }); + + expect(res).toEqual({ + ok: true, + data: { + slackToken: "xoxb-1", + slackClientSecret: "slack-cs", + slackSigningSecret: "slack-ss", + gitlabToken: "glpat-board", + switchboardToken: "sb-tok", + switchboardAdminToken: "sb-admin", + }, + }); + }); + + test("omits keys entirely (never a blank string) when they aren't set", async () => { + const h = readHandler({ boardSecrets: async () => ({ slackToken: "xoxb-1" }) }); + + const res = await h({ token: "test-token", scope: "board" }); + + expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } }); + expect("slackClientSecret" in (res as any).data).toBe(false); + expect("gitlabToken" in (res as any).data).toBe(false); + }); + + test("carries no other field even if the loader returns one — e.g. linearApiKey never leaks into board", async () => { + const h = readHandler({ + boardSecrets: async () => ({ slackToken: "xoxb-1", linearApiKey: "leak" } as any), + }); + + const res = await h({ token: "test-token", scope: "board" }); + + expect(Object.keys((res as any).data)).toEqual(["slackToken"]); + }); + + test("board scope never leaks an rt-domain value seeded only for extension scope (e.g. linearApiKey, or a differently-sourced gitlabToken)", async () => { + const h = readHandler({ + extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" }), + boardSecrets: async () => ({ slackToken: "xoxb-1" }), + }); + + const res = await h({ token: "test-token", scope: "board" }); + + expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } }); + expect("linearApiKey" in (res as any).data).toBe(false); + expect("gitlabToken" in (res as any).data).toBe(false); + }); + + test("board scope never leaks a deck-domain value", async () => { + const h = readHandler({ + deckSecrets: async () => ({ cfApiToken: "cf-tok", cfZoneId: "zone-1" }), + boardSecrets: async () => ({ slackToken: "xoxb-1" }), + }); + + const res = await h({ token: "test-token", scope: "board" }); + + expect(res).toEqual({ ok: true, data: { slackToken: "xoxb-1" } }); + expect("cfApiToken" in (res as any).data).toBe(false); + expect("cfZoneId" in (res as any).data).toBe(false); + }); + + test("extension and deck scopes never leak a board-seeded value (slack*/switchboard* stay out of both)", async () => { + const boardSecrets = async () => ({ + slackToken: "xoxb-1", + slackClientSecret: "slack-cs", + slackSigningSecret: "slack-ss", + gitlabToken: "glpat-board", + switchboardToken: "sb-tok", + switchboardAdminToken: "sb-admin", + }); + + const extensionRes = await readHandler({ + extensionSecrets: async () => ({ linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" }), + boardSecrets, + })({ token: "test-token" }); + expect(extensionRes).toEqual({ ok: true, data: { linearApiKey: "lin_api_x", gitlabToken: "glpat-extension" } }); + expect("slackToken" in (extensionRes as any).data).toBe(false); + expect("switchboardToken" in (extensionRes as any).data).toBe(false); + + const deckRes = await readHandler({ + deckSecrets: async () => ({ cfApiToken: "cf-tok", cfZoneId: "zone-1" }), + boardSecrets, + })({ token: "test-token", scope: "deck" }); + expect(deckRes).toEqual({ ok: true, data: { cfApiToken: "cf-tok", cfZoneId: "zone-1" } }); + expect("slackToken" in (deckRes as any).data).toBe(false); + expect("switchboardAdminToken" in (deckRes as any).data).toBe(false); + }); + + test("the token gate applies to the board scope exactly as it does to extension/deck", async () => { + let boardCalled = false; + const h = readHandler({ boardSecrets: async () => { boardCalled = true; return {}; } }); + + const missing = await h({ scope: "board" }); + expect(missing).toEqual({ ok: false, error: "missing-token" }); + + const wrong = await h({ token: "wrong", scope: "board" }); + expect(wrong).toEqual({ ok: false, error: "bad-token" }); + + expect(boardCalled).toBe(false); + }); + + test("a board-domain readSecret throw propagates as a rejected promise (transport error), never a partial ok", async () => { + const h = readHandler({ boardSecrets: async () => { throw new Error("sops -d exploded"); } }); + + await expect(h({ token: "test-token", scope: "board" })).rejects.toThrow("sops -d exploded"); + }); +}); + +// Exercises the real `loadBoardSecrets` (not the handler's `boardSecrets` +// override) with an injected `ReadSecretFn`, so a future whitelist edit or +// domain typo in BOARD_SECRET_ENTRIES fails this suite even though the +// handler-level tests above only stub the loader's return shape. +describe("loadBoardSecrets", () => { + test("reads exactly the six (domain, key) entries, in order", async () => { + const calls: Array<[string, string]> = []; + const fake: ReadSecretFn = async (domain, key) => { + calls.push([domain, key]); + return `${domain}:${key}`; + }; + + const data = await loadBoardSecrets(fake); + + expect(calls).toEqual([ + ["board", "slackToken"], + ["board", "slackClientSecret"], + ["board", "slackSigningSecret"], + ["rt", "gitlabToken"], + ["rt", "switchboardToken"], + ["rt", "switchboardAdminToken"], + ]); + const expected: BoardSecretsData = { + slackToken: "board:slackToken", + slackClientSecret: "board:slackClientSecret", + slackSigningSecret: "board:slackSigningSecret", + gitlabToken: "rt:gitlabToken", + switchboardToken: "rt:switchboardToken", + switchboardAdminToken: "rt:switchboardAdminToken", + }; + expect(data).toEqual(expected); + }); + + test("omits an entry whose read returns null", async () => { + const fake: ReadSecretFn = async (domain, key) => + domain === "board" && key === "slackClientSecret" ? null : `${domain}:${key}`; + + const data = await loadBoardSecrets(fake); + + expect("slackClientSecret" in data).toBe(false); + expect(data.slackToken).toBe("board:slackToken"); + }); + + test("a throw on the first rt-domain read (after all three board-domain reads succeed) rejects the whole call — nothing partial, and later entries are never attempted", async () => { + const calls: Array<[string, string]> = []; + const fake: ReadSecretFn = async (domain, key) => { + calls.push([domain, key]); + if (domain === "rt") throw new Error("sops -d exploded"); + return `${domain}:${key}`; + }; + + await expect(loadBoardSecrets(fake)).rejects.toThrow("sops -d exploded"); + expect(calls).toEqual([ + ["board", "slackToken"], + ["board", "slackClientSecret"], + ["board", "slackSigningSecret"], + ["rt", "gitlabToken"], + ]); + }); +}); diff --git a/lib/daemon/handlers/secrets.ts b/lib/daemon/handlers/secrets.ts index f488290b..d52310bd 100644 --- a/lib/daemon/handlers/secrets.ts +++ b/lib/daemon/handlers/secrets.ts @@ -30,13 +30,17 @@ * need to change; a socket caller must read ~/.mattstack/rt/api-token * itself and pass it the same way. * - * `payload.scope` adds a second whitelist alongside the - * extension's, each reading its own encrypted domain — "extension" (default, - * so existing callers need no change) reads the `rt` domain's linearApiKey/ - * gitlabToken; "deck" reads the `deck` domain's cfApiToken/cfZoneId. The - * token gate above applies identically to both; scope is checked only after - * it passes, and an unknown scope is refused before either domain reader - * runs — the two whitelists must never blend into one combined read. + * `payload.scope` adds further whitelists alongside the extension's, each + * reading its own encrypted domain(s) — "extension" (default, so existing + * callers need no change) reads the `rt` domain's linearApiKey/gitlabToken; + * "deck" reads the `deck` domain's cfApiToken/cfZoneId; "board" is + * CROSS-DOMAIN, reading slackToken/slackClientSecret/slackSigningSecret + * from the `board` domain and gitlabToken/switchboardToken/ + * switchboardAdminToken from the `rt` domain. The token gate above applies + * identically to every scope; scope is checked only after it passes, and an + * unknown scope is refused before any domain reader runs — the whitelists + * must never blend into one combined read, even where board's rt-domain + * entries overlap a key name (gitlabToken) extension's whitelist also uses. */ import { loadSecrets } from "../../linear.ts"; @@ -50,18 +54,28 @@ import type { HandlerContext, HandlerMap, TypedHandlers } from "./types.ts"; const DECK_SECRET_DOMAIN = "deck"; const DECK_SECRET_KEYS = ["cfApiToken", "cfZoneId"] as const; -let realDeckSecretsSeamsSingleton: SecretsSeams | null = null; +/** Cross-domain whitelist for the "board" scope — explicit (domain, key) pairs rather than one domain's key list, since board draws from both `board` and `rt`. */ +const BOARD_SECRET_ENTRIES = [ + ["board", "slackToken"], + ["board", "slackClientSecret"], + ["board", "slackSigningSecret"], + ["rt", "gitlabToken"], + ["rt", "switchboardToken"], + ["rt", "switchboardAdminToken"], +] as const; -/** Lazily-built real seams, private to this module — a separate instance from lib/linear.ts's own singleton only because module scope doesn't let this file reuse that one, not because the seams themselves are domain-bound (`readSecret` takes `domain` as a parameter). */ -function defaultDeckSecretsSeams(): SecretsSeams { - return realDeckSecretsSeamsSingleton ??= { +let realSecretsSeamsSingleton: SecretsSeams | null = null; + +/** Lazily-built real seams, private to this module — a separate instance from lib/linear.ts's own singleton only because module scope doesn't let this file reuse that one, not because the seams themselves are domain-bound (`readSecret` takes `domain` as a parameter). Shared by every scope's loader below. */ +function defaultSecretsSeams(): SecretsSeams { + return realSecretsSeamsSingleton ??= { ageKeySeam: createRealAgeKeySeam(), execSeam: createRealSecretsExecSeam(), }; } async function loadDeckSecrets(): Promise<{ cfApiToken?: string; cfZoneId?: string }> { - const seams = defaultDeckSecretsSeams(); + const seams = defaultSecretsSeams(); const out: { cfApiToken?: string; cfZoneId?: string } = {}; for (const key of DECK_SECRET_KEYS) { const value = await readSecret(DECK_SECRET_DOMAIN, key, seams); @@ -70,6 +84,32 @@ async function loadDeckSecrets(): Promise<{ cfApiToken?: string; cfZoneId?: stri return out; } +export interface BoardSecretsData { + slackToken?: string; + slackClientSecret?: string; + slackSigningSecret?: string; + gitlabToken?: string; + switchboardToken?: string; + switchboardAdminToken?: string; +} + +/** `readSecret` injected as a plain (domain, key) -> value|null function, not the full `SecretsSeams` — lets a test exercise the real per-entry read sequence and partial-failure ordering without faking sops/age-key exec plumbing. */ +export type ReadSecretFn = (domain: string, key: string) => Promise; + +function defaultReadSecret(domain: string, key: string): Promise { + return readSecret(domain, key, defaultSecretsSeams()); +} + +/** Reads BOARD_SECRET_ENTRIES in order; a throw on any entry (e.g. the first `rt`-domain read after `board`'s three succeed) rejects the whole call with nothing partial returned — callers see either every readable key or an error, never a half-populated object. */ +export async function loadBoardSecrets(readSecretFn: ReadSecretFn = defaultReadSecret): Promise { + const out: BoardSecretsData = {}; + for (const [domain, key] of BOARD_SECRET_ENTRIES) { + const value = await readSecretFn(domain, key); + if (value !== null) out[key] = value; + } + return out; +} + const SECRETS_KEY: Record = { gitlab: "gitlabToken", github: "githubToken", @@ -82,6 +122,8 @@ export interface SecretsHandlerOverrides { extensionSecrets?: () => Promise<{ linearApiKey?: string; gitlabToken?: string }>; /** Defaults to `loadDeckSecrets` (the `deck` encrypted domain) for secrets:read's "deck" scope. */ deckSecrets?: () => Promise<{ cfApiToken?: string; cfZoneId?: string }>; + /** Defaults to `loadBoardSecrets` (cross-domain: `board` + `rt`) for secrets:read's "board" scope. */ + boardSecrets?: () => Promise; /** Defaults to `loadOrCreateApiToken` (the real ~/.mattstack/rt/api-token, shared with api-auth.ts). */ apiToken?: () => string; } @@ -98,6 +140,7 @@ export function createSecretsHandlers( const secrets = overrides.secrets ?? loadSecrets; const extensionSecrets = overrides.extensionSecrets ?? loadSecrets; const deckSecrets = overrides.deckSecrets ?? loadDeckSecrets; + const boardSecrets = overrides.boardSecrets ?? loadBoardSecrets; const apiToken = overrides.apiToken ?? (() => loadOrCreateApiToken()); return { @@ -154,6 +197,18 @@ export function createSecretsHandlers( ctx.log.debug({ scope, keys: Object.keys(data) }, "secrets:read"); return { ok: true as const, data }; } + if (scope === "board") { + const all = await boardSecrets(); + const data: BoardSecretsData = {}; + if (all.slackToken) data.slackToken = all.slackToken; + if (all.slackClientSecret) data.slackClientSecret = all.slackClientSecret; + if (all.slackSigningSecret) data.slackSigningSecret = all.slackSigningSecret; + if (all.gitlabToken) data.gitlabToken = all.gitlabToken; + if (all.switchboardToken) data.switchboardToken = all.switchboardToken; + if (all.switchboardAdminToken) data.switchboardAdminToken = all.switchboardAdminToken; + ctx.log.debug({ scope, keys: Object.keys(data) }, "secrets:read"); + return { ok: true as const, data }; + } return { ok: false as const, error: "bad-scope" }; }, }; diff --git a/packages/rt-client/src/commands.ts b/packages/rt-client/src/commands.ts index 929da858..02fe0558 100644 --- a/packages/rt-client/src/commands.ts +++ b/packages/rt-client/src/commands.ts @@ -72,14 +72,17 @@ export interface Commands { "secrets:forge-token": { payload: { repoName: string; forge: ForgeSlug }; data: ForgeTokenData }; /** * A per-`scope` whitelisted subset of secrets, each scope reading its own - * encrypted domain: "extension" (default, so the VS Code extension needs - * no change) is linearApiKey/gitlabToken from the `rt` domain; "deck" is - * cfApiToken/cfZoneId from the `deck` domain. `data` is a union of the two - * exact per-scope shapes, not a merged bag of all four keys — that makes - * a caller narrowing on the wrong scope's fields a compile error instead - * of a silent `undefined`. Both optional per key (present only when set). - * Not a general secrets export — extend a whitelist here, in lockstep - * with lib/daemon/handlers/secrets.ts and (for "extension") + * encrypted domain(s): "extension" (default, so the VS Code extension + * needs no change) is linearApiKey/gitlabToken from the `rt` domain; + * "deck" is cfApiToken/cfZoneId from the `deck` domain; "board" is + * cross-domain — slackToken/slackClientSecret/slackSigningSecret from the + * `board` domain plus gitlabToken/switchboardToken/switchboardAdminToken + * from the `rt` domain. `data` is a union of the per-scope shapes, not a + * merged bag of every key — that makes a caller narrowing on the wrong + * scope's fields a compile error instead of a silent `undefined`. Every + * key optional (present only when set). Not a general secrets export — + * extend a whitelist here, in lockstep with + * lib/daemon/handlers/secrets.ts and (for "extension") * extensions/vscode/rt-context/src/secrets.ts, if a consumer needs another * key. * @@ -91,8 +94,18 @@ export interface Commands { * applies identically to every scope. */ "secrets:read": { - payload: { token?: string; scope?: "extension" | "deck" }; - data: { linearApiKey?: string; gitlabToken?: string } | { cfApiToken?: string; cfZoneId?: string }; + payload: { token?: string; scope?: "extension" | "deck" | "board" }; + data: + | { linearApiKey?: string; gitlabToken?: string } + | { cfApiToken?: string; cfZoneId?: string } + | { + slackToken?: string; + slackClientSecret?: string; + slackSigningSecret?: string; + gitlabToken?: string; + switchboardToken?: string; + switchboardAdminToken?: string; + }; }; "events:emit": { payload: { topic: string; payload?: unknown }; data: { id: number } }; "events:wait": { payload: { pattern: string; after?: number; waitMs?: number }; data: { events: EventsBusEvent[]; cursor: number } }; diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index 7c0826f2..dfadd945 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -223,6 +223,10 @@ export const REGISTRY: readonly SettingDef[] = [ }, // --- board (team) -------------------------------------------------------- + // board.* rows carry NO `default`: the board's store-ownership latch is + // `getSetting(key).value === undefined`, and a registry default materializes + // as a present value — adding one flips that key store-authoritative on + // every install and blanks the file's value. { key: "board.gitlabHost", type: "string",