build(intake): add the Anthropic SDK and warn when its key is absent (#223)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 19:25:49 -05:00
co-authored by Claude Opus 5
parent 94e4f8c98a
commit 747ed2d00a
4 changed files with 118 additions and 3 deletions
+75 -1
View File
@@ -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"
}
}
}
}
+3 -1
View File
@@ -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",
+19 -1
View File
@@ -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)]
};
}
+21
View File
@@ -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/);
});
});
});