create-ailk 0.12.0 → 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 (56) 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 +25 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/__tests__/build.test.ts +131 -0
  53. package/templates/database/auth/schema.prisma +61 -0
  54. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  55. package/templates/database/package.json +3 -3
  56. package/versions.json +34 -34
@@ -3,12 +3,17 @@
3
3
  *
4
4
  * `POST /v1/leads/{submit,subscribe,callback}` called by a registered spoke of
5
5
  * a multi-workspace hub (#7532, spec
6
- * docs/specs/7532-a-spoke-s-public-reads-spec.md D3, D4; AC-2, AC-3, AC-5).
6
+ * docs/specs/7532-a-spoke-s-public-reads-spec.md D3, D4; AC-2, AC-3, AC-5),
7
+ * and a keyless caller of the same hub, refused with no switch set since #7633
8
+ * (spec docs/specs/7633-expected-a-keyless-public-read-spec.md D3, D8; AC-1,
9
+ * AC-5).
7
10
  * The three real lead routes run through the real resolver and the real
8
11
  * lead-routing service; only the database and the email provider are doubles.
9
12
  *
10
13
  * Fixture (spec § 5): three workspaces — the sentinel default `F` (the site
11
14
  * alias), `W1` and `W2`; spokes `S1` → `W1`, `S1b` → `W1`, `S2` → `W2`.
15
+ * The registry is non-empty (`db.spoke.findFirst` answers a row), so this is
16
+ * a hub in the #7633 sense.
12
17
  */
13
18
  import { createHash } from "node:crypto";
14
19
  import { Writable } from "node:stream";
@@ -51,12 +56,14 @@ jest.mock("../../../services/request-confirm.js", () => {
51
56
  });
52
57
 
53
58
  import * as configModule from "../../../config/index.js";
59
+ import type { HubSpokeKeyMode } from "../../../config/index.js";
54
60
  import {
55
61
  SENTINEL_WORKSPACE_ID,
56
62
  mockSentinelWorkspaceExists,
57
63
  } from "../../../lib/__mocks__/prisma.js";
58
64
  import { prisma } from "../../../lib/prisma.js";
59
65
  import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
66
+ import { __resetHubStatusForTests } from "../../../services/public-site-resolver.js";
60
67
  import { sendRequestConfirmLink } from "../../../services/request-confirm.js";
61
68
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
62
69
  import { leadCallbackRoute, leadSubmitRoute, leadSubscribeRoute } from "../index";
@@ -99,12 +106,14 @@ function seedRegistry(revoked: readonly string[] = []): void {
99
106
  }: {
100
107
  where: { credentialHash: string };
101
108
  }) => rows.get(where.credentialHash) ?? null) as never);
109
+ // A non-empty registry: this deployment is a hub (#7633 D1).
110
+ db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
102
111
  }
103
112
 
