create-ailk 0.12.1 → 0.13.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.
Files changed (55) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +15 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/auth/schema.prisma +61 -0
  53. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  54. package/templates/database/package.json +1 -1
  55. package/versions.json +34 -34
@@ -18,6 +18,7 @@ import {
18
18
  rateLimitMiddleware,
19
19
  __resetBuckets,
20
20
  __bucketCount,
21
+ FORWARDED_VISITOR_ROUTES,
21
22
  buildTrustedKeyDigests,
22
23
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
23
24
  resolveRateLimitIdentity,
@@ -764,6 +765,81 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
764
765
  });
765
766
  });
766
767
 
768
+ // #7633 conductor amendment (the relay's security review, #7644): once a
769
+ // site relays its visitors' booking calls, every one reaches the hub from
770
+ // the site's server. The two relayed schedule routes join the keyed-visitor
771
+ // list, each under its own method only.
772
+ describe("the two relayed schedule routes count each keyed visitor separately (#7633)", () => {
773
+ const AVAILABILITY = "/v1/schedule/availability";
774
+ const BOOK = "/v1/schedule/book";
775
+ const EVENT_META = "/v1/schedule/event-meta";
776
+
777
+ /** A schedule-shaped scope: the four routes, each under GET and POST. */
778
+ function scheduleApp(): FastifyInstance {
779
+ const instance = Fastify({ trustProxy: true });
780
+ instance.register(rateLimitMiddleware, {
781
+ scope: "schedule",
782
+ perIpPerMinute: 10,
783
+ perKeyPerMinute: 300,
784
+ trustedKeyDigests: digests(),
785
+ });
786
+ for (const url of [AVAILABILITY, BOOK, EVENT_META, "/v1/schedule/meeting-types"]) {
787
+ instance.get(url, async () => ({ ok: true }));
788
+ instance.post(url, async () => ({ ok: true }));
789
+ }
790
+ return instance;
791
+ }
792
+
793
+ async function call(
794
+ method: "GET" | "POST",
795
+ url: string,
796
+ headers: Record<string, string>,
797
+ ): Promise<number> {
798
+ if (!app) throw new Error("no app");
799
+ const res = await app.inject({ method, url, headers });
800
+ return res.statusCode;
801
+ }
802
+
803
+ it.each<["GET" | "POST", string]>([
804
+ ["GET", AVAILABILITY],
805
+ ["POST", BOOK],
806
+ ])("%s %s: two visitors behind one site key are counted separately", async (method, url) => {
807
+ app = scheduleApp();
808
+ await app.ready();
809
+ for (let i = 0; i < 10; i++) {
810
+ expect(await call(method, url, keyed("203.0.113.1"))).toBe(200);
811
+ }
812
+ expect(await call(method, url, keyed("203.0.113.1"))).toBe(429);
813
+ expect(await call(method, url, keyed("203.0.113.2"))).toBe(200);
814
+ });
815
+
816
+ it.each<["GET" | "POST", string]>([
817
+ ["POST", AVAILABILITY],
818
+ ["GET", BOOK],
819
+ ["GET", EVENT_META],
820
+ ["GET", "/v1/schedule/meeting-types"],
821
+ ])("%s %s is not on the list: the key is ignored and the eleventh distinct visitor is 429", async (method, url) => {
822
+ app = scheduleApp();
823
+ await app.ready();
824
+ const codes: number[] = [];
825
+ for (let i = 1; i <= 11; i++) {
826
+ codes.push(await call(method, url, keyed(`203.0.113.${i}`)));
827
+ }
828
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
829
+ expect(codes[10]).toBe(429);
830
+ });
831
+
832
+ it("lists each route under its own method", () => {
833
+ expect(FORWARDED_VISITOR_ROUTES).toEqual([
834
+ { method: "POST", url: "/v1/leads/submit" },
835
+ { method: "POST", url: "/v1/leads/subscribe" },
836
+ { method: "POST", url: "/v1/leads/callback" },
837
+ { method: "GET", url: AVAILABILITY },
838
+ { method: "POST", url: BOOK },
839
+ ]);
840
+ });
841
+ });
842
+
767
843
  describe("AC-5 — visitor IP validation and normalisation", () => {
768
844
  function identityApp(): FastifyInstance {
769
845
  const instance = Fastify({ trustProxy: true });
@@ -17,6 +17,22 @@
17
17
  * and matched against `ApiToken.token` (stored as hash). Revoked / expired
18
18
  * tokens fail closed; lastUsedAt is bumped on a successful hit.
19
19
  *
20
+ * 3. Agent path (#3591) — a bearer starting `ailk_agt_` is an agent's key
21
+ * (`lib/agent-store.ts`). Its digest must match an enabled `Agent` whose
22
+ * key-holder still holds a membership; anything else is the same 401.
23
+ * The request acts as the agent's person (`request.userId`) and carries
24
+ * `request.agent` — never `request.sessionTenant`, which stays the
25
+ * cookie-only signal. An agent reaches ONLY routes a tenant guard tagged
26
+ * (`config.tenantGuarded`, set by both guards' `onRoute` hook) and is 403
27
+ * everywhere else; inside them the guard clamps its role. Each agent is
28
+ * limited to 60 requests a minute, at most 10 of them writes (any
29
+ * method but GET and HEAD).
30
+ *
31
+ * Every authenticated request carries `request.actor` — the person, or the
32
+ * agent and the person it acts for — and its request/reply loggers are
33
+ * rebound to name it, so Fastify's "request completed" line says who acted.
34
+ * No log line carries a credential.
35
+ *
20
36
  * Decision matrix (per spec §3):
21
37
  * - Cookie valid → cookie path wins. Bearer ignored.
22
38
  * - Cookie absent, bearer valid → bearer path; CSRF skipped.
@@ -38,6 +54,13 @@ import type {
38
54
  FastifyRequest,
39
55
  } from "fastify";
40
56
 
57
+ import {
58
+ AGENT_KEY_PREFIX,
59
+ findAgentByKeyHash,
60
+ hashAgentKey,
61
+ touchAgentLastActive,
62
+ type AgentGrant,
63
+ } from "../lib/agent-store.js";
41
64
  import { prisma } from "../lib/prisma.js";
42
65
 
43
66
  /**
@@ -51,21 +74,57 @@ const COOKIE_NAMES = [
51
74
 
52
75
  const UNAUTHORIZED = { error: "unauthorized" } as const;
53
76
  const DATABASE_NOT_CONFIGURED = { error: "database_not_configured" } as const;
77
+ const FORBIDDEN = {
78
+ ok: false,
79
+ error: { code: "forbidden", message: "Forbidden" },
80
+ } as const;
81
+ /**
82
+ * How stale `Agent.lastActiveAt` may be before an admitted request writes it
83
+ * again: the column is "active within the last minute", not a request log,
84
+ * so a busy agent costs one UPDATE a minute rather than one per request.
85
+ */
86
+ const AGENT_ACTIVITY_WRITE_INTERVAL_MS = 60_000;
87
+
88
+ const RATE_LIMITED = {
89
+ ok: false,
90
+ error: { code: "rate_limited", message: "Too many requests" },
91
+ } as const;
54
92
 
55
93
  declare module "fastify" {
56
94
  interface FastifyRequest {
57
95
  userId?: Id<"user">;
58
96
  sessionTenant?: SessionTenant;
97
+ /** Set ONLY on the agent path (#3591 D4). */
98
+ agent?: AgentGrant;
99
+ /** Who made the request — set on every authenticated request (#3591 D9). */
100
+ actor?: RequestActor;
101
+ }
102
+ interface FastifyContextConfig {
103
+ /**
104
+ * Set by both tenant guards' `onRoute` hook on every route registered
105
+ * after them in their scope (#3591 D6). An agent is refused on any route
106
+ * without it.
107
+ */
108
+ tenantGuarded?: boolean;
59
109
  }
60
110
  }
61
111
 
112
+ /**
113
+ * Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
114
+ * act as their person; an agent acts on behalf of its responsible person.
115
+ */
116
+ export type RequestActor =
117
+ | { type: "user"; userId: Id<"user"> }
118
+ | { type: "agent"; agentId: Id<"agent">; onBehalfOfUserId: Id<"user"> };
119
+
62
120
  /**
63
121
  * Active tenant context carried by a cookie session (multi-tenant module).
64
- * Set ONLY on the cookie path — bearer tokens carry no workspace binding yet
65
- * (#3591 tracks tenant-scoped agent auth), so bearer-authenticated requests
66
- * are denied by the tenant guard (default-deny), never silently granted a
67
- * tenant. `sessionRowId` is the Session PK, used by the workspace-switch
68
- * service to update the active tenant server-side.
122
+ * Set ONLY on the cookie path — a personal `ApiToken` carries no workspace
123
+ * binding, and an agent carries its own (`request.agent`, #3591), so neither
124
+ * ever sets this. Code that needs a cookie session (the billing portal, the
125
+ * workspace switch) can rely on its presence. `sessionRowId` is the Session
126
+ * PK, used by the workspace-switch service to update the active tenant
127
+ * server-side.
69
128
  */
70
129
  export type SessionTenant = {
71
130
  sessionRowId: Id<"session">;
@@ -181,6 +240,152 @@ async function resolveBearerToken(
181
240
  return row ?? undefined;
182
241
  }
183
242
 
243
+ /**
244
+ * Decorate `request.actor` and rebind the request and reply loggers to name
245
+ * it (#3591 D9). Fastify writes its "request completed" line through
246
+ * `reply.log`, so that line carries the actor too.
247
+ */
248
+ function bindActor(
249
+ request: FastifyRequest,
250
+ reply: FastifyReply,
251
+ actor: RequestActor,
252
+ ): void {
253
+ request.actor = actor;
254
+ request.log = request.log.child(
255
+ actor.type === "agent"
256
+ ? {
257
+ actorType: "agent",
258
+ agentId: actor.agentId,
259
+ onBehalfOfUserId: actor.onBehalfOfUserId,
260
+ }
261
+ : { actorType: "user" },
262
+ );
263
+ reply.log = request.log;
264
+ }
265
+
266
+ // ── The per-agent limit (#3591 D4 step 5, SEC-3591-04/-14) ─────────────────
267
+ //
268
+ // In the handler, not `onRequest`: the shared limiter runs before any
269
+ // identity exists, and a bucket must only ever be keyed on a VERIFIED agent
270
+ // id, so an invented key never gets one. The shape of
271
+ // `routes/subscription/portal.ts`'s per-user budget.
272
+
273
+ const AGENT_WINDOW_MS = 60_000;
274
+ const AGENT_REQUESTS_PER_WINDOW = 60;
275
+ const AGENT_WRITES_PER_WINDOW = 10;
276
+ /** Cap on distinct buckets — the oldest is evicted at capacity (DEF-011's shape). */
277
+ const MAX_AGENT_BUCKETS = 5_000;
278
+
279
+ type AgentBucket = { windowStartMs: number; requests: number; writes: number };
280
+ const agentBuckets = new Map<string, AgentBucket>();
281
+
282
+ /** Test-only reset. Not intended for production use. */
283
+ export function __resetAgentRateBuckets(): void {
284
+ agentBuckets.clear();
285
+ }
286
+
287
+ function isWrite(method: string): boolean {
288
+ const m = method.toUpperCase();
289
+ return m !== "GET" && m !== "HEAD";
290
+ }
291
+
292
+ /** `undefined` when admitted; the seconds to wait when the agent is over budget. */
293
+ function chargeAgentRateLimit(
294
+ agentId: string,
295
+ method: string,
296
+ now: number,
297
+ ): number | undefined {
298
+ let bucket = agentBuckets.get(agentId);
299
+ if (!bucket || now - bucket.windowStartMs >= AGENT_WINDOW_MS) {
300
+ if (!agentBuckets.has(agentId) && agentBuckets.size >= MAX_AGENT_BUCKETS) {
301
+ for (const [id, b] of agentBuckets) {
302
+ if (now - b.windowStartMs >= AGENT_WINDOW_MS) agentBuckets.delete(id);
303
+ }
304
+ if (agentBuckets.size >= MAX_AGENT_BUCKETS) {
305
+ const oldest = agentBuckets.keys().next().value;
306
+ if (oldest !== undefined) agentBuckets.delete(oldest);
307
+ }
308
+ }
309
+ // Re-insert, so Map order stays oldest-window-first for the eviction above.
310
+ agentBuckets.delete(agentId);
311
+ bucket = { windowStartMs: now, requests: 0, writes: 0 };
312
+ agentBuckets.set(agentId, bucket);
313
+ }
314
+ bucket.requests += 1;
315
+ if (isWrite(method)) bucket.writes += 1;
316
+ if (
317
+ bucket.requests > AGENT_REQUESTS_PER_WINDOW ||
318
+ bucket.writes > AGENT_WRITES_PER_WINDOW
319
+ ) {
320
+ return Math.max(
321
+ 1,
322
+ Math.ceil((bucket.windowStartMs + AGENT_WINDOW_MS - now) / 1_000),
323
+ );
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ /**
329
+ * The agent path (#3591 D4, D6). The key is never logged and never echoed:
330
+ * every refusal is a fixed body.
331
+ */
332
+ async function authenticateAgent(
333
+ request: FastifyRequest,
334
+ reply: FastifyReply,
335
+ key: string,
336
+ now: number,
337
+ ): Promise<void> {
338
+ const row = await findAgentByKeyHash(hashAgentKey(key)).catch(() => null);
339
+ // Unknown, switched off, or its key-holder has left the organization (the
340
+ // FK set the column null) — one body for each, so none is an oracle.
341
+ if (!row || !row.enabled || !row.keyIssuedToMembershipId) {
342
+ reply.code(401).send(UNAUTHORIZED);
343
+ return;
344
+ }
345
+
346
+ // Brand assertion at the Prisma read edge (ADR-0031 rule 5): the agents
347
+ // table's format CHECKs guarantee these columns hold well-formed ids.
348
+ const agentId = row.id as Id<"agent">;
349
+ const personId = row.responsible.userId as Id<"user">;
350
+ request.userId = personId;
351
+ request.agent = {
352
+ agentId,
353
+ workspaceId: row.workspaceId as Id<"workspace">,
354
+ organizationId: row.organizationId as Id<"organization">,
355
+ responsibleMembershipId: row.responsible.id as Id<"membership">,
356
+ permissionLevel: row.permissionLevel,
357
+ };
358
+ bindActor(request, reply, {
359
+ type: "agent",
360
+ agentId,
361
+ onBehalfOfUserId: personId,
362
+ });
363
+
364
+ const retryAfter = chargeAgentRateLimit(agentId, request.method, now);
365
+ if (retryAfter !== undefined) {
366
+ reply.code(429).header("Retry-After", String(retryAfter)).send(RATE_LIMITED);
367
+ return;
368
+ }
369
+
370
+ // D6 — an agent reaches only routes a tenant guard runs on. Every other
371
+ // route in the auth scope (tokens, user data, `/me` reads, the leads union,
372
+ // the workspace switcher), and any route added outside a tenant scope
373
+ // later, is closed to it by default.
374
+ if (request.routeOptions.config?.tenantGuarded !== true) {
375
+ reply.code(403).send(FORBIDDEN);
376
+ return;
377
+ }
378
+
379
+ // Best-effort, as the token path bumps lastUsedAt: a failure never 401s.
380
+ // Only an admitted request writes, and only when the stamp is a minute old.
381
+ if (
382
+ !row.lastActiveAt ||
383
+ now - row.lastActiveAt.getTime() >= AGENT_ACTIVITY_WRITE_INTERVAL_MS
384
+ ) {
385
+ touchAgentLastActive(agentId, new Date(now)).catch(() => undefined);
386
+ }
387
+ }
388
+
184
389
  export type AuthPreHandlerOptions = {
185
390
  /**
186
391
  * The browser origins a cookie-authenticated write may come from — exact
@@ -283,6 +488,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
283
488
  activeOrganizationId: session.activeOrganizationId as Id<"organization"> | null,
284
489
  activeWorkspaceId: session.activeWorkspaceId as Id<"workspace"> | null,
285
490
  };
491
+ bindActor(request, reply, { type: "user", userId: request.userId });
286
492
  return;
287
493
  }
288
494
  // Cookie present but invalid / expired → 401 (no silent bearer fallback).
@@ -291,6 +497,10 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
291
497
  }
292
498
 
293
499
  const bearer = readBearer(request);
500
+ if (bearer?.startsWith(AGENT_KEY_PREFIX)) {
501
+ await authenticateAgent(request, reply, bearer, now);
502
+ return;
503
+ }
294
504
  if (bearer) {
295
505
  const row = await resolveBearerToken(bearer).catch(() => undefined);
296
506
  if (!row) {
@@ -306,6 +516,8 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
306
516
  return;
307
517
  }
308
518
  request.userId = row.userId as Id<"user">;
519
+ // A personal token acts as its person, as before (#3591 D9).
520
+ bindActor(request, reply, { type: "user", userId: request.userId });
309
521
  // Best-effort: bump lastUsedAt. Failure here MUST NOT 401 — the caller
310
522
  // is already authenticated. Swallow the error and continue.
311
523
  prisma.apiToken
@@ -333,6 +545,8 @@ export const authPreHandlerPlugin: FastifyPluginCallback<
333
545
  const handler = makeAuthPreHandler(opts);
334
546
  fastify.decorateRequest("userId", undefined);
335
547
  fastify.decorateRequest("sessionTenant", undefined);
548
+ fastify.decorateRequest("agent", undefined);
549
+ fastify.decorateRequest("actor", undefined);
336
550
  fastify.addHook("preHandler", handler);
337
551
  done();
338
552
  };
@@ -16,7 +16,10 @@
16
16
  * Default-deny — every branch that is not an explicit grant is a denial:
17
17
  *
18
18
  * - unauthenticated → 401
19
- * - bearer-authenticated (no cookie session — #3591) → 403
19
+ * - a personal ApiToken (no cookie session, no agent) → 403
20
+ * - an agent (#3591) whose person is not the operator,
21
+ * or not bound to the default workspace under the
22
+ * operator's own Membership → 403
20
23
  * - AILK_OPERATOR_EMAIL is unset → 403
21
24
  * - the caller's email is not the pinned one → 403
22
25
  * - the deployment is not unambiguously single-tenant → 403
@@ -57,6 +60,7 @@ import type {
57
60
  } from "fastify";
58
61
 
59
62
  import {
63
+ resolveDefaultTenantForAgent,
60
64
  resolveDefaultTenantForOperator,
61
65
  type TenantContext,
62
66
  } from "../services/default-tenant.js";
@@ -90,14 +94,20 @@ export async function defaultTenantPreHandler(
90
94
  return;
91
95
  }
92
96
 
93
- // Bearer tokens carry no workspace binding (#3591) — denied here, the same
94
- // default-deny `resolveActiveTenant` applies on the multi-tenant path.
95
- if (!request.sessionTenant) {
97
+ // An agent (#3591 D5) goes through the same operator resolution, bound to
98
+ // its own workspace and Membership, with its role clamped. Any other caller
99
+ // without a cookie session — a personal ApiToken — carries no workspace
100
+ // binding and is denied, the same default-deny `resolveActiveTenant`
101
+ // applies on the multi-tenant path.
102
+ let tenant: TenantContext | null;
103
+ if (request.agent) {
104
+ tenant = await resolveDefaultTenantForAgent(userId, request.agent);
105
+ } else if (!request.sessionTenant) {
96
106
  reply.code(403).send(FORBIDDEN);
97
107
  return;
108
+ } else {
109
+ tenant = await resolveDefaultTenantForOperator(userId);
98
110
  }
99
-
100
- const tenant = await resolveDefaultTenantForOperator(userId);
101
111
  if (!tenant) {
102
112
  reply.code(403).send(FORBIDDEN);
103
113
  return;
@@ -116,8 +126,18 @@ export async function defaultTenantPreHandler(
116
126
  }
117
127
 
118
128
  /**
119
- * Plugin form, mirroring `tenantPreHandlerPlugin`: skip-override so the hook
120
- * propagates to sibling route plugins registered in the same scope.
129
+ * Tag a route as tenant-guarded (#3591 D6). Both guard plugins add this as an
130
+ * `onRoute` hook, so it reaches exactly the routes their `preHandler` does:
131
+ * every route registered after the plugin in its scope. The auth preHandler
132
+ * refuses an agent on any route without the tag.
133
+ */
134
+ export function tagTenantGuarded(routeOptions: { config?: object }): void {
135
+ routeOptions.config = { ...routeOptions.config, tenantGuarded: true };
136
+ }
137
+
138
+ /**
139
+ * Plugin form, mirroring `tenantPreHandlerPlugin`: skip-override so the hooks
140
+ * propagate to sibling route plugins registered in the same scope.
121
141
  */
122
142
  export const defaultTenantPreHandlerPlugin: FastifyPluginCallback = (
123
143
  fastify,
@@ -125,6 +145,7 @@ export const defaultTenantPreHandlerPlugin: FastifyPluginCallback = (
125
145
  done,
126
146
  ) => {
127
147
  fastify.decorateRequest("tenant", undefined);
148
+ fastify.addHook("onRoute", tagTenantGuarded);
128
149
  fastify.addHook("preHandler", defaultTenantPreHandler);
129
150
  done();
130
151
  };
@@ -49,7 +49,13 @@
49
49
  * Per-visitor lead writes from a keyed spoke (#7394): a spoke's lead writes
50
50
  * carry the visitor's IP in `x-ailk-visitor-ip`, which this middleware
51
51
  * believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
52
- * and only on the three `FORWARDED_VISITOR_ROUTES`. A believed visitor is
52
+ * and only on the `FORWARDED_VISITOR_ROUTES`: the three lead writes, and
53
+ * since #7633 the two schedule calls a site's same-origin relay forwards
54
+ * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book`. The
55
+ * global limiter (`server.ts`) passes the hub's digests, so a listed call is
56
+ * counted per visitor there; a scope's own limiter does so only when its
57
+ * registration passes `trustedKeyDigests` too, as the leads and schedule
58
+ * scopes do. A believed visitor is
53
59
  * counted as `v<n>,<ip>` (n = the key's position in
54
60
  * `AILK_VISITOR_FORWARD_KEYS`), and every such request also counts against
55
61
  * its key's own `key,<n>` bucket. Both forms contain a comma, which `req.ip`
@@ -142,14 +148,28 @@ export const DEFAULT_PER_IP_PER_MINUTE_STRICT = 10;
142
148
  export const MAX_BUCKETS = 10_000;
143
149
 
144
150
  /**
145
- * The only routes on which a forwarded visitor IP is believed (#7394 DD-2):
146
- * the three lead writes. Matched against `req.routeOptions.url`, the
151
+ * The schedule scope's per-forwarding-key ceiling (#7633): well below the
152
+ * lead routes' 300, because a booking is a real booking at the provider.
153
+ * `AILK_RATE_LIMIT_SCHEDULE_PER_KEY_PER_MINUTE` overrides it.
154
+ */
155
+ export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
156
+
157
+ /**
158
+ * The only (method, route) pairs on which a forwarded visitor IP is believed
159
+ * (#7394 DD-2): the three lead writes, and the two schedule calls a site's
160
+ * relay forwards on its visitors' behalf (#7633, the relay's security
161
+ * review). Matched against `req.method` and `req.routeOptions.url`, the
147
162
  * registered route pattern.
148
163
  */
149
- export const FORWARDED_VISITOR_ROUTES: readonly string[] = [
150
- "/v1/leads/submit",
151
- "/v1/leads/subscribe",
152
- "/v1/leads/callback",
164
+ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
165
+ method: "GET" | "POST";
166
+ url: string;
167
+ }>[] = [
168
+ { method: "POST", url: "/v1/leads/submit" },
169
+ { method: "POST", url: "/v1/leads/subscribe" },
170
+ { method: "POST", url: "/v1/leads/callback" },
171
+ { method: "GET", url: "/v1/schedule/availability" },
172
+ { method: "POST", url: "/v1/schedule/book" },
153
173
  ];
154
174
 
155
175
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
@@ -250,8 +270,9 @@ function normaliseVisitorIp(raw: string): string | null {
250
270
  }
251
271
 
252
272
  /**
253
- * The rate-limit identity of a request (#7394 DD-2). A `POST` to one of
254
- * `FORWARDED_VISITOR_ROUTES`, presenting a key whose digest matches a
273
+ * The rate-limit identity of a request (#7394 DD-2). A request matching one
274
+ * of `FORWARDED_VISITOR_ROUTES` by method and route, presenting a key whose
275
+ * digest matches a
255
276
  * non-null slot, with a valid `x-ailk-visitor-ip`, is `v<n>,<ip>` where `n`
256
277
  * is the first matching slot. Everything else is `req.ip`. Every slot is
257
278
  * compared, in constant time per slot, without stopping at the first match.
@@ -262,10 +283,11 @@ export function resolveRateLimitIdentity(
262
283
  ): { identity: string; keyIndex: number | null } {
263
284
  const fallback = { identity: req.ip, keyIndex: null };
264
285
  if (!trustedKeyDigests.some((digest) => digest !== null)) return fallback;
265
- if (req.method !== "POST") return fallback;
266
- if (!FORWARDED_VISITOR_ROUTES.includes(req.routeOptions.url ?? "")) {
267
- return fallback;
268
- }
286
+ const url = req.routeOptions.url ?? "";
287
+ const listed = FORWARDED_VISITOR_ROUTES.some(
288
+ (route) => route.method === req.method && route.url === url,
289
+ );
290
+ if (!listed) return fallback;
269
291
 
270
292
  const presented = req.headers[FORWARD_KEY_HEADER];
271
293
  if (typeof presented !== "string" || presented === "") return fallback;