From 1b87e08262ca8cafa7b3e4d73fd28c22784d59d8 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 19:25:49 -0500 Subject: [PATCH] build(intake): add the Anthropic SDK and warn when its key is absent (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both packages go in dependencies rather than devDependencies. The final Docker stage installs with --omit=dev, so the wrong section produces a container that fails on the first submission and nowhere else — which is how sharp went wrong in #226. ANTHROPIC_API_KEY is a warning, not a requirement. Absent, the container still boots and a submission still arrives, keeps its photos and waits in the queue undrafted. The photos are often the only copy of an item no longer in the sender's hands, so losing a consignment to an expired key would be a worse outcome than an item arriving without its description written. Silence would be wrong too: an operator who believes drafting is on and finds every item undrafted has nothing to tell them why. Co-Authored-By: Claude Opus 5 --- backend/package-lock.json | 76 +++++++++++++++++++++++- backend/package.json | 4 +- backend/src/envValidation.ts | 20 ++++++- backend/tests/unit/envValidation.test.ts | 21 +++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 59b06f3..2668c3b 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "redefined-designs-backend", "version": "1.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", @@ -20,7 +21,8 @@ "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.14", "pg": "^8.12.0", - "sharp": "^0.35.4" + "sharp": "^0.35.4", + "zod": "^4.5.4" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -52,6 +54,26 @@ "node": ">=20.9.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz", + "integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -518,6 +540,14 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -3142,6 +3172,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "node_modules/@tinqerjs/pg-promise-adapter": { "version": "0.0.27", "resolved": "https://registry.npmjs.org/@tinqerjs/pg-promise-adapter/-/pg-promise-adapter-0.0.27.tgz", @@ -6123,6 +6158,11 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -7579,6 +7619,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9272,6 +9324,15 @@ "node": ">=10" } }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9579,6 +9640,11 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10075,6 +10141,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 9f2c296..04fbdd0 100755 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "migrate:create": "node-pg-migrate create --migration-file-language js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", @@ -39,7 +40,8 @@ "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.14", "pg": "^8.12.0", - "sharp": "^0.35.4" + "sharp": "^0.35.4", + "zod": "^4.5.4" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/backend/src/envValidation.ts b/backend/src/envValidation.ts index 568a876..4ba3d98 100644 --- a/backend/src/envValidation.ts +++ b/backend/src/envValidation.ts @@ -162,6 +162,24 @@ function checkAdminGate(env: NodeJS.ProcessEnv): string[] { ]; } +// Optional on purpose, and unlike the two above, unset costs nothing in +// safety. A submission still arrives, keeps its photos and waits in the queue +// undrafted (#223). It is a warning rather than an error because the photos are +// often the only copy of an item no longer in the sender's hands, so losing a +// consignment to an expired key would be far worse than an item arriving +// without its description written. Silence would be the wrong answer too: an +// operator who believes drafting is on and finds every item undrafted has +// nothing to tell them why. +function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { + if (isPresent(env, 'ANTHROPIC_API_KEY')) { + return []; + } + return [ + 'ANTHROPIC_API_KEY is not set — submitted items will arrive undrafted and wait in the ' + + 'review queue for someone to write them up by hand.' + ]; +} + // Optional, and the same shape as the admin gate above: unset is a working // configuration with one defence switched off, which is worth saying out loud // rather than leaving to be discovered. Set, it has to be an absolute origin — @@ -205,6 +223,6 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation { ...mail.errors, ...uploads.errors ], - warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings] + warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env)] }; } diff --git a/backend/tests/unit/envValidation.test.ts b/backend/tests/unit/envValidation.test.ts index ef900ec..154c13d 100644 --- a/backend/tests/unit/envValidation.test.ts +++ b/backend/tests/unit/envValidation.test.ts @@ -215,4 +215,25 @@ describe('UPLOADS_BASE_URL', () => { const { errors } = validateEnv(withEnv({ UPLOADS_BASE_URL: '/uploads' })); expect(errors).toContainEqual(expect.stringContaining('absolute origin')); }); + // #223. MINIMAL deliberately has no ANTHROPIC_API_KEY, so it is already the + // absent case. + describe('the intake drafting key', () => { + // Absent is a working configuration, so this must never reach the errors + // list. A submission that arrives undrafted is a far better outcome than a + // container that will not boot. + it('is not required', () => { + expect(validateEnv(MINIMAL).errors).toEqual([]); + }); + + // But silence would be worse than a warning: an operator who thinks + // drafting is on and finds every item undrafted has no way to tell why. + it('warns when it is absent', () => { + expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/); + }); + + it('says nothing when it is set', () => { + const { warnings } = validateEnv(withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' })); + expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/); + }); + }); });