create-ailk 0.18.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- 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/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 +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- 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.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -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/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- 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__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- 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 +126 -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 +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- 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 +114 -50
- 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 +177 -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/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -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 +4 -1
- 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 +80 -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 +108 -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 +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- 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 +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- 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/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
|
@@ -33,10 +33,19 @@
|
|
|
33
33
|
* (`lib/hub-sign-in-store.ts`). It is admitted only beside the SAME
|
|
34
34
|
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
35
|
* unexpired, its client enabled and its person's email verified, and only
|
|
36
|
-
* on `HUB_SESSION_ROUTES` — an exact list of
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
36
|
+
* on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
|
|
37
|
+
* pairs — the credential's own `me` and the member routes, 18 in all
|
|
38
|
+
* (#8220); any other route is 404 (never 403, so the account area's
|
|
39
|
+
* `read()` loads "nothing for this caller"). It acts as its person
|
|
40
|
+
* (`request.userId`) and carries `request.hubSession` — never
|
|
41
|
+
* `request.sessionTenant` — and its request logger names the spoke
|
|
42
|
+
* (`spokeId`). Each person is limited, at each spoke, to 60 requests and
|
|
43
|
+
* 10 writes a minute and 30 writes an hour — a fixed hour window from
|
|
44
|
+
* the bucket's first admitted write (#8220 D2, SEC-8169-4): the bucket is
|
|
45
|
+
* keyed on the VERIFIED spoke and person, never a header, and charged
|
|
46
|
+
* only after every check above passes, so a refused credential or an
|
|
47
|
+
* off-list route never gets one. The write limits refuse only writes,
|
|
48
|
+
* and a request the budget refuses is not counted.
|
|
40
49
|
*
|
|
41
50
|
* Every authenticated request carries `request.actor` — the person, or the
|
|
42
51
|
* agent and the person it acts for — and its request/reply loggers are
|
|
@@ -141,21 +150,39 @@ export type HubSession = {
|
|
|
141
150
|
};
|
|
142
151
|
|
|
143
152
|
/**
|
|
144
|
-
* The only routes a hub-issued credential reaches (#7894 D6): an
|
|
145
|
-
* method + registered URL, never a prefix
|
|
146
|
-
* by a reviewed edit of this list
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
153
|
+
* The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
|
|
154
|
+
* EXACT list of method + registered URL, never a prefix and never computed,
|
|
155
|
+
* so a route added later joins only by a reviewed edit of this list
|
|
156
|
+
* (SEC-7894-3). Three groups, in this order: the credential's own `me` and
|
|
157
|
+
* #7877's three member reads; #8169's eight member reads; #8170's six member
|
|
158
|
+
* writes (roadmap §7.2 row order). The member profile write IS admitted — it
|
|
159
|
+
* is the person's own account, which roadmap D9 saves through it. Not
|
|
160
|
+
* admitted, deliberately: `GET /v1/waitlist-signups/me`,
|
|
161
|
+
* `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
|
|
162
|
+
* `POST /v1/subscription/portal-session` (they answer for the HUB's site and
|
|
163
|
+
* the person's platform billing, SEC-7894-1, -2), the token routes and the
|
|
164
|
+
* hub's own-account `/v1/user/*` routes, `PATCH /v1/user/profile` among them.
|
|
152
165
|
*/
|
|
153
|
-
export const HUB_SESSION_ROUTES: readonly string[] = [
|
|
166
|
+
export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
|
|
154
167
|
"GET /v1/hub-sign-in/me",
|
|
155
168
|
"GET /v1/member/pieces",
|
|
156
169
|
"GET /v1/member/messages",
|
|
157
170
|
"GET /v1/member/messages/:id/file",
|
|
158
|
-
|
|
171
|
+
"GET /v1/member/purchases",
|
|
172
|
+
"GET /v1/member/appointments",
|
|
173
|
+
"GET /v1/member/artifacts",
|
|
174
|
+
"GET /v1/member/artifacts/:pieceId/versions/:version/file",
|
|
175
|
+
"GET /v1/member/meeting-types",
|
|
176
|
+
"GET /v1/member/availability",
|
|
177
|
+
"GET /v1/member/request-form",
|
|
178
|
+
"GET /v1/member/billing",
|
|
179
|
+
"POST /v1/member/artifacts/:pieceId/review",
|
|
180
|
+
"POST /v1/member/bookings",
|
|
181
|
+
"POST /v1/member/requests",
|
|
182
|
+
"POST /v1/member/messages",
|
|
183
|
+
"PATCH /v1/member/profile",
|
|
184
|
+
"POST /v1/member/deletion-request",
|
|
185
|
+
]);
|
|
159
186
|
|
|
160
187
|
/**
|
|
161
188
|
* Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
|
|
@@ -311,12 +338,13 @@ function bindActor(
|
|
|
311
338
|
reply.log = request.log;
|
|
312
339
|
}
|
|
313
340
|
|
|
314
|
-
// ── The per-
|
|
341
|
+
// ── The per-caller limits (#3591 D4 step 5, SEC-3591-04/-14; #8220 D2) ──────
|
|
315
342
|
//
|
|
316
343
|
// In the handler, not `onRequest`: the shared limiter runs before any
|
|
317
|
-
// identity exists, and a bucket must only ever be keyed on a VERIFIED
|
|
318
|
-
//
|
|
319
|
-
// `routes/subscription/portal.ts`'s per-user budget.
|
|
344
|
+
// identity exists, and a bucket must only ever be keyed on a VERIFIED id, so
|
|
345
|
+
// an invented key or credential never gets one. The shape of
|
|
346
|
+
// `routes/subscription/portal.ts`'s per-user budget. An agent and a person
|
|
347
|
+
// never share a map.
|
|
320
348
|
|
|
321
349
|
const AGENT_WINDOW_MS = 60_000;
|
|
322
350
|
const AGENT_REQUESTS_PER_WINDOW = 60;
|
|
@@ -324,53 +352,159 @@ const AGENT_WRITES_PER_WINDOW = 10;
|
|
|
324
352
|
/** Cap on distinct buckets — the oldest is evicted at capacity (DEF-011's shape). */
|
|
325
353
|
const MAX_AGENT_BUCKETS = 5_000;
|
|
326
354
|
|
|
327
|
-
|
|
328
|
-
const
|
|
355
|
+
const HUB_SESSION_WINDOW_MS = 60_000;
|
|
356
|
+
const HUB_SESSION_REQUESTS_PER_WINDOW = 60;
|
|
357
|
+
const HUB_SESSION_WRITES_PER_WINDOW = 10;
|
|
358
|
+
const HUB_SESSION_WRITE_HOUR_MS = 3_600_000;
|
|
359
|
+
const HUB_SESSION_WRITES_PER_HOUR = 30;
|
|
360
|
+
const MAX_HUB_SESSION_BUCKETS = 5_000;
|
|
361
|
+
|
|
362
|
+
type CallerBucket = {
|
|
363
|
+
windowStartMs: number;
|
|
364
|
+
requests: number;
|
|
365
|
+
writes: number;
|
|
366
|
+
/** The hourly write window — set by the bucket's first admitted write. */
|
|
367
|
+
hourStartMs: number | null;
|
|
368
|
+
hourWrites: number;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
type CallerLimits = {
|
|
372
|
+
windowMs: number;
|
|
373
|
+
maxBuckets: number;
|
|
374
|
+
requests: number;
|
|
375
|
+
writes: number;
|
|
376
|
+
/** An hourly write ceiling — the hub-credential path's alone. */
|
|
377
|
+
writesPerHour?: { limit: number; windowMs: number };
|
|
378
|
+
/** When true, the write limits refuse only writes; a read meets only `requests`. */
|
|
379
|
+
refuseOnlyWrites: boolean;
|
|
380
|
+
/** When true, a refused request still counts toward every limit. */
|
|
381
|
+
countRefused: boolean;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const AGENT_LIMITS: CallerLimits = {
|
|
385
|
+
windowMs: AGENT_WINDOW_MS,
|
|
386
|
+
maxBuckets: MAX_AGENT_BUCKETS,
|
|
387
|
+
requests: AGENT_REQUESTS_PER_WINDOW,
|
|
388
|
+
writes: AGENT_WRITES_PER_WINDOW,
|
|
389
|
+
refuseOnlyWrites: false,
|
|
390
|
+
countRefused: true,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const HUB_SESSION_LIMITS: CallerLimits = {
|
|
394
|
+
windowMs: HUB_SESSION_WINDOW_MS,
|
|
395
|
+
maxBuckets: MAX_HUB_SESSION_BUCKETS,
|
|
396
|
+
requests: HUB_SESSION_REQUESTS_PER_WINDOW,
|
|
397
|
+
writes: HUB_SESSION_WRITES_PER_WINDOW,
|
|
398
|
+
writesPerHour: {
|
|
399
|
+
limit: HUB_SESSION_WRITES_PER_HOUR,
|
|
400
|
+
windowMs: HUB_SESSION_WRITE_HOUR_MS,
|
|
401
|
+
},
|
|
402
|
+
refuseOnlyWrites: true,
|
|
403
|
+
countRefused: false,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const agentBuckets = new Map<string, CallerBucket>();
|
|
407
|
+
const hubSessionBuckets = new Map<string, CallerBucket>();
|
|
329
408
|
|
|
330
409
|
/** Test-only reset. Not intended for production use. */
|
|
331
410
|
export function __resetAgentRateBuckets(): void {
|
|
332
411
|
agentBuckets.clear();
|
|
333
412
|
}
|
|
334
413
|
|
|
414
|
+
/** Test-only reset. Not intended for production use. */
|
|
415
|
+
export function __resetHubSessionRateBuckets(): void {
|
|
416
|
+
hubSessionBuckets.clear();
|
|
417
|
+
}
|
|
418
|
+
|
|
335
419
|
function isWrite(method: string): boolean {
|
|
336
420
|
const m = method.toUpperCase();
|
|
337
421
|
return m !== "GET" && m !== "HEAD";
|
|
338
422
|
}
|
|
339
423
|
|
|
340
|
-
/**
|
|
341
|
-
function
|
|
342
|
-
|
|
424
|
+
/** A bucket no window still holds — the eviction pass may drop it. */
|
|
425
|
+
function isStale(
|
|
426
|
+
bucket: CallerBucket,
|
|
427
|
+
now: number,
|
|
428
|
+
limits: CallerLimits,
|
|
429
|
+
): boolean {
|
|
430
|
+
if (now - bucket.windowStartMs < limits.windowMs) return false;
|
|
431
|
+
return (
|
|
432
|
+
bucket.hourStartMs === null ||
|
|
433
|
+
limits.writesPerHour === undefined ||
|
|
434
|
+
now - bucket.hourStartMs >= limits.writesPerHour.windowMs
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Charge one request to `key`'s bucket. `undefined` when admitted; the
|
|
440
|
+
* seconds to wait — to the end of the latest window that refused, at least
|
|
441
|
+
* 1 — when over budget.
|
|
442
|
+
*/
|
|
443
|
+
function chargeCallerBucket(
|
|
444
|
+
buckets: Map<string, CallerBucket>,
|
|
445
|
+
key: string,
|
|
343
446
|
method: string,
|
|
344
447
|
now: number,
|
|
448
|
+
limits: CallerLimits,
|
|
345
449
|
): number | undefined {
|
|
346
|
-
let bucket =
|
|
347
|
-
if (!bucket || now - bucket.windowStartMs >=
|
|
348
|
-
if (!
|
|
349
|
-
for (const [
|
|
350
|
-
if (
|
|
450
|
+
let bucket = buckets.get(key);
|
|
451
|
+
if (!bucket || now - bucket.windowStartMs >= limits.windowMs) {
|
|
452
|
+
if (!bucket && buckets.size >= limits.maxBuckets) {
|
|
453
|
+
for (const [k, b] of buckets) {
|
|
454
|
+
if (isStale(b, now, limits)) buckets.delete(k);
|
|
351
455
|
}
|
|
352
|
-
if (
|
|
353
|
-
const oldest =
|
|
354
|
-
if (oldest !== undefined)
|
|
456
|
+
if (buckets.size >= limits.maxBuckets) {
|
|
457
|
+
const oldest = buckets.keys().next().value;
|
|
458
|
+
if (oldest !== undefined) buckets.delete(oldest);
|
|
355
459
|
}
|
|
356
460
|
}
|
|
357
|
-
// Re-insert, so Map order stays oldest-window-first for the eviction
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
461
|
+
// Re-insert, so Map order stays oldest-window-first for the eviction
|
|
462
|
+
// above. A new minute keeps the hour's start and count.
|
|
463
|
+
buckets.delete(key);
|
|
464
|
+
bucket = {
|
|
465
|
+
windowStartMs: now,
|
|
466
|
+
requests: 0,
|
|
467
|
+
writes: 0,
|
|
468
|
+
hourStartMs: bucket?.hourStartMs ?? null,
|
|
469
|
+
hourWrites: bucket?.hourWrites ?? 0,
|
|
470
|
+
};
|
|
471
|
+
buckets.set(key, bucket);
|
|
361
472
|
}
|
|
362
|
-
|
|
363
|
-
if (isWrite(method)) bucket.writes += 1;
|
|
473
|
+
const hour = limits.writesPerHour;
|
|
364
474
|
if (
|
|
365
|
-
|
|
366
|
-
bucket.
|
|
475
|
+
hour !== undefined &&
|
|
476
|
+
bucket.hourStartMs !== null &&
|
|
477
|
+
now - bucket.hourStartMs >= hour.windowMs
|
|
367
478
|
) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
Math.ceil((bucket.windowStartMs + AGENT_WINDOW_MS - now) / 1_000),
|
|
371
|
-
);
|
|
479
|
+
bucket.hourStartMs = null;
|
|
480
|
+
bucket.hourWrites = 0;
|
|
372
481
|
}
|
|
373
|
-
|
|
482
|
+
|
|
483
|
+
const write = isWrite(method);
|
|
484
|
+
const requests = bucket.requests + 1;
|
|
485
|
+
const writes = bucket.writes + (write ? 1 : 0);
|
|
486
|
+
const hourWrites = bucket.hourWrites + (write ? 1 : 0);
|
|
487
|
+
const minuteEnd = bucket.windowStartMs + limits.windowMs;
|
|
488
|
+
const refusedUntil: number[] = [];
|
|
489
|
+
if (requests > limits.requests) refusedUntil.push(minuteEnd);
|
|
490
|
+
if (write || !limits.refuseOnlyWrites) {
|
|
491
|
+
if (writes > limits.writes) refusedUntil.push(minuteEnd);
|
|
492
|
+
if (write && hour !== undefined && hourWrites > hour.limit) {
|
|
493
|
+
refusedUntil.push((bucket.hourStartMs ?? now) + hour.windowMs);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const refused = refusedUntil.length > 0;
|
|
498
|
+
if (!refused || limits.countRefused) {
|
|
499
|
+
bucket.requests = requests;
|
|
500
|
+
bucket.writes = writes;
|
|
501
|
+
if (write && hour !== undefined) {
|
|
502
|
+
bucket.hourWrites = hourWrites;
|
|
503
|
+
bucket.hourStartMs ??= now;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!refused) return undefined;
|
|
507
|
+
return Math.max(1, Math.ceil((Math.max(...refusedUntil) - now) / 1_000));
|
|
374
508
|
}
|
|
375
509
|
|
|
376
510
|
/**
|
|
@@ -412,7 +546,13 @@ async function authenticateAgent(
|
|
|
412
546
|
onBehalfOfUserId: personId,
|
|
413
547
|
});
|
|
414
548
|
|
|
415
|
-
const retryAfter =
|
|
549
|
+
const retryAfter = chargeCallerBucket(
|
|
550
|
+
agentBuckets,
|
|
551
|
+
agentId,
|
|
552
|
+
request.method,
|
|
553
|
+
now,
|
|
554
|
+
AGENT_LIMITS,
|
|
555
|
+
);
|
|
416
556
|
if (retryAfter !== undefined) {
|
|
417
557
|
reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
|
|
418
558
|
return;
|
|
@@ -440,7 +580,9 @@ async function authenticateAgent(
|
|
|
440
580
|
/**
|
|
441
581
|
* The hub-credential path (#7894 D6). The credential is never logged and
|
|
442
582
|
* never echoed: every refusal is a fixed body. (1)-(3) failing is the one 401;
|
|
443
|
-
* (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run
|
|
583
|
+
* (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run;
|
|
584
|
+
* (5), the person over budget at this spoke (#8220 D2), is 429 with no
|
|
585
|
+
* handler run.
|
|
444
586
|
*/
|
|
445
587
|
async function authenticateHubSession(
|
|
446
588
|
request: FastifyRequest,
|
|
@@ -490,6 +632,24 @@ async function authenticateHubSession(
|
|
|
490
632
|
workspaceId: spoke.workspaceId as Id<"workspace">,
|
|
491
633
|
};
|
|
492
634
|
bindActor(request, reply, { type: "user", userId: request.userId });
|
|
635
|
+
// #8220 D10 — every line the request logger writes after admission names
|
|
636
|
+
// the spoke that acted: its id, never its key or the credential. (Lines a
|
|
637
|
+
// helper writes through the module-level `logger` carry no request binding.)
|
|
638
|
+
request.log = request.log.child({ spokeId: spoke.id });
|
|
639
|
+
reply.log = request.log;
|
|
640
|
+
|
|
641
|
+
// (5) The verified (spoke, person) is within its budget.
|
|
642
|
+
const retryAfter = chargeCallerBucket(
|
|
643
|
+
hubSessionBuckets,
|
|
644
|
+
`${spoke.id}:${grant.userId}`,
|
|
645
|
+
request.method,
|
|
646
|
+
now,
|
|
647
|
+
HUB_SESSION_LIMITS,
|
|
648
|
+
);
|
|
649
|
+
if (retryAfter !== undefined) {
|
|
650
|
+
reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
493
653
|
}
|
|
494
654
|
|
|
495
655
|
export type AuthPreHandlerOptions = {
|
|
@@ -245,6 +245,10 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// #8185 D3 — a spoke's same-origin relay marks every forwarded call.
|
|
249
|
+
// The mark can only remove the spoke-checkout marker, never grant it.
|
|
250
|
+
sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
|
|
251
|
+
|
|
248
252
|
const result = await routeLead(
|
|
249
253
|
buildInput(kind, body, workspaceId),
|
|
250
254
|
sourceMeta,
|
|
@@ -228,9 +228,30 @@ const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
|
|
|
228
228
|
const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
|
|
229
229
|
const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
|
|
230
230
|
const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
|
|
231
|
+
// #8171 D8 — the workspace's Requests read (the inbound member rows), the
|
|
232
|
+
// SAME scope.
|
|
233
|
+
const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
|
|
231
234
|
// #7877 D11 — the member reads: auth only, under the SAME module predicate.
|
|
232
235
|
const MEMBER_PIECES = "./routes/member/pieces.js";
|
|
233
236
|
const MEMBER_MESSAGES = "./routes/member/messages.js";
|
|
237
|
+
// #8169 D9 — the Phase 1 member reads. Five under the SAME predicate; the two
|
|
238
|
+
// that reach services/scheduling/ under scheduling && (workspace ||
|
|
239
|
+
// multi-tenant), because only the scheduling module delivers that directory.
|
|
240
|
+
const MEMBER_PURCHASES = "./routes/member/purchases.js";
|
|
241
|
+
const MEMBER_APPOINTMENTS = "./routes/member/appointments.js";
|
|
242
|
+
const MEMBER_ARTIFACTS = "./routes/member/artifacts.js";
|
|
243
|
+
const MEMBER_REQUEST_FORM = "./routes/member/request-form.js";
|
|
244
|
+
const MEMBER_BILLING = "./routes/member/billing.js";
|
|
245
|
+
const MEMBER_MEETING_TYPES = "./routes/member/meeting-types.js";
|
|
246
|
+
const MEMBER_AVAILABILITY = "./routes/member/availability.js";
|
|
247
|
+
// #8170 D11 — the member writes. Four under the tenancy predicate (the
|
|
248
|
+
// message POST rides MEMBER_MESSAGES); the booking, which reaches
|
|
249
|
+
// services/scheduling/, under scheduling && (workspace || multi-tenant).
|
|
250
|
+
const MEMBER_REQUESTS = "./routes/member/requests.js";
|
|
251
|
+
const MEMBER_ARTIFACT_REVIEW = "./routes/member/artifact-review.js";
|
|
252
|
+
const MEMBER_PROFILE = "./routes/member/profile.js";
|
|
253
|
+
const MEMBER_DELETION_REQUEST = "./routes/member/deletion-request.js";
|
|
254
|
+
const MEMBER_BOOKINGS = "./routes/member/bookings.js";
|
|
234
255
|
// #3591 PR-B — the agent seat's management routes and an agent's own
|
|
235
256
|
// context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
|
|
236
257
|
// scope.
|
|
@@ -316,6 +337,11 @@ const SLICE_PLUGINS = {
|
|
|
316
337
|
specifier: WORKSPACES_PIECES,
|
|
317
338
|
load: () => import(WORKSPACES_PIECES),
|
|
318
339
|
},
|
|
340
|
+
// #8171 D8 — the Requests read, the SAME scope.
|
|
341
|
+
workspaceRequestRoutes: {
|
|
342
|
+
specifier: WORKSPACES_REQUESTS,
|
|
343
|
+
load: () => import(WORKSPACES_REQUESTS),
|
|
344
|
+
},
|
|
319
345
|
workspacePlanRoutes: {
|
|
320
346
|
specifier: WORKSPACES_PLAN,
|
|
321
347
|
load: () => import(WORKSPACES_PLAN),
|
|
@@ -333,6 +359,58 @@ const SLICE_PLUGINS = {
|
|
|
333
359
|
specifier: MEMBER_MESSAGES,
|
|
334
360
|
load: () => import(MEMBER_MESSAGES),
|
|
335
361
|
},
|
|
362
|
+
// #8169 D9 — the Phase 1 member reads, in authScope beside the two above.
|
|
363
|
+
memberPurchaseRoutes: {
|
|
364
|
+
specifier: MEMBER_PURCHASES,
|
|
365
|
+
load: () => import(MEMBER_PURCHASES),
|
|
366
|
+
},
|
|
367
|
+
memberAppointmentRoutes: {
|
|
368
|
+
specifier: MEMBER_APPOINTMENTS,
|
|
369
|
+
load: () => import(MEMBER_APPOINTMENTS),
|
|
370
|
+
},
|
|
371
|
+
memberArtifactRoutes: {
|
|
372
|
+
specifier: MEMBER_ARTIFACTS,
|
|
373
|
+
load: () => import(MEMBER_ARTIFACTS),
|
|
374
|
+
},
|
|
375
|
+
memberRequestFormRoutes: {
|
|
376
|
+
specifier: MEMBER_REQUEST_FORM,
|
|
377
|
+
load: () => import(MEMBER_REQUEST_FORM),
|
|
378
|
+
},
|
|
379
|
+
memberBillingRoutes: {
|
|
380
|
+
specifier: MEMBER_BILLING,
|
|
381
|
+
load: () => import(MEMBER_BILLING),
|
|
382
|
+
},
|
|
383
|
+
// The two that reach services/scheduling/ (scheduling-owned, D9).
|
|
384
|
+
memberMeetingTypeRoutes: {
|
|
385
|
+
specifier: MEMBER_MEETING_TYPES,
|
|
386
|
+
load: () => import(MEMBER_MEETING_TYPES),
|
|
387
|
+
},
|
|
388
|
+
memberAvailabilityRoutes: {
|
|
389
|
+
specifier: MEMBER_AVAILABILITY,
|
|
390
|
+
load: () => import(MEMBER_AVAILABILITY),
|
|
391
|
+
},
|
|
392
|
+
// #8170 D11 — the member writes, in authScope beside the reads.
|
|
393
|
+
memberRequestRoutes: {
|
|
394
|
+
specifier: MEMBER_REQUESTS,
|
|
395
|
+
load: () => import(MEMBER_REQUESTS),
|
|
396
|
+
},
|
|
397
|
+
memberArtifactReviewRoutes: {
|
|
398
|
+
specifier: MEMBER_ARTIFACT_REVIEW,
|
|
399
|
+
load: () => import(MEMBER_ARTIFACT_REVIEW),
|
|
400
|
+
},
|
|
401
|
+
memberProfileRoutes: {
|
|
402
|
+
specifier: MEMBER_PROFILE,
|
|
403
|
+
load: () => import(MEMBER_PROFILE),
|
|
404
|
+
},
|
|
405
|
+
memberDeletionRequestRoutes: {
|
|
406
|
+
specifier: MEMBER_DELETION_REQUEST,
|
|
407
|
+
load: () => import(MEMBER_DELETION_REQUEST),
|
|
408
|
+
},
|
|
409
|
+
// The one that reaches services/scheduling/ (scheduling-owned, D11).
|
|
410
|
+
memberBookingRoutes: {
|
|
411
|
+
specifier: MEMBER_BOOKINGS,
|
|
412
|
+
load: () => import(MEMBER_BOOKINGS),
|
|
413
|
+
},
|
|
336
414
|
// #3591 PR-B — the agent seat, the SAME scope.
|
|
337
415
|
workspaceAgentRoutes: {
|
|
338
416
|
specifier: WORKSPACES_AGENTS,
|
|
@@ -927,6 +1005,30 @@ export async function build() {
|
|
|
927
1005
|
if (isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")) {
|
|
928
1006
|
await authScope.register(await sliceRoutePlugin("memberPieceRoutes"));
|
|
929
1007
|
await authScope.register(await sliceRoutePlugin("memberMessageRoutes"));
|
|
1008
|
+
// #8169 D9 — the Phase 1 member reads, under the same predicate.
|
|
1009
|
+
await authScope.register(await sliceRoutePlugin("memberPurchaseRoutes"));
|
|
1010
|
+
await authScope.register(await sliceRoutePlugin("memberAppointmentRoutes"));
|
|
1011
|
+
await authScope.register(await sliceRoutePlugin("memberArtifactRoutes"));
|
|
1012
|
+
await authScope.register(await sliceRoutePlugin("memberRequestFormRoutes"));
|
|
1013
|
+
await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
|
|
1014
|
+
// #8170 D11 — the member writes, under the same predicate.
|
|
1015
|
+
await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
|
|
1016
|
+
await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
|
|
1017
|
+
await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
|
|
1018
|
+
await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
|
|
1019
|
+
}
|
|
1020
|
+
// #8169 D9 — the two member reads that reach services/scheduling/, which
|
|
1021
|
+
// only the scheduling module delivers: a scaffold with a tenancy module
|
|
1022
|
+
// and no scheduling carries neither file, and one with scheduling and no
|
|
1023
|
+
// tenancy module registers neither.
|
|
1024
|
+
if (
|
|
1025
|
+
isModuleEnabled("scheduling") &&
|
|
1026
|
+
(isModuleEnabled("workspace") || isModuleEnabled("multi-tenant"))
|
|
1027
|
+
) {
|
|
1028
|
+
await authScope.register(await sliceRoutePlugin("memberMeetingTypeRoutes"));
|
|
1029
|
+
await authScope.register(await sliceRoutePlugin("memberAvailabilityRoutes"));
|
|
1030
|
+
// #8170 D11 — the member booking, under the same predicate.
|
|
1031
|
+
await authScope.register(await sliceRoutePlugin("memberBookingRoutes"));
|
|
930
1032
|
}
|
|
931
1033
|
|
|
932
1034
|
// Tenant-guarded domain routes (multi-tenant module) — the tenant
|
|
@@ -1104,6 +1206,11 @@ export async function build() {
|
|
|
1104
1206
|
await tenantScope.register(
|
|
1105
1207
|
await sliceRoutePlugin("workspacePieceRoutes"),
|
|
1106
1208
|
);
|
|
1209
|
+
// #8171 D8 — the Requests read: the inbound member rows, behind
|
|
1210
|
+
// `inbox-read`; the content only to a role holding `people-read`.
|
|
1211
|
+
await tenantScope.register(
|
|
1212
|
+
await sliceRoutePlugin("workspaceRequestRoutes"),
|
|
1213
|
+
);
|
|
1107
1214
|
await tenantScope.register(
|
|
1108
1215
|
await sliceRoutePlugin("workspacePlanRoutes"),
|
|
1109
1216
|
);
|
|
@@ -1198,6 +1198,64 @@ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
|
|
|
1198
1198
|
expect(planLines()).toEqual([]);
|
|
1199
1199
|
});
|
|
1200
1200
|
|
|
1201
|
+
// #8185 — the hub stores its conclusion that a lead is a spoke's live paid
|
|
1202
|
+
// checkout (`Lead.spokeCheckout`): the plan rule's own predicate, plus "not
|
|
1203
|
+
// relayed". An unmarked create names no `spokeCheckout` key at all.
|
|
1204
|
+
const createData = () =>
|
|
1205
|
+
(prisma.lead.create as jest.Mock).mock.calls[0][0].data as Record<string, unknown>;
|
|
1206
|
+
|
|
1207
|
+
it("a live spoke checkout lead is created with spokeCheckout true and still sets the plan once", async () => {
|
|
1208
|
+
await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
|
|
1209
|
+
expect(createData()).toMatchObject({ spokeCheckout: true });
|
|
1210
|
+
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("the same lead relayed for a visitor is created with no spokeCheckout key and still sets the plan", async () => {
|
|
1214
|
+
await routeLead(checkoutLead, { ...spokeMeta, relayed: true }, { idempotencyKey: KEY });
|
|
1215
|
+
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1216
|
+
expect(setPlan).toHaveBeenCalledTimes(1);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it.each([
|
|
1220
|
+
["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
|
|
1221
|
+
["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
|
|
1222
|
+
[
|
|
1223
|
+
"amountTotal 0",
|
|
1224
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
|
|
1225
|
+
spokeMeta,
|
|
1226
|
+
KEY,
|
|
1227
|
+
],
|
|
1228
|
+
[
|
|
1229
|
+
"a negative amountTotal",
|
|
1230
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
|
|
1231
|
+
spokeMeta,
|
|
1232
|
+
KEY,
|
|
1233
|
+
],
|
|
1234
|
+
["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
|
|
1235
|
+
["no key", checkoutLead, spokeMeta, undefined],
|
|
1236
|
+
["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
|
|
1237
|
+
[
|
|
1238
|
+
"page contact",
|
|
1239
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
|
|
1240
|
+
spokeMeta,
|
|
1241
|
+
KEY,
|
|
1242
|
+
],
|
|
1243
|
+
[
|
|
1244
|
+
"a numeric offering",
|
|
1245
|
+
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
|
|
1246
|
+
spokeMeta,
|
|
1247
|
+
KEY,
|
|
1248
|
+
],
|
|
1249
|
+
] as const)("no spokeCheckout key in the create data for %s", async (_label, input, meta, key) => {
|
|
1250
|
+
await routeLead(
|
|
1251
|
+
input as typeof checkoutLead,
|
|
1252
|
+
meta,
|
|
1253
|
+
key === undefined ? {} : { idempotencyKey: key },
|
|
1254
|
+
);
|
|
1255
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1256
|
+
expect(createData()).not.toHaveProperty("spokeCheckout");
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1201
1259
|
it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
|
|
1202
1260
|
await routeLead(
|
|
1203
1261
|
{ ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
|
|
@@ -63,6 +63,15 @@
|
|
|
63
63
|
* `routeLead`'s result, and logs one bare `checkout-plan` line — never the
|
|
64
64
|
* offering, the email or the key. A lead that does not qualify makes no
|
|
65
65
|
* Person call at all.
|
|
66
|
+
*
|
|
67
|
+
* SPOKE CHECKOUT MARKER (#8185 D2). The same predicate, plus "not relayed
|
|
68
|
+
* for a visitor" (`SourceMeta.relayed`), is evaluated once BEFORE the lead's
|
|
69
|
+
* create and stored as `Lead.spokeCheckout` — the hub's conclusion at write
|
|
70
|
+
* time, which `GET /v1/member/purchases` serves as a purchase
|
|
71
|
+
* (`lib/member-store.ts`). Written only in the submit create below, never by
|
|
72
|
+
* an update; an unmarked create carries no `spokeCheckout` key at all and
|
|
73
|
+
* relies on the column default. The plan rule above is unchanged: it still
|
|
74
|
+
* runs after the write, from the same candidate.
|
|
66
75
|
*/
|
|
67
76
|
|
|
68
77
|
import { randomUUID } from "node:crypto";
|
|
@@ -117,6 +126,13 @@ export type SourceMeta = {
|
|
|
117
126
|
* `false`. Never persisted — `captureRecordFor` copies fields by name.
|
|
118
127
|
*/
|
|
119
128
|
viaSpoke?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* The request carried `x-ailk-relayed`, i.e. it was forwarded for a
|
|
131
|
+
* visitor by a spoke's same-origin relay (#8185 D3). Absent reads as
|
|
132
|
+
* `false`. Never persisted. It can only REMOVE the spoke-checkout marker,
|
|
133
|
+
* never grant it.
|
|
134
|
+
*/
|
|
135
|
+
relayed?: boolean;
|
|
120
136
|
};
|
|
121
137
|
|
|
122
138
|
/** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
|
|
@@ -340,9 +356,15 @@ async function persistToDb(
|
|
|
340
356
|
input: RouteLeadInput,
|
|
341
357
|
idempotencyKey?: string,
|
|
342
358
|
viaSpoke = false,
|
|
359
|
+
relayed = false,
|
|
343
360
|
): Promise<PersistResult> {
|
|
344
361
|
if (input.kind === "submit") {
|
|
345
362
|
const personId = await resolveLeadPersonId(input);
|
|
363
|
+
// #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
|
|
364
|
+
// evaluated before the create so its result can be stored. A relayed
|
|
365
|
+
// request is never a spoke's server call (D3).
|
|
366
|
+
const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
|
|
367
|
+
const spokeCheckout = candidate !== null && relayed !== true;
|
|
346
368
|
let row: DbRow;
|
|
347
369
|
try {
|
|
348
370
|
row = (await prisma.lead.create({
|
|
@@ -371,6 +393,8 @@ async function persistToDb(
|
|
|
371
393
|
// #852 D6 — the Person `resolveLeadPersonId` found or created.
|
|
372
394
|
personId,
|
|
373
395
|
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
396
|
+
// #8185 D4 — only a marked create names the column.
|
|
397
|
+
...(spokeCheckout && { spokeCheckout: true }),
|
|
374
398
|
},
|
|
375
399
|
select: { id: true },
|
|
376
400
|
})) as DbRow;
|
|
@@ -386,7 +410,6 @@ async function persistToDb(
|
|
|
386
410
|
return { ok: "keyed", outcome: classifyKeyedRow(winner, input) };
|
|
387
411
|
}
|
|
388
412
|
// #7877 D10 — only after THIS request's own write, so a replay sets nothing.
|
|
389
|
-
const candidate = checkoutPlanCandidate(input, idempotencyKey, viaSpoke);
|
|
390
413
|
if (candidate !== null && personId !== null) {
|
|
391
414
|
await applyCheckoutPlan(input, personId, candidate);
|
|
392
415
|
}
|
|
@@ -729,7 +752,12 @@ export async function routeLead(
|
|
|
729
752
|
let skipReason: string | undefined;
|
|
730
753
|
|
|
731
754
|
try {
|
|
732
|
-
const dbResult = await persistToDb(
|
|
755
|
+
const dbResult = await persistToDb(
|
|
756
|
+
input,
|
|
757
|
+
idempotencyKey,
|
|
758
|
+
sourceMeta.viaSpoke === true,
|
|
759
|
+
sourceMeta.relayed === true,
|
|
760
|
+
);
|
|
733
761
|
// #7869 D5 — a lost race answers like the lookup above: no capture
|
|
734
762
|
// record, no notification.
|
|
735
763
|
if (dbResult.ok === "keyed") return keyedResult(dbResult.outcome);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 2,
|
|
3
|
-
"ignoreCommand": "git diff --quiet
|
|
4
|
-
"buildCommand": "cd ../.. && pnpm run --filter @
|
|
3
|
+
"ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
|
|
4
|
+
"buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
|
|
5
5
|
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
|
|
6
6
|
"outputDirectory": "public",
|
|
7
7
|
"framework": null,
|
|
@@ -58,6 +58,16 @@ MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP=50
|
|
|
58
58
|
# hosted Checkout Session, so the default is deliberately lower.
|
|
59
59
|
MCP_ABUSE_START_PURCHASE_DAILY_CAP=20
|
|
60
60
|
|
|
61
|
+
# Maximum number of create_booking calls per IP per day (#8071). Its own
|
|
62
|
+
# budget — each call creates a real calendar event and emails the attendee an
|
|
63
|
+
# invite, so the default matches start_purchase's.
|
|
64
|
+
MCP_ABUSE_CREATE_BOOKING_DAILY_CAP=20
|
|
65
|
+
|
|
66
|
+
# Maximum number of submit_flow_step calls per IP per day (#8071). Its own
|
|
67
|
+
# budget, counted per step, not per flow — equal to capture_lead's, because a
|
|
68
|
+
# one-step flow creates a Lead in one call.
|
|
69
|
+
MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP=50
|
|
70
|
+
|
|
61
71
|
# Comma-separated list of User-Agent substrings to block (case-insensitive).
|
|
62
72
|
# Built-in list already blocks curl, wget, python-requests, scrapy, etc.
|
|
63
73
|
# Add extra patterns here as needed.
|