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
|
@@ -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
|
|
113
|
+
function setMode(mode: HubSpokeKeyMode): void {
|
|
105
114
|
jest.replaceProperty(configModule, "config", {
|
|
106
115
|
...configModule.config,
|
|
107
|
-
AILK_HUB_REQUIRE_SPOKE_KEY:
|
|
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
|
-
|
|
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>,
|
|
255
|
-
["(i) an unregistered key", { "x-ailk-spoke-key": KEY_UNKNOWN },
|
|
256
|
-
["(ii) a duplicated header", { "x-ailk-spoke-key": `${KEY_S1}, ${KEY_S2}` },
|
|
257
|
-
["(iii) an empty key", { "x-ailk-spoke-key": "" },
|
|
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)
|
|
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
|
-
|
|
326
|
+
"auto",
|
|
267
327
|
],
|
|
268
|
-
])("%s (AC-3)", (_label, headers,
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
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("
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
-
* -
|
|
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("
|
|
468
|
-
expect([...
|
|
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 `
|
|
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
|
|
34
|
-
* own identity-blind posture (an applying founder has no account, and
|
|
35
|
-
* resume token is a per-draft capability rather than an identity)
|
|
36
|
-
* tenant-scoped
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
23
|
-
* `
|
|
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
|
|
23
|
-
*
|
|
24
|
-
* the same 401 / 403 / 400 /
|
|
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
|
|
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 (!
|
|
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"),
|