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
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
9
|
* (logger context, request-id propagation) without touching every consumer.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
admitLiveRestrictedKey,
|
|
13
|
+
assertSandboxKey,
|
|
14
|
+
getStripeClient,
|
|
15
|
+
StripeConfigError,
|
|
16
|
+
} from "@working-theory/stripe-client";
|
|
12
17
|
|
|
13
18
|
export {
|
|
14
19
|
// PRECONDITION (#6113): do not narrow or bypass this sandbox-only guard for a
|
|
@@ -18,6 +23,8 @@ export {
|
|
|
18
23
|
// Widened by #6852 (the #6113 follow-up): the same function must bind only
|
|
19
24
|
// on a server-stamped metadata.ailk_user_id — an unverified Checkout email
|
|
20
25
|
// never makes a FIRST binding either.
|
|
26
|
+
// Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
|
|
27
|
+
// `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
|
|
21
28
|
assertSandboxKey,
|
|
22
29
|
createAdHocPrice,
|
|
23
30
|
createBillingPortalSession,
|
|
@@ -60,6 +67,89 @@ export type {
|
|
|
60
67
|
StripeWebhookEvent,
|
|
61
68
|
} from "@working-theory/stripe-client";
|
|
62
69
|
|
|
70
|
+
/**
|
|
71
|
+
* apps/api's live-key admission (#8450 D2 — D-68's apps/api leg, the half
|
|
72
|
+
* #6881 left unbuilt). Composed at runtime so this file's own text never
|
|
73
|
+
* trips the repo-wide live-key lint.
|
|
74
|
+
*/
|
|
75
|
+
const LIVE_RESTRICTED_KEY_PREFIX = ["rk", "live", ""].join("_");
|
|
76
|
+
|
|
77
|
+
let liveKeyAdmitted = false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `VERCEL_ENV` when set, else `NODE_ENV` — packages/spoke-checkout's own
|
|
81
|
+
* rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
|
|
82
|
+
* (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
|
|
83
|
+
* `vercel.json` sets `NODE_ENV=production` in every environment, Preview
|
|
84
|
+
* included.
|
|
85
|
+
*/
|
|
86
|
+
function isProductionBuild(): boolean {
|
|
87
|
+
if (process.env.VERCEL_ENV !== undefined) {
|
|
88
|
+
return process.env.VERCEL_ENV === "production";
|
|
89
|
+
}
|
|
90
|
+
if (process.env.VERCEL !== undefined) return false;
|
|
91
|
+
return process.env.NODE_ENV === "production";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Admit this process's Stripe key at server build, or throw
|
|
96
|
+
* `StripeConfigError` (#8450 D2). Called once, by `server.ts`'s `build()`,
|
|
97
|
+
* and nowhere else.
|
|
98
|
+
*
|
|
99
|
+
* Only a restricted live key, beside `STRIPE_MODE=live`, on a production
|
|
100
|
+
* build is admitted: the package's one-way flag is set and `"live"` comes
|
|
101
|
+
* back. `STRIPE_MODE` unset or `test` is today's boot — `assertSandboxKey`,
|
|
102
|
+
* its message unchanged — and returns `"test"`. Every other combination
|
|
103
|
+
* throws. It takes no parameter and reads `process.env` only, so it vouches
|
|
104
|
+
* for the same key `getStripeClient()` later reads. No message names any
|
|
105
|
+
* part of the key.
|
|
106
|
+
*/
|
|
107
|
+
export function admitApiStripeKey(): "test" | "live" {
|
|
108
|
+
const mode = process.env.STRIPE_MODE;
|
|
109
|
+
const key = process.env.STRIPE_SECRET_KEY ?? "";
|
|
110
|
+
|
|
111
|
+
if (mode !== undefined && mode !== "test" && mode !== "live") {
|
|
112
|
+
throw new StripeConfigError('STRIPE_MODE must be "test" or "live".');
|
|
113
|
+
}
|
|
114
|
+
if (mode === "live") {
|
|
115
|
+
if (!key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) {
|
|
116
|
+
throw new StripeConfigError(
|
|
117
|
+
"STRIPE_MODE=live needs a restricted live Stripe key. " +
|
|
118
|
+
"Unrestricted, publishable and test-mode keys are refused.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!isProductionBuild()) {
|
|
122
|
+
throw new StripeConfigError(
|
|
123
|
+
"A restricted live Stripe key is admitted only with STRIPE_MODE=live on a production build " +
|
|
124
|
+
"(VERCEL_ENV=production, or NODE_ENV=production off Vercel).",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
admitLiveRestrictedKey();
|
|
128
|
+
liveKeyAdmitted = true;
|
|
129
|
+
return "live";
|
|
130
|
+
}
|
|
131
|
+
assertSandboxKey(key);
|
|
132
|
+
return "test";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The request-time key check for the routes that may run live (#8450 D3):
|
|
137
|
+
* the member portal session and the checkout start and read. It returns
|
|
138
|
+
* before the sandbox-only guard for the restricted live class alone, and
|
|
139
|
+
* only once `admitApiStripeKey()` admitted it; every other key meets
|
|
140
|
+
* `assertSandboxKey`'s allow-list, so an unrestricted or publishable live
|
|
141
|
+
* key is refused whether or not the process admitted.
|
|
142
|
+
*/
|
|
143
|
+
export function assertAdmittedStripeKey(key: string): void {
|
|
144
|
+
if (liveKeyAdmitted && key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) return;
|
|
145
|
+
assertSandboxKey(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
|
|
149
|
+
export function stripeKeyMode(): "test" | "live" {
|
|
150
|
+
return liveKeyAdmitted ? "live" : "test";
|
|
151
|
+
}
|
|
152
|
+
|
|
63
153
|
/**
|
|
64
154
|
* Expire a Checkout Session (#4912 FR7/D7).
|
|
65
155
|
*
|
|
@@ -67,8 +157,9 @@ export type {
|
|
|
67
157
|
* session already exists, its id never reached the `FlowCheckout` row, and
|
|
68
158
|
* nothing will ever join the two — so the session is closed rather than left
|
|
69
159
|
* live and payable (#4863). Built on the already-re-exported
|
|
70
|
-
* `getStripeClient`, whose construction asserts
|
|
71
|
-
*
|
|
160
|
+
* `getStripeClient`, whose construction asserts its key allow-list, so this
|
|
161
|
+
* call inherits the same gate as every other Stripe call here (test-mode, or
|
|
162
|
+
* the restricted live key once `admitApiStripeKey()` admitted it, #8450).
|
|
72
163
|
*/
|
|
73
164
|
export async function expireCheckoutSession(stripeSessionId: string): Promise<void> {
|
|
74
165
|
await getStripeClient().checkout.sessions.expire(stripeSessionId);
|
|
@@ -90,8 +181,9 @@ const PLAN_NAME_MAX = 200;
|
|
|
90
181
|
* first item's product name (else the price nickname); and its default
|
|
91
182
|
* payment method (else the customer's invoice default). At most three reads,
|
|
92
183
|
* no expand path deeper than three levels (SEC-8171-3). Built on
|
|
93
|
-
* `getStripeClient`, so it inherits
|
|
94
|
-
*
|
|
184
|
+
* `getStripeClient`, so it inherits its allow-list: a missing key, or a live
|
|
185
|
+
* key this process did not admit (#8450), throws `StripeConfigError`. It logs
|
|
186
|
+
* nothing and returns no id.
|
|
95
187
|
*/
|
|
96
188
|
export async function readCustomerBillingSummary(
|
|
97
189
|
customerId: string,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
|
|
6
6
|
*
|
|
7
7
|
* A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
|
|
8
|
-
* only while its grant is live and its person verified, and only on the
|
|
8
|
+
* only while its grant is live and its person verified, and only on the 20
|
|
9
9
|
* routes of `HUB_SESSION_ROUTES` (#8220 D1, AC-1). Any other route answers 404
|
|
10
10
|
* with no handler run (the status `lib/account-config.ts`'s `read()` already
|
|
11
11
|
* loads as "nothing for this caller"), never 403. Each (person, spoke) is
|
|
@@ -53,7 +53,7 @@ let spokeA: SeededSpoke;
|
|
|
53
53
|
let spokeB: SeededSpoke;
|
|
54
54
|
let handlerRuns: string[];
|
|
55
55
|
|
|
56
|
-
/** D1's 18 strings, in the list's order. */
|
|
56
|
+
/** D1's 18 strings plus #8435's two contact routes and #8438's two consents routes (22), in the list's order. */
|
|
57
57
|
const ADMITTED = [
|
|
58
58
|
"GET /v1/hub-sign-in/me",
|
|
59
59
|
"GET /v1/member/pieces",
|
|
@@ -72,11 +72,20 @@ const ADMITTED = [
|
|
|
72
72
|
"POST /v1/member/requests",
|
|
73
73
|
"POST /v1/member/messages",
|
|
74
74
|
"PATCH /v1/member/profile",
|
|
75
|
+
"GET /v1/member/profile/contact",
|
|
76
|
+
"PATCH /v1/member/profile/contact",
|
|
75
77
|
"POST /v1/member/deletion-request",
|
|
78
|
+
"GET /v1/member/consents",
|
|
79
|
+
"POST /v1/member/consents",
|
|
80
|
+
// #8515 D13 — the client's onboarding read and write.
|
|
81
|
+
"GET /v1/member/onboarding",
|
|
82
|
+
"POST /v1/member/onboarding",
|
|
76
83
|
];
|
|
77
84
|
|
|
78
85
|
/** Every route the credential must NOT reach, each a recording stub. */
|
|
79
86
|
const OFF_LIST = [
|
|
87
|
+
"GET /v1/consent/platform",
|
|
88
|
+
"POST /v1/consent/platform",
|
|
80
89
|
"GET /v1/member/other",
|
|
81
90
|
"GET /v1/member/messages/:id",
|
|
82
91
|
"GET /v1/waitlist-signups/me",
|
|
@@ -168,9 +177,9 @@ afterEach(async () => {
|
|
|
168
177
|
});
|
|
169
178
|
|
|
170
179
|
describe("HUB_SESSION_ROUTES", () => {
|
|
171
|
-
it("is exactly the
|
|
180
|
+
it("is exactly the 24 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6)", () => {
|
|
172
181
|
expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
|
|
173
|
-
expect(HUB_SESSION_ROUTES.length).toBe(
|
|
182
|
+
expect(HUB_SESSION_ROUTES.length).toBe(24);
|
|
174
183
|
for (const route of HUB_SESSION_ROUTES) {
|
|
175
184
|
expect({
|
|
176
185
|
route,
|
|
@@ -580,6 +580,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
580
580
|
organizationId: DEFAULT_ORG_ID,
|
|
581
581
|
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
582
582
|
role: "member",
|
|
583
|
+
agent: true,
|
|
583
584
|
});
|
|
584
585
|
await app.close();
|
|
585
586
|
});
|
|
@@ -599,6 +600,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
599
600
|
organizationId: DEFAULT_ORG_ID,
|
|
600
601
|
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
601
602
|
role: "member",
|
|
603
|
+
agent: true,
|
|
602
604
|
grants: ["content-edit", "magnets-manage"],
|
|
603
605
|
});
|
|
604
606
|
await app.close();
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file rate-limit-cross-isolate.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the shared store (#5040 D8: AC-1, AC-7, AC-8). Runs
|
|
5
|
+
* only when `AILK_TEST_LIVE_DATABASE === "1"` — `apps/api/src/__tests__/setup.ts`
|
|
6
|
+
* always sets a placeholder `DATABASE_URL`, so the usual
|
|
7
|
+
* `DATABASE_URL ? describe : describe.skip` gate can't distinguish a real
|
|
8
|
+
* database from the placeholder; `.github/workflows/api-postgres-compat.yml`'s
|
|
9
|
+
* full-suite step sets this variable beside its real `DATABASE_URL`.
|
|
10
|
+
*
|
|
11
|
+
* Uses the `pg` driver directly (raw SQL), the same reason
|
|
12
|
+
* `database/__tests__/prefixed-ids.db.test.ts` does: the generated Prisma
|
|
13
|
+
* client is ESM-only and cannot load under this package's CJS ts-jest
|
|
14
|
+
* transform. AC-1 drives the store's SQL through a `pg.Client` shim that
|
|
15
|
+
* turns a tagged template's strings and values into `$1…$n` text, not
|
|
16
|
+
* through the Prisma binding production uses — that binding is exercised by
|
|
17
|
+
* `rate-limit-store.test.ts`'s mocked-SQL-client unit tests.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
22
|
+
import { Client } from "pg";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
createDatabaseRateLimitStore,
|
|
26
|
+
type RateLimitSqlClient,
|
|
27
|
+
} from "../../lib/rate-limit-store.js";
|
|
28
|
+
|
|
29
|
+
const describeDb =
|
|
30
|
+
process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
|
|
31
|
+
|
|
32
|
+
function sha256Hex(value: string): string {
|
|
33
|
+
return createHash("sha256").update(value).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Turns a tagged template's strings + values into `$1…$n` text for `pg`. */
|
|
37
|
+
function sqlClient(pgClient: Client): RateLimitSqlClient {
|
|
38
|
+
function toParamText(
|
|
39
|
+
strings: TemplateStringsArray,
|
|
40
|
+
values: readonly unknown[],
|
|
41
|
+
): string {
|
|
42
|
+
let text = strings[0] ?? "";
|
|
43
|
+
for (let i = 0; i < values.length; i++) {
|
|
44
|
+
text += `$${i + 1}${strings[i + 1] ?? ""}`;
|
|
45
|
+
}
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function queryRaw(
|
|
50
|
+
strings: TemplateStringsArray,
|
|
51
|
+
...values: unknown[]
|
|
52
|
+
): Promise<unknown[]> {
|
|
53
|
+
const res = await pgClient.query(toParamText(strings, values), values);
|
|
54
|
+
return res.rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function executeRaw(
|
|
58
|
+
strings: TemplateStringsArray,
|
|
59
|
+
...values: unknown[]
|
|
60
|
+
): Promise<number> {
|
|
61
|
+
const res = await pgClient.query(toParamText(strings, values), values);
|
|
62
|
+
return res.rowCount ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
$queryRaw: queryRaw as RateLimitSqlClient["$queryRaw"],
|
|
67
|
+
$executeRaw: executeRaw as RateLimitSqlClient["$executeRaw"],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describeDb("rate limiter — the shared database store (live, #5040)", () => {
|
|
72
|
+
let db: Client;
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
76
|
+
await db.connect();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await db.end();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("AC-1: 20 concurrent requests spread 10/10 over two isolated module instances, one identity, perIpPerMinute 5 — exactly 5×200, 15×429, stored count 20", async () => {
|
|
84
|
+
const identity = `rl-demo-${randomUUID()}`;
|
|
85
|
+
const clientA = new Client({ connectionString: process.env.DATABASE_URL });
|
|
86
|
+
const clientB = new Client({ connectionString: process.env.DATABASE_URL });
|
|
87
|
+
await clientA.connect();
|
|
88
|
+
await clientB.connect();
|
|
89
|
+
|
|
90
|
+
let appA: FastifyInstance | undefined;
|
|
91
|
+
let appB: FastifyInstance | undefined;
|
|
92
|
+
|
|
93
|
+
// Each isolateModules block gives a fresh require cache: requiring
|
|
94
|
+
// rate-limit-store AND rate-limit inside it yields module instances
|
|
95
|
+
// distinct from every other test file's top-level import, with their
|
|
96
|
+
// own process-default-store memoization — exactly the D8 pattern PR
|
|
97
|
+
// #5544 already used for the memory-store proof.
|
|
98
|
+
jest.isolateModules(() => {
|
|
99
|
+
const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
|
|
100
|
+
const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
|
|
101
|
+
const app = Fastify({ trustProxy: true });
|
|
102
|
+
app.register(rateLimitMod.rateLimitMiddleware, {
|
|
103
|
+
perIpPerMinute: 5,
|
|
104
|
+
store: storeMod.createDatabaseRateLimitStore(sqlClient(clientA)),
|
|
105
|
+
});
|
|
106
|
+
app.get("/test", async () => ({ ok: true }));
|
|
107
|
+
appA = app;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
jest.isolateModules(() => {
|
|
111
|
+
const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
|
|
112
|
+
const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
|
|
113
|
+
const app = Fastify({ trustProxy: true });
|
|
114
|
+
app.register(rateLimitMod.rateLimitMiddleware, {
|
|
115
|
+
perIpPerMinute: 5,
|
|
116
|
+
store: storeMod.createDatabaseRateLimitStore(sqlClient(clientB)),
|
|
117
|
+
});
|
|
118
|
+
app.get("/test", async () => ({ ok: true }));
|
|
119
|
+
appB = app;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!appA || !appB) {
|
|
123
|
+
throw new Error("isolateModules did not synchronously assign both apps");
|
|
124
|
+
}
|
|
125
|
+
await appA.ready();
|
|
126
|
+
await appB.ready();
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const inject = (app: FastifyInstance) =>
|
|
130
|
+
app.inject({
|
|
131
|
+
method: "GET",
|
|
132
|
+
url: "/test",
|
|
133
|
+
headers: { "x-forwarded-for": identity },
|
|
134
|
+
});
|
|
135
|
+
const results = await Promise.all([
|
|
136
|
+
...Array.from({ length: 10 }, () => inject(appA!)),
|
|
137
|
+
...Array.from({ length: 10 }, () => inject(appB!)),
|
|
138
|
+
]);
|
|
139
|
+
const statuses = results.map((r) => r.statusCode);
|
|
140
|
+
expect(statuses.filter((s) => s === 200)).toHaveLength(5);
|
|
141
|
+
expect(statuses.filter((s) => s === 429)).toHaveLength(15);
|
|
142
|
+
|
|
143
|
+
const hash = sha256Hex(identity);
|
|
144
|
+
const row = await db.query(
|
|
145
|
+
`SELECT "count" FROM "rate_limit_counters" WHERE "key_hash" = $1`,
|
|
146
|
+
[hash],
|
|
147
|
+
);
|
|
148
|
+
expect(row.rows[0]?.count).toBe(20);
|
|
149
|
+
} finally {
|
|
150
|
+
await appA.close();
|
|
151
|
+
await appB.close();
|
|
152
|
+
await clientA.end();
|
|
153
|
+
await clientB.end();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("AC-7: sweep deletes an expired row only on the interval trigger, never on 'limited'", async () => {
|
|
158
|
+
const store = createDatabaseRateLimitStore(sqlClient(db));
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const staleHash = sha256Hex(`rl-stale-${randomUUID()}`);
|
|
161
|
+
const openHash = sha256Hex(`rl-open-${randomUUID()}`);
|
|
162
|
+
|
|
163
|
+
await db.query(
|
|
164
|
+
`INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
|
|
165
|
+
[staleHash, now - 120_000, 60_000],
|
|
166
|
+
);
|
|
167
|
+
await db.query(
|
|
168
|
+
`INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
|
|
169
|
+
[openHash, now, 60_000],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
await store.sweep(now, 60_000, "limited");
|
|
173
|
+
let rows = await db.query(
|
|
174
|
+
`SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
|
|
175
|
+
[staleHash, openHash],
|
|
176
|
+
);
|
|
177
|
+
expect(rows.rows).toHaveLength(2);
|
|
178
|
+
|
|
179
|
+
await store.sweep(now, 60_000, "interval");
|
|
180
|
+
rows = await db.query(
|
|
181
|
+
`SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
|
|
182
|
+
[staleHash, openHash],
|
|
183
|
+
);
|
|
184
|
+
expect(rows.rows.map((r: { key_hash: string }) => r.key_hash)).toEqual([
|
|
185
|
+
openHash,
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("AC-8: key_hash is always a sha256 digest, never the raw identity, and both format CHECKs hold", async () => {
|
|
190
|
+
const store = createDatabaseRateLimitStore(sqlClient(db));
|
|
191
|
+
const identity = `203.0.113.${1 + Math.floor(Math.random() * 254)}`;
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
await store.hit(identity, now, 60_000);
|
|
194
|
+
|
|
195
|
+
const allRows = await db.query(`SELECT "key_hash" FROM "rate_limit_counters"`);
|
|
196
|
+
for (const row of allRows.rows as Array<{ key_hash: string }>) {
|
|
197
|
+
expect(row.key_hash).not.toContain("203.0.113");
|
|
198
|
+
}
|
|
199
|
+
const hash = sha256Hex(identity);
|
|
200
|
+
const matched = await db.query(
|
|
201
|
+
`SELECT 1 FROM "rate_limit_counters" WHERE "key_hash" = $1`,
|
|
202
|
+
[hash],
|
|
203
|
+
);
|
|
204
|
+
expect(matched.rows).toHaveLength(1);
|
|
205
|
+
|
|
206
|
+
await expect(
|
|
207
|
+
db.query(
|
|
208
|
+
`INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ('not-hex', $1, 60000, 1)`,
|
|
209
|
+
[now],
|
|
210
|
+
),
|
|
211
|
+
).rejects.toThrow(/rate_limit_counters_key_hash_hex/);
|
|
212
|
+
|
|
213
|
+
await expect(
|
|
214
|
+
db.query(
|
|
215
|
+
`INSERT INTO "rate_limit_counters" ("id", "key_hash", "window_start_ms", "window_ms", "count") VALUES ('bad', $1, $2, 60000, 1)`,
|
|
216
|
+
[sha256Hex(`rl-bad-id-${randomUUID()}`), now],
|
|
217
|
+
),
|
|
218
|
+
).rejects.toThrow(/rate_limit_counters_id_format/);
|
|
219
|
+
});
|
|
220
|
+
});
|