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.
- package/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- 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 —
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* service to update the active tenant
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
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
|
-
*
|
|
120
|
-
*
|
|
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
|
|
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
|
|
146
|
-
*
|
|
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
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
254
|
-
* `FORWARDED_VISITOR_ROUTES
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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;
|