create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* apps/api's live-key admission (#8450 D2, D3) over the REAL
|
|
4
|
+
* `@working-theory/stripe-client` — no mock, so the process-wide flag the
|
|
5
|
+
* package keeps is the one these cases arm or leave alone.
|
|
6
|
+
*
|
|
7
|
+
* Admission is one-way per module registry, so every case loads
|
|
8
|
+
* `lib/stripe.ts` inside its own `jest.isolateModules` with `process.env`
|
|
9
|
+
* set before the `require` and restored after.
|
|
10
|
+
*
|
|
11
|
+
* Every live-shaped key is composed at runtime from fragments, so this file
|
|
12
|
+
* never spells a live prefix (architecture.yaml's `(sk|pk|rk)_live_` pattern)
|
|
13
|
+
* and never names the SDK's admission call.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
type Shim = typeof import("../stripe");
|
|
19
|
+
|
|
20
|
+
const RESTRICTED_LIVE = ["rk", "live", ""].join("_");
|
|
21
|
+
const SECRET_LIVE = ["sk", "live", ""].join("_");
|
|
22
|
+
const PUBLISHABLE_LIVE = ["pk", "live", ""].join("_");
|
|
23
|
+
// A distinctive tail: no refusal message may carry any of it.
|
|
24
|
+
const TAIL = "FixtureOnlyQv7Zq9X";
|
|
25
|
+
|
|
26
|
+
const restrictedLiveKey = RESTRICTED_LIVE + TAIL;
|
|
27
|
+
const secretLiveKey = SECRET_LIVE + TAIL;
|
|
28
|
+
const publishableLiveKey = PUBLISHABLE_LIVE + TAIL;
|
|
29
|
+
const testKey = "sk_test_" + TAIL;
|
|
30
|
+
|
|
31
|
+
const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "VERCEL", "NODE_ENV"] as const;
|
|
32
|
+
type EnvKey = (typeof ENV_KEYS)[number];
|
|
33
|
+
|
|
34
|
+
/** Load a fresh `lib/stripe.ts` under `env`, run `fn`, then restore the four variables. */
|
|
35
|
+
function withShim(env: Partial<Record<EnvKey, string>>, fn: (shim: Shim) => void): void {
|
|
36
|
+
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
37
|
+
try {
|
|
38
|
+
for (const k of ENV_KEYS) {
|
|
39
|
+
if (env[k] === undefined) delete process.env[k];
|
|
40
|
+
else process.env[k] = env[k];
|
|
41
|
+
}
|
|
42
|
+
jest.isolateModules(() => {
|
|
43
|
+
fn(require("../stripe") as Shim);
|
|
44
|
+
});
|
|
45
|
+
} finally {
|
|
46
|
+
for (const k of ENV_KEYS) {
|
|
47
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
48
|
+
else process.env[k] = saved[k];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The thrown error, asserted to be the shim's `StripeConfigError` and to carry no key material. */
|
|
54
|
+
function refusal(shim: Shim, fn: () => unknown): Error {
|
|
55
|
+
let caught: unknown;
|
|
56
|
+
try {
|
|
57
|
+
fn();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
caught = err;
|
|
60
|
+
}
|
|
61
|
+
expect(caught).toBeInstanceOf(shim.StripeConfigError);
|
|
62
|
+
const message = (caught as Error).message;
|
|
63
|
+
expect(message).not.toContain(TAIL);
|
|
64
|
+
expect(message).not.toContain(TAIL.slice(-4));
|
|
65
|
+
return caught as Error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LIVE_PRODUCTION = {
|
|
69
|
+
STRIPE_SECRET_KEY: restrictedLiveKey,
|
|
70
|
+
STRIPE_MODE: "live",
|
|
71
|
+
VERCEL_ENV: "production",
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
describe("admitApiStripeKey — D2's table", () => {
|
|
75
|
+
it("takes no parameter, so nothing can arm it from a synthetic object", () => {
|
|
76
|
+
withShim({ STRIPE_SECRET_KEY: testKey }, (shim) => {
|
|
77
|
+
expect(shim.admitApiStripeKey.length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("admits a restricted live key beside STRIPE_MODE=live on a Vercel production build", () => {
|
|
82
|
+
withShim(LIVE_PRODUCTION, (shim) => {
|
|
83
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
84
|
+
expect(shim.admitApiStripeKey()).toBe("live");
|
|
85
|
+
expect(shim.stripeKeyMode()).toBe("live");
|
|
86
|
+
|
|
87
|
+
// F2/F3 — the admitted class passes; the other two live classes do not.
|
|
88
|
+
expect(() => shim.assertAdmittedStripeKey(restrictedLiveKey)).not.toThrow();
|
|
89
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(secretLiveKey));
|
|
90
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(publishableLiveKey));
|
|
91
|
+
// A test key still passes after admission.
|
|
92
|
+
expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
|
|
93
|
+
|
|
94
|
+
// The client the route then calls accepts the same key.
|
|
95
|
+
expect(shim.getStripeClient()).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("off Vercel, reads NODE_ENV=production as the production build", () => {
|
|
100
|
+
withShim(
|
|
101
|
+
{ STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", NODE_ENV: "production" },
|
|
102
|
+
(shim) => {
|
|
103
|
+
expect(shim.admitApiStripeKey()).toBe("live");
|
|
104
|
+
expect(shim.stripeKeyMode()).toBe("live");
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it.each([
|
|
110
|
+
["a Vercel preview build", { VERCEL_ENV: "preview", NODE_ENV: "production" }],
|
|
111
|
+
["a non-production build off Vercel", { NODE_ENV: "test" }],
|
|
112
|
+
// vercel.json sets NODE_ENV=production everywhere, Preview included.
|
|
113
|
+
["Vercel with VERCEL_ENV missing, even beside NODE_ENV=production", { VERCEL: "1", NODE_ENV: "production" }],
|
|
114
|
+
])("refuses a restricted live key with STRIPE_MODE=live on %s", (_label, build) => {
|
|
115
|
+
withShim({ STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", ...build }, (shim) => {
|
|
116
|
+
const err = refusal(shim, () => shim.admitApiStripeKey());
|
|
117
|
+
expect(err.message).toMatch(/production/);
|
|
118
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
119
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
|
|
120
|
+
refusal(shim, () => shim.getStripeClient());
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([
|
|
125
|
+
["an unrestricted live key", secretLiveKey],
|
|
126
|
+
["a publishable live key", publishableLiveKey],
|
|
127
|
+
["a test key", testKey],
|
|
128
|
+
])("refuses STRIPE_MODE=live beside %s", (_label, key) => {
|
|
129
|
+
withShim({ STRIPE_SECRET_KEY: key, STRIPE_MODE: "live", VERCEL_ENV: "production" }, (shim) => {
|
|
130
|
+
const err = refusal(shim, () => shim.admitApiStripeKey());
|
|
131
|
+
expect(err.message).toMatch(/STRIPE_MODE=live/);
|
|
132
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
133
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it.each(["production", "LIVE", ""])("refuses STRIPE_MODE=%j, whatever the key", (mode) => {
|
|
138
|
+
withShim({ ...LIVE_PRODUCTION, STRIPE_MODE: mode }, (shim) => {
|
|
139
|
+
const err = refusal(shim, () => shim.admitApiStripeKey());
|
|
140
|
+
expect(err.message).toMatch(/STRIPE_MODE must be "test" or "live"/);
|
|
141
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
142
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
|
|
143
|
+
refusal(shim, () => shim.getStripeClient());
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([undefined, "test"])("with STRIPE_MODE=%s, runs today's sandbox check on a test key", (mode) => {
|
|
148
|
+
withShim({ STRIPE_SECRET_KEY: testKey, STRIPE_MODE: mode }, (shim) => {
|
|
149
|
+
expect(shim.admitApiStripeKey()).toBe("test");
|
|
150
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
151
|
+
expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each([undefined, "test"])(
|
|
156
|
+
"with STRIPE_MODE=%s, refuses a restricted live key with today's sandbox message, even on production",
|
|
157
|
+
(mode) => {
|
|
158
|
+
withShim({ ...LIVE_PRODUCTION, STRIPE_MODE: mode }, (shim) => {
|
|
159
|
+
const err = refusal(shim, () => shim.admitApiStripeKey());
|
|
160
|
+
expect(err.message).toMatch(/Stripe live mode is blocked — sandbox only/);
|
|
161
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
162
|
+
refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
|
|
163
|
+
refusal(shim, () => shim.getStripeClient());
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("assertAdmittedStripeKey — without admission", () => {
|
|
170
|
+
it("is today's sandbox allow-list: every live class refused, a test key accepted", () => {
|
|
171
|
+
withShim({ STRIPE_SECRET_KEY: testKey }, (shim) => {
|
|
172
|
+
expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
|
|
173
|
+
for (const key of [restrictedLiveKey, secretLiveKey, publishableLiveKey]) {
|
|
174
|
+
const err = refusal(shim, () => shim.assertAdmittedStripeKey(key));
|
|
175
|
+
expect(err.message).toMatch(/Stripe live mode is blocked — sandbox only/);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("admitApiStripeKey — call sites (#8450 § 9 SEC-5)", () => {
|
|
182
|
+
// The sweep is a MONOREPO invariant: it reads the whole git tree. A
|
|
183
|
+
// scaffolded site (`create-ailk` verify-scaffold, which copies apps/api into
|
|
184
|
+
// a site that is not a git repository) has no tree to sweep and cannot grow
|
|
185
|
+
// a new call site of its own, so the sweep is skipped there (#8479/#8478).
|
|
186
|
+
const repoRoot = path.resolve(__dirname, "../../../../..");
|
|
187
|
+
const inGitTree = (() => {
|
|
188
|
+
try {
|
|
189
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
190
|
+
cwd: repoRoot,
|
|
191
|
+
stdio: "ignore",
|
|
192
|
+
});
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
const itInRepo = inGitTree ? it : it.skip;
|
|
199
|
+
|
|
200
|
+
itInRepo("is named only by lib/stripe.ts, server.ts and #8450's own tests", () => {
|
|
201
|
+
const files = execFileSync("git", ["grep", "-l", "admitApiStripeKey", "--", ":!*.md"], {
|
|
202
|
+
cwd: repoRoot,
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
})
|
|
205
|
+
.split("\n")
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.sort();
|
|
208
|
+
expect(files).toEqual([
|
|
209
|
+
"apps/api/src/lib/__tests__/stripe-admission.test.ts",
|
|
210
|
+
"apps/api/src/lib/__tests__/stripe.test.ts",
|
|
211
|
+
"apps/api/src/lib/stripe.ts",
|
|
212
|
+
"apps/api/src/routes/checkout/__tests__/live-key.test.ts",
|
|
213
|
+
"apps/api/src/routes/subscription/__tests__/portal-live-key.test.ts",
|
|
214
|
+
"apps/api/src/server.ts",
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -62,6 +62,13 @@ describe("apps/api/src/lib/stripe — surface re-exports", () => {
|
|
|
62
62
|
expect(new stripeShim.UnknownProductCodeError()).toBeInstanceOf(Error);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
// #8450 D2, D3 — apps/api's own live-key admission and its request-time guard.
|
|
66
|
+
it("exports admitApiStripeKey, assertAdmittedStripeKey and stripeKeyMode as functions", () => {
|
|
67
|
+
expect(typeof stripeShim.admitApiStripeKey).toBe("function");
|
|
68
|
+
expect(typeof stripeShim.assertAdmittedStripeKey).toBe("function");
|
|
69
|
+
expect(typeof stripeShim.stripeKeyMode).toBe("function");
|
|
70
|
+
});
|
|
71
|
+
|
|
65
72
|
it("shim functions are the same references as @working-theory/stripe-client exports", () => {
|
|
66
73
|
expect(stripeShim.getStripeClient).toBe(stripeClient.getStripeClient);
|
|
67
74
|
expect(stripeShim.assertSandboxKey).toBe(stripeClient.assertSandboxKey);
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent ledger's read boundary (#8438, spec
|
|
3
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md D4).
|
|
4
|
+
*
|
|
5
|
+
* `consent_grants` is append-only and a Person's current state is the newest
|
|
6
|
+
* row per purpose, so every reader here asks the database for exactly that.
|
|
7
|
+
* Nothing is read through a window: `DISTINCT ON (purpose_id) … ORDER BY
|
|
8
|
+
* purpose_id, created_at DESC, id DESC` is one indexed pass over ALL the
|
|
9
|
+
* matching rows, so no number of other rows can push an answer out
|
|
10
|
+
* (SEC-8438-2).
|
|
11
|
+
*
|
|
12
|
+
* TWO READINGS of a Person's rows (SEC-8438-1). A Person is keyed on an email
|
|
13
|
+
* anyone can type into a form, so a row an anonymous writer attached to it
|
|
14
|
+
* proves nothing about what the person themself chose:
|
|
15
|
+
*
|
|
16
|
+
* - OWN rows: `person_id = P` and a source only a verified caller or a
|
|
17
|
+
* signed link sent to the person can write — `sign_up`,
|
|
18
|
+
* `account_settings`, `email_unsubscribe` (`OWN_CONSENT_SOURCES`). The
|
|
19
|
+
* member's screens, the sign-up prompt, and every future send read only
|
|
20
|
+
* these.
|
|
21
|
+
* - ANY-SOURCE rows: every row with `person_id = P`, plus the cookie rows
|
|
22
|
+
* of every visitor linked to P. The operator's Person page reads these,
|
|
23
|
+
* marked `unverified` when the source proves nothing.
|
|
24
|
+
*
|
|
25
|
+
* Every query takes an explicit `workspaceId`, and a join to a linked
|
|
26
|
+
* visitor's rows matches `workspace_id` on both tables (SEC-8438-11) — a
|
|
27
|
+
* visitor id is client-chosen and not namespaced, so another tenant's rows
|
|
28
|
+
* under the same id must never join.
|
|
29
|
+
*
|
|
30
|
+
* This file is the ONLY non-test reader of `consent_grants` and
|
|
31
|
+
* `consent_visitor_links` (`architecture.yaml` names it as a data-access
|
|
32
|
+
* boundary, the kind `member-store.ts` is). The writes are
|
|
33
|
+
* `services/consent.ts`. Logs carry nothing: no email, visitor id, token or
|
|
34
|
+
* statement.
|
|
35
|
+
*/
|
|
36
|
+
import type { Prisma } from "@working-theory/database";
|
|
37
|
+
import {
|
|
38
|
+
consentPurposeKind,
|
|
39
|
+
type ConsentPurposeKind,
|
|
40
|
+
type ConsentSource,
|
|
41
|
+
type SiteConsentConfig,
|
|
42
|
+
} from "@working-theory/validation";
|
|
43
|
+
|
|
44
|
+
import { prisma } from "./prisma.js";
|
|
45
|
+
|
|
46
|
+
/** What every reader needs of a client: raw reads only. */
|
|
47
|
+
export type ConsentReadClient = Pick<Prisma.TransactionClient, "$queryRaw">;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The sources only a verified caller or a signed link can write (D4,
|
|
51
|
+
* SEC-8438-1). The SQL below names the same three literals; the store's test
|
|
52
|
+
* asserts the two agree.
|
|
53
|
+
*/
|
|
54
|
+
export const OWN_CONSENT_SOURCES = [
|
|
55
|
+
"sign_up",
|
|
56
|
+
"account_settings",
|
|
57
|
+
"email_unsubscribe",
|
|
58
|
+
] as const satisfies readonly ConsentSource[];
|
|
59
|
+
|
|
60
|
+
/** The sources whose rows prove nothing the client confirmed themself. */
|
|
61
|
+
const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
|
|
62
|
+
"magnet",
|
|
63
|
+
"api",
|
|
64
|
+
"cookie_banner",
|
|
65
|
+
"legacy",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Whether a row from `source` is unconfirmed by the client (D4). */
|
|
69
|
+
export function isUnverifiedSource(source: ConsentSource): boolean {
|
|
70
|
+
return UNVERIFIED_SOURCES.has(source);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A history page's size (D7). */
|
|
74
|
+
export const CONSENT_HISTORY_PAGE_SIZE = 500;
|
|
75
|
+
|
|
76
|
+
/** The most platform rows one user can have in view (D4). */
|
|
77
|
+
const PLATFORM_ROWS_MAX = 100;
|
|
78
|
+
|
|
79
|
+
/** The newest ledger row for one purpose, as the readers serve it. */
|
|
80
|
+
export interface ConsentStateRow {
|
|
81
|
+
id: string;
|
|
82
|
+
purposeId: string;
|
|
83
|
+
granted: boolean;
|
|
84
|
+
at: Date;
|
|
85
|
+
source: ConsentSource;
|
|
86
|
+
sourceRef: string | null;
|
|
87
|
+
policyVersion: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ConsentHistoryRow extends ConsentStateRow {
|
|
91
|
+
locale: string;
|
|
92
|
+
statement: string;
|
|
93
|
+
subjectType: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A registry purpose with the newest row read for it and what that means now. */
|
|
97
|
+
export interface PurposeConsentState {
|
|
98
|
+
purposeId: string;
|
|
99
|
+
kind: ConsentPurposeKind;
|
|
100
|
+
row: ConsentStateRow | null;
|
|
101
|
+
/** Granted now: the row's answer, else on for a preference and off otherwise. */
|
|
102
|
+
effective: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The effective answer for a purpose given its newest row (D4). */
|
|
106
|
+
export function effectiveConsent(
|
|
107
|
+
kind: ConsentPurposeKind,
|
|
108
|
+
row: { granted: boolean } | null,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (row !== null) return row.granted;
|
|
111
|
+
return kind === "preference";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fold the newest row per purpose id into the registry's purposes. */
|
|
115
|
+
export function foldRowsIntoPurposes(
|
|
116
|
+
config: SiteConsentConfig,
|
|
117
|
+
rows: readonly ConsentStateRow[],
|
|
118
|
+
): PurposeConsentState[] {
|
|
119
|
+
const byPurpose = new Map(rows.map((row) => [row.purposeId, row]));
|
|
120
|
+
return config.purposes.map((purpose) => {
|
|
121
|
+
const kind = consentPurposeKind(purpose);
|
|
122
|
+
const row = byPurpose.get(purpose.id) ?? null;
|
|
123
|
+
return {
|
|
124
|
+
purposeId: purpose.id,
|
|
125
|
+
kind,
|
|
126
|
+
row,
|
|
127
|
+
effective: effectiveConsent(kind, row),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The newest OWN row per purpose id for a Person, unfolded (D4): one indexed
|
|
134
|
+
* `DISTINCT ON` pass over all own rows, never a window.
|
|
135
|
+
*/
|
|
136
|
+
export async function ownConsentRows(
|
|
137
|
+
workspaceId: string,
|
|
138
|
+
personId: string,
|
|
139
|
+
db: ConsentReadClient = prisma,
|
|
140
|
+
): Promise<ConsentStateRow[]> {
|
|
141
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
142
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
143
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
144
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
145
|
+
FROM "consent_grants" g
|
|
146
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
147
|
+
AND g."person_id" = ${personId}
|
|
148
|
+
AND g."source" IN ('sign_up', 'account_settings', 'email_unsubscribe')
|
|
149
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The newest ANY-SOURCE row per purpose id for a Person: `person_id = P` or a
|
|
154
|
+
* cookie row of a visitor linked to P, in this workspace on both tables.
|
|
155
|
+
* Includes purposes the registry no longer declares — the operator still sees
|
|
156
|
+
* what was recorded.
|
|
157
|
+
*/
|
|
158
|
+
export async function anySourceConsentRows(
|
|
159
|
+
workspaceId: string,
|
|
160
|
+
personId: string,
|
|
161
|
+
db: ConsentReadClient = prisma,
|
|
162
|
+
): Promise<ConsentStateRow[]> {
|
|
163
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
164
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
165
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
166
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
167
|
+
FROM "consent_grants" g
|
|
168
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
169
|
+
AND (
|
|
170
|
+
g."person_id" = ${personId}
|
|
171
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
172
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
173
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
174
|
+
))
|
|
175
|
+
)
|
|
176
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A Person's OWN reading, per registry purpose (D4). The member's screens and
|
|
181
|
+
* the sign-up prompt read only this.
|
|
182
|
+
*/
|
|
183
|
+
export async function ownConsentState(
|
|
184
|
+
workspaceId: string,
|
|
185
|
+
personId: string,
|
|
186
|
+
config: SiteConsentConfig,
|
|
187
|
+
db: ConsentReadClient = prisma,
|
|
188
|
+
): Promise<PurposeConsentState[]> {
|
|
189
|
+
return foldRowsIntoPurposes(
|
|
190
|
+
config,
|
|
191
|
+
await ownConsentRows(workspaceId, personId, db),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A Person's ANY-SOURCE reading, per registry purpose. The operator's Person
|
|
197
|
+
* page and the unsubscribe dedupe read it; a row here is never a member's own
|
|
198
|
+
* answer (D4).
|
|
199
|
+
*/
|
|
200
|
+
export async function anySourceConsentState(
|
|
201
|
+
workspaceId: string,
|
|
202
|
+
personId: string,
|
|
203
|
+
config: SiteConsentConfig,
|
|
204
|
+
db: ConsentReadClient = prisma,
|
|
205
|
+
): Promise<PurposeConsentState[]> {
|
|
206
|
+
return foldRowsIntoPurposes(
|
|
207
|
+
config,
|
|
208
|
+
await anySourceConsentRows(workspaceId, personId, db),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One page of a Person's any-source history, newest first. */
|
|
213
|
+
export interface ConsentHistoryPage {
|
|
214
|
+
rows: ConsentHistoryRow[];
|
|
215
|
+
/** The id of the page's last row when another page follows, else `null`. */
|
|
216
|
+
nextCursor: string | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A Person's any-source history, 500 rows a page, newest first, keyed by
|
|
221
|
+
* `(created_at, id)`. Every page is complete: nothing is truncated (D7).
|
|
222
|
+
* Returns `"invalid_cursor"` for a cursor that is not one of this Person's
|
|
223
|
+
* rows — checked inside the Person's own scope, so a row id of another
|
|
224
|
+
* Person or workspace reads exactly like an unknown one.
|
|
225
|
+
*/
|
|
226
|
+
export async function personConsentHistory(
|
|
227
|
+
workspaceId: string,
|
|
228
|
+
personId: string,
|
|
229
|
+
cursor: string | undefined,
|
|
230
|
+
db: ConsentReadClient = prisma,
|
|
231
|
+
): Promise<ConsentHistoryPage | "invalid_cursor"> {
|
|
232
|
+
if (cursor !== undefined) {
|
|
233
|
+
const found = await db.$queryRaw<Array<{ id: string }>>`
|
|
234
|
+
SELECT g."id" FROM "consent_grants" g
|
|
235
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
236
|
+
AND g."id" = ${cursor}
|
|
237
|
+
AND (
|
|
238
|
+
g."person_id" = ${personId}
|
|
239
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
240
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
241
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
242
|
+
))
|
|
243
|
+
)
|
|
244
|
+
LIMIT 1`;
|
|
245
|
+
if (found.length === 0) return "invalid_cursor";
|
|
246
|
+
}
|
|
247
|
+
const cursorId = cursor ?? null;
|
|
248
|
+
const rows = await db.$queryRaw<ConsentHistoryRow[]>`
|
|
249
|
+
SELECT g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
250
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion",
|
|
251
|
+
g."locale", g."statement", g."subject_type" AS "subjectType"
|
|
252
|
+
FROM "consent_grants" g
|
|
253
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
254
|
+
AND (
|
|
255
|
+
g."person_id" = ${personId}
|
|
256
|
+
OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
|
|
257
|
+
SELECT l."visitor_id" FROM "consent_visitor_links" l
|
|
258
|
+
WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
|
|
259
|
+
))
|
|
260
|
+
)
|
|
261
|
+
AND (${cursorId}::text IS NULL OR (g."created_at", g."id") < (
|
|
262
|
+
SELECT c."created_at", c."id" FROM "consent_grants" c WHERE c."id" = ${cursorId}::text
|
|
263
|
+
))
|
|
264
|
+
ORDER BY g."created_at" DESC, g."id" DESC
|
|
265
|
+
LIMIT ${CONSENT_HISTORY_PAGE_SIZE + 1}`;
|
|
266
|
+
const page = rows.slice(0, CONSENT_HISTORY_PAGE_SIZE);
|
|
267
|
+
const last = page[page.length - 1];
|
|
268
|
+
return {
|
|
269
|
+
rows: page,
|
|
270
|
+
nextCursor:
|
|
271
|
+
rows.length > CONSENT_HISTORY_PAGE_SIZE && last ? last.id : null,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The newest row recording a visitor's choice for one purpose — the cookie
|
|
277
|
+
* writer's dedupe: each CHANGE of choice is logged, a replay adds nothing
|
|
278
|
+
* (D3, SEC-8438-2).
|
|
279
|
+
*/
|
|
280
|
+
export async function latestVisitorChoice(
|
|
281
|
+
workspaceId: string,
|
|
282
|
+
visitorId: string,
|
|
283
|
+
purposeId: string,
|
|
284
|
+
db: ConsentReadClient = prisma,
|
|
285
|
+
): Promise<{ granted: boolean; policyVersion: number | null } | null> {
|
|
286
|
+
const rows = await db.$queryRaw<
|
|
287
|
+
Array<{ granted: boolean; policyVersion: number | null }>
|
|
288
|
+
>`
|
|
289
|
+
SELECT g."granted", g."policy_version" AS "policyVersion"
|
|
290
|
+
FROM "consent_grants" g
|
|
291
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
292
|
+
AND g."subject_type" = 'visitor'
|
|
293
|
+
AND g."subject_id" = ${visitorId}
|
|
294
|
+
AND g."purpose_id" = ${purposeId}
|
|
295
|
+
ORDER BY g."created_at" DESC, g."id" DESC
|
|
296
|
+
LIMIT 1`;
|
|
297
|
+
return rows[0] ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* When the caller's own `User` row was created — the sign-up window's only
|
|
302
|
+
* input (SEC-8438-5: `via: "sign_up"` is honored for a user created within
|
|
303
|
+
* the last 24 hours). `null` when no such user exists.
|
|
304
|
+
*/
|
|
305
|
+
export async function userCreatedAt(
|
|
306
|
+
userId: string,
|
|
307
|
+
db: Pick<Prisma.TransactionClient, "user"> = prisma,
|
|
308
|
+
): Promise<Date | null> {
|
|
309
|
+
const user = await db.user.findUnique({
|
|
310
|
+
where: { id: userId },
|
|
311
|
+
select: { createdAt: true },
|
|
312
|
+
});
|
|
313
|
+
return user?.createdAt ?? null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The owner's rows for the Working Theory platform purposes (C8): subject
|
|
318
|
+
* `user` / `userId`, newest per purpose, at most 100.
|
|
319
|
+
*/
|
|
320
|
+
export async function userPlatformRows(
|
|
321
|
+
workspaceId: string,
|
|
322
|
+
userId: string,
|
|
323
|
+
db: ConsentReadClient = prisma,
|
|
324
|
+
): Promise<ConsentStateRow[]> {
|
|
325
|
+
return db.$queryRaw<ConsentStateRow[]>`
|
|
326
|
+
SELECT DISTINCT ON (g."purpose_id")
|
|
327
|
+
g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
|
|
328
|
+
g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
|
|
329
|
+
FROM "consent_grants" g
|
|
330
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
331
|
+
AND g."subject_type" = 'user'
|
|
332
|
+
AND g."subject_id" = ${userId}
|
|
333
|
+
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC
|
|
334
|
+
LIMIT ${PLATFORM_ROWS_MAX}`;
|
|
335
|
+
}
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* workspace's bytes.
|
|
25
25
|
*
|
|
26
26
|
* Neither ever resolves a client file (SEC-7877-A, #7877): an asset tagged
|
|
27
|
-
* `private`,
|
|
28
|
-
* miss is — so a magnet pointed at a client file's key mints nothing and a
|
|
27
|
+
* `private`, one a member-area message attaches, or one a delivered piece
|
|
28
|
+
* version names (#8171 D3), is the same `null` a miss is — so a magnet pointed at a client file's key mints nothing and a
|
|
29
29
|
* grant minted before the file became one serves nothing.
|
|
30
30
|
*
|
|
31
31
|
* No write, no `findMany`, no list, no unscoped read — both functions take
|
|
@@ -36,14 +36,15 @@ import { PRIVATE_ASSET_TAG } from "@working-theory/validation";
|
|
|
36
36
|
|
|
37
37
|
import { prisma } from "./prisma.js";
|
|
38
38
|
|
|
39
|
-
/** A client file (SEC-7877-A) — `private`-tagged,
|
|
39
|
+
/** A client file (SEC-7877-A) — `private`-tagged, attached to a message, or a version's file (#8171 D3). */
|
|
40
40
|
async function isClientFile(
|
|
41
41
|
assetId: string,
|
|
42
42
|
workspaceId: string,
|
|
43
43
|
tags: string[],
|
|
44
44
|
): Promise<boolean> {
|
|
45
45
|
if (tags.includes(PRIVATE_ASSET_TAG)) return true;
|
|
46
|
-
|
|
46
|
+
if ((await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0) return true;
|
|
47
|
+
return (await prisma.pieceVersion.count({ where: { assetId, workspaceId } })) > 0;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** What the mint-time resolution needs, and nothing more. */
|