create-ailk 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +25 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/__tests__/build.test.ts +131 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +3 -3
- package/versions.json +34 -34
|
@@ -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 `
|
|
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
|
|
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 {
|