104
- function setStrictMode(on: boolean): void {
113
+ function setMode(mode: HubSpokeKeyMode): void {
105
114
  jest.replaceProperty(configModule, "config", {
106
115
  ...configModule.config,
107
- AILK_HUB_REQUIRE_SPOKE_KEY: on,
116
+ AILK_HUB_REQUIRE_SPOKE_KEY: mode,
108
117
  });
109
118
  }
110
119
 
@@ -156,7 +165,8 @@ describe("a spoke calling the lead routes on a multi-workspace hub", () => {
156
165
  process.env.DATABASE_URL = "postgres://stub";
157
166
  process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
158
167
  process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
159
- setStrictMode(false);
168
+ setMode("auto");
169
+ __resetHubStatusForTests();
160
170
  seedRegistry();
161
171
  mockSentinelWorkspaceExists(db, W1, W2);
162
172
  db.workspace.count.mockResolvedValue(3);
@@ -249,27 +259,77 @@ describe("a spoke calling the lead routes on a multi-workspace hub", () => {
249
259
  expect(db.callback.updateMany).not.toHaveBeenCalled();
250
260
  });
251
261
 
262
+ // ── #7633 AC-1 — a keyless call on a hub, with no switch set ────────────
263
+
264
+ it.each<Kind>(["submit", "subscribe", "callback"])(
265
+ "a keyless POST /v1/leads/%s naming siteId W1 answers 503 tenant_unresolved — the very body an unknown key gets — with no write (#7633 AC-1)",
266
+ async (kind) => {
267
+ const body = { ...BODIES[kind], siteId: W1 };
268
+ const keyless = await post(kind, {}, body);
269
+
270
+ expect(keyless.statusCode).toBe(503);
271
+ expect(keyless.json().error).toBe("tenant_unresolved");
272
+ expectNoWrite();
273
+
274
+ const unknown = await post(kind, { "x-ailk-spoke-key": KEY_UNKNOWN }, body);
275
+ expect(keyless.statusCode).toBe(unknown.statusCode);
276
+ expect(keyless.json()).toEqual(unknown.json());
277
+ expectNoWrite();
278
+ },
279
+ );
280
+
281
+ // ── #7633 conductor amendment — lead capture is never lost ──────────────
282
+
283
+ it("a cold start whose hub-status read fails still captures a keyless lead (it reaches routeLead), logged at error", async () => {
284
+ __resetHubStatusForTests();
285
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
286
+
287
+ const res = await post("submit");
288
+ expect(res.statusCode).toBe(201);
289
+ expect(db.lead.create).toHaveBeenCalledTimes(1);
290
+ const { data } = db.lead.create.mock.calls[0]![0] as { data: { siteId: string } };
291
+ expect(data.siteId).toBe(F);
292
+ expect(logLines.join("\n")).toContain('"level":50');
293
+ });
294
+
295
+ it("a process that has seen a hub keeps refusing a keyless lead through the same outage", async () => {
296
+ expect((await post("submit")).statusCode).toBe(503);
297
+ db.spoke.findFirst.mockRejectedValue(new Error("connection reset"));
298
+ expect((await post("submit")).statusCode).toBe(503);
299
+ expectNoWrite();
300
+ });
301
+
252
302
  // ── AC-3 ────────────────────────────────────────────────────────────────
253
303
 
254
- describe.each<[string, Record<string, string>, boolean]>([
255
- ["(i) an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, false],
256
- ["(ii) a duplicated header", { "x-ailk-spoke-key": `${KEY_S1}, ${KEY_S2}` }, false],
257
- ["(iii) an empty key", { "x-ailk-spoke-key": "" }, false],
304
+ describe.each<[string, Record<string, string>, HubSpokeKeyMode]>([
305
+ ["(i) an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN }, "auto"],
306
+ ["(ii) a duplicated header", { "x-ailk-spoke-key": `${KEY_S1}, ${KEY_S2}` }, "auto"],
307
+ ["(iii) an empty key", { "x-ailk-spoke-key": "" }, "auto"],
308
+ [
309
+ "(v) mode \"on\", a header-less request naming W1 every other way",
310
+ {
311
+ host: "w1.example",
312
+ "x-forwarded-host": "w1.example",
313
+ origin: "https://w1.example",
314
+ "x-ailk-workspace-id": W1,
315
+ },
316
+ "on",
317
+ ],
258
318
  [
259
- "(v) strict mode, a header-less request naming W1 every other way",
319
+ "(v) no switch set, a header-less request naming W1 every other way (#7633)",
260
320
  {
261
321
  host: "w1.example",
262
322
  "x-forwarded-host": "w1.example",
263
323
  origin: "https://w1.example",
264
324
  "x-ailk-workspace-id": W1,
265
325
  },
266
- true,
326
+ "auto",
267
327
  ],
268
- ])("%s (AC-3)", (_label, headers, strict) => {
328
+ ])("%s (AC-3)", (_label, headers, mode) => {
269
329
  it.each<Kind>(["submit", "subscribe", "callback"])(
270
330
  "POST /v1/leads/%s answers 503 tenant_unresolved before any write",
271
331
  async (kind) => {
272
- setStrictMode(strict);
332
+ setMode(mode);
273
333
  const res = await post(kind, headers, { ...BODIES[kind], siteId: W1 });
274
334
 
275
335
  expect(res.statusCode).toBe(503);
@@ -311,14 +371,15 @@ describe("a spoke calling the lead routes on a multi-workspace hub", () => {
311
371
  expect(otherData.workspaceId).toBe(W2);
312
372
  });
313
373
 
314
- it("in strict mode, S1's header-less retry is refused too", async () => {
315
- setStrictMode(true);
374
+ it.each<HubSpokeKeyMode>(["on", "auto"])("in mode %j, S1's header-less retry is refused too", async (mode) => {
375
+ setMode(mode);
316
376
  const res = await post("submit");
317
377
  expect(res.statusCode).toBe(503);
318
378
  expectNoWrite();
319
379
  });
320
380
 
321
- it("with strict mode off, S1's header-less retry is anonymous and lands under the alias F, never W1 (SEC-7532-3)", async () => {
381
+ it("under the emergency opt-out (mode \"off\"), S1's header-less retry is anonymous and lands under the alias F, never W1 (SEC-7532-3)", async () => {
382
+ setMode("off");
322
383
  const res = await post("submit");
323
384
  expect(res.statusCode).toBe(201);
324
385
  const { data } = db.lead.create.mock.calls[0]![0] as {
@@ -34,10 +34,14 @@
34
34
  * reads the spoke's key from `x-ailk-spoke-key` through
35
35
  * `resolveCallerSite()` (`services/public-site-resolver.ts`) and writes the
36
36
  * lead into that spoke's own workspace. A refused key — malformed, unknown or
37
- * revoked, or no key at all on a hub in strict mode — is the same 503
38
- * `tenant_unresolved` before any write. A header-less request on a hub
39
- * without strict mode takes the alias resolution above, unchanged. The body's
40
- * `siteId` stays ignored on every branch.
37
+ * revoked, or no key at all on a hub (a deployment whose spoke registry holds
38
+ * a row, or one whose `AILK_HUB_REQUIRE_SPOKE_KEY` is `true`; #7633) — is the
39
+ * same 503 `tenant_unresolved` before any write. A header-less request on a
40
+ * deployment with no registered spoke, or under the emergency opt-out
41
+ * (`AILK_HUB_REQUIRE_SPOKE_KEY=false`), takes the alias resolution above,
42
+ * unchanged. So does a keyless request whose hub-status read fails before the
43
+ * process has any answer: lead capture is never lost (#7633, conductor
44
+ * amendment to NFR3). The body's `siteId` stays ignored on every branch.
41
45
  */
42
46
 
43
47
  import { track } from "@working-theory/analytics/server";
@@ -143,8 +147,12 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
143
147
  };
144
148
 
145
149
  // #7532 D4 — a registered spoke resolves first, to its own workspace; a
146
- // refused key never falls through to the alias below.
147
- const caller = await resolveCallerSite(request);
150
+ // refused key never falls through to the alias below. A keyless lead
151
+ // whose hub-status read fails on a cold start proceeds as on a non-hub,
152
+ // so routeLead's outage fallback captures it (#7633 amendment).
153
+ const caller = await resolveCallerSite(request, {
154
+ keylessOnRegistryFailure: "anonymous",
155
+ });
148
156
  if (caller.kind === "refused") {
149
157
  return reply.status(503).send({
150
158
  error: "tenant_unresolved",
@@ -16,11 +16,12 @@
16
16
  * (a clause carrying one is treated as a contract violation and returns
17
17
  * nothing, the `flow-checkouts` test's own discipline);
18
18
  * - a signed-in caller who owns nothing gets `200 []`, never a 403;
19
- * - `PROJECT_LISTING_ROLES` is untouched and this route never consults it.
19
+ * - the tenant routes' permission (`inbox-read`, #7636 D2) is untouched and
20
+ * this route never consults it.
20
21
  */
21
22
  import Fastify from "fastify";
22
23
  import type { FastifyInstance } from "fastify";
23
- import { projectListingMeResponseSchema } from "@working-theory/validation";
24
+ import { PERMISSION_TABLE, projectListingMeResponseSchema } from "@working-theory/validation";
24
25
 
25
26
  // `services/project-listings.ts` imports `routeLead` from `lead-routing.js` at
26
27
  // module scope, which transitively imports `@working-theory/email` and
@@ -55,7 +56,6 @@ jest.mock("../../../lib/prisma.js", () => ({
55
56
 
56
57
  import { prisma } from "../../../lib/prisma.js";
57
58
  import { authPreHandlerPlugin } from "../../../middleware/auth.js";
58
- import { PROJECT_LISTING_ROLES } from "../../../services/project-listings.js";
59
59
  import { projectListingMeRoute } from "../index.js";
60
60
 
61
61
  const FRONTEND = "https://app.example.com";
@@ -464,9 +464,8 @@ describe("GET /v1/project-listings/me — the read-time verification posture", (
464
464
  });
465
465
 
466
466
  describe("the existing operator role floor is untouched", () => {
467
- it("PROJECT_LISTING_ROLES is still exactly owner + admin", () => {
468
- expect([...PROJECT_LISTING_ROLES].sort()).toEqual(["admin", "owner"]);
469
- expect(PROJECT_LISTING_ROLES.size).toBe(2);
467
+ it("the tenant routes read the permission table's inbox-read row, which holds every Membership role", () => {
468
+ expect([...PERMISSION_TABLE["inbox-read"]].sort()).toEqual(["admin", "member", "owner"]);
470
469
  });
471
470
 
472
471
  it("the /me route never consults the tenant context or the role floor", async () => {
@@ -26,18 +26,23 @@
26
26
  * `/me` (#5295) sits between the two scopes: `authScope` only, no tenant guard.
27
27
  * A founder is not a workspace member, so the guard's default-deny answers 403
28
28
  * for their own submission — and the fix is this ADDITIVE route, joined on the
29
- * owner link, rather than a relaxed `PROJECT_LISTING_ROLES`. Its static `/me`
29
+ * owner link, rather than a relaxed `inbox-read` permission. Its static `/me`
30
30
  * segment takes router precedence over the parametric `/:id` below, exactly as
31
31
  * the already-shipped static `/public` does.
32
32
  *
33
- * API<->MCP parity: no MCP twin. The anonymous set mirrors the leads routes'
34
- * own identity-blind posture (an applying founder has no account, and the
35
- * resume token is a per-draft capability rather than an identity); the
36
- * tenant-scoped set is an identity-bound dashboard surface, the same #3591
37
- * exception the sibling `/v1/waitlist-experiments/*` routes record. The
38
- * directory read (#7521) takes the same no-twin posture as `/public`: agents
39
- * read a directory from its page's JSON-LD, and a `list_directory` tool is a
40
- * follow-up.
33
+ * API<->MCP parity: the anonymous set has no MCP twin — it mirrors the leads
34
+ * routes' own identity-blind posture (an applying founder has no account, and
35
+ * the resume token is a per-draft capability rather than an identity). Three
36
+ * tenant-scoped routes have a twin on the signed-in workspace MCP surface,
37
+ * `/mcp/workspace` (#3591 D13), each parsing with a
38
+ * `@working-theory/validation` export (D10):
39
+ * `GET /v1/project-listings` — `list_inbox_applications`,
40
+ * `GET /v1/project-listings/:id` — `get_inbox_application`,
41
+ * `PATCH /v1/project-listings/:id` — `decide_inbox_application`. The copy
42
+ * edit (`PATCH /:id/copy`) keeps the no-twin posture of the sibling
43
+ * `/v1/waitlist-experiments/*` routes. The directory read (#7521) takes the
44
+ * same no-twin posture as `/public`: agents read a directory from its page's
45
+ * JSON-LD, and a `list_directory` tool is a follow-up.
41
46
  */
42
47
  import type { FastifyPluginAsync } from "fastify";
43
48
 
@@ -18,9 +18,10 @@
18
18
  * `tenantScope`): a founder is not a workspace member, and the tenant guard is
19
19
  * default-deny, so a founder reaching the tenant-scoped list route gets a 403
20
20
  * for their own submission. That is the problem this route exists to solve, and
21
- * it is solved by ADDING this route rather than by relaxing
22
- * `PROJECT_LISTING_ROLES` — the operator floor on
23
- * `GET|PATCH /v1/project-listings[/:id]` is unchanged and still `owner|admin`.
21
+ * it is solved by ADDING this route rather than by relaxing the tenant
22
+ * routes' permissions — `GET|PATCH /v1/project-listings[/:id]` stay on the
23
+ * permission table's `inbox-read` / `applications-decide` rows (#7636 D2),
24
+ * which a founder, holding no Membership, never reaches.
24
25
  *
25
26
  * Identity is `request.userId` ONLY. The route declares no query schema and
26
27
  * reads no query or body key — an `email` parameter is never parsed and never
@@ -19,9 +19,10 @@
19
19
  * untouched by this route — it adds no status, no transition and no decision
20
20
  * target.
21
21
  *
22
- * Auth, tenant scoping and the error shapes are `patch.ts`'s: the same
23
- * owner-or-admin floor, the same `siteId` scoping from `request.tenant`, and
24
- * the same 401 / 403 / 400 / 404 bodies. A foreign or missing id is `404`, so a
22
+ * Auth, tenant scoping and the error shapes are `patch.ts`'s, with the
23
+ * `listings-edit` permission (owner, admin — #7636 D2) as the floor, the
24
+ * same `siteId` scoping from `request.tenant`, and the same 401 / 403 / 400 /
25
+ * 404 bodies. A foreign or missing id is `404`, so a
25
26
  * cross-tenant probe cannot tell "exists but not yours" from "does not exist".
26
27
  *
27
28
  * `questions` is replaced wholesale, and its `extras` array carries a
@@ -42,13 +43,14 @@
42
43
  import { zProjectListingId } from "@working-theory/ids";
43
44
  import {
44
45
  buildProjectListingDraftPatchSchema,
46
+ can,
45
47
  projectListingSchema,
46
48
  } from "@working-theory/validation";
47
49
  import type { FastifyReply, FastifyRequest } from "fastify";
48
50
  import { z } from "zod";
49
51
 
50
52
  import { listingConfigFor } from "../../services/listing-config.js";
51
- import { patchListingCopy, PROJECT_LISTING_ROLES } from "../../services/project-listings.js";
53
+ import { patchListingCopy } from "../../services/project-listings.js";
52
54
 
53
55
  import { sendValidated } from "./respond.js";
54
56
 
@@ -158,7 +160,7 @@ export async function patchProjectListingCopyHandler(
158
160
  ): Promise<void> {
159
161
  const tenant = request.tenant;
160
162
  if (!tenant) return unauthorized(reply);
161
- if (!PROJECT_LISTING_ROLES.has(tenant.role)) {
163
+ if (!can(tenant.role, "listings-edit")) {
162
164
  request.log.warn(
163
165
  { method: request.method, path: request.url, status: 403 },
164
166
  "[project-listings] copy patch denied; insufficient role",
@@ -0,0 +1,198 @@
1
+ // oss: false
2
+ /**
3
+ * @file inbox-wire-schemas.test.ts
4
+ *
5
+ * The ten inbox routes parse their request with `@working-theory/validation`
6
+ * exports (#7672, spec 3591 D10), in a form the API ↔ MCP parity guard binds:
7
+ * `bindArgs` over each route file's LIVE text yields exactly the D10 export
8
+ * for each of params, query and body, and `argsFromBindings` resolves every
9
+ * binding to an argument tree — never `unbound`, never `unrepresentable`.
10
+ *
11
+ * And the id patterns those exports restate (validation imports nothing, so
12
+ * it cannot import `@working-theory/ids`) are the registry's own, so a param
13
+ * the route accepted before the move is the param it accepts after it.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import { idRegexSource, UUID_V7_SOURCE } from "@working-theory/ids";
19
+ import * as validation from "@working-theory/validation";
20
+ import { z } from "zod";
21
+
22
+ import {
23
+ argsFromBindings,
24
+ bindArgs,
25
+ type ArgBinding,
26
+ } from "../../../../../../tests/parity/api-parity/bind-args";
27
+
28
+ const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..", "..");
29
+ const routeText = (file: string) => readFileSync(join(REPO_ROOT, file), "utf8");
30
+
31
+ const MESSAGES = "apps/api/src/routes/workspaces/messages.ts";
32
+ const SUBMISSIONS = "apps/api/src/routes/workspaces/submissions.ts";
33
+ const LISTINGS = "apps/api/src/routes/project-listings";
34
+
35
+ const ITEM_PARAMS = "<{ Params: { workspaceId: string; leadId: string } }>(\n";
36
+
37
+ type Row = {
38
+ route: string;
39
+ file: string;
40
+ anchor: string;
41
+ bindings: ArgBinding[];
42
+ };
43
+
44
+ /** Spec 3591 D10's table — the ten inbox rows, export by export. */
45
+ const ROWS: Row[] = [
46
+ {
47
+ route: "GET /v1/workspaces/:workspaceId/messages",
48
+ file: MESSAGES,
49
+ anchor: '"/v1/workspaces/:workspaceId/messages",',
50
+ bindings: [
51
+ { exportName: "inboxWorkspaceParamsSchema", target: "params" },
52
+ { exportName: "inboxMessageListQuerySchema", target: "query" },
53
+ ],
54
+ },
55
+ {
56
+ route: "GET /v1/workspaces/:workspaceId/messages/:leadId",
57
+ file: MESSAGES,
58
+ anchor: `fastify.get${ITEM_PARAMS} "/v1/workspaces/:workspaceId/messages/:leadId",`,
59
+ bindings: [{ exportName: "inboxMessageParamsSchema", target: "params" }],
60
+ },
61
+ {
62
+ route: "PATCH /v1/workspaces/:workspaceId/messages/:leadId",
63
+ file: MESSAGES,
64
+ anchor: `fastify.patch${ITEM_PARAMS} "/v1/workspaces/:workspaceId/messages/:leadId",`,
65
+ bindings: [
66
+ { exportName: "inboxMessageParamsSchema", target: "params" },
67
+ { exportName: "inboxHandledBodySchema", target: "body" },
68
+ ],
69
+ },
70
+ {
71
+ route: "POST /v1/workspaces/:workspaceId/messages/:leadId/accept",
72
+ file: MESSAGES,
73
+ anchor: '"/v1/workspaces/:workspaceId/messages/:leadId/accept",',
74
+ bindings: [
75
+ { exportName: "inboxMessageParamsSchema", target: "params" },
76
+ { exportName: "inboxAcceptBodySchema", target: "body" },
77
+ ],
78
+ },
79
+ {
80
+ route: "GET /v1/workspaces/:workspaceId/submissions",
81
+ file: SUBMISSIONS,
82
+ anchor: '"/v1/workspaces/:workspaceId/submissions",',
83
+ bindings: [
84
+ { exportName: "inboxWorkspaceParamsSchema", target: "params" },
85
+ { exportName: "inboxSubmissionListQuerySchema", target: "query" },
86
+ ],
87
+ },
88
+ {
89
+ route: "GET /v1/workspaces/:workspaceId/submissions/:leadId",
90
+ file: SUBMISSIONS,
91
+ anchor: `fastify.get${ITEM_PARAMS} "/v1/workspaces/:workspaceId/submissions/:leadId",`,
92
+ bindings: [{ exportName: "inboxSubmissionParamsSchema", target: "params" }],
93
+ },
94
+ {
95
+ route: "PATCH /v1/workspaces/:workspaceId/submissions/:leadId",
96
+ file: SUBMISSIONS,
97
+ anchor: `fastify.patch${ITEM_PARAMS} "/v1/workspaces/:workspaceId/submissions/:leadId",`,
98
+ bindings: [
99
+ { exportName: "inboxSubmissionParamsSchema", target: "params" },
100
+ { exportName: "inboxHandledBodySchema", target: "body" },
101
+ ],
102
+ },
103
+ {
104
+ route: "GET /v1/project-listings",
105
+ file: `${LISTINGS}/list.ts`,
106
+ anchor: "export async function listProjectListingsHandler(",
107
+ bindings: [
108
+ { exportName: "projectListingListQuerySchema", target: "query" },
109
+ ],
110
+ },
111
+ {
112
+ route: "GET /v1/project-listings/:id",
113
+ file: `${LISTINGS}/get.ts`,
114
+ anchor: "export async function getProjectListingHandler(",
115
+ bindings: [{ exportName: "projectListingParamsSchema", target: "params" }],
116
+ },
117
+ {
118
+ route: "PATCH /v1/project-listings/:id",
119
+ file: `${LISTINGS}/patch.ts`,
120
+ anchor: "export async function patchProjectListingHandler(",
121
+ bindings: [
122
+ { exportName: "projectListingParamsSchema", target: "params" },
123
+ { exportName: "projectListingPatchSchema", target: "body" },
124
+ ],
125
+ },
126
+ ];
127
+
128
+ describe("the inbox routes parse with @working-theory/validation exports (D10)", () => {
129
+ it("covers the ten inbox rows", () => {
130
+ expect(ROWS).toHaveLength(10);
131
+ });
132
+
133
+ it.each(ROWS.map((row) => [row.route, row] as const))(
134
+ "%s binds exactly its D10 exports",
135
+ (_route, row) => {
136
+ const bound = bindArgs(routeText(row.file), row.anchor);
137
+ expect(bound).toEqual({ bindings: row.bindings });
138
+ },
139
+ );
140
+
141
+ it.each(ROWS.map((row) => [row.route, row] as const))(
142
+ "%s resolves to an argument tree",
143
+ (_route, row) => {
144
+ const args = argsFromBindings(
145
+ bindArgs(routeText(row.file), row.anchor),
146
+ validation,
147
+ z.toJSONSchema as never,
148
+ );
149
+ expect(args).not.toHaveProperty("unbound");
150
+ expect(args).not.toHaveProperty("unrepresentable");
151
+ },
152
+ );
153
+ });
154
+
155
+ describe("the exported id patterns are the registry's", () => {
156
+ const pattern = (schema: z.ZodType, key: string): unknown =>
157
+ (
158
+ z.toJSONSchema(schema) as {
159
+ properties: Record<string, { pattern?: string }>;
160
+ }
161
+ ).properties[key]?.pattern;
162
+
163
+ it.each([
164
+ [
165
+ "workspaceId",
166
+ validation.inboxWorkspaceParamsSchema,
167
+ idRegexSource("workspace"),
168
+ ],
169
+ [
170
+ "workspaceId",
171
+ validation.inboxMessageParamsSchema,
172
+ idRegexSource("workspace"),
173
+ ],
174
+ ["leadId", validation.inboxMessageParamsSchema, idRegexSource("lead")],
175
+ [
176
+ "workspaceId",
177
+ validation.inboxSubmissionParamsSchema,
178
+ idRegexSource("workspace"),
179
+ ],
180
+ ["leadId", validation.inboxSubmissionParamsSchema, idRegexSource("lead")],
181
+ [
182
+ "id",
183
+ validation.projectListingParamsSchema,
184
+ idRegexSource("projectListing"),
185
+ ],
186
+ // `routes/magnets/index.ts`'s magnet id — `flow_` is not a registry entity.
187
+ [
188
+ "magnetId",
189
+ validation.inboxSubmissionListQuerySchema,
190
+ `^flow_${UUID_V7_SOURCE}$`,
191
+ ],
192
+ ] as const)(
193
+ "%s pattern equals the @working-theory/ids source",
194
+ (key, schema, expected) => {
195
+ expect(pattern(schema, key)).toBe(expected);
196
+ },
197
+ );
198
+ });
@@ -33,6 +33,7 @@ import { defaultTenantPreHandlerPlugin } from "./middleware/default-tenant.js";
33
33
  import {
34
34
  rateLimitMiddleware,
35
35
  buildTrustedKeyDigests,
36
+ DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE,
36
37
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
37
38
  resolveRateLimitIdentity,
38
39
  } from "./middleware/rate-limit.js";
@@ -216,6 +217,10 @@ const WORKSPACES_MESSAGES = "./routes/workspaces/messages.js";
216
217
  // WORKSPACES_MESSAGES in the same workspace/multi-tenant scope.
217
218
  const WORKSPACES_SUBMISSIONS = "./routes/workspaces/submissions.js";
218
219
  const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
220
+ // #3591 PR-B — the agent seat's management routes and an agent's own
221
+ // context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
222
+ // scope.
223
+ const WORKSPACES_AGENTS = "./routes/workspaces/agents.js";
219
224
  // #6183 S9b — a SEPARATE specifier from WORKSPACES_CONFIG: this route is
220
225
  // registered under isModuleEnabled("scheduling") alone, never the
221
226
  // workspace/multi-tenant scope config.js shares with general.js/members.js
@@ -287,6 +292,11 @@ const SLICE_PLUGINS = {
287
292
  specifier: WORKSPACES_SUBSCRIBERS,
288
293
  load: () => import(WORKSPACES_SUBSCRIBERS),
289
294
  },
295
+ // #3591 PR-B — the agent seat, the SAME scope.
296
+ workspaceAgentRoutes: {
297
+ specifier: WORKSPACES_AGENTS,
298
+ load: () => import(WORKSPACES_AGENTS),
299
+ },
290
300
  // #6183 S9b — registered in the scheduling scope, not the
291
301
  // workspace/multi-tenant scope its siblings above share.
292
302
  workspaceMeetingsRoutes: {
@@ -1014,6 +1024,12 @@ export async function build() {
1014
1024
  await tenantScope.register(
1015
1025
  await sliceRoutePlugin("workspaceSubscriberRoutes"),
1016
1026
  );
1027
+ // The agent seat (#3591 PR-B) — the SAME scope and guard: owners and
1028
+ // admins list, add, re-key and switch off agents (`agents-manage`),
1029
+ // and an agent reads its own context at GET /v1/agent/context.
1030
+ await tenantScope.register(
1031
+ await sliceRoutePlugin("workspaceAgentRoutes"),
1032
+ );
1017
1033
  // The asset store's MANAGE routes — list, PATCH, DELETE (#6093 D16).
1018
1034
  // Registered HERE, in the plain tenant scope, and NOT inside the
1019
1035
  // upload's own nested strict-rate-limit scope below: a JSON
@@ -1380,10 +1396,21 @@ export async function build() {
1380
1396
  // (#5111, DEF-004). Module-conditional (S1, #5088): the scheduling module
1381
1397
  // owns this whole route set, so an OSS build that excludes it registers
1382
1398
  // neither the routes nor their imports.
1399
+ //
1400
+ // `scope: "schedule"` with `trustedKeyDigests` (#7633, the relay's security
1401
+ // review): a site's same-origin relay sends every visitor's availability
1402
+ // and booking call from its own server, so with the forwarding key each
1403
+ // visitor is counted separately, and the key as a whole is capped at
1404
+ // DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE. The scope's buckets are its own,
1405
+ // and AILK_RATE_LIMIT_SCHEDULE_PER_MINUTE / ..._PER_KEY_PER_MINUTE override
1406
+ // the code values, as the leads scope's do.
1383
1407
  if (isModuleEnabled("scheduling")) {
1384
1408
  await server.register(async (scheduleScope) => {
1385
1409
  scheduleScope.register(rateLimitMiddleware, {
1386
1410
  perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
1411
+ scope: "schedule",
1412
+ trustedKeyDigests: visitorForwardKeyDigests,
1413
+ perKeyPerMinute: DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE,
1387
1414
  });
1388
1415
  await scheduleScope.register(
1389
1416
  await sliceRoutePlugin("listAvailabilityRoute"),