diff --git a/backend/src/intake/anthropicClient.ts b/backend/src/intake/anthropicClient.ts index 11cc3ce..68ec09d 100644 --- a/backend/src/intake/anthropicClient.ts +++ b/backend/src/intake/anthropicClient.ts @@ -13,11 +13,37 @@ import Anthropic from '@anthropic-ai/sdk'; let cached: Anthropic | null = null; let resolved = false; +/** + * The headers a key needs beyond the key itself. + * + * An *identity-linked* key — one issued against a workspace rather than + * standing alone — is refused without an `anthropic-workspace-id` naming the + * workspace the request acts in: + * + * 400 invalid_request_error: anthropic-workspace-id is required when + * authenticating with an identity-linked API key + * + * Nothing about a key's shape says which kind it is, so this cannot be detected + * from configuration — only from a real call, which is what #223's task 8 was + * for and what found it (#271). + * + * Sent only when set. Plenty of keys need no workspace, and sending an empty + * header would turn the ordinary case into a different error. + */ +function workspaceHeaders(): Record | undefined { + const workspaceId = process.env.ANTHROPIC_WORKSPACE_ID; + if (workspaceId === undefined || workspaceId.trim() === '') return undefined; + return { 'anthropic-workspace-id': workspaceId.trim() }; +} + export function getAnthropicClient(): Anthropic | null { if (resolved) return cached; const key = process.env.ANTHROPIC_API_KEY; - cached = key !== undefined && key.trim() !== '' ? new Anthropic({ apiKey: key }) : null; + cached = + key !== undefined && key.trim() !== '' + ? new Anthropic({ apiKey: key, defaultHeaders: workspaceHeaders() }) + : null; resolved = true; return cached; diff --git a/backend/tests/unit/anthropicClient.test.ts b/backend/tests/unit/anthropicClient.test.ts new file mode 100644 index 0000000..d1f3fd1 --- /dev/null +++ b/backend/tests/unit/anthropicClient.test.ts @@ -0,0 +1,70 @@ +import { getAnthropicClient, resetAnthropicClient } from '../../src/intake/anthropicClient'; + +const KEY = 'sk-ant-test-key'; + +beforeEach(() => { + resetAnthropicClient(); + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_WORKSPACE_ID; +}); + +afterAll(() => { + resetAnthropicClient(); +}); + +/** The SDK stores what it was given, so the header is readable back off it. */ +function headerOf(client: unknown): string | undefined { + const headers = (client as { _options?: { defaultHeaders?: Record } })._options + ?.defaultHeaders; + return headers?.['anthropic-workspace-id']; +} + +describe('getAnthropicClient', () => { + it('is null without a key, because unconfigured is a working configuration', () => { + expect(getAnthropicClient()).toBeNull(); + }); + + it('is null for a key that is only whitespace', () => { + process.env.ANTHROPIC_API_KEY = ' '; + expect(getAnthropicClient()).toBeNull(); + }); + + it('builds a client from a key', () => { + process.env.ANTHROPIC_API_KEY = KEY; + expect(getAnthropicClient()).not.toBeNull(); + }); + + /** + * An identity-linked key is refused with a 400 unless the request names the + * workspace it acts in. Nothing about a key's shape says which kind it is, so + * this only showed up on a real call (#271). + */ + it('sends the workspace id when one is configured', () => { + process.env.ANTHROPIC_API_KEY = KEY; + process.env.ANTHROPIC_WORKSPACE_ID = 'wrkspc_abc123'; + + expect(headerOf(getAnthropicClient())).toBe('wrkspc_abc123'); + }); + + // Plenty of keys need no workspace. Sending an empty header would turn the + // ordinary case into a different error rather than leaving it working. + it('sends no workspace header when none is configured', () => { + process.env.ANTHROPIC_API_KEY = KEY; + + expect(headerOf(getAnthropicClient())).toBeUndefined(); + }); + + it('sends no workspace header for a value that is only whitespace', () => { + process.env.ANTHROPIC_API_KEY = KEY; + process.env.ANTHROPIC_WORKSPACE_ID = ' '; + + expect(headerOf(getAnthropicClient())).toBeUndefined(); + }); + + it('trims a workspace id that arrived with padding', () => { + process.env.ANTHROPIC_API_KEY = KEY; + process.env.ANTHROPIC_WORKSPACE_ID = ' wrkspc_abc123 '; + + expect(headerOf(getAnthropicClient())).toBe('wrkspc_abc123'); + }); +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5f3cb46..9a21546 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -92,6 +92,9 @@ # failing, so an empty value is a working configuration. # ANTHROPIC_API_KEY Optional. Drafts a listing from a submitted photo # (#223). Unset means submissions still arrive and wait +# ANTHROPIC_WORKSPACE_ID Required alongside the key above when that key is +# identity-linked. Without it every draft fails with a +# 400 naming the missing header (#271). # INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the # intake notification email (#224). Absent, the email # still sends and carries no shortcuts. @@ -229,6 +232,12 @@ services: # than the bill. - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + # Required alongside the key when that key is identity-linked. Such a key + # is refused with a 400 unless the request names its workspace, and a + # key's shape does not say which kind it is (#271). Optional: ordinary + # keys need no workspace. + - ANTHROPIC_WORKSPACE_ID=${ANTHROPIC_WORKSPACE_ID:-} + # Signs the regenerate and discard links in the intake notification email # (#224). Optional: absent, the notification still sends and links to the # review queue without shortcuts. Rotating it revokes every outstanding diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml index 08c7997..f797512 100644 --- a/docker-compose.qa.yml +++ b/docker-compose.qa.yml @@ -54,6 +54,11 @@ # credential here that spends money per call, and it is # reachable by anyone holding an upload link. Leave it unset # and submissions still arrive, undrafted. +# QA_ANTHROPIC_WORKSPACE_ID — required alongside the key above when that key +# is identity-linked, i.e. issued against a workspace rather +# than standing alone. Without it every draft fails with a +# 400 naming the missing header (#271). Ordinary keys need +# no workspace and can leave it unset. # QA_INTAKE_ACTION_SECRET — optional. Signs the regenerate and discard links # in the notification email (#224). Absent, the email still # sends and simply carries no shortcuts. Its own value, not @@ -149,6 +154,16 @@ services: # without its description written. - ANTHROPIC_API_KEY=${QA_ANTHROPIC_API_KEY} + # Required alongside the key when that key is identity-linked — one issued + # against a workspace rather than standing alone. Such a key is refused + # with a 400 unless the request names the workspace it acts in, and + # nothing about a key's shape says which kind it is, so this only shows up + # on a real call. It did: every QA draft failed until this existed (#271). + # + # Optional. Plenty of keys need no workspace, and sending an empty one + # would turn the ordinary case into a different error. + - ANTHROPIC_WORKSPACE_ID=${QA_ANTHROPIC_WORKSPACE_ID:-} + # Signs the regenerate and discard links in the intake notification email # (#224). Optional: absent, the notification still sends and simply links # to the review queue without shortcuts. Anyone holding a link can act on diff --git a/docs/ops/production-stack-cutover.md b/docs/ops/production-stack-cutover.md index 24941ac..68b3999 100644 --- a/docs/ops/production-stack-cutover.md +++ b/docs/ops/production-stack-cutover.md @@ -60,7 +60,7 @@ Everything here is lost when the stack is deleted, and the rollback in step 8 is **The stack name**, exactly as Portainer shows it. If it is not `redefined-designs`, note that — the new stack must be created with that name, because the stack name becomes the compose project name and reusing QA's would make Compose reconcile the two against each other. -**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest. The compose file interpolates fifteen names — `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL`, `BACKUP_PASSPHRASE`, `ANTHROPIC_API_KEY` and `INTAKE_ACTION_SECRET` — and an unset one substitutes to an empty string rather than failing. None of it is recoverable from anything in this repository. Take everything the stack holds rather than working from this list; it is here to say how much there is, and it is checked against the file rather than from memory. +**Every stack environment variable, name and value.** They belong to the stack, and deleting it discards them. This is the step whose omission is felt hardest. The compose file interpolates sixteen names — `DEMO_MODE`, `DB_PASSWORD`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `ADMIN_GATE_SECRET`, `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `USPS_CLIENT_ID`, `USPS_CLIENT_SECRET`, `UPLOADS_BASE_URL`, `BACKUP_PASSPHRASE`, `ANTHROPIC_API_KEY`, `ANTHROPIC_WORKSPACE_ID` and `INTAKE_ACTION_SECRET` — and an unset one substitutes to an empty string rather than failing. None of it is recoverable from anything in this repository. Take everything the stack holds rather than working from this list; it is here to say how much there is, and it is checked against the file rather than from memory. `USPS_CLIENT_ID` and `USPS_CLIENT_SECRET` deserve naming because losing them is the one failure here that is completely silent. Address validation is skipped when they are empty rather than failing, so checkout keeps working and quietly stops validating addresses. Nothing in step 7 catches it, and there is no crash loop to notice.