create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent-migration.test.ts
|
|
3
|
+
* @description Drift guard between the consent_grants migration's BACKFILL
|
|
4
|
+
* and the consent service (#5275, spec §5.1 last bullet + §5.3 second
|
|
5
|
+
* boundary): every pre-existing listing gets one grant marked
|
|
6
|
+
* wording-UNRECOVERABLE — the exact `UNRECOVERABLE_CONSENT_STATEMENT`
|
|
7
|
+
* literal, so a later reader can match it — stamped at the listing's own
|
|
8
|
+
* consent_at, and project_listings itself is never touched.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
14
|
+
|
|
15
|
+
import { UNRECOVERABLE_CONSENT_STATEMENT } from "../consent";
|
|
16
|
+
|
|
17
|
+
const MIGRATION = join(
|
|
18
|
+
resolve(__dirname, "..", "..", "..", "..", ".."),
|
|
19
|
+
"database",
|
|
20
|
+
"migrations",
|
|
21
|
+
"20260911180000_consent_grants",
|
|
22
|
+
"migration.sql",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe("consent_grants migration backfill (#5275 D11)", () => {
|
|
26
|
+
const sql = readFileSync(MIGRATION, "utf-8");
|
|
27
|
+
|
|
28
|
+
it("creates the table with its two indexes", () => {
|
|
29
|
+
expect(sql).toContain('CREATE TABLE "consent_grants"');
|
|
30
|
+
expect(sql).toContain('("site_id", "subject_type", "subject_id")');
|
|
31
|
+
expect(sql).toContain('("site_id", "purpose_id")');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("inserts one row per pre-existing project_listings row", () => {
|
|
35
|
+
expect(sql).toContain('INSERT INTO "consent_grants"');
|
|
36
|
+
expect(sql).toMatch(/FROM "project_listings"/);
|
|
37
|
+
expect(sql).toContain("'project_listing'");
|
|
38
|
+
expect(sql).toContain("'service_terms'");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("marks every backfilled row with the exact unrecoverable sentinel and a COMPUTED hash", () => {
|
|
42
|
+
expect(sql).toContain(`'${UNRECOVERABLE_CONSENT_STATEMENT}'`);
|
|
43
|
+
expect(sql).toContain(
|
|
44
|
+
`encode(sha256(convert_to('${UNRECOVERABLE_CONSENT_STATEMENT}', 'UTF8')), 'hex')`,
|
|
45
|
+
);
|
|
46
|
+
expect(sql).toContain("'und'");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("sources created_at from the listing's own consent_at", () => {
|
|
50
|
+
const insert = sql.slice(sql.indexOf('INSERT INTO "consent_grants"'));
|
|
51
|
+
expect(insert).toContain('"consent_at"');
|
|
52
|
+
expect(insert).toContain('"created_at"');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("never UPDATEs or ALTERs project_listings — consent_at stays as it was", () => {
|
|
56
|
+
expect(sql).not.toMatch(/UPDATE\s+"project_listings"/i);
|
|
57
|
+
expect(sql).not.toMatch(/ALTER\s+TABLE\s+"project_listings"/i);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("never stamps today's default wording onto a grant nobody read", () => {
|
|
61
|
+
expect(sql).not.toContain("You agree to being contacted");
|
|
62
|
+
const defaultStatement = SITE_CONSENT_CONFIG_DEFAULTS.purposes[0]!.label["en"]!;
|
|
63
|
+
expect(sql).not.toContain(defaultStatement);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent.test.ts
|
|
3
|
+
* @description Unit tests for the consent-grant writer (#5275 Part B) —
|
|
4
|
+
* `recordConsent`, `recordLegacyConsent`, `consentStatementHash`,
|
|
5
|
+
* `consentConfigForSite`.
|
|
6
|
+
*
|
|
7
|
+
* External dependency boundaries mocked:
|
|
8
|
+
* - @working-theory/database (via moduleNameMapper -> prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
|
|
9
|
+
*
|
|
10
|
+
* The evidence rule under test: a row carries the statement the registry
|
|
11
|
+
* RESOLVED (the same function the renderer used), in the locale it was
|
|
12
|
+
* actually served, or the unrecoverable sentinel — never anything a request
|
|
13
|
+
* body said and never today's wording for a grant nobody read.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
17
|
+
jest.mock("../../lib/prisma.js");
|
|
18
|
+
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_CONSENT_PURPOSE_ID,
|
|
23
|
+
resolveConsentStatement,
|
|
24
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
25
|
+
siteConsentConfigSchema,
|
|
26
|
+
} from "@working-theory/validation";
|
|
27
|
+
|
|
28
|
+
import { prisma } from "../../lib/prisma.js";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
consentConfigForSite,
|
|
32
|
+
consentStatementHash,
|
|
33
|
+
recordConsent,
|
|
34
|
+
recordLegacyConsent,
|
|
35
|
+
UNRECOVERABLE_CONSENT_LOCALE,
|
|
36
|
+
UNRECOVERABLE_CONSENT_STATEMENT,
|
|
37
|
+
requiredConsentUnmet,
|
|
38
|
+
} from "../consent";
|
|
39
|
+
|
|
40
|
+
const SITE_ID = "site-1";
|
|
41
|
+
const SUBJECT = { subjectType: "project_listing", subjectId: "prjl_00000000-0000-7000-8000-000000000001" };
|
|
42
|
+
|
|
43
|
+
/** Two purposes, one of them bilingual — the fixture every locale case reads from. */
|
|
44
|
+
const CONFIG = siteConsentConfigSchema.parse({
|
|
45
|
+
policies: { terms: "/terms", privacy: "/privacy" },
|
|
46
|
+
purposes: [
|
|
47
|
+
{
|
|
48
|
+
id: "service_terms",
|
|
49
|
+
required: true,
|
|
50
|
+
label: {
|
|
51
|
+
en: "You agree to our [Terms](terms).",
|
|
52
|
+
fr: "Vous acceptez nos [Conditions](terms).",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "marketing_email",
|
|
57
|
+
required: false,
|
|
58
|
+
label: { en: "Send me occasional product news." },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Row = Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
function writtenRows(call = 0): Row[] {
|
|
66
|
+
const arg = (prisma.consentGrant.createMany as jest.Mock).mock.calls[call]![0] as { data: Row[] };
|
|
67
|
+
return arg.data;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("recordConsent (#5275)", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
74
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("one granted + one declined purpose → ONE createMany with two rows, one granted:true and one granted:false (D8)", async () => {
|
|
79
|
+
const written = await recordConsent({
|
|
80
|
+
siteId: SITE_ID,
|
|
81
|
+
...SUBJECT,
|
|
82
|
+
locale: "en",
|
|
83
|
+
config: CONFIG,
|
|
84
|
+
consents: { service_terms: true, marketing_email: false },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(written).toBe(2);
|
|
88
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
89
|
+
const rows = writtenRows();
|
|
90
|
+
expect(rows).toHaveLength(2);
|
|
91
|
+
expect(rows.find((r) => r["purposeId"] === "service_terms")).toMatchObject({
|
|
92
|
+
siteId: SITE_ID,
|
|
93
|
+
...SUBJECT,
|
|
94
|
+
granted: true,
|
|
95
|
+
});
|
|
96
|
+
expect(rows.find((r) => r["purposeId"] === "marketing_email")).toMatchObject({
|
|
97
|
+
siteId: SITE_ID,
|
|
98
|
+
...SUBJECT,
|
|
99
|
+
granted: false,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("each row carries the RESOLVED statement text and the locale it was actually served in (D7)", async () => {
|
|
104
|
+
// `fr-CA` is asked for; the bilingual purpose serves `fr` (language-subtag
|
|
105
|
+
// fallback), the English-only purpose serves `en` (default-locale
|
|
106
|
+
// fallback). The row records the locale READ, not the one requested.
|
|
107
|
+
await recordConsent({
|
|
108
|
+
siteId: SITE_ID,
|
|
109
|
+
...SUBJECT,
|
|
110
|
+
locale: "fr-CA",
|
|
111
|
+
config: CONFIG,
|
|
112
|
+
consents: { service_terms: true, marketing_email: true },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const rows = writtenRows();
|
|
116
|
+
const terms = rows.find((r) => r["purposeId"] === "service_terms")!;
|
|
117
|
+
const marketing = rows.find((r) => r["purposeId"] === "marketing_email")!;
|
|
118
|
+
|
|
119
|
+
const expectedTerms = resolveConsentStatement(CONFIG.purposes[0]!, "fr-CA");
|
|
120
|
+
expect(expectedTerms.locale).toBe("fr");
|
|
121
|
+
expect(terms["statement"]).toBe("Vous acceptez nos [Conditions](terms).");
|
|
122
|
+
expect(terms["statement"]).toBe(expectedTerms.statement);
|
|
123
|
+
expect(terms["locale"]).toBe("fr");
|
|
124
|
+
expect(terms["statementHash"]).toBe(consentStatementHash(expectedTerms.statement));
|
|
125
|
+
|
|
126
|
+
expect(marketing["statement"]).toBe("Send me occasional product news.");
|
|
127
|
+
expect(marketing["locale"]).toBe("en");
|
|
128
|
+
expect(marketing["locale"]).not.toBe("fr-CA");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("a second recordConsent for the same subject + purpose APPENDS — createMany again, never update/upsert (D9)", async () => {
|
|
132
|
+
const input = {
|
|
133
|
+
siteId: SITE_ID,
|
|
134
|
+
...SUBJECT,
|
|
135
|
+
locale: "en",
|
|
136
|
+
config: CONFIG,
|
|
137
|
+
consents: { marketing_email: true },
|
|
138
|
+
};
|
|
139
|
+
await recordConsent(input);
|
|
140
|
+
await recordConsent({ ...input, consents: { marketing_email: false } });
|
|
141
|
+
|
|
142
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(writtenRows(0)[0]).toMatchObject({ purposeId: "marketing_email", granted: true });
|
|
144
|
+
expect(writtenRows(1)[0]).toMatchObject({ purposeId: "marketing_email", granted: false });
|
|
145
|
+
// The mock delegate deliberately exposes no mutating method: the writer
|
|
146
|
+
// has nothing to update or upsert WITH, so append is structural.
|
|
147
|
+
const delegate = prisma.consentGrant as Record<string, unknown>;
|
|
148
|
+
expect(delegate["update"]).toBeUndefined();
|
|
149
|
+
expect(delegate["upsert"]).toBeUndefined();
|
|
150
|
+
expect(delegate["updateMany"]).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("an unknown purpose id writes NOTHING for it — no statement was rendered (evidence rule)", async () => {
|
|
154
|
+
const written = await recordConsent({
|
|
155
|
+
siteId: SITE_ID,
|
|
156
|
+
...SUBJECT,
|
|
157
|
+
locale: "en",
|
|
158
|
+
config: CONFIG,
|
|
159
|
+
consents: { service_terms: true, not_a_purpose: true },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(written).toBe(1);
|
|
163
|
+
const rows = writtenRows();
|
|
164
|
+
expect(rows).toHaveLength(1);
|
|
165
|
+
expect(rows[0]!["purposeId"]).toBe("service_terms");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("only unknown purpose ids → no createMany at all, returns 0", async () => {
|
|
169
|
+
const written = await recordConsent({
|
|
170
|
+
siteId: SITE_ID,
|
|
171
|
+
...SUBJECT,
|
|
172
|
+
locale: "en",
|
|
173
|
+
config: CONFIG,
|
|
174
|
+
consents: { not_a_purpose: true },
|
|
175
|
+
});
|
|
176
|
+
expect(written).toBe(0);
|
|
177
|
+
expect(prisma.consentGrant.createMany).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("never writes the unrecoverable sentinel when a statement was resolved", async () => {
|
|
181
|
+
await recordConsent({
|
|
182
|
+
siteId: SITE_ID,
|
|
183
|
+
...SUBJECT,
|
|
184
|
+
locale: "en",
|
|
185
|
+
config: SITE_CONSENT_CONFIG_DEFAULTS,
|
|
186
|
+
consents: { [DEFAULT_CONSENT_PURPOSE_ID]: true },
|
|
187
|
+
});
|
|
188
|
+
const [row] = writtenRows();
|
|
189
|
+
expect(row!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
|
|
190
|
+
expect(row!["locale"]).not.toBe(UNRECOVERABLE_CONSENT_LOCALE);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("recordLegacyConsent (#5275 D11)", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
jest.clearAllMocks();
|
|
197
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
198
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("writes exactly one row: sentinel statement, locale 'und', purpose service_terms under the default config", async () => {
|
|
203
|
+
const written = await recordLegacyConsent({
|
|
204
|
+
siteId: SITE_ID,
|
|
205
|
+
...SUBJECT,
|
|
206
|
+
config: SITE_CONSENT_CONFIG_DEFAULTS,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(written).toBe(1);
|
|
210
|
+
expect(prisma.consentGrant.createMany).toHaveBeenCalledTimes(1);
|
|
211
|
+
const rows = writtenRows();
|
|
212
|
+
expect(rows).toHaveLength(1);
|
|
213
|
+
expect(rows[0]).toEqual({
|
|
214
|
+
siteId: SITE_ID,
|
|
215
|
+
...SUBJECT,
|
|
216
|
+
purposeId: DEFAULT_CONSENT_PURPOSE_ID,
|
|
217
|
+
granted: true,
|
|
218
|
+
locale: UNRECOVERABLE_CONSENT_LOCALE,
|
|
219
|
+
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
220
|
+
statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
221
|
+
});
|
|
222
|
+
expect(rows[0]!["purposeId"]).toBe("service_terms");
|
|
223
|
+
expect(rows[0]!["locale"]).toBe("und");
|
|
224
|
+
// Never today's wording for a grant nobody read.
|
|
225
|
+
expect(rows[0]!["statement"]).not.toContain("You agree to being contacted");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("targets the first REQUIRED purpose, not merely the first purpose", async () => {
|
|
229
|
+
const config = siteConsentConfigSchema.parse({
|
|
230
|
+
purposes: [
|
|
231
|
+
{ id: "marketing_email", required: false, label: { en: "News." } },
|
|
232
|
+
{ id: "service_terms", required: true, label: { en: "Terms." } },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
|
|
236
|
+
expect(writtenRows()[0]!["purposeId"]).toBe("service_terms");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("falls back to the first purpose when none is required", async () => {
|
|
240
|
+
const config = siteConsentConfigSchema.parse({
|
|
241
|
+
purposes: [{ id: "marketing_email", required: false, label: { en: "News." } }],
|
|
242
|
+
});
|
|
243
|
+
await recordLegacyConsent({ siteId: SITE_ID, ...SUBJECT, config });
|
|
244
|
+
expect(writtenRows()[0]!["purposeId"]).toBe("marketing_email");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("consentStatementHash", () => {
|
|
249
|
+
it("is sha256 hex", () => {
|
|
250
|
+
expect(consentStatementHash("abc")).toBe(
|
|
251
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
|
252
|
+
);
|
|
253
|
+
expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toBe(
|
|
254
|
+
createHash("sha256").update(UNRECOVERABLE_CONSENT_STATEMENT).digest("hex"),
|
|
255
|
+
);
|
|
256
|
+
expect(consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT)).toMatch(/^[0-9a-f]{64}$/);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("consentConfigForSite", () => {
|
|
261
|
+
it("resolves every site to the platform defaults until a config row exists", async () => {
|
|
262
|
+
await expect(consentConfigForSite(SITE_ID)).resolves.toBe(SITE_CONSENT_CONFIG_DEFAULTS);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("requiredConsentUnmet (#5275, security review Medium 1)", () => {
|
|
267
|
+
const config = {
|
|
268
|
+
policies: {},
|
|
269
|
+
purposes: [
|
|
270
|
+
{ id: "service_terms", required: true, label: { en: "a" } },
|
|
271
|
+
{ id: "marketing", required: false, label: { en: "b" } },
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
it("names a required purpose that is declined OR absent, and passes when every required one is true", () => {
|
|
276
|
+
expect(requiredConsentUnmet(config, { service_terms: false })).toBe("service_terms");
|
|
277
|
+
expect(requiredConsentUnmet(config, {})).toBe("service_terms");
|
|
278
|
+
expect(requiredConsentUnmet(config, { marketing: true })).toBe("service_terms");
|
|
279
|
+
expect(requiredConsentUnmet(config, { service_terms: true })).toBeUndefined();
|
|
280
|
+
expect(requiredConsentUnmet(config, { service_terms: true, marketing: false })).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent.ts
|
|
3
|
+
* @description The consent-grant writer (#5275 Part B) — `recordConsent`,
|
|
4
|
+
* the one function that turns a person's answers to a site's consent
|
|
5
|
+
* purposes into `ConsentGrant` rows, plus `recordLegacyConsent`, the D11
|
|
6
|
+
* path for a caller that could only say `consent: true`.
|
|
7
|
+
*
|
|
8
|
+
* SURFACE-AGNOSTIC BY DESIGN (D10). A grant's subject is a `subjectType` +
|
|
9
|
+
* `subjectId` pair of plain scalars with no relation into any other table
|
|
10
|
+
* (ADR 0009 invariant 1), so a new surface — the newsletter subscribe path
|
|
11
|
+
* is the named follow-up — adopts consent by adding ONE call here and NO
|
|
12
|
+
* migration. Nothing in this module knows what a project listing is.
|
|
13
|
+
*
|
|
14
|
+
* THE EVIDENCE RULE (spec §5.2): no row is ever written with statement text
|
|
15
|
+
* the system did not actually render to that person. Two consequences:
|
|
16
|
+
*
|
|
17
|
+
* - `recordConsent` resolves each statement from the SAME registry and the
|
|
18
|
+
* SAME `resolveConsentStatement` the renderer used (D7), so the recorded
|
|
19
|
+
* wording and the rendered wording are one string by construction, and a
|
|
20
|
+
* request body never carries wording at all. A purpose id the registry
|
|
21
|
+
* does not know is skipped, not written: no statement was rendered for it.
|
|
22
|
+
* - A grant whose wording cannot be proven — a legacy `consent: true` body,
|
|
23
|
+
* or the migration backfill — carries `UNRECOVERABLE_CONSENT_STATEMENT`,
|
|
24
|
+
* never today's default text. Stamping current wording onto a grant
|
|
25
|
+
* nobody read would fabricate evidence (D11).
|
|
26
|
+
*
|
|
27
|
+
* APPEND-ONLY (D9): every write is a `createMany`; this module never
|
|
28
|
+
* updates or upserts, so a later withdrawal sits beside the earlier grant
|
|
29
|
+
* rather than replacing it. Declines are written as `granted: false` (D8).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
import { logger } from "@working-theory/observability";
|
|
35
|
+
import {
|
|
36
|
+
consentPurposeById,
|
|
37
|
+
resolveConsentStatement,
|
|
38
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
39
|
+
type SiteConsentConfig,
|
|
40
|
+
} from "@working-theory/validation";
|
|
41
|
+
|
|
42
|
+
import { prisma } from "../lib/prisma.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one delegate this module writes through — the shape of `prisma` AND of
|
|
46
|
+
* the `tx` client an interactive `$transaction` hands out, so a caller that
|
|
47
|
+
* creates the subject row can write the grant in the SAME transaction (the
|
|
48
|
+
* listing's `start` does): either the row and its evidence both land, or
|
|
49
|
+
* neither does.
|
|
50
|
+
*/
|
|
51
|
+
export interface ConsentWriteClient {
|
|
52
|
+
consentGrant: {
|
|
53
|
+
createMany(args: { data: ConsentGrantRow[] }): Promise<{ count: number }>;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ConsentGrantRow {
|
|
58
|
+
siteId: string;
|
|
59
|
+
subjectType: string;
|
|
60
|
+
subjectId: string;
|
|
61
|
+
purposeId: string;
|
|
62
|
+
granted: boolean;
|
|
63
|
+
locale: string;
|
|
64
|
+
statement: string;
|
|
65
|
+
statementHash: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The most keys a `consents` map may carry on an anonymous request. Any real
|
|
70
|
+
* registry is a handful of purposes; the cap bounds the per-request work an
|
|
71
|
+
* unbounded map could buy (security review, Low 3).
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_CONSENT_KEYS = 64;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The statement sentinel for a grant whose wording cannot be proven — a row
|
|
77
|
+
* carrying it records THAT a person consented (and when), not WHAT they read.
|
|
78
|
+
* Written on exactly two paths: `recordLegacyConsent` (a body carrying only
|
|
79
|
+
* the legacy `consent: true` literal, D11) and the consent_grants migration's
|
|
80
|
+
* backfill of pre-existing listings, which must carry this exact literal.
|
|
81
|
+
* It is never a substitute for real wording: a caller that rendered a
|
|
82
|
+
* statement records it through `recordConsent`.
|
|
83
|
+
*/
|
|
84
|
+
export const UNRECOVERABLE_CONSENT_STATEMENT =
|
|
85
|
+
"[unrecoverable: no statement was rendered by this system for this grant]";
|
|
86
|
+
|
|
87
|
+
/** BCP-47 `und` — the locale of a statement nobody read. */
|
|
88
|
+
export const UNRECOVERABLE_CONSENT_LOCALE = "und";
|
|
89
|
+
|
|
90
|
+
/** sha256 hex of a statement — the `statementHash` that groups identical wordings. */
|
|
91
|
+
export function consentStatementHash(statement: string): string {
|
|
92
|
+
return createHash("sha256").update(statement).digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The site's consent registry.
|
|
97
|
+
*
|
|
98
|
+
* There is no per-site consent config ROW yet (spec §4 deliberately adds
|
|
99
|
+
* none), so every site resolves to `SITE_CONSENT_CONFIG_DEFAULTS`. This
|
|
100
|
+
* function is the single seam a `SiteConsentConfig` row wires into later
|
|
101
|
+
* (the `listingConfigFor` shape: read the blob, `consentConfigFor(blob)`).
|
|
102
|
+
* The RENDERER (`packages/ui`) resolves its statements from the same
|
|
103
|
+
* `SITE_CONSENT_CONFIG_DEFAULTS`, which is what keeps the recorded statement
|
|
104
|
+
* identical to the rendered one until a config row exists — at which point
|
|
105
|
+
* both sides must read the same row.
|
|
106
|
+
*/
|
|
107
|
+
export async function consentConfigForSite(
|
|
108
|
+
_siteId: string,
|
|
109
|
+
): Promise<SiteConsentConfig> {
|
|
110
|
+
return SITE_CONSENT_CONFIG_DEFAULTS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The id of the first REQUIRED registry purpose `consents` does not GRANT —
|
|
115
|
+
* declined (`false`) or simply absent — or `undefined` when every required
|
|
116
|
+
* purpose is `true`. Absence counts as unmet on purpose (security review,
|
|
117
|
+
* Medium 1): a body carrying `consents: {}` beside `consent: true` would
|
|
118
|
+
* otherwise create the subject with no grant row at all, which is a worse
|
|
119
|
+
* record than the legacy sentinel. A surface whose subject cannot exist
|
|
120
|
+
* without the required purposes (the listing: the row goes on to publish the
|
|
121
|
+
* founder's address) checks this before creating the subject.
|
|
122
|
+
*/
|
|
123
|
+
export function requiredConsentUnmet(
|
|
124
|
+
config: SiteConsentConfig,
|
|
125
|
+
consents: Record<string, boolean>,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
for (const purpose of config.purposes) {
|
|
128
|
+
if (purpose.required && consents[purpose.id] !== true) return purpose.id;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface RecordConsentInput {
|
|
134
|
+
siteId: string;
|
|
135
|
+
/** e.g. `project_listing`, `subscriber` — a plain scalar, no relation (D10). */
|
|
136
|
+
subjectType: string;
|
|
137
|
+
subjectId: string;
|
|
138
|
+
/** The locale the statements were RENDERED in; the served locale is what gets recorded. */
|
|
139
|
+
locale: string;
|
|
140
|
+
config: SiteConsentConfig;
|
|
141
|
+
/** purposeId → the person's answer. `false` is a stored decline (D8). */
|
|
142
|
+
consents: Record<string, boolean>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write one `ConsentGrant` row per answered purpose in one `createMany`.
|
|
147
|
+
* Returns the number of rows written. Append-only — never update/upsert.
|
|
148
|
+
* `db` defaults to the shared client; pass a `$transaction` client to land
|
|
149
|
+
* the grant atomically with the subject row it belongs to.
|
|
150
|
+
*/
|
|
151
|
+
export async function recordConsent(
|
|
152
|
+
input: RecordConsentInput,
|
|
153
|
+
db: ConsentWriteClient = prisma,
|
|
154
|
+
): Promise<number> {
|
|
155
|
+
const rows: ConsentGrantRow[] = [];
|
|
156
|
+
const unknown: string[] = [];
|
|
157
|
+
|
|
158
|
+
for (const [purposeId, granted] of Object.entries(input.consents)) {
|
|
159
|
+
const purpose = consentPurposeById(input.config, purposeId);
|
|
160
|
+
if (!purpose) {
|
|
161
|
+
// Not in the registry ⇒ no statement was rendered for it ⇒ nothing to
|
|
162
|
+
// record (the evidence rule). Collected and logged ONCE per call below,
|
|
163
|
+
// because a client naming a purpose the site does not declare is a
|
|
164
|
+
// drift someone should see — and a map full of junk keys must not buy
|
|
165
|
+
// a log line each.
|
|
166
|
+
unknown.push(purposeId);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const { statement, locale } = resolveConsentStatement(purpose, input.locale);
|
|
170
|
+
rows.push({
|
|
171
|
+
siteId: input.siteId,
|
|
172
|
+
subjectType: input.subjectType,
|
|
173
|
+
subjectId: input.subjectId,
|
|
174
|
+
purposeId,
|
|
175
|
+
granted,
|
|
176
|
+
locale,
|
|
177
|
+
statement,
|
|
178
|
+
statementHash: consentStatementHash(statement),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (unknown.length > 0) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
{
|
|
185
|
+
siteId: input.siteId,
|
|
186
|
+
subjectType: input.subjectType,
|
|
187
|
+
unknownCount: unknown.length,
|
|
188
|
+
sample: unknown.slice(0, 5),
|
|
189
|
+
},
|
|
190
|
+
"[consent] skipping unknown purpose ids — not in the site's consent registry, no statement was rendered for them",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (rows.length === 0) return 0;
|
|
195
|
+
const result = await db.consentGrant.createMany({ data: rows });
|
|
196
|
+
return result.count;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface RecordLegacyConsentInput {
|
|
200
|
+
siteId: string;
|
|
201
|
+
subjectType: string;
|
|
202
|
+
subjectId: string;
|
|
203
|
+
config: SiteConsentConfig;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The D11 path: a body that carried only the legacy `consent: true` literal.
|
|
208
|
+
* Writes ONE grant against the site's first `required` purpose (falling back
|
|
209
|
+
* to its first purpose), `granted: true`, with the statement marked
|
|
210
|
+
* unrecoverable — the caller affirmed, but this system rendered it no
|
|
211
|
+
* wording to affirm.
|
|
212
|
+
*/
|
|
213
|
+
export async function recordLegacyConsent(
|
|
214
|
+
input: RecordLegacyConsentInput,
|
|
215
|
+
db: ConsentWriteClient = prisma,
|
|
216
|
+
): Promise<number> {
|
|
217
|
+
const purpose =
|
|
218
|
+
input.config.purposes.find((p) => p.required) ?? input.config.purposes[0];
|
|
219
|
+
if (!purpose) return 0;
|
|
220
|
+
|
|
221
|
+
const result = await db.consentGrant.createMany({
|
|
222
|
+
data: [
|
|
223
|
+
{
|
|
224
|
+
siteId: input.siteId,
|
|
225
|
+
subjectType: input.subjectType,
|
|
226
|
+
subjectId: input.subjectId,
|
|
227
|
+
purposeId: purpose.id,
|
|
228
|
+
granted: true,
|
|
229
|
+
locale: UNRECOVERABLE_CONSENT_LOCALE,
|
|
230
|
+
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
231
|
+
statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
return result.count;
|
|
236
|
+
}
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* @description The deliverable-fulfillment service (#3961 D3/D4/D5/D7;
|
|
4
4
|
* extended #3998 D1/D3/D5; extended FEAT-074 U4b, #4718 D4).
|
|
5
5
|
*
|
|
6
|
+
* NOT marked `oss: false` and NOT in signal-collectors's `ownedFiles`
|
|
7
|
+
* (module-architecture.ts), despite D-48/D6 placing this file with
|
|
8
|
+
* signal-collectors — `services/recommender-capture.ts` imports it directly
|
|
9
|
+
* and is itself reached from `server.ts` by a plain, unconditional static
|
|
10
|
+
* import today (commerce/#5364 has not yet landed the gate around it).
|
|
11
|
+
* Marking or excluding this file broke both the OSS-strip and the create-ailk
|
|
12
|
+
* starter snapshot for EVERY module, not just signal-collectors (spec §5.4
|
|
13
|
+
* escalation boundary; see module-architecture.ts's comment on this file).
|
|
14
|
+
* Revert once #5364 lands.
|
|
15
|
+
*
|
|
6
16
|
* `fulfillDeliverable` is the one entry point, called from flow-engine's
|
|
7
17
|
* completion branch after `routeLead` succeeds and only when the session's
|
|
8
18
|
* `configSnapshot` carries a `deliverables` block (D4 — the block is opt-in
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* NOT marked `oss: false` (D-48/#5366 escalation — see
|
|
3
|
+
* `routes/project-listings/me.ts`'s header comment): imported by
|
|
4
|
+
* `routes/project-listings/{start,public}.ts`, each of which ships
|
|
5
|
+
* unconditionally because `routes/project-listings/index.ts`'s multi-tenant
|
|
6
|
+
* literal `import()` forces it. Registration stays gated.
|
|
7
|
+
*
|
|
2
8
|
* @file listing-config.ts
|
|
3
9
|
* @description The per-site project-listings config read (#5033) — one
|
|
4
10
|
* function, `listingConfigFor`, that resolves a site's window bounds and
|
|
@@ -53,9 +59,61 @@ export async function listingConfigFor(siteId: string): Promise<SiteListingConfi
|
|
|
53
59
|
);
|
|
54
60
|
return SITE_LISTING_CONFIG_DEFAULTS;
|
|
55
61
|
}
|
|
62
|
+
warnOnDroppedField(siteId, row.data, parsed.data);
|
|
56
63
|
return parsed.data;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The two `.catch(undefined)` members of `siteListingConfigSchema` are silent
|
|
68
|
+
* by construction (#5406): a malformed `resumeEmail` or `siteName` is DROPPED
|
|
69
|
+
* rather than failing the whole blob, so the site keeps its own window bounds
|
|
70
|
+
* and collected fields instead of silently reverting to the platform's more
|
|
71
|
+
* permissive defaults.
|
|
72
|
+
*
|
|
73
|
+
* Silent is the right BEHAVIOUR and the wrong OBSERVABILITY. An operator who
|
|
74
|
+
* typos a subject key gets a site that quietly stops sending the resume email
|
|
75
|
+
* with nothing anywhere saying why. This closes that: when the stored blob has
|
|
76
|
+
* the key and the parsed config does not, the drop is logged.
|
|
77
|
+
*
|
|
78
|
+
* Field NAMES only, never values — `resumeEmail` carries the site's addresses
|
|
79
|
+
* and the founder-facing copy, and this module sits on the anonymous path.
|
|
80
|
+
*/
|
|
81
|
+
function warnOnDroppedField(
|
|
82
|
+
siteId: string,
|
|
83
|
+
stored: unknown,
|
|
84
|
+
parsed: SiteListingConfig,
|
|
85
|
+
): void {
|
|
86
|
+
if (typeof stored !== "object" || stored === null) return;
|
|
87
|
+
const raw = stored as Record<string, unknown>;
|
|
88
|
+
for (const field of ["resumeEmail", "siteName"] as const) {
|
|
89
|
+
if (raw[field] !== undefined && parsed[field] === undefined) {
|
|
90
|
+
logger.warn(
|
|
91
|
+
{ siteId, field },
|
|
92
|
+
"[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `resumeEmail.maxSendsPerHour` carries its OWN `.catch(undefined)` (#5414),
|
|
98
|
+
// so a bad cap is dropped while the block around it survives — which the
|
|
99
|
+
// loop above, comparing whole members, cannot see. Checked only when the
|
|
100
|
+
// block itself parsed: a dropped block is already reported once above, and
|
|
101
|
+
// reporting it twice would make the narrower line the less trustworthy one.
|
|
102
|
+
const storedEmail = raw["resumeEmail"];
|
|
103
|
+
if (
|
|
104
|
+
typeof storedEmail === "object" &&
|
|
105
|
+
storedEmail !== null &&
|
|
106
|
+
(storedEmail as Record<string, unknown>)["maxSendsPerHour"] !== undefined &&
|
|
107
|
+
parsed.resumeEmail !== undefined &&
|
|
108
|
+
parsed.resumeEmail.maxSendsPerHour === undefined
|
|
109
|
+
) {
|
|
110
|
+
logger.warn(
|
|
111
|
+
{ siteId, field: "resumeEmail.maxSendsPerHour" },
|
|
112
|
+
"[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
/**
|
|
60
118
|
* Resolve a site from its PUBLIC SITE KEY (#5048 D2).
|
|
61
119
|
*
|