create-ailk 0.14.0 → 0.15.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +23 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +110 -9
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/sync-routes.js +11 -2
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/apps/api/CLAUDE.md +4 -2
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +71 -19
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -33
|
@@ -27,13 +27,15 @@ export interface AgentCaller {
|
|
|
27
27
|
workspaceId: string;
|
|
28
28
|
/** The person's own role; the guard clamps the agent to `member`. */
|
|
29
29
|
role: "owner" | "admin" | "member";
|
|
30
|
+
/** The agent's stored grants (#7864); the guard keeps those `role` holds. */
|
|
31
|
+
grants: string[];
|
|
30
32
|
headers: { authorization: string };
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function buildAgentCaller(
|
|
34
36
|
scope: { organizationId: string; workspaceId: string },
|
|
35
37
|
overrides: Partial<
|
|
36
|
-
Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role">
|
|
38
|
+
Pick<AgentCaller, "agentId" | "userId" | "membershipId" | "role" | "grants">
|
|
37
39
|
> = {},
|
|
38
40
|
): AgentCaller & { key: string } {
|
|
39
41
|
const key = mintAgentKey();
|
|
@@ -42,6 +44,7 @@ export function buildAgentCaller(
|
|
|
42
44
|
userId: "user_00000000-0000-7000-8000-00000000a9e2",
|
|
43
45
|
membershipId: "member_00000000-0000-7000-8000-00000000a9e3",
|
|
44
46
|
role: "member",
|
|
47
|
+
grants: [],
|
|
45
48
|
...scope,
|
|
46
49
|
...overrides,
|
|
47
50
|
key,
|
|
@@ -64,6 +67,7 @@ export function agentKeyLookup(
|
|
|
64
67
|
workspaceId: caller.workspaceId,
|
|
65
68
|
organizationId: caller.organizationId,
|
|
66
69
|
permissionLevel: "member",
|
|
70
|
+
grants: caller.grants,
|
|
67
71
|
enabled: true,
|
|
68
72
|
keyIssuedToMembershipId: caller.membershipId,
|
|
69
73
|
lastActiveAt: null,
|
|
@@ -70,8 +70,16 @@ export const prisma = {
|
|
|
70
70
|
// #3591 — `lib/agent-store.ts`'s list names each agent's creator by id.
|
|
71
71
|
findMany: jest.fn(),
|
|
72
72
|
update: jest.fn(),
|
|
73
|
+
// #7803 — `activateWorkspace` records the remembered workspace on the
|
|
74
|
+
// caller's own row (tenant-context.ts, spec D3).
|
|
75
|
+
updateMany: jest.fn(),
|
|
73
76
|
create: jest.fn(),
|
|
74
77
|
},
|
|
78
|
+
// #7803 — `activateWorkspace` writes the session's active-workspace pointer
|
|
79
|
+
// in one `$transaction` with the `user.updateMany` above.
|
|
80
|
+
session: {
|
|
81
|
+
update: jest.fn(),
|
|
82
|
+
},
|
|
75
83
|
apiToken: {
|
|
76
84
|
create: jest.fn(),
|
|
77
85
|
findMany: jest.fn(),
|
|
@@ -210,6 +218,8 @@ export const prisma = {
|
|
|
210
218
|
findFirst: jest.fn(),
|
|
211
219
|
findMany: jest.fn(),
|
|
212
220
|
upsert: jest.fn(),
|
|
221
|
+
// #7844 D7 — `copyFlowDefinition`'s one create-only write.
|
|
222
|
+
create: jest.fn(),
|
|
213
223
|
},
|
|
214
224
|
// `deleteMany` — used by the FlowSession retention/cleanup sweep
|
|
215
225
|
// (flow-engine.ts's `cleanupAbandonedSessions`, #3974 AC3).
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* transaction, and refuses (not found) a workspace of another
|
|
14
14
|
* organization or a person with no membership there (SEC-3591-15);
|
|
15
15
|
* - re-keying and switching are scoped by organization: another
|
|
16
|
-
* organization's agent is "not found", and nothing is written
|
|
16
|
+
* organization's agent is "not found", and nothing is written;
|
|
17
|
+
* - setting grants (#7864 D4, AC-4) collapses duplicates into D1 order,
|
|
18
|
+
* refuses a grant its person's role does not hold, and is scoped by
|
|
19
|
+
* organization like the other writes.
|
|
17
20
|
*
|
|
18
21
|
* Test fixtures never hold a key-shaped literal: every `ailk_agt_` value is
|
|
19
22
|
* built at run time by `mintAgentKey()`, or the repo-wide secret scan flags it.
|
|
@@ -36,6 +39,7 @@ import {
|
|
|
36
39
|
replaceServedAgentKey,
|
|
37
40
|
setAgentEnabled,
|
|
38
41
|
setServedAgentEnabled,
|
|
42
|
+
setServedAgentGrants,
|
|
39
43
|
touchAgentLastActive,
|
|
40
44
|
} from "../agent-store.js";
|
|
41
45
|
import { prisma } from "../prisma.js";
|
|
@@ -71,6 +75,7 @@ const AGENT_ROW = {
|
|
|
71
75
|
workspaceId: WS_A1,
|
|
72
76
|
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
73
77
|
permissionLevel: "member",
|
|
78
|
+
grants: [] as string[],
|
|
74
79
|
enabled: true,
|
|
75
80
|
lastActiveAt: null,
|
|
76
81
|
createdAt: new Date("2026-10-04T00:00:00Z"),
|
|
@@ -129,6 +134,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
129
134
|
workspaceId: WS_A1,
|
|
130
135
|
organizationId: ORG_A,
|
|
131
136
|
permissionLevel: "member",
|
|
137
|
+
grants: ["content-edit"],
|
|
132
138
|
enabled: true,
|
|
133
139
|
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
134
140
|
lastActiveAt: null,
|
|
@@ -146,6 +152,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
146
152
|
workspaceId: true,
|
|
147
153
|
organizationId: true,
|
|
148
154
|
permissionLevel: true,
|
|
155
|
+
grants: true,
|
|
149
156
|
enabled: true,
|
|
150
157
|
keyIssuedToMembershipId: true,
|
|
151
158
|
lastActiveAt: true,
|
|
@@ -157,6 +164,7 @@ describe("findAgentByKeyHash", () => {
|
|
|
157
164
|
workspaceId: WS_A1,
|
|
158
165
|
organizationId: ORG_A,
|
|
159
166
|
permissionLevel: "member",
|
|
167
|
+
grants: ["content-edit"],
|
|
160
168
|
enabled: true,
|
|
161
169
|
keyIssuedToMembershipId: OWNER_MEMBERSHIP,
|
|
162
170
|
lastActiveAt: null,
|
|
@@ -399,6 +407,110 @@ describe("the served writes (route-facing)", () => {
|
|
|
399
407
|
});
|
|
400
408
|
});
|
|
401
409
|
|
|
410
|
+
describe("setServedAgentGrants (#7864 D4, AC-4)", () => {
|
|
411
|
+
/** The row the transaction reads: the agent with its person's role. */
|
|
412
|
+
function agentWithRole(role: string, grants: string[] = []) {
|
|
413
|
+
return { organizationId: ORG_A, grants, responsible: { role } };
|
|
414
|
+
}
|
|
415
|
+
let listGrants: string[];
|
|
416
|
+
|
|
417
|
+
beforeEach(() => {
|
|
418
|
+
listGrants = [];
|
|
419
|
+
(prisma.agent.updateMany as jest.Mock).mockImplementation(
|
|
420
|
+
({ data }: { data: { grants: string[] } }) => {
|
|
421
|
+
listGrants = data.grants;
|
|
422
|
+
return Promise.resolve({ count: 1 });
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
(prisma.agent.findMany as jest.Mock).mockImplementation(() =>
|
|
426
|
+
Promise.resolve([
|
|
427
|
+
{
|
|
428
|
+
...AGENT_ROW,
|
|
429
|
+
grants: listGrants,
|
|
430
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
431
|
+
keyIssuedTo: { userId: OWNER, user: { name: "Olive" } },
|
|
432
|
+
},
|
|
433
|
+
]),
|
|
434
|
+
);
|
|
435
|
+
(prisma.user.findMany as jest.Mock).mockResolvedValue([{ id: OWNER, name: "Olive" }]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("writes the set in D1 order with duplicates collapsed, in one transaction, and serves the list row", async () => {
|
|
439
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("admin", ["assets-write"]));
|
|
440
|
+
|
|
441
|
+
const result = await setServedAgentGrants({
|
|
442
|
+
organizationId: ORG_A,
|
|
443
|
+
agentId: AGENT,
|
|
444
|
+
grants: ["magnets-manage", "content-edit", "magnets-manage"],
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
448
|
+
expect(prisma.agent.findUnique).toHaveBeenCalledWith({
|
|
449
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
450
|
+
select: { organizationId: true, grants: true, responsible: { select: { role: true } } },
|
|
451
|
+
});
|
|
452
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
453
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
454
|
+
data: { grants: ["content-edit", "magnets-manage"] },
|
|
455
|
+
});
|
|
456
|
+
expect(result).toMatchObject({
|
|
457
|
+
ok: true,
|
|
458
|
+
agent: { id: AGENT, grants: ["content-edit", "magnets-manage"] },
|
|
459
|
+
previous: ["assets-write"],
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("an empty set removes every grant", async () => {
|
|
464
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(
|
|
465
|
+
agentWithRole("owner", ["content-edit", "content-publish"]),
|
|
466
|
+
);
|
|
467
|
+
const result = await setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: [] });
|
|
468
|
+
expect(prisma.agent.updateMany).toHaveBeenCalledWith({
|
|
469
|
+
where: { id: AGENT, organizationId: ORG_A },
|
|
470
|
+
data: { grants: [] },
|
|
471
|
+
});
|
|
472
|
+
expect(result).toMatchObject({ ok: true, agent: { grants: [] }, previous: ["content-edit", "content-publish"] });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("refuses a grant its person's role does not hold, and writes nothing", async () => {
|
|
476
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValue(agentWithRole("member"));
|
|
477
|
+
await expect(
|
|
478
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
479
|
+
).resolves.toEqual({ ok: false, reason: "exceeds-person" });
|
|
480
|
+
expect(prisma.agent.updateMany).not.toHaveBeenCalled();
|
|
481
|
+
expect(prisma.agent.findMany).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("is not-found for an unknown agent or one of another organization, and writes nothing", async () => {
|
|
485
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce(null);
|
|
486
|
+
await expect(
|
|
487
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
488
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
489
|
+
|
|
490
|
+
(prisma.agent.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
491
|
+
...agentWithRole("owner"),
|
|
492
|
+
organizationId: ORG_B,
|
|
493
|
+
});
|
|
494
|
+
await expect(
|
|
495
|
+
setServedAgentGrants({ organizationId: ORG_A, agentId: AGENT, grants: ["content-edit"] }),
|
|
496
|
+
).resolves.toEqual({ ok: false, reason: "not-found" });
|
|
497
|
+
expect(prisma.agent.updateMany).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("the list row serves only the five, in D1 order, whatever the row holds", async () => {
|
|
501
|
+
(prisma.agent.findMany as jest.Mock).mockResolvedValue([
|
|
502
|
+
{
|
|
503
|
+
...AGENT_ROW,
|
|
504
|
+
grants: ["meeting-types-manage", "content-edit", "agents-manage"],
|
|
505
|
+
responsible: { userId: PERSON, user: { name: "Pat", email: "pat@example.test" } },
|
|
506
|
+
keyIssuedTo: null,
|
|
507
|
+
},
|
|
508
|
+
]);
|
|
509
|
+
const [row] = await listAgentsForOrganization(ORG_A);
|
|
510
|
+
expect(row?.grants).toEqual(["content-edit", "meeting-types-manage"]);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
402
514
|
describe("listAgentsForOrganization", () => {
|
|
403
515
|
beforeEach(() => {
|
|
404
516
|
(prisma.agent.findMany as jest.Mock).mockResolvedValue([
|
|
@@ -423,6 +535,7 @@ describe("listAgentsForOrganization", () => {
|
|
|
423
535
|
workspaceId: WS_A1,
|
|
424
536
|
responsible: { userId: PERSON, name: "Pat", email: "pat@example.test" },
|
|
425
537
|
permissionLevel: "member",
|
|
538
|
+
grants: [],
|
|
426
539
|
enabled: true,
|
|
427
540
|
lastActiveAt: null,
|
|
428
541
|
createdAt: AGENT_ROW.createdAt,
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* (#6228): the assertion's first arm now skips on any OTHER adapter, so a
|
|
18
18
|
* case that means to exercise the REAL resolution must say which adapter it
|
|
19
19
|
* is booting under rather than inherit whatever the ambient env holds.
|
|
20
|
+
*
|
|
21
|
+
* `describe("site stamp (#7868)")` covers the Checkout `metadata.site` stamp
|
|
22
|
+
* and the webhook's foreign-site test (spec D2/D7).
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
@@ -28,7 +31,9 @@ import {
|
|
|
28
31
|
__resetSiteWorkspaceCacheForTests,
|
|
29
32
|
assertSiteAliasResolves,
|
|
30
33
|
currentSiteId,
|
|
34
|
+
isForeignSiteStamp,
|
|
31
35
|
resolveSiteWorkspaceId,
|
|
36
|
+
siteStamp,
|
|
32
37
|
} from "../site.js";
|
|
33
38
|
|
|
34
39
|
const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
|
|
@@ -236,3 +241,52 @@ describe("assertSiteAliasResolves — the #6228 content-adapter carve-out (arm 0
|
|
|
236
241
|
expect(prisma.workspace.findUnique).toHaveBeenCalled();
|
|
237
242
|
});
|
|
238
243
|
});
|
|
244
|
+
|
|
245
|
+
describe("site stamp (#7868)", () => {
|
|
246
|
+
// Stripe types metadata values as strings; a non-string arrives only from a
|
|
247
|
+
// malformed payload, so the fixture is cast at this one boundary.
|
|
248
|
+
const nonStringStamp: Record<string, unknown> = { site: 42 };
|
|
249
|
+
|
|
250
|
+
it("siteStamp() with AILK_SITE_ID unset stamps the default alias", () => {
|
|
251
|
+
delete process.env.AILK_SITE_ID;
|
|
252
|
+
expect(siteStamp()).toEqual({ site: "default" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("siteStamp() follows AILK_SITE_ID, read per call", () => {
|
|
256
|
+
process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
|
|
257
|
+
expect(siteStamp()).toEqual({ site: PINNED_WORKSPACE_ID });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("siteStamp() is empty when AILK_SITE_ID is the empty string — Stripe is never sent an empty value (SEC-7868-5)", () => {
|
|
261
|
+
process.env.AILK_SITE_ID = "";
|
|
262
|
+
expect(siteStamp()).toEqual({});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it.each([
|
|
266
|
+
["undefined metadata", undefined],
|
|
267
|
+
["null metadata", null],
|
|
268
|
+
["no stamp", {}],
|
|
269
|
+
["an empty stamp", { site: "" }],
|
|
270
|
+
["the own stamp", { site: "default" }],
|
|
271
|
+
["a non-string stamp", nonStringStamp as Record<string, string>],
|
|
272
|
+
])("isForeignSiteStamp is false for %s (unstamped is own, roadmap D4)", (_label, metadata) => {
|
|
273
|
+
delete process.env.AILK_SITE_ID;
|
|
274
|
+
expect(isForeignSiteStamp(metadata)).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("isForeignSiteStamp is true for another site's stamp", () => {
|
|
278
|
+
delete process.env.AILK_SITE_ID;
|
|
279
|
+
expect(isForeignSiteStamp({ site: "samhenry" })).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("isForeignSiteStamp compares exactly — a case-folded own alias is foreign", () => {
|
|
283
|
+
delete process.env.AILK_SITE_ID;
|
|
284
|
+
expect(isForeignSiteStamp({ site: "Default" })).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("isForeignSiteStamp reads the alias per call, not once at import", () => {
|
|
288
|
+
process.env.AILK_SITE_ID = PINNED_WORKSPACE_ID;
|
|
289
|
+
expect(isForeignSiteStamp({ site: PINNED_WORKSPACE_ID })).toBe(false);
|
|
290
|
+
expect(isForeignSiteStamp({ site: "default" })).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -27,13 +27,25 @@
|
|
|
27
27
|
* `findAgentByKeyHash` and `touchAgentLastActive` only;
|
|
28
28
|
* - the management routes (`routes/workspaces/agents.ts`) call the rest.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* GRANTS (#7864, spec docs/specs/7864-agent-key-grants-spec.md D2–D4). An
|
|
31
|
+
* agent's row carries the permissions an owner or admin granted it one by one
|
|
32
|
+
* — only the five `AGENT_GRANTABLE_PERMISSIONS`. `setServedAgentGrants` is
|
|
33
|
+
* the column's only writer, and refuses a grant its person's own role does
|
|
34
|
+
* not hold; the tenant guards apply the same rule on every request, so a
|
|
35
|
+
* grant stored before a demotion is held to the person's current role.
|
|
36
|
+
*
|
|
37
|
+
* Nothing caches a row: a switch-off, a re-key, a grant change or a removed
|
|
38
|
+
* membership takes effect on the next request.
|
|
32
39
|
*/
|
|
33
40
|
import { createHash, randomBytes } from "node:crypto";
|
|
34
41
|
|
|
35
42
|
import type { Id } from "@working-theory/ids";
|
|
36
|
-
import
|
|
43
|
+
import {
|
|
44
|
+
AGENT_GRANTABLE_PERMISSIONS,
|
|
45
|
+
can,
|
|
46
|
+
type AgentGrantablePermission,
|
|
47
|
+
type AgentPermissionLevel,
|
|
48
|
+
} from "@working-theory/validation";
|
|
37
49
|
|
|
38
50
|
import { prisma } from "./prisma.js";
|
|
39
51
|
|
|
@@ -71,6 +83,21 @@ export interface AgentGrant {
|
|
|
71
83
|
organizationId: Id<"organization">;
|
|
72
84
|
responsibleMembershipId: Id<"membership">;
|
|
73
85
|
permissionLevel: AgentPermissionLevel;
|
|
86
|
+
/**
|
|
87
|
+
* The grants stored on the row (#7864 D3). The tenant guards keep only
|
|
88
|
+
* those its person's role also holds (`agentTenant`).
|
|
89
|
+
*/
|
|
90
|
+
grants: readonly string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A stored grant set in `AGENT_GRANTABLE_PERMISSIONS` order, without
|
|
95
|
+
* duplicates and without anything outside the five.
|
|
96
|
+
*/
|
|
97
|
+
function orderedGrants(
|
|
98
|
+
grants: readonly string[],
|
|
99
|
+
): AgentGrantablePermission[] {
|
|
100
|
+
return AGENT_GRANTABLE_PERMISSIONS.filter((p) => grants.includes(p));
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
// ── The auth preHandler's three functions ────────────────────────────────────
|
|
@@ -80,6 +107,8 @@ export interface AgentKeyRow {
|
|
|
80
107
|
workspaceId: string;
|
|
81
108
|
organizationId: string;
|
|
82
109
|
permissionLevel: AgentPermissionLevel;
|
|
110
|
+
/** `undefined` only from a test double; the auth preHandler reads it as none. */
|
|
111
|
+
grants: string[] | undefined;
|
|
83
112
|
enabled: boolean;
|
|
84
113
|
keyIssuedToMembershipId: string | null;
|
|
85
114
|
lastActiveAt: Date | null;
|
|
@@ -97,6 +126,7 @@ export async function findAgentByKeyHash(
|
|
|
97
126
|
workspaceId: true,
|
|
98
127
|
organizationId: true,
|
|
99
128
|
permissionLevel: true,
|
|
129
|
+
grants: true,
|
|
100
130
|
enabled: true,
|
|
101
131
|
keyIssuedToMembershipId: true,
|
|
102
132
|
lastActiveAt: true,
|
|
@@ -180,6 +210,12 @@ interface SetAgentEnabledParams {
|
|
|
180
210
|
enabled: boolean;
|
|
181
211
|
}
|
|
182
212
|
|
|
213
|
+
interface SetAgentGrantsParams {
|
|
214
|
+
organizationId: string;
|
|
215
|
+
agentId: string;
|
|
216
|
+
grants: readonly AgentGrantablePermission[];
|
|
217
|
+
}
|
|
218
|
+
|
|
183
219
|
/**
|
|
184
220
|
* Add an agent for `responsibleUserId`, bound to `workspaceId`. The returned
|
|
185
221
|
* `key` is the only copy that will ever exist: the caller shows it once.
|
|
@@ -280,6 +316,58 @@ export async function setServedAgentEnabled(
|
|
|
280
316
|
});
|
|
281
317
|
}
|
|
282
318
|
|
|
319
|
+
/** A grant set the agent's person's own role does not wholly hold. */
|
|
320
|
+
export type AgentGrantExceedsPerson = { ok: false; reason: "exceeds-person" };
|
|
321
|
+
export type ServedAgentGrants = ServedAgent & {
|
|
322
|
+
/** The grants stored before this write, in D1 order — for the audit row. */
|
|
323
|
+
previous: AgentGrantablePermission[];
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Replace an agent's grants with `grants` (#7864 D4) and answer with its list
|
|
328
|
+
* row. One transaction: the agent is read by id inside the caller's
|
|
329
|
+
* organization, with its responsible person's role; any requested grant that
|
|
330
|
+
* role does not hold refuses the whole set (`exceeds-person`) and writes
|
|
331
|
+
* nothing. An agent of another organization, or none, is `not-found`.
|
|
332
|
+
*/
|
|
333
|
+
export async function setServedAgentGrants(
|
|
334
|
+
params: SetAgentGrantsParams,
|
|
335
|
+
): Promise<ServedAgentGrants | AgentNotFound | AgentGrantExceedsPerson> {
|
|
336
|
+
return prisma.$transaction(async (tx) => {
|
|
337
|
+
const current = await tx.agent.findUnique({
|
|
338
|
+
where: agentScope(params),
|
|
339
|
+
select: {
|
|
340
|
+
organizationId: true,
|
|
341
|
+
grants: true,
|
|
342
|
+
responsible: { select: { role: true } },
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
if (!current || current.organizationId !== params.organizationId) {
|
|
346
|
+
return { ok: false, reason: "not-found" } as const;
|
|
347
|
+
}
|
|
348
|
+
const grants = orderedGrants(params.grants);
|
|
349
|
+
if (!grants.every((grant) => can(current.responsible.role, grant))) {
|
|
350
|
+
return { ok: false, reason: "exceeds-person" } as const;
|
|
351
|
+
}
|
|
352
|
+
const { count } = await tx.agent.updateMany({
|
|
353
|
+
where: agentScope(params),
|
|
354
|
+
data: { grants },
|
|
355
|
+
});
|
|
356
|
+
if (count !== 1) return { ok: false, reason: "not-found" } as const;
|
|
357
|
+
const [agent] = await readAgentListRows(
|
|
358
|
+
{ id: params.agentId, organizationId: params.organizationId },
|
|
359
|
+
tx,
|
|
360
|
+
);
|
|
361
|
+
return agent
|
|
362
|
+
? ({
|
|
363
|
+
ok: true,
|
|
364
|
+
agent,
|
|
365
|
+
previous: orderedGrants(current.grants),
|
|
366
|
+
} as const)
|
|
367
|
+
: ({ ok: false, reason: "not-found" } as const);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
283
371
|
/** The list row of a just-written agent, read on the write's own client. */
|
|
284
372
|
async function servedWith(
|
|
285
373
|
db: AgentDb,
|
|
@@ -392,6 +480,8 @@ export interface AgentListRow {
|
|
|
392
480
|
workspaceId: string;
|
|
393
481
|
responsible: { userId: string; name: string | null; email: string };
|
|
394
482
|
permissionLevel: AgentPermissionLevel;
|
|
483
|
+
/** The stored grants (#7864 D4), in `AGENT_GRANTABLE_PERMISSIONS` order. */
|
|
484
|
+
grants: AgentGrantablePermission[];
|
|
395
485
|
enabled: boolean;
|
|
396
486
|
lastActiveAt: Date | null;
|
|
397
487
|
createdAt: Date;
|
|
@@ -431,6 +521,7 @@ async function readAgentListRows(
|
|
|
431
521
|
name: true,
|
|
432
522
|
workspaceId: true,
|
|
433
523
|
permissionLevel: true,
|
|
524
|
+
grants: true,
|
|
434
525
|
enabled: true,
|
|
435
526
|
lastActiveAt: true,
|
|
436
527
|
createdAt: true,
|
|
@@ -466,6 +557,7 @@ async function readAgentListRows(
|
|
|
466
557
|
email: r.responsible.user.email,
|
|
467
558
|
},
|
|
468
559
|
permissionLevel: r.permissionLevel,
|
|
560
|
+
grants: orderedGrants(r.grants),
|
|
469
561
|
enabled: r.enabled,
|
|
470
562
|
lastActiveAt: r.lastActiveAt,
|
|
471
563
|
createdAt: r.createdAt,
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
* `currentSiteId()` async would edit every one of its dozen non-test call
|
|
21
21
|
* sites across routes/services/bins, including the two route files NFR1
|
|
22
22
|
* requires to stay byte-unchanged.
|
|
23
|
+
*
|
|
24
|
+
* The Checkout site stamp (#7868, spec D1/D2): `SITE_STAMP_KEY`,
|
|
25
|
+
* `siteStamp()` and `isForeignSiteStamp()` are the one definition of the
|
|
26
|
+
* `metadata.site` key every hub-created Checkout session carries and the
|
|
27
|
+
* Stripe webhook reads. The stamp is `currentSiteId()`, so writer and reader
|
|
28
|
+
* cannot disagree inside one deployment. A session is foreign only when its
|
|
29
|
+
* stamp is a non-empty string other than `currentSiteId()`; an unstamped
|
|
30
|
+
* session is the hub's own (roadmap D4).
|
|
23
31
|
*/
|
|
24
32
|
import { resolvedAdapterKind } from "@working-theory/content-adapters";
|
|
25
33
|
import {
|
|
@@ -35,6 +43,35 @@ export function currentSiteId(): string {
|
|
|
35
43
|
return process.env.AILK_SITE_ID ?? "default";
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/** The Checkout session metadata key naming the site a session belongs to (#7868 D1). */
|
|
47
|
+
export const SITE_STAMP_KEY = "site";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The metadata a hub-created Checkout session carries (#7868 D1/D2):
|
|
51
|
+
* `{ [SITE_STAMP_KEY]: currentSiteId() }`, read per call. Empty when the alias
|
|
52
|
+
* is `""` (an `AILK_SITE_ID` set to the empty string), so Stripe is never sent
|
|
53
|
+
* an empty value (SEC-7868-5) — and the webhook treats that deployment's
|
|
54
|
+
* sessions as unstamped, exactly as today.
|
|
55
|
+
*/
|
|
56
|
+
export function siteStamp(): Record<string, string> {
|
|
57
|
+
const site = currentSiteId();
|
|
58
|
+
return site === "" ? {} : { [SITE_STAMP_KEY]: site };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True only when `metadata[SITE_STAMP_KEY]` is a non-empty string that is not
|
|
63
|
+
* `currentSiteId()` — a session another site on the shared Stripe account
|
|
64
|
+
* opened (#7868 D2). An absent, empty or non-string stamp is the hub's own
|
|
65
|
+
* (roadmap D4: Payment Links, invoices, the portal and pre-stamp sessions
|
|
66
|
+
* carry none). Exact, case-sensitive comparison, as the spokes' own check.
|
|
67
|
+
*/
|
|
68
|
+
export function isForeignSiteStamp(
|
|
69
|
+
metadata: Record<string, string> | null | undefined,
|
|
70
|
+
): boolean {
|
|
71
|
+
const stamp: unknown = metadata?.[SITE_STAMP_KEY];
|
|
72
|
+
return typeof stamp === "string" && stamp !== "" && stamp !== currentSiteId();
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
/**
|
|
39
76
|
* The resolved workspace id, memoized after the first SUCCESSFUL resolution
|
|
40
77
|
* ("resolves the alias once at startup", ownership brief § Layer 2 / #6155
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* the hub — read that workspace's enabled meeting types and public content,
|
|
19
19
|
* and write leads and bookings into it.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
21
|
+
* Three kinds of caller, kept apart:
|
|
22
22
|
* - the public resolver (`services/public-site-resolver.ts`) calls
|
|
23
23
|
* `hashSpokeKey`, `findSpokeByKeyHash` and `hasRegisteredSpoke` only;
|
|
24
24
|
* - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
|
|
25
|
-
* and `listSpokes`. No route imports them
|
|
25
|
+
* and `listSpokes`. No route imports them;
|
|
26
|
+
* - the operator flow-copy service (`services/flow-copy.ts`, run only by
|
|
27
|
+
* `bin/flows-copy.ts`) calls `hasLiveSpokeForWorkspace` only (#7844 D7).
|
|
26
28
|
*
|
|
27
29
|
* Nothing here caches a row: a revocation takes effect on the next request
|
|
28
30
|
* (NFR4). The resolver holds `hasRegisteredSpoke`'s answer itself (#7633 D3).
|
|
@@ -73,6 +75,20 @@ export async function hasRegisteredSpoke(): Promise<boolean> {
|
|
|
73
75
|
return Boolean(row);
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Whether at least one unrevoked spoke is bound to this workspace (#7844 D7):
|
|
80
|
+
* the flow-copy tool's target check. Selects `id` only.
|
|
81
|
+
*/
|
|
82
|
+
export async function hasLiveSpokeForWorkspace(
|
|
83
|
+
workspaceId: string,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
const row = await prisma.spoke.findFirst({
|
|
86
|
+
where: { workspaceId, revokedAt: null },
|
|
87
|
+
select: { id: true },
|
|
88
|
+
});
|
|
89
|
+
return Boolean(row);
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
// ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
|
|
77
93
|
|
|
78
94
|
export interface SpokeListRow {
|
|
@@ -194,6 +194,8 @@ const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
|
194
194
|
|
|
195
195
|
type AgentFixture = {
|
|
196
196
|
id: string;
|
|
197
|
+
/** Absent → the row carries none (the double omits the column). */
|
|
198
|
+
grants?: string[];
|
|
197
199
|
enabled?: boolean;
|
|
198
200
|
keyIssuedToMembershipId?: string | null;
|
|
199
201
|
lastActiveAt?: Date | null;
|
|
@@ -215,6 +217,7 @@ function installAgents(agents: Array<AgentFixture & { key: string }>): void {
|
|
|
215
217
|
workspaceId: WS_A1,
|
|
216
218
|
organizationId: ORG_A,
|
|
217
219
|
permissionLevel: "member",
|
|
220
|
+
...(a.grants === undefined ? {} : { grants: a.grants }),
|
|
218
221
|
enabled: a.enabled ?? true,
|
|
219
222
|
keyIssuedToMembershipId:
|
|
220
223
|
a.keyIssuedToMembershipId === undefined ? ADMIN_MEMBERSHIP : a.keyIssuedToMembershipId,
|
|
@@ -298,6 +301,7 @@ describe("auth preHandler — agent branch (#3591)", () => {
|
|
|
298
301
|
organizationId: ORG_A,
|
|
299
302
|
responsibleMembershipId: PERSON_MEMBERSHIP,
|
|
300
303
|
permissionLevel: "member",
|
|
304
|
+
grants: [],
|
|
301
305
|
},
|
|
302
306
|
actor: { type: "agent", agentId: AGENT_A, onBehalfOfUserId: PERSON },
|
|
303
307
|
sessionTenant: null,
|
|
@@ -530,6 +530,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
530
530
|
workspaceId: string;
|
|
531
531
|
organizationId: string;
|
|
532
532
|
responsibleMembershipId: string;
|
|
533
|
+
grants: string[];
|
|
533
534
|
}> = {},
|
|
534
535
|
) {
|
|
535
536
|
return {
|
|
@@ -539,6 +540,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
539
540
|
responsibleMembershipId: (overrides.responsibleMembershipId ??
|
|
540
541
|
OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
|
|
541
542
|
permissionLevel: "member" as const,
|
|
543
|
+
grants: overrides.grants ?? [],
|
|
542
544
|
};
|
|
543
545
|
}
|
|
544
546
|
|
|
@@ -582,6 +584,26 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
|
|
|
582
584
|
await app.close();
|
|
583
585
|
});
|
|
584
586
|
|
|
587
|
+
it("a granted agent of the owner-operator carries its grants on the tenant; a forged one is dropped (#7864 D3)", async () => {
|
|
588
|
+
mockSingleTenantWorkspace();
|
|
589
|
+
mockOperatorMembership();
|
|
590
|
+
const app = buildAgentApp(
|
|
591
|
+
agentBinding({ grants: ["magnets-manage", "agents-manage", "content-edit"] }),
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const res = await app.inject({ method: "GET", url: "/scoped" });
|
|
595
|
+
|
|
596
|
+
expect(res.statusCode).toBe(200);
|
|
597
|
+
expect(res.json().tenant).toEqual({
|
|
598
|
+
userId: USER_ID,
|
|
599
|
+
organizationId: DEFAULT_ORG_ID,
|
|
600
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
601
|
+
role: "member",
|
|
602
|
+
grants: ["content-edit", "magnets-manage"],
|
|
603
|
+
});
|
|
604
|
+
await app.close();
|
|
605
|
+
});
|
|
606
|
+
|
|
585
607
|
it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
|
|
586
608
|
mockSingleTenantWorkspace();
|
|
587
609
|
mockOperatorMembership();
|
|
@@ -354,6 +354,9 @@ async function authenticateAgent(
|
|
|
354
354
|
organizationId: row.organizationId as Id<"organization">,
|
|
355
355
|
responsibleMembershipId: row.responsible.id as Id<"membership">,
|
|
356
356
|
permissionLevel: row.permissionLevel,
|
|
357
|
+
// #7864 D3 — the one place a missing value becomes none; the tenant
|
|
358
|
+
// guards keep only the grants its person's role also holds.
|
|
359
|
+
grants: row.grants ?? [],
|
|
357
360
|
};
|
|
358
361
|
bindActor(request, reply, {
|
|
359
362
|
type: "agent",
|
|
@@ -222,6 +222,29 @@ describe("GET /api/openapi.json", () => {
|
|
|
222
222
|
},
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
+
it("documents /v1/leads/submit's optional Idempotency-Key header and its 422 (#7869)", async () => {
|
|
226
|
+
const res = await app.inject({
|
|
227
|
+
method: "GET",
|
|
228
|
+
url: "/v1/openapi.json",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const spec = JSON.parse(res.body);
|
|
232
|
+
const post = spec.paths["/v1/leads/submit"].post;
|
|
233
|
+
expect(post.parameters).toEqual(
|
|
234
|
+
expect.arrayContaining([
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
name: "Idempotency-Key",
|
|
237
|
+
in: "header",
|
|
238
|
+
required: false,
|
|
239
|
+
}),
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
expect(
|
|
243
|
+
post.responses["422"].content["application/json"].schema.properties.error
|
|
244
|
+
.enum,
|
|
245
|
+
).toEqual(["idempotency_key_reused"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
225
248
|
it("no longer describes the API as read-only", async () => {
|
|
226
249
|
const res = await app.inject({
|
|
227
250
|
method: "GET",
|