create-ailk 0.12.1 → 0.14.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 (163) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +247 -33
  3. package/component-catalog.md +10 -6
  4. package/dist/apply-design.d.ts +21 -2
  5. package/dist/apply-design.js +120 -19
  6. package/dist/cli.js +1 -0
  7. package/dist/design-compile.d.ts +70 -2
  8. package/dist/design-compile.js +97 -1
  9. package/dist/design-home.d.ts +74 -0
  10. package/dist/design-home.js +256 -0
  11. package/dist/design-package.d.ts +39 -2
  12. package/dist/design-package.js +223 -9
  13. package/dist/design-presets.d.ts +17 -1
  14. package/dist/design-presets.js +17 -5
  15. package/dist/design-scaffold.d.ts +6 -25
  16. package/dist/design-scaffold.js +26 -74
  17. package/dist/index.d.ts +1 -0
  18. package/dist/lib/design-checks.d.ts +12 -1
  19. package/dist/lib/design-checks.js +38 -2
  20. package/dist/lib/module-license-gate.d.ts +9 -0
  21. package/dist/lib/module-license-gate.js +18 -0
  22. package/dist/module-architecture.js +27 -0
  23. package/dist/programmatic.js +70 -26
  24. package/dist/site-chrome.d.ts +38 -0
  25. package/dist/site-chrome.js +109 -2
  26. package/dist/site-fonts.d.ts +21 -0
  27. package/dist/site-fonts.js +54 -0
  28. package/dist/site-roster.d.ts +16 -0
  29. package/dist/site-roster.js +81 -0
  30. package/dist/starter-grid.js +15 -0
  31. package/dist/surfaces.js +12 -0
  32. package/dist/team-profiles.d.ts +29 -0
  33. package/dist/team-profiles.js +43 -0
  34. package/package.json +3 -3
  35. package/release-scope.json +1 -1
  36. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  37. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  38. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  39. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  40. package/site-templates/designs/minimalism/DESIGN.md +164 -34
  41. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  42. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  43. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  44. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  45. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  46. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  48. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  49. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  50. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  51. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  52. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  53. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  54. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  55. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  56. package/templates/.env.example +23 -9
  57. package/templates/README.md +2 -0
  58. package/templates/apps/api/CLAUDE.md +24 -17
  59. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  61. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  62. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  63. package/templates/apps/api/src/config/index.ts +35 -20
  64. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  65. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  66. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  67. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  68. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  69. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  70. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  71. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  72. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  74. package/templates/apps/api/src/middleware/auth.ts +219 -5
  75. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  76. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  77. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  78. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  79. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  80. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  81. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  82. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  83. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  84. package/templates/apps/api/src/server.ts +27 -0
  85. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  86. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  87. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  88. package/templates/apps/api/src/services/audit-log.ts +278 -47
  89. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  90. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  91. package/templates/apps/api/src/services/project-listings.ts +1 -21
  92. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  93. package/templates/apps/mcp/CLAUDE.md +44 -5
  94. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  95. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  96. package/templates/apps/mcp/package.json +1 -1
  97. package/templates/apps/mcp/src/http-transport.ts +107 -1
  98. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  99. package/templates/apps/web/.env.example +64 -37
  100. package/templates/apps/web/CLAUDE.md +11 -0
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  104. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  105. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  106. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  107. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +69 -0
  108. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +64 -0
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  112. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  113. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  114. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  115. package/templates/apps/web/app/[locale]/layout.tsx +156 -53
  116. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  117. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  118. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  119. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  120. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  121. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  122. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  124. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  125. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  126. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  127. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  128. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  129. package/templates/apps/web/app/not-found.tsx +47 -0
  130. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  131. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  132. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  133. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  134. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  135. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  136. package/templates/apps/web/lib/relay.ts +279 -0
  137. package/templates/apps/web/lib/site-chrome.ts +12 -0
  138. package/templates/apps/web/lib/site-fonts.ts +15 -0
  139. package/templates/apps/web/lib/site-logo.tsx +39 -1
  140. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  141. package/templates/apps/web/lib/site-theme.ts +13 -1
  142. package/templates/apps/web/messages/en.json +7 -1
  143. package/templates/apps/web/middleware.ts +5 -4
  144. package/templates/apps/web/next.config.mjs +6 -0
  145. package/templates/database/CHANGELOG.md +22 -0
  146. package/templates/database/__tests__/agents.db.test.ts +235 -0
  147. package/templates/database/auth/schema.prisma +61 -0
  148. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  149. package/templates/database/package.json +1 -1
  150. package/versions.json +35 -35
  151. package/site-templates/designs/minimalism/sample/README.md +0 -48
  152. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  153. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  154. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  155. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  156. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  157. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  158. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  159. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  160. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  161. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  162. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  163. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -0,0 +1,521 @@
