create-ailk 0.19.0 → 0.20.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/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -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__/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/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- 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 +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- 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-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- 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 +115 -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/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/lead-routing.ts +105 -12
- 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/public-site-resolver.ts +21 -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 +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- 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__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- 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 +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
- 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-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- 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__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- 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/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file stripe-boot.test.ts
|
|
4
|
+
*
|
|
5
|
+
* The real `build()` admits apps/api's Stripe key at boot (#8450 D4): an
|
|
6
|
+
* unset `STRIPE_MODE` is today's sandbox-only boot, and only a restricted
|
|
7
|
+
* live key beside `STRIPE_MODE=live` on a production build boots live. Each
|
|
8
|
+
* case runs in its own module registry (`jest.isolateModulesAsync`), because
|
|
9
|
+
* admission is one-way per process. Live-shaped keys are composed at runtime
|
|
10
|
+
* from fragments, never written out.
|
|
11
|
+
*
|
|
12
|
+
* External boundaries are mocked exactly as `server.test.ts` mocks them.
|
|
13
|
+
*/
|
|
14
|
+
import type { FastifyInstance } from "fastify";
|
|
15
|
+
|
|
16
|
+
jest.mock("@fastify/cors", () => {
|
|
17
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
jest.mock("@fastify/rate-limit", () => {
|
|
21
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
jest.mock("@working-theory/email", () => ({
|
|
25
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
type ServerModule = typeof import("../server");
|
|
29
|
+
type StripeShim = typeof import("../lib/stripe");
|
|
30
|
+
|
|
31
|
+
const restrictedLiveKey = ["rk", "live", ""].join("_") + "FixtureOnlyBoot";
|
|
32
|
+
|
|
33
|
+
const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV"] as const;
|
|
34
|
+
type EnvKey = (typeof ENV_KEYS)[number];
|
|
35
|
+
|
|
36
|
+
/** Boot `build()` in a fresh registry under `env`; hand `fn` the app (or the rejection) and the shim. */
|
|
37
|
+
async function boot(
|
|
38
|
+
env: Partial<Record<EnvKey, string>>,
|
|
39
|
+
fn: (result: { app?: FastifyInstance; error?: unknown; shim: StripeShim }) => Promise<void>,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
42
|
+
try {
|
|
43
|
+
for (const k of ENV_KEYS) {
|
|
44
|
+
if (env[k] === undefined) delete process.env[k];
|
|
45
|
+
else process.env[k] = env[k];
|
|
46
|
+
}
|
|
47
|
+
await jest.isolateModulesAsync(async () => {
|
|
48
|
+
const { build } = require("../server") as ServerModule;
|
|
49
|
+
const shim = require("../lib/stripe") as StripeShim;
|
|
50
|
+
let app: FastifyInstance | undefined;
|
|
51
|
+
let error: unknown;
|
|
52
|
+
try {
|
|
53
|
+
app = await build();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
error = err;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await fn({ app, error, shim });
|
|
59
|
+
} finally {
|
|
60
|
+
await app?.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
for (const k of ENV_KEYS) {
|
|
65
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
66
|
+
else process.env[k] = saved[k];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** POST the checkout route `n` times from one IP; return the status codes. */
|
|
72
|
+
async function postCheckout(app: FastifyInstance, n: number): Promise<number[]> {
|
|
73
|
+
const codes: number[] = [];
|
|
74
|
+
for (let i = 0; i < n; i += 1) {
|
|
75
|
+
const res = await app.inject({
|
|
76
|
+
method: "POST",
|
|
77
|
+
url: "/v1/checkout/sessions",
|
|
78
|
+
remoteAddress: "203.0.113.7",
|
|
79
|
+
payload: {},
|
|
80
|
+
});
|
|
81
|
+
codes.push(res.statusCode);
|
|
82
|
+
}
|
|
83
|
+
return codes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("build() — Stripe key admission at boot (#8450 D4)", () => {
|
|
87
|
+
it("refuses a restricted live key when STRIPE_MODE is unset (unchanged by default)", async () => {
|
|
88
|
+
await boot({ STRIPE_SECRET_KEY: restrictedLiveKey, VERCEL_ENV: "production" }, async ({ app, error, shim }) => {
|
|
89
|
+
expect(app).toBeUndefined();
|
|
90
|
+
expect(error).toBeInstanceOf(shim.StripeConfigError);
|
|
91
|
+
expect((error as Error).message).toMatch(/sandbox only/);
|
|
92
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("boots in test mode on a test key with STRIPE_MODE unset, with no checkout bucket", async () => {
|
|
97
|
+
await boot({ STRIPE_SECRET_KEY: "sk_test_boot_fixture" }, async ({ app, error, shim }) => {
|
|
98
|
+
expect(error).toBeUndefined();
|
|
99
|
+
expect(app).toBeDefined();
|
|
100
|
+
expect(shim.stripeKeyMode()).toBe("test");
|
|
101
|
+
// No commerce claim, so every POST is the gate's own answer — never a 429.
|
|
102
|
+
const codes = await postCheckout(app!, 12);
|
|
103
|
+
expect(codes).not.toContain(429);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("boots live on a restricted live key beside STRIPE_MODE=live on a production build", async () => {
|
|
108
|
+
await boot(
|
|
109
|
+
{ STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", VERCEL_ENV: "production" },
|
|
110
|
+
async ({ app, error, shim }) => {
|
|
111
|
+
expect(error).toBeUndefined();
|
|
112
|
+
expect(app).toBeDefined();
|
|
113
|
+
expect(shim.stripeKeyMode()).toBe("live");
|
|
114
|
+
expect(() => shim.assertAdmittedStripeKey(restrictedLiveKey)).not.toThrow();
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("gives a live process's checkout routes their own per-IP bucket (§ 9 SEC-14)", async () => {
|
|
120
|
+
await boot(
|
|
121
|
+
{ STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", VERCEL_ENV: "production" },
|
|
122
|
+
async ({ app }) => {
|
|
123
|
+
const { __resetBuckets, DEFAULT_PER_IP_PER_MINUTE_STRICT } =
|
|
124
|
+
require("../middleware/rate-limit") as typeof import("../middleware/rate-limit");
|
|
125
|
+
__resetBuckets();
|
|
126
|
+
const codes = await postCheckout(app!, DEFAULT_PER_IP_PER_MINUTE_STRICT + 1);
|
|
127
|
+
expect(codes.slice(0, DEFAULT_PER_IP_PER_MINUTE_STRICT)).not.toContain(429);
|
|
128
|
+
expect(codes[DEFAULT_PER_IP_PER_MINUTE_STRICT]).toBe(429);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rate-limit-probe.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for `runRateLimitProbe` (#5040 AC-10), with a stubbed `fetch`
|
|
5
|
+
* and a no-op `sleep`. Each stub tracks per-identity call counts; the probe
|
|
6
|
+
* itself picks its TEST-NET-3 addresses at random, so the stubs key their
|
|
7
|
+
* behavior off "the first identity used" and "every other identity" rather
|
|
8
|
+
* than a literal address.
|
|
9
|
+
*/
|
|
10
|
+
import { runRateLimitProbe, type ProbeFetch } from "../../lib/rate-limit-probe.js";
|
|
11
|
+
|
|
12
|
+
const ORIGIN = "https://example-deployment.vercel.app";
|
|
13
|
+
const noopSleep = async () => undefined;
|
|
14
|
+
|
|
15
|
+
function headerIdentity(init: { headers: Record<string, string> }): string {
|
|
16
|
+
return init.headers["x-forwarded-for"] ?? "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Limits every distinct identity independently to `limit` per window. */
|
|
20
|
+
function perIdentityLimiter(limit: number): ProbeFetch {
|
|
21
|
+
const counts = new Map<string, number>();
|
|
22
|
+
return async (_url, init) => {
|
|
23
|
+
const id = headerIdentity(init);
|
|
24
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
25
|
+
counts.set(id, count);
|
|
26
|
+
return { status: count <= limit ? 200 : 429 };
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Limits ONLY the first identity used; every other identity always
|
|
32
|
+
* succeeds (#5040 AC-10(d): "answers 200 to every burst request").
|
|
33
|
+
*/
|
|
34
|
+
function firstIdentityOnlyLimiter(limit: number): ProbeFetch {
|
|
35
|
+
let firstId: string | undefined;
|
|
36
|
+
const counts = new Map<string, number>();
|
|
37
|
+
return async (_url, init) => {
|
|
38
|
+
const id = headerIdentity(init);
|
|
39
|
+
if (firstId === undefined) firstId = id;
|
|
40
|
+
if (id !== firstId) return { status: 200 };
|
|
41
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
42
|
+
counts.set(id, count);
|
|
43
|
+
return { status: count <= limit ? 200 : 429 };
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Limits the first identity and never lets ANY other identity through
|
|
49
|
+
* either (#5040 AC-10(f): "answers 429 to every burst request").
|
|
50
|
+
*/
|
|
51
|
+
function alwaysOverLimiter(limit: number): ProbeFetch {
|
|
52
|
+
let firstId: string | undefined;
|
|
53
|
+
const counts = new Map<string, number>();
|
|
54
|
+
return async (_url, init) => {
|
|
55
|
+
const id = headerIdentity(init);
|
|
56
|
+
if (firstId === undefined) firstId = id;
|
|
57
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
58
|
+
counts.set(id, count);
|
|
59
|
+
if (id === firstId) return { status: count <= limit ? 200 : 429 };
|
|
60
|
+
return { status: 429 };
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("runRateLimitProbe (#5040 AC-10)", () => {
|
|
65
|
+
it("(a) a per-identity limiter gives xff: survives, burst: holds (10/30), exit 0", async () => {
|
|
66
|
+
const log = jest.fn();
|
|
67
|
+
const code = await runRateLimitProbe({
|
|
68
|
+
origin: ORIGIN,
|
|
69
|
+
fetch: perIdentityLimiter(10),
|
|
70
|
+
sleep: noopSleep,
|
|
71
|
+
log,
|
|
72
|
+
});
|
|
73
|
+
expect(code).toBe(0);
|
|
74
|
+
expect(log).toHaveBeenCalledWith("xff: survives");
|
|
75
|
+
expect(log).toHaveBeenCalledWith("burst: holds (10/30)");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("(b) a shared, header-blind identity gives xff: overwritten, sleeps exactly once with 62000, exit 0", async () => {
|
|
79
|
+
let count = 0;
|
|
80
|
+
const fetch: ProbeFetch = async () => {
|
|
81
|
+
count += 1;
|
|
82
|
+
return { status: count <= 10 ? 200 : 429 };
|
|
83
|
+
};
|
|
84
|
+
const sleep = jest.fn(async (ms: number) => {
|
|
85
|
+
// Simulate the real window rolling over once the probe waits it out.
|
|
86
|
+
void ms;
|
|
87
|
+
count = 0;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const code = await runRateLimitProbe({
|
|
91
|
+
origin: ORIGIN,
|
|
92
|
+
fetch,
|
|
93
|
+
sleep,
|
|
94
|
+
log: jest.fn(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
expect(sleep).toHaveBeenCalledTimes(1);
|
|
99
|
+
expect(sleep).toHaveBeenCalledWith(62_000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("(c) a limiter that never answers 429 gives limiter: not-observed, exit 1", async () => {
|
|
103
|
+
const log = jest.fn();
|
|
104
|
+
const fetchAlways200: ProbeFetch = async () => ({ status: 200 });
|
|
105
|
+
const code = await runRateLimitProbe({
|
|
106
|
+
origin: ORIGIN,
|
|
107
|
+
fetch: fetchAlways200,
|
|
108
|
+
sleep: noopSleep,
|
|
109
|
+
log,
|
|
110
|
+
});
|
|
111
|
+
expect(code).toBe(1);
|
|
112
|
+
expect(log).toHaveBeenCalledWith("limiter: not-observed");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("(d) only the first identity is limited, every other answers 200: burst: exceeded (30/30), exit 1", async () => {
|
|
116
|
+
const log = jest.fn();
|
|
117
|
+
const code = await runRateLimitProbe({
|
|
118
|
+
origin: ORIGIN,
|
|
119
|
+
fetch: firstIdentityOnlyLimiter(10),
|
|
120
|
+
sleep: noopSleep,
|
|
121
|
+
log,
|
|
122
|
+
});
|
|
123
|
+
expect(code).toBe(1);
|
|
124
|
+
expect(log).toHaveBeenCalledWith("xff: survives");
|
|
125
|
+
expect(log).toHaveBeenCalledWith("burst: exceeded (30/30)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("(e) --origin http://example.com is refused before any fetch call", async () => {
|
|
129
|
+
const fetch = jest.fn();
|
|
130
|
+
const log = jest.fn();
|
|
131
|
+
const code = await runRateLimitProbe({
|
|
132
|
+
origin: "http://example.com",
|
|
133
|
+
fetch,
|
|
134
|
+
sleep: noopSleep,
|
|
135
|
+
log,
|
|
136
|
+
});
|
|
137
|
+
expect(code).toBe(2);
|
|
138
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
139
|
+
expect(log).toHaveBeenCalledWith("probe: error invalid-origin");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("(f) every burst request answers 429: burst: inconclusive (0/30), exit 1", async () => {
|
|
143
|
+
const log = jest.fn();
|
|
144
|
+
const code = await runRateLimitProbe({
|
|
145
|
+
origin: ORIGIN,
|
|
146
|
+
fetch: alwaysOverLimiter(10),
|
|
147
|
+
sleep: noopSleep,
|
|
148
|
+
log,
|
|
149
|
+
});
|
|
150
|
+
expect(code).toBe(1);
|
|
151
|
+
expect(log).toHaveBeenCalledWith("burst: inconclusive (0/30)");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("a loopback http origin is accepted", async () => {
|
|
155
|
+
const code = await runRateLimitProbe({
|
|
156
|
+
origin: "http://localhost:3002",
|
|
157
|
+
fetch: perIdentityLimiter(10),
|
|
158
|
+
sleep: noopSleep,
|
|
159
|
+
log: jest.fn(),
|
|
160
|
+
});
|
|
161
|
+
expect(code).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("a network error during the burst answers probe: error <code>, exit 2", async () => {
|
|
165
|
+
const log = jest.fn();
|
|
166
|
+
let count = 0;
|
|
167
|
+
const fetch: ProbeFetch = async () => {
|
|
168
|
+
count += 1;
|
|
169
|
+
if (count <= 11) return { status: count <= 10 ? 200 : 429 };
|
|
170
|
+
if (count === 12) return { status: 200 }; // the xff probe (survives)
|
|
171
|
+
const err = new Error("boom") as Error & { code: string };
|
|
172
|
+
err.code = "ECONNRESET";
|
|
173
|
+
throw err;
|
|
174
|
+
};
|
|
175
|
+
const code = await runRateLimitProbe({
|
|
176
|
+
origin: ORIGIN,
|
|
177
|
+
fetch,
|
|
178
|
+
sleep: noopSleep,
|
|
179
|
+
log,
|
|
180
|
+
});
|
|
181
|
+
expect(code).toBe(2);
|
|
182
|
+
expect(log).toHaveBeenCalledWith("probe: error ECONNRESET");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* istanbul ignore file -- CLI bootstrap; the logic it wraps
|
|
3
|
+
(runRateLimitProbe) is unit-tested directly in __tests__/rate-limit-probe.test.ts
|
|
4
|
+
— the abandon-sweep.ts / lib/abandon-email.ts split. */
|
|
5
|
+
/**
|
|
6
|
+
* The live rate-limiter probe (#5040 D9, FR-7, AC-10).
|
|
7
|
+
*
|
|
8
|
+
* Dev: `pnpm --filter @working-theory/api rate-limit:probe --origin <https origin>`
|
|
9
|
+
* Production: `node dist/bin/rate-limit-probe.js --origin <https origin>`
|
|
10
|
+
*
|
|
11
|
+
* Answers the two platform facts #5040 and #5111 need a live deployment for:
|
|
12
|
+
*
|
|
13
|
+
* 1. Does a client-chosen `x-forwarded-for` survive to the serverless
|
|
14
|
+
* function, or does the edge overwrite it? (`xff: survives | overwritten`)
|
|
15
|
+
* 2. Does a concurrent burst against ONE identity stay within the
|
|
16
|
+
* configured limit? (`burst: holds | exceeded | inconclusive`)
|
|
17
|
+
*
|
|
18
|
+
* It only ever calls `GET <origin>/v1/aeo/score/example` — a no-fetch,
|
|
19
|
+
* no-write read already behind the aeo scope's strict limiter (#5111) — and
|
|
20
|
+
* refuses an origin that is not `https:` or loopback `http:` before any
|
|
21
|
+
* network call.
|
|
22
|
+
*/
|
|
23
|
+
import { runRateLimitProbe } from "../lib/rate-limit-probe.js";
|
|
24
|
+
|
|
25
|
+
function parseOrigin(argv: readonly string[]): string | undefined {
|
|
26
|
+
const index = argv.indexOf("--origin");
|
|
27
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main(): Promise<number> {
|
|
31
|
+
const origin = parseOrigin(process.argv.slice(2));
|
|
32
|
+
if (!origin) {
|
|
33
|
+
console.log("probe: error missing --origin");
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
return runRateLimitProbe({
|
|
37
|
+
origin,
|
|
38
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main()
|
|
43
|
+
.then((code) => {
|
|
44
|
+
process.exitCode = code;
|
|
45
|
+
})
|
|
46
|
+
.catch((err: unknown) => {
|
|
47
|
+
// Belt-and-braces, as abandon-sweep.ts: runRateLimitProbe catches its own
|
|
48
|
+
// network/status errors, so this only stops a bare unhandled rejection.
|
|
49
|
+
console.log(
|
|
50
|
+
`probe: error ${err instanceof Error ? err.message : String(err)}`,
|
|
51
|
+
);
|
|
52
|
+
process.exitCode = 2;
|
|
53
|
+
});
|
|
@@ -436,6 +436,10 @@ export const prisma = {
|
|
|
436
436
|
createMany: jest.fn(),
|
|
437
437
|
findMany: jest.fn(),
|
|
438
438
|
},
|
|
439
|
+
// #8438 D2 — a banner visitor linked to a Person (insert-or-ignore).
|
|
440
|
+
consentVisitorLink: {
|
|
441
|
+
createMany: jest.fn(),
|
|
442
|
+
},
|
|
439
443
|
// ── Inbox namespace (Appointment — #6085, brief H7) ──────────────────────
|
|
440
444
|
// `updateMany` — the § D7(a) guarded write, one per family (lifecycle /
|
|
441
445
|
// payment / no-show); `findFirst` — the disambiguating read on a zero-match
|
|
@@ -542,6 +546,10 @@ export const prisma = {
|
|
|
542
546
|
// `$executeRaw` — the per-booking `pg_advisory_xact_lock` (#7532 D5),
|
|
543
547
|
// taken inside `$transaction`. Suites on that path arm both.
|
|
544
548
|
$executeRaw: jest.fn(),
|
|
549
|
+
// `$queryRaw` — the consent ledger's reads (lib/consent-store.ts, #8438 D4):
|
|
550
|
+
// `DISTINCT ON` current-state and keyset history cannot be a Prisma
|
|
551
|
+
// `findMany`. Suites on that path arm it with the rows to serve.
|
|
552
|
+
$queryRaw: jest.fn(),
|
|
545
553
|
$connect: jest.fn(),
|
|
546
554
|
$disconnect: jest.fn(),
|
|
547
555
|
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent-ledger-double.ts
|
|
3
|
+
* @description An in-memory model of the consent ledger for suites that run
|
|
4
|
+
* the REAL `services/consent.ts` and `lib/consent-store.ts` over the mocked
|
|
5
|
+
* Prisma client (`lib/__mocks__/prisma.ts`) — #8438.
|
|
6
|
+
*
|
|
7
|
+
* `armConsentLedger(prisma)` wires the mocked client so that:
|
|
8
|
+
* - `$transaction(fn)` runs `fn` on the same client;
|
|
9
|
+
* - `consentGrant.createMany` appends to the returned `ledger`;
|
|
10
|
+
* - `consentVisitorLink.createMany` appends to `links` (skipping a repeat);
|
|
11
|
+
* - `$queryRaw` answers the way the store's SQL would — newest row per
|
|
12
|
+
* purpose for the own / any-source / platform readings, the visitor's
|
|
13
|
+
* newest choice, and keyset history pages — by recognising each query's
|
|
14
|
+
* SQL text.
|
|
15
|
+
*
|
|
16
|
+
* This proves the writers' and routes' decisions ("write only when the answer
|
|
17
|
+
* changes", "own rows only", "a cursor of another Person is invalid") against
|
|
18
|
+
* a model with the same semantics as the SQL. The SQL itself is proved
|
|
19
|
+
* against a real database in `database/__tests__/consent-ledger.db.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type LedgerRow = {
|
|
23
|
+
id: string;
|
|
24
|
+
workspaceId: string;
|
|
25
|
+
subjectType: string;
|
|
26
|
+
subjectId: string;
|
|
27
|
+
personId: string | null;
|
|
28
|
+
purposeId: string;
|
|
29
|
+
granted: boolean;
|
|
30
|
+
source: string;
|
|
31
|
+
sourceRef: string | null;
|
|
32
|
+
policyVersion: number | null;
|
|
33
|
+
locale: string;
|
|
34
|
+
statement: string;
|
|
35
|
+
createdAt: Date;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type LinkRow = {
|
|
39
|
+
workspaceId: string;
|
|
40
|
+
visitorId: string;
|
|
41
|
+
personId: string;
|
|
42
|
+
source: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const OWN_SOURCES = ["sign_up", "account_settings", "email_unsubscribe"];
|
|
46
|
+
|
|
47
|
+
type Mockable = {
|
|
48
|
+
mockImplementation: (fn: (...args: never[]) => unknown) => unknown;
|
|
49
|
+
mockResolvedValue: (v: unknown) => unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
interface ArmablePrisma {
|
|
53
|
+
$transaction: unknown;
|
|
54
|
+
$executeRaw: unknown;
|
|
55
|
+
$queryRaw: unknown;
|
|
56
|
+
consentGrant: { createMany: unknown };
|
|
57
|
+
consentVisitorLink: { createMany: unknown };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ConsentLedgerDouble {
|
|
61
|
+
ledger: LedgerRow[];
|
|
62
|
+
links: LinkRow[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let clock = 0;
|
|
66
|
+
|
|
67
|
+
export function armConsentLedger(prisma: unknown): ConsentLedgerDouble {
|
|
68
|
+
const p = prisma as ArmablePrisma;
|
|
69
|
+
const double: ConsentLedgerDouble = { ledger: [], links: [] };
|
|
70
|
+
clock = 0;
|
|
71
|
+
|
|
72
|
+
(p.$transaction as Mockable).mockImplementation((async (
|
|
73
|
+
fn: (tx: unknown) => unknown,
|
|
74
|
+
) => fn(prisma)) as never);
|
|
75
|
+
(p.$executeRaw as Mockable).mockResolvedValue(1);
|
|
76
|
+
|
|
77
|
+
(p.consentGrant.createMany as Mockable).mockImplementation(((args: {
|
|
78
|
+
data: Array<Omit<LedgerRow, "id" | "createdAt">>;
|
|
79
|
+
}) => {
|
|
80
|
+
for (const row of args.data) {
|
|
81
|
+
clock += 1;
|
|
82
|
+
double.ledger.push({
|
|
83
|
+
...row,
|
|
84
|
+
id: `cnsnt_00000000-0000-7000-8000-${String(clock).padStart(12, "0")}`,
|
|
85
|
+
createdAt: new Date(Date.UTC(2026, 9, 12, 0, 0, clock)),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return Promise.resolve({ count: args.data.length });
|
|
89
|
+
}) as never);
|
|
90
|
+
|
|
91
|
+
(p.consentVisitorLink.createMany as Mockable).mockImplementation(((args: {
|
|
92
|
+
data: LinkRow[];
|
|
93
|
+
}) => {
|
|
94
|
+
let count = 0;
|
|
95
|
+
for (const row of args.data) {
|
|
96
|
+
const seen = double.links.some(
|
|
97
|
+
(l) =>
|
|
98
|
+
l.workspaceId === row.workspaceId &&
|
|
99
|
+
l.visitorId === row.visitorId &&
|
|
100
|
+
l.personId === row.personId,
|
|
101
|
+
);
|
|
102
|
+
if (!seen) {
|
|
103
|
+
double.links.push(row);
|
|
104
|
+
count += 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return Promise.resolve({ count });
|
|
108
|
+
}) as never);
|
|
109
|
+
|
|
110
|
+
(p.$queryRaw as Mockable).mockImplementation(((
|
|
111
|
+
strings: TemplateStringsArray,
|
|
112
|
+
...values: unknown[]
|
|
113
|
+
) => {
|
|
114
|
+
const sql = strings.join("?");
|
|
115
|
+
const [workspaceId] = values as [string];
|
|
116
|
+
|
|
117
|
+
const linkedVisitors = (personId: string) =>
|
|
118
|
+
new Set(
|
|
119
|
+
double.links
|
|
120
|
+
.filter(
|
|
121
|
+
(l) => l.workspaceId === workspaceId && l.personId === personId,
|
|
122
|
+
)
|
|
123
|
+
.map((l) => l.visitorId),
|
|
124
|
+
);
|
|
125
|
+
const anySource = (personId: string) =>
|
|
126
|
+
double.ledger.filter(
|
|
127
|
+
(r) =>
|
|
128
|
+
r.workspaceId === workspaceId &&
|
|
129
|
+
(r.personId === personId ||
|
|
130
|
+
(r.subjectType === "visitor" &&
|
|
131
|
+
linkedVisitors(personId).has(r.subjectId))),
|
|
132
|
+
);
|
|
133
|
+
const newestPerPurpose = (rows: LedgerRow[]) => {
|
|
134
|
+
const byPurpose = new Map<string, LedgerRow>();
|
|
135
|
+
for (const row of rows) byPurpose.set(row.purposeId, row);
|
|
136
|
+
return [...byPurpose.values()];
|
|
137
|
+
};
|
|
138
|
+
const state = (r: LedgerRow) => ({
|
|
139
|
+
id: r.id,
|
|
140
|
+
purposeId: r.purposeId,
|
|
141
|
+
granted: r.granted,
|
|
142
|
+
at: r.createdAt,
|
|
143
|
+
source: r.source,
|
|
144
|
+
sourceRef: r.sourceRef,
|
|
145
|
+
policyVersion: r.policyVersion,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// The visitor's newest choice for one purpose (the cookie writer's dedupe).
|
|
149
|
+
if (
|
|
150
|
+
sql.includes(`"subject_type" = 'visitor'`) &&
|
|
151
|
+
sql.includes("LIMIT 1") &&
|
|
152
|
+
!sql.includes("person_id")
|
|
153
|
+
) {
|
|
154
|
+
const [, visitorId, purposeId] = values as [string, string, string];
|
|
155
|
+
const rows = double.ledger.filter(
|
|
156
|
+
(r) =>
|
|
157
|
+
r.workspaceId === workspaceId &&
|
|
158
|
+
r.subjectType === "visitor" &&
|
|
159
|
+
r.subjectId === visitorId &&
|
|
160
|
+
r.purposeId === purposeId,
|
|
161
|
+
);
|
|
162
|
+
const last = rows[rows.length - 1];
|
|
163
|
+
return Promise.resolve(
|
|
164
|
+
last
|
|
165
|
+
? [{ granted: last.granted, policyVersion: last.policyVersion }]
|
|
166
|
+
: [],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// The platform reading: subject `user`.
|
|
170
|
+
if (sql.includes(`"subject_type" = 'user'`)) {
|
|
171
|
+
const [, userId] = values as [string, string];
|
|
172
|
+
return Promise.resolve(
|
|
173
|
+
newestPerPurpose(
|
|
174
|
+
double.ledger.filter(
|
|
175
|
+
(r) =>
|
|
176
|
+
r.workspaceId === workspaceId &&
|
|
177
|
+
r.subjectType === "user" &&
|
|
178
|
+
r.subjectId === userId,
|
|
179
|
+
),
|
|
180
|
+
).map(state),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
// A history cursor check: does this row id belong to this Person's scope?
|
|
184
|
+
if (sql.includes('SELECT g."id" FROM "consent_grants" g')) {
|
|
185
|
+
const [, cursor, personId] = values as [string, string, string];
|
|
186
|
+
return Promise.resolve(
|
|
187
|
+
anySource(personId)
|
|
188
|
+
.filter((r) => r.id === cursor)
|
|
189
|
+
.map((r) => ({ id: r.id })),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// A history page: newest first, keyset on (created_at, id).
|
|
193
|
+
if (
|
|
194
|
+
sql.includes('ORDER BY g."created_at" DESC, g."id" DESC') &&
|
|
195
|
+
sql.includes('g."locale"')
|
|
196
|
+
) {
|
|
197
|
+
const personId = values[1] as string;
|
|
198
|
+
const cursor = values[values.length - 3] as string | null;
|
|
199
|
+
let rows = anySource(personId).slice().reverse();
|
|
200
|
+
if (cursor) {
|
|
201
|
+
const at = rows.findIndex((r) => r.id === cursor);
|
|
202
|
+
rows = rows.slice(at + 1);
|
|
203
|
+
}
|
|
204
|
+
const limit = values[values.length - 1] as number;
|
|
205
|
+
return Promise.resolve(
|
|
206
|
+
rows.slice(0, limit).map((r) => ({
|
|
207
|
+
...state(r),
|
|
208
|
+
locale: r.locale,
|
|
209
|
+
statement: r.statement,
|
|
210
|
+
subjectType: r.subjectType,
|
|
211
|
+
})),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
// The own reading (a Person's own-source rows).
|
|
215
|
+
if (sql.includes(`'sign_up', 'account_settings', 'email_unsubscribe'`)) {
|
|
216
|
+
const [, personId] = values as [string, string];
|
|
217
|
+
return Promise.resolve(
|
|
218
|
+
newestPerPurpose(
|
|
219
|
+
double.ledger.filter(
|
|
220
|
+
(r) =>
|
|
221
|
+
r.workspaceId === workspaceId &&
|
|
222
|
+
r.personId === personId &&
|
|
223
|
+
OWN_SOURCES.includes(r.source),
|
|
224
|
+
),
|
|
225
|
+
).map(state),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// The any-source reading.
|
|
229
|
+
if (sql.includes("consent_visitor_links")) {
|
|
230
|
+
const [, personId] = values as [string, string];
|
|
231
|
+
return Promise.resolve(newestPerPurpose(anySource(personId)).map(state));
|
|
232
|
+
}
|
|
233
|
+
return Promise.resolve([]);
|
|
234
|
+
}) as never);
|
|
235
|
+
|
|
236
|
+
return double;
|
|
237
|
+
}
|