1
+ /**
2
+ * The agents table — its ONE data-access boundary (#3591, spec
3
+ * docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md D1, D2, D12).
4
+ *
5
+ * An agent is a stored delegate of ONE person (its responsible `Membership`),
6
+ * bound to ONE workspace. It authenticates with a key sent as
7
+ * `Authorization: Bearer ailk_agt_…` and passes the same tenant guard a
8
+ * browser session does, through its person's live membership. This file is
9
+ * the only one that names `prisma.agent`.
10
+ *
11
+ * THE KEY IS NEVER STORED. The row keeps the lowercase hex SHA-256 of the
12
+ * trimmed key (`credentialHash`) and its first 17 characters
13
+ * (`credentialPrefix`: `ailk_agt_` plus 8 random ones) so an owner can
14
+ * recognise a key in a list. A fast digest is enough: the key carries 256
15
+ * bits of entropy, so a lookup by its unique digest leaks nothing usable —
16
+ * the spoke-key shape (`./spoke-store.ts`).
17
+ *
18
+ * NO READ TAKES A SCOPE OF ITS OWN. Every read is keyed by a key's digest, an
19
+ * agent id, or an organization id the CALLER passes from a granted
20
+ * `TenantContext`. `createAgent` derives the organization from the target
21
+ * workspace's own row and the membership from the organization's compound
22
+ * key, in one transaction, so a row's three references always agree
23
+ * (SEC-3591-15).
24
+ *
25
+ * Two kinds of caller, kept apart:
26
+ * - the auth preHandler (`middleware/auth.ts`) calls `hashAgentKey`,
27
+ * `findAgentByKeyHash` and `touchAgentLastActive` only;
28
+ * - the management routes (`routes/workspaces/agents.ts`) call the rest.
29
+ *
30
+ * Nothing caches a row: a switch-off, a re-key or a removed membership takes
31
+ * effect on the next request.
32
+ */
33
+ import { createHash, randomBytes } from "node:crypto";
34
+
35
+ import type { Id } from "@working-theory/ids";
36
+ import type { AgentPermissionLevel } from "@working-theory/validation";
37
+
38
+ import { prisma } from "./prisma.js";
39
+
40
+ /** Every agent key starts with this, so a leaked one is recognisable. */
41
+ export const AGENT_KEY_PREFIX = "ailk_agt_";
42
+
43
+ /** How much of the key the row keeps for recognition: the prefix + 8 characters. */
44
+ const CREDENTIAL_PREFIX_LENGTH = AGENT_KEY_PREFIX.length + 8;
45
+
46
+ /** A new key: `ailk_agt_` + 32 random bytes in base64url (52 characters). */
47
+ export function mintAgentKey(): string {
48
+ return `${AGENT_KEY_PREFIX}${randomBytes(32).toString("base64url")}`;
49
+ }
50
+
51
+ /** The stored digest of a key: lowercase hex SHA-256 of the TRIMMED value. */
52
+ export function hashAgentKey(key: string): string {
53
+ return createHash("sha256").update(key.trim()).digest("hex");
54
+ }
55
+
56
+ /** The digest and recognisable prefix a fresh key is stored as. */
57
+ function credentialFor(key: string) {
58
+ return {
59
+ credentialHash: hashAgentKey(key),
60
+ credentialPrefix: key.slice(0, CREDENTIAL_PREFIX_LENGTH),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * What the tenant guards need to know about an authenticated agent — set by
66
+ * the auth preHandler as `request.agent`. Never carries the key.
67
+ */
68
+ export interface AgentGrant {
69
+ agentId: Id<"agent">;
70
+ workspaceId: Id<"workspace">;
71
+ organizationId: Id<"organization">;
72
+ responsibleMembershipId: Id<"membership">;
73
+ permissionLevel: AgentPermissionLevel;
74
+ }
75
+
76
+ // ── The auth preHandler's three functions ────────────────────────────────────
77
+
78
+ export interface AgentKeyRow {
79
+ id: string;
80
+ workspaceId: string;
81
+ organizationId: string;
82
+ permissionLevel: AgentPermissionLevel;
83
+ enabled: boolean;
84
+ keyIssuedToMembershipId: string | null;
85
+ lastActiveAt: Date | null;
86
+ responsible: { id: string; userId: string };
87
+ }
88
+
89
+ /** The one read the auth preHandler makes: a `findUnique` by digest. */
90
+ export async function findAgentByKeyHash(
91
+ credentialHash: string,
92
+ ): Promise<AgentKeyRow | null> {
93
+ return prisma.agent.findUnique({
94
+ where: { credentialHash },
95
+ select: {
96
+ id: true,
97
+ workspaceId: true,
98
+ organizationId: true,
99
+ permissionLevel: true,
100
+ enabled: true,
101
+ keyIssuedToMembershipId: true,
102
+ lastActiveAt: true,
103
+ responsible: { select: { id: true, userId: true } },
104
+ },
105
+ });
106
+ }
107
+
108
+ /** Stamp the agent's `lastActiveAt` — best effort; the caller swallows a failure. */
109
+ export async function touchAgentLastActive(
110
+ agentId: string,
111
+ at: Date,
112
+ ): Promise<void> {
113
+ await prisma.agent.update({
114
+ where: { id: agentId },
115
+ data: { lastActiveAt: at },
116
+ select: { id: true },
117
+ });
118
+ }
119
+
120
+ // ── The management functions — scoped by the caller's organization ───────────
121
+
122
+ /** An agent as the management functions hand it back. Never the digest. */
123
+ export interface AgentRow {
124
+ id: string;
125
+ name: string;
126
+ organizationId: string;
127
+ workspaceId: string;
128
+ responsibleMembershipId: string;
129
+ permissionLevel: AgentPermissionLevel;
130
+ enabled: boolean;
131
+ lastActiveAt: Date | null;
132
+ createdAt: Date;
133
+ credentialPrefix: string;
134
+ createdByUserId: string;
135
+ keyIssuedToMembershipId: string | null;
136
+ }
137
+
138
+ const AGENT_ROW_SELECT = {
139
+ id: true,
140
+ name: true,
141
+ organizationId: true,
142
+ workspaceId: true,
143
+ responsibleMembershipId: true,
144
+ permissionLevel: true,
145
+ enabled: true,
146
+ lastActiveAt: true,
147
+ createdAt: true,
148
+ credentialPrefix: true,
149
+ createdByUserId: true,
150
+ keyIssuedToMembershipId: true,
151
+ } as const;
152
+
153
+ /** One body for every miss, so no caller can tell "absent" from "not yours". */
154
+ export type AgentNotFound = { ok: false; reason: "not-found" };
155
+ export type AgentWithKey = { ok: true; agent: AgentRow; key: string };
156
+
157
+ /** The client a management write runs on: the singleton, or a transaction's. */
158
+ type AgentDb = Pick<
159
+ typeof prisma,
160
+ "agent" | "membership" | "workspace" | "user"
161
+ >;
162
+
163
+ interface CreateAgentParams {
164
+ organizationId: string;
165
+ workspaceId: string;
166
+ responsibleUserId: string;
167
+ createdByUserId: string;
168
+ name: string;
169
+ }
170
+
171
+ interface ReplaceAgentKeyParams {
172
+ organizationId: string;
173
+ agentId: string;
174
+ issuedByUserId: string;
175
+ }
176
+
177
+ interface SetAgentEnabledParams {
178
+ organizationId: string;
179
+ agentId: string;
180
+ enabled: boolean;
181
+ }
182
+
183
+ /**
184
+ * Add an agent for `responsibleUserId`, bound to `workspaceId`. The returned
185
+ * `key` is the only copy that will ever exist: the caller shows it once.
186
+ *
187
+ * `organizationId` is the caller's granted `tenant.organizationId`. The
188
+ * workspace row must belong to it, and the responsible person and the
189
+ * creator must each hold a `Membership` in it — every miss is the same
190
+ * `not-found`. The creator's membership becomes `keyIssuedToMembershipId`:
191
+ * they are the one person who has seen the key.
192
+ */
193
+ export async function createAgent(
194
+ params: CreateAgentParams,
195
+ ): Promise<AgentWithKey | AgentNotFound> {
196
+ return prisma.$transaction((tx) => createAgentIn(tx, params));
197
+ }
198
+
199
+ /**
200
+ * Replace an agent's key. The old key is refused on its next request; the
201
+ * agent's id, name, person and history are kept. `issuedByUserId` — the
202
+ * owner or admin re-keying it — becomes the one person who has seen the key.
203
+ */
204
+ export async function replaceAgentKey(
205
+ params: ReplaceAgentKeyParams,
206
+ ): Promise<AgentWithKey | AgentNotFound> {
207
+ return prisma.$transaction(async (tx) => {
208
+ const key = await replaceAgentKeyIn(tx, params);
209
+ if (key === null) return { ok: false, reason: "not-found" } as const;
210
+ const agent = await tx.agent.findUnique({
211
+ where: { id: params.agentId },
212
+ select: AGENT_ROW_SELECT,
213
+ });
214
+ return agent
215
+ ? ({ ok: true, agent, key } as const)
216
+ : ({ ok: false, reason: "not-found" } as const);
217
+ });
218
+ }
219
+
220
+ /** Switch an agent off or back on. `null` when the organization has no such agent. */
221
+ export async function setAgentEnabled(
222
+ params: SetAgentEnabledParams,
223
+ ): Promise<AgentRow | null> {
224
+ if (!(await setAgentEnabledIn(prisma, params))) return null;
225
+ return prisma.agent.findUnique({
226
+ where: { id: params.agentId },
227
+ select: AGENT_ROW_SELECT,
228
+ });
229
+ }
230
+
231
+ // ── The management routes' writes — each returns the row it serves ──────────
232
+ //
233
+ // The route builds its response, and its audit row, from what these return.
234
+ // Each runs the write and the list-row read in ONE transaction, so a write
235
+ // that committed always comes back with its row: a re-key can never answer
236
+ // an error after the old key is already dead, nor leave a change the route
237
+ // could not record.
238
+
239
+ /** A management write's result in the list-row shape spec D11 serves. */
240
+ export type ServedAgent = { ok: true; agent: AgentListRow };
241
+ export type ServedAgentWithKey = { ok: true; agent: AgentListRow; key: string };
242
+
243
+ /** `createAgent`, answering with the list row. */
244
+ export async function createServedAgent(
245
+ params: CreateAgentParams,
246
+ ): Promise<ServedAgentWithKey | AgentNotFound> {
247
+ return prisma.$transaction(async (tx) => {
248
+ const created = await createAgentIn(tx, params);
249
+ if (!created.ok) return created;
250
+ return servedWith(tx, params.organizationId, created.agent.id, created.key);
251
+ });
252
+ }
253
+
254
+ /** `replaceAgentKey`, answering with the list row. */
255
+ export async function replaceServedAgentKey(
256
+ params: ReplaceAgentKeyParams,
257
+ ): Promise<ServedAgentWithKey | AgentNotFound> {
258
+ return prisma.$transaction(async (tx) => {
259
+ const key = await replaceAgentKeyIn(tx, params);
260
+ if (key === null) return { ok: false, reason: "not-found" } as const;
261
+ return servedWith(tx, params.organizationId, params.agentId, key);
262
+ });
263
+ }
264
+
265
+ /** `setAgentEnabled`, answering with the list row. */
266
+ export async function setServedAgentEnabled(
267
+ params: SetAgentEnabledParams,
268
+ ): Promise<ServedAgent | AgentNotFound> {
269
+ return prisma.$transaction(async (tx) => {
270
+ if (!(await setAgentEnabledIn(tx, params))) {
271
+ return { ok: false, reason: "not-found" } as const;
272
+ }
273
+ const [agent] = await readAgentListRows(
274
+ { id: params.agentId, organizationId: params.organizationId },
275
+ tx,
276
+ );
277
+ return agent
278
+ ? ({ ok: true, agent } as const)
279
+ : ({ ok: false, reason: "not-found" } as const);
280
+ });
281
+ }
282
+
283
+ /** The list row of a just-written agent, read on the write's own client. */
284
+ async function servedWith(
285
+ db: AgentDb,
286
+ organizationId: string,
287
+ agentId: string,
288
+ key: string,
289
+ ): Promise<ServedAgentWithKey | AgentNotFound> {
290
+ const [agent] = await readAgentListRows({ id: agentId, organizationId }, db);
291
+ return agent
292
+ ? ({ ok: true, agent, key } as const)
293
+ : ({ ok: false, reason: "not-found" } as const);
294
+ }
295
+
296
+ // ── The writes themselves, on a caller's client ──────────────────────────────
297
+
298
+ async function createAgentIn(
299
+ db: AgentDb,
300
+ params: CreateAgentParams,
301
+ ): Promise<AgentWithKey | AgentNotFound> {
302
+ const workspace = await db.workspace.findUnique({
303
+ where: { id: params.workspaceId },
304
+ select: { id: true, organizationId: true },
305
+ });
306
+ if (!workspace || workspace.organizationId !== params.organizationId) {
307
+ return { ok: false, reason: "not-found" } as const;
308
+ }
309
+
310
+ const responsible = await db.membership.findUnique({
311
+ where: {
312
+ organizationId_userId: {
313
+ organizationId: workspace.organizationId,
314
+ userId: params.responsibleUserId,
315
+ },
316
+ },
317
+ select: { id: true },
318
+ });
319
+ const issuer = await db.membership.findUnique({
320
+ where: {
321
+ organizationId_userId: {
322
+ organizationId: workspace.organizationId,
323
+ userId: params.createdByUserId,
324
+ },
325
+ },
326
+ select: { id: true },
327
+ });
328
+ if (!responsible || !issuer) {
329
+ return { ok: false, reason: "not-found" } as const;
330
+ }
331
+
332
+ const key = mintAgentKey();
333
+ const agent = await db.agent.create({
334
+ data: {
335
+ organizationId: workspace.organizationId,
336
+ workspaceId: workspace.id,
337
+ responsibleMembershipId: responsible.id,
338
+ keyIssuedToMembershipId: issuer.id,
339
+ createdByUserId: params.createdByUserId,
340
+ name: params.name,
341
+ ...credentialFor(key),
342
+ },
343
+ select: AGENT_ROW_SELECT,
344
+ });
345
+ return { ok: true, agent, key } as const;
346
+ }
347
+
348
+ /** The new key, or `null` when the scope has no such agent or no such issuer. */
349
+ async function replaceAgentKeyIn(
350
+ db: AgentDb,
351
+ params: ReplaceAgentKeyParams,
352
+ ): Promise<string | null> {
353
+ const issuer = await db.membership.findUnique({
354
+ where: {
355
+ organizationId_userId: {
356
+ organizationId: params.organizationId,
357
+ userId: params.issuedByUserId,
358
+ },
359
+ },
360
+ select: { id: true },
361
+ });
362
+ if (!issuer) return null;
363
+
364
+ const key = mintAgentKey();
365
+ const { count } = await db.agent.updateMany({
366
+ where: agentScope(params),
367
+ data: { ...credentialFor(key), keyIssuedToMembershipId: issuer.id },
368
+ });
369
+ return count === 1 ? key : null;
370
+ }
371
+
372
+ async function setAgentEnabledIn(
373
+ db: AgentDb,
374
+ params: SetAgentEnabledParams,
375
+ ): Promise<boolean> {
376
+ const { count } = await db.agent.updateMany({
377
+ where: agentScope(params),
378
+ data: { enabled: params.enabled },
379
+ });
380
+ return count === 1;
381
+ }
382
+
383
+ /** One agent, by id, inside the caller's organization — whichever workspace it is bound to. */
384
+ function agentScope(params: { organizationId: string; agentId: string }) {
385
+ return { id: params.agentId, organizationId: params.organizationId };
386
+ }
387
+
388
+ /** One row of the organization's agent list (the shape spec D11 serves). */
389
+ export interface AgentListRow {
390
+ id: string;
391
+ name: string;
392
+ workspaceId: string;
393
+ responsible: { userId: string; name: string | null; email: string };
394
+ permissionLevel: AgentPermissionLevel;
395
+ enabled: boolean;
396
+ lastActiveAt: Date | null;
397
+ createdAt: Date;
398
+ keyPrefix: string;
399
+ createdBy: { userId: string; name: string | null };
400
+ keyIssuedTo: { userId: string; name: string | null } | null;
401
+ }
402
+
403
+ /**
404
+ * Every agent of the organization, newest first — or, with `userId`, only
405
+ * that person's. Never selects the digest.
406
+ */
407
+ export async function listAgentsForOrganization(
408
+ organizationId: string,
409
+ userId?: string,
410
+ ): Promise<AgentListRow[]> {
411
+ return readAgentListRows(
412
+ userId ? { organizationId, responsible: { userId } } : { organizationId },
413
+ );
414
+ }
415
+
416
+ /**
417
+ * The list read. Every `where` carries the caller's `organizationId`.
418
+ * `createdBy` is named by a second read because `createdByUserId` carries no
419
+ * relation (D1).
420
+ */
421
+ async function readAgentListRows(
422
+ where:
423
+ | { organizationId: string; responsible?: { userId: string } }
424
+ | { id: string; organizationId: string },
425
+ db: AgentDb = prisma,
426
+ ): Promise<AgentListRow[]> {
427
+ const rows = await db.agent.findMany({
428
+ where,
429
+ select: {
430
+ id: true,
431
+ name: true,
432
+ workspaceId: true,
433
+ permissionLevel: true,
434
+ enabled: true,
435
+ lastActiveAt: true,
436
+ createdAt: true,
437
+ credentialPrefix: true,
438
+ createdByUserId: true,
439
+ responsible: {
440
+ select: { userId: true, user: { select: { name: true, email: true } } },
441
+ },
442
+ keyIssuedTo: {
443
+ select: { userId: true, user: { select: { name: true } } },
444
+ },
445
+ },
446
+ orderBy: { createdAt: "desc" },
447
+ });
448
+
449
+ const creatorIds = [...new Set(rows.map((r) => r.createdByUserId))];
450
+ const creators =
451
+ creatorIds.length === 0
452
+ ? []
453
+ : await db.user.findMany({
454
+ where: { id: { in: creatorIds } },
455
+ select: { id: true, name: true },
456
+ });
457
+ const creatorName = new Map(creators.map((u) => [u.id, u.name]));
458
+
459
+ return rows.map((r) => ({
460
+ id: r.id,
461
+ name: r.name,
462
+ workspaceId: r.workspaceId,
463
+ responsible: {
464
+ userId: r.responsible.userId,
465
+ name: r.responsible.user.name,
466
+ email: r.responsible.user.email,
467
+ },
468
+ permissionLevel: r.permissionLevel,
469
+ enabled: r.enabled,
470
+ lastActiveAt: r.lastActiveAt,
471
+ createdAt: r.createdAt,
472
+ keyPrefix: r.credentialPrefix,
473
+ createdBy: {
474
+ userId: r.createdByUserId,
475
+ name: creatorName.get(r.createdByUserId) ?? null,
476
+ },
477
+ keyIssuedTo: r.keyIssuedTo
478
+ ? { userId: r.keyIssuedTo.userId, name: r.keyIssuedTo.user.name }
479
+ : null,
480
+ }));
481
+ }
482
+
483
+ /** What an agent needs to scope its calls and copy its person on a reply. */
484
+ export interface AgentContext {
485
+ agent: {
486
+ id: string;
487
+ name: string;
488
+ workspaceId: string;
489
+ permissionLevel: AgentPermissionLevel;
490
+ };
491
+ responsible: { name: string | null; email: string };
492
+ }
493
+
494
+ /** The agent's own context, by its id (taken from `request.agent`, never the wire). */
495
+ export async function findAgentContext(
496
+ agentId: string,
497
+ ): Promise<AgentContext | null> {
498
+ const row = await prisma.agent.findUnique({
499
+ where: { id: agentId },
500
+ select: {
501
+ id: true,
502
+ name: true,
503
+ workspaceId: true,
504
+ permissionLevel: true,
505
+ responsible: { select: { user: { select: { name: true, email: true } } } },
506
+ },
507
+ });
508
+ if (!row) return null;
509
+ return {
510
+ agent: {
511
+ id: row.id,
512
+ name: row.name,
513
+ workspaceId: row.workspaceId,
514
+ permissionLevel: row.permissionLevel,
515
+ },
516
+ responsible: {
517
+ name: row.responsible.user.name,
518
+ email: row.responsible.user.email,
519
+ },
520
+ };
521
+ }
@@ -20,11 +20,12 @@
20
20
  *
21
21
  * Two kinds of caller, kept apart:
22
22
  * - the public resolver (`services/public-site-resolver.ts`) calls
23
- * `hashSpokeKey` and `findSpokeByKeyHash` only;
23
+ * `hashSpokeKey`, `findSpokeByKeyHash` and `hasRegisteredSpoke` only;
24
24
  * - the operator CLI (`bin/spokes.ts`) calls `registerSpoke`, `revokeSpoke`
25
25
  * and `listSpokes`. No route imports them.
26
26
  *
27
- * Nothing caches a row: a revocation takes effect on the next request (NFR4).
27
+ * Nothing here caches a row: a revocation takes effect on the next request
28
+ * (NFR4). The resolver holds `hasRegisteredSpoke`'s answer itself (#7633 D3).
28
29
  */
29
30
  import { createHash, randomBytes } from "node:crypto";
30
31
 
@@ -62,6 +63,16 @@ export async function findSpokeByKeyHash(
62
63
  });
63
64
  }
64
65
 
66
+ /**
67
+ * Whether this deployment is a hub: its registry holds at least one spoke,
68
+ * revoked or not (#7633 D1). Reads every time; the resolver holds the
69
+ * answer (D3).
70
+ */
71
+ export async function hasRegisteredSpoke(): Promise<boolean> {
72
+ const row = await prisma.spoke.findFirst({ select: { id: true } });
73
+ return Boolean(row);
74
+ }
75
+
65
76
  // ── Operator functions — `bin/spokes.ts` only ────────────────────────────────
66
77
 
67
78
  export interface SpokeListRow {