create-ailk 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -34
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file audit-log.ts
|
|
3
|
-
* @description The
|
|
4
|
-
*
|
|
5
|
-
* `ops.AuditLog`. Writes exactly one row per
|
|
6
|
-
*
|
|
7
|
-
* stored row.
|
|
3
|
+
* @description The audit writer (#6040, REL-017 row 5 / FEAT-084 SUB-096;
|
|
4
|
+
* reshaped by #3591 D8) — `recordAuditAction`, the one production write site
|
|
5
|
+
* for `ops.AuditLog`. Writes exactly one row per audited action: tenant,
|
|
6
|
+
* actor, action, and outcome, with personal data redacted before it can
|
|
7
|
+
* reach the stored row.
|
|
8
|
+
*
|
|
9
|
+
* ## WHO ACTED
|
|
10
|
+
*
|
|
11
|
+
* The caller names the actor: a person (`actorType: "user"`, `actorId` their
|
|
12
|
+
* user id), or an agent acting on behalf of a person (`actorType: "agent"`,
|
|
13
|
+
* `actorId` the agent's id, `metadata.onBehalfOf` the person's user id). The
|
|
14
|
+
* `action` column is `<domain>.<tool>` — `inbox.accept_message`,
|
|
15
|
+
* `magnets.magnet.create` — and never says who acted; that is the
|
|
16
|
+
* `actorType` column's job.
|
|
8
17
|
*
|
|
9
18
|
* ## AUDIT FAILURE DOES NOT BLOCK THE AUDITED ACTION
|
|
10
19
|
*
|
|
@@ -16,7 +25,7 @@
|
|
|
16
25
|
* about what happened. The compensating control is that the failure is
|
|
17
26
|
* loud: a typed `{ ok: false }` the caller can branch on, plus a
|
|
18
27
|
* `logger.error` line. Nothing in TypeScript FORCES a caller to read the
|
|
19
|
-
* result — `await
|
|
28
|
+
* result — `await recordAuditAction(input)` as a bare statement compiles
|
|
20
29
|
* and lints clean — so the caller issue (#6041 or SUB-097) owns branching on
|
|
21
30
|
* `ok` and raising its own alert; a lint/metric enforcing that belongs to
|
|
22
31
|
* whichever issue adds the first caller.
|
|
@@ -70,7 +79,7 @@
|
|
|
70
79
|
* — a caller may inject its own, e.g. an interactive `$transaction` client).
|
|
71
80
|
*/
|
|
72
81
|
|
|
73
|
-
import { createHash } from "node:crypto";
|
|
82
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
74
83
|
|
|
75
84
|
import type { Prisma } from "@working-theory/database";
|
|
76
85
|
import {
|
|
@@ -89,12 +98,20 @@ import { isUniqueViolation, type TenantContext } from "./default-tenant.js";
|
|
|
89
98
|
|
|
90
99
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
91
100
|
|
|
92
|
-
/** The full actor-kind vocabulary the model comments (not a DB CHECK).
|
|
93
|
-
* `"
|
|
94
|
-
*
|
|
101
|
+
/** The full actor-kind vocabulary the model comments (not a DB CHECK). This
|
|
102
|
+
* file writes `"user"` and `"agent"` (#3591 D8); `"system"` is kept so a
|
|
103
|
+
* future writer for that actor shares one union. */
|
|
95
104
|
export type AuditActorType = "user" | "agent" | "system";
|
|
96
105
|
|
|
97
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Who acted (#3591 D8) — the same shape as `request.actor`
|
|
108
|
+
* (`middleware/auth.ts`'s `RequestActor`), so a route passes it through.
|
|
109
|
+
*/
|
|
110
|
+
export type AuditActor =
|
|
111
|
+
| { type: "user"; userId: Id<"user"> }
|
|
112
|
+
| { type: "agent"; agentId: Id<"agent">; onBehalfOfUserId: Id<"user"> };
|
|
113
|
+
|
|
114
|
+
export type AuditActionOutcome =
|
|
98
115
|
"succeeded" | "failed" | "refused" | "awaiting_approval";
|
|
99
116
|
|
|
100
117
|
/** One row as this writer assembles it — never re-exported; a caller sees
|
|
@@ -104,10 +121,12 @@ export type AgentActionOutcome =
|
|
|
104
121
|
* Prisma's `InputJsonValue` under `exactOptionalPropertyTypes` — an
|
|
105
122
|
* `unknown`-valued record does not. */
|
|
106
123
|
interface AuditLogMetadata {
|
|
107
|
-
v:
|
|
108
|
-
outcome:
|
|
124
|
+
v: 2;
|
|
125
|
+
outcome: AuditActionOutcome;
|
|
109
126
|
tool: string;
|
|
110
127
|
idempotencyKey: string;
|
|
128
|
+
/** The person an agent acted for (#3591 D8) — present only on an agent row. */
|
|
129
|
+
onBehalfOf?: string;
|
|
111
130
|
summary?: string;
|
|
112
131
|
failureReason?: string;
|
|
113
132
|
}
|
|
@@ -146,16 +165,22 @@ export interface AuditWriteClient {
|
|
|
146
165
|
* its constraint is an `invalid_input` refusal, never a truncation or a
|
|
147
166
|
* silent coercion — a coerced audit row is a wrong audit row.
|
|
148
167
|
*/
|
|
149
|
-
export interface
|
|
168
|
+
export interface RecordAuditActionInput {
|
|
150
169
|
/** MUST come from `resolveActiveTenant` / `resolveDefaultTenant` — never
|
|
151
170
|
* assembled from request input. See the file docblock. */
|
|
152
171
|
tenant: TenantContext;
|
|
153
|
-
/** <=128 chars,
|
|
154
|
-
|
|
172
|
+
/** Who acted — `request.actor`. Each id it carries is <=128 chars,
|
|
173
|
+
* `/^[A-Za-z0-9_:.-]+$/`, then personal-data-scanned unless it is a
|
|
174
|
+
* registry-minted `user_` or `agent_` id. */
|
|
175
|
+
actor: AuditActor;
|
|
176
|
+
/** <=32 chars, `/^[a-z][a-z0-9_]*$/` — the area the action belongs to
|
|
177
|
+
* (`"inbox"`, `"magnets"`, `"scheduling"`), the first segment of
|
|
178
|
+
* `action`. */
|
|
179
|
+
domain: string;
|
|
155
180
|
/** <=64 chars, `/^[a-z0-9_.-]+$/` — an identifier, so `action` cannot
|
|
156
181
|
* carry prose. */
|
|
157
182
|
tool: string;
|
|
158
|
-
outcome:
|
|
183
|
+
outcome: AuditActionOutcome;
|
|
159
184
|
/** <=256 chars, `/^[A-Za-z0-9_:.-]+$/`, then personal-data-scanned.
|
|
160
185
|
* REQUIRED — an optional key would silently degrade to "no dedup" for a
|
|
161
186
|
* caller who forgets it. Stable across a replay of the same logical
|
|
@@ -185,7 +210,7 @@ export interface RecordAgentActionInput {
|
|
|
185
210
|
targetId?: string;
|
|
186
211
|
}
|
|
187
212
|
|
|
188
|
-
export type
|
|
213
|
+
export type RecordAuditActionResult =
|
|
189
214
|
| { ok: true; outcome: "recorded" | "duplicate"; id: Id<"auditLog"> }
|
|
190
215
|
| {
|
|
191
216
|
ok: false;
|
|
@@ -211,10 +236,11 @@ export interface AuditRowDigestInput {
|
|
|
211
236
|
workspaceId: string;
|
|
212
237
|
actorId: string;
|
|
213
238
|
actorType: AuditActorType;
|
|
239
|
+
onBehalfOf: string | undefined;
|
|
214
240
|
action: string;
|
|
215
241
|
targetType: string | undefined;
|
|
216
242
|
targetId: string | undefined;
|
|
217
|
-
outcome:
|
|
243
|
+
outcome: AuditActionOutcome;
|
|
218
244
|
summary: string | undefined;
|
|
219
245
|
failureReason: string | undefined;
|
|
220
246
|
idempotencyKey: string;
|
|
@@ -228,13 +254,16 @@ export interface AuditRowDigestInput {
|
|
|
228
254
|
const MAX_TEXT = 2_000;
|
|
229
255
|
|
|
230
256
|
/** An MCP tool name is an identifier — this charset cannot express an `@`,
|
|
231
|
-
* and it bounds the `action` column (`"
|
|
257
|
+
* and it bounds the `action` column (`domain + "." + tool`). */
|
|
232
258
|
const TOOL_RE = /^[a-z0-9_.-]{1,64}$/;
|
|
233
259
|
|
|
260
|
+
/** The action's first segment — `"inbox"`, `"magnets"`, `"scheduling"`. */
|
|
261
|
+
const DOMAIN_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
262
|
+
|
|
234
263
|
/** The schema's own examples are `"workspace"`, `"lead"`, `"user"`. */
|
|
235
264
|
const TARGET_TYPE_RE = /^[a-z][a-z0-9_]{0,31}$/;
|
|
236
265
|
|
|
237
|
-
/**
|
|
266
|
+
/** An actor id's shape is open (Q2) — any identifier, registry-minted or not. */
|
|
238
267
|
const ACTOR_ID_RE = /^[A-Za-z0-9_:.-]{1,128}$/;
|
|
239
268
|
|
|
240
269
|
/** `targetId` when `targetType` does not name a known registry entity. */
|
|
@@ -271,6 +300,24 @@ const PHONE_RE = /\+?\d(?:[ .-]?\d){6,14}/g;
|
|
|
271
300
|
* runs are already consumed above; this is the residual). */
|
|
272
301
|
const LONG_DIGIT_RUN_RE = /\d{9,}/g;
|
|
273
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Where free text splits into tokens for the registry-id exemption below:
|
|
305
|
+
* whitespace and `=` (so `responsibleUserId=<id>` holds the id as a token).
|
|
306
|
+
*/
|
|
307
|
+
const FREE_TEXT_TOKEN_SEPARATOR_RE = /(\s+|=)/;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether a free-text token is EXACTLY a registry-minted `user_` or `agent_`
|
|
311
|
+
* id (#3591 D8) — the same anchored pattern `actorIdRefusal` exempts. Such an
|
|
312
|
+
* id's hex can hold a digit run the phone pattern flags (about one id in
|
|
313
|
+
* three), which would turn a `summary` that names a person by id — the agent
|
|
314
|
+
* lifecycle rows' "for whom" — into `[REDACTED:phone]`. A token that merely
|
|
315
|
+
* contains one, or only looks like one, is scanned like any other text.
|
|
316
|
+
*/
|
|
317
|
+
function isRegistryActorId(token: string): boolean {
|
|
318
|
+
return idRegex("user").test(token) || idRegex("agent").test(token);
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
const EMAIL_MARKER = "[REDACTED:email]";
|
|
275
322
|
const PHONE_MARKER = "[REDACTED:phone]";
|
|
276
323
|
const NUMBER_MARKER = "[REDACTED:number]";
|
|
@@ -292,8 +339,8 @@ function isKnownEntity(value: string): value is Entity {
|
|
|
292
339
|
* `redactHighEntropy` first (catches a live secret pasted into
|
|
293
340
|
* `failureReason` — the natural home for provider error text), then applies
|
|
294
341
|
* the three anchored, bounded personal-data patterns. Exported — directly
|
|
295
|
-
* unit-testable, and reused (as a detection predicate) for
|
|
296
|
-
* `idempotencyKey` below.
|
|
342
|
+
* unit-testable, and reused (as a detection predicate) for the actor's ids
|
|
343
|
+
* and `idempotencyKey` below.
|
|
297
344
|
*
|
|
298
345
|
* What this does NOT catch, stated rather than implied: a postal address or
|
|
299
346
|
* a personal name in free text (no achievable regex exists across locales),
|
|
@@ -318,9 +365,31 @@ export function redactPersonalData(text: string): string {
|
|
|
318
365
|
return out;
|
|
319
366
|
}
|
|
320
367
|
|
|
368
|
+
/**
|
|
369
|
+
* `summary` / `failureReason`'s redaction: `redactPersonalData`, except that a
|
|
370
|
+
* token which is exactly a registry `user_` / `agent_` id is kept whole (see
|
|
371
|
+
* `isRegistryActorId`). The text between such tokens is scanned as ONE run,
|
|
372
|
+
* separators included, so a phone number written with spaces is still caught.
|
|
373
|
+
* Identifier fields are not routed here — an actor id has its own exemption,
|
|
374
|
+
* and `idempotencyKey` stays scanned.
|
|
375
|
+
*/
|
|
376
|
+
function redactFreeText(text: string): string {
|
|
377
|
+
let out = "";
|
|
378
|
+
let run = "";
|
|
379
|
+
for (const part of text.normalize("NFKC").split(FREE_TEXT_TOKEN_SEPARATOR_RE)) {
|
|
380
|
+
if (isRegistryActorId(part)) {
|
|
381
|
+
out += redactPersonalData(run) + part;
|
|
382
|
+
run = "";
|
|
383
|
+
} else {
|
|
384
|
+
run += part;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out + redactPersonalData(run);
|
|
388
|
+
}
|
|
389
|
+
|
|
321
390
|
/** True when `redactPersonalData` would change `text` — used to REFUSE
|
|
322
391
|
* (never silently rewrite) an identifier-shaped field that happens to
|
|
323
|
-
* contain a personal-data-shaped substring (
|
|
392
|
+
* contain a personal-data-shaped substring (an actor id, `idempotencyKey`). */
|
|
324
393
|
function personalDataDetected(text: string): boolean {
|
|
325
394
|
// Checked by MARKER PRESENCE, not by inequality against the input — an
|
|
326
395
|
// inequality check is a call-order-dependent accident: NFKC alone can
|
|
@@ -365,6 +434,7 @@ export function deriveAuditRowId(input: AuditRowDigestInput): string {
|
|
|
365
434
|
input.workspaceId,
|
|
366
435
|
input.actorId,
|
|
367
436
|
input.actorType,
|
|
437
|
+
input.onBehalfOf,
|
|
368
438
|
input.action,
|
|
369
439
|
input.targetType,
|
|
370
440
|
input.targetId,
|
|
@@ -417,6 +487,8 @@ function classifyError(error: unknown): { code: string; name: string } {
|
|
|
417
487
|
interface ValidatedFields {
|
|
418
488
|
workspaceId: string;
|
|
419
489
|
actorId: string;
|
|
490
|
+
actorType: "user" | "agent";
|
|
491
|
+
onBehalfOf: string | undefined;
|
|
420
492
|
tool: string;
|
|
421
493
|
targetType: string | undefined;
|
|
422
494
|
targetId: string | undefined;
|
|
@@ -445,34 +517,60 @@ function validateFreeText(
|
|
|
445
517
|
message: "contains a NUL or other C0 control character",
|
|
446
518
|
};
|
|
447
519
|
}
|
|
448
|
-
return { ok: true, value:
|
|
520
|
+
return { ok: true, value: redactFreeText(text) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* An actor's id: the identifier grammar, then the personal-data scan — except
|
|
525
|
+
* for a registry-minted `user_` or `agent_` id, which is an identifier, not
|
|
526
|
+
* personal data. Its hex can still hold a digit run the phone pattern flags
|
|
527
|
+
* (about one id in three), which silently dropped real actors' rows (#7636
|
|
528
|
+
* D7, security review finding 1; #3591 D8). Every other id is still scanned.
|
|
529
|
+
* Returns the refusal message, or `undefined` when the id is admitted.
|
|
530
|
+
*/
|
|
531
|
+
function actorIdRefusal(id: string, field: string): string | undefined {
|
|
532
|
+
if (!ACTOR_ID_RE.test(id)) {
|
|
533
|
+
return `${field} does not match the required identifier grammar`;
|
|
534
|
+
}
|
|
535
|
+
const registryId = idRegex("user").test(id) || idRegex("agent").test(id);
|
|
536
|
+
if (!registryId && personalDataDetected(id)) {
|
|
537
|
+
return `${field} appears to carry personal data`;
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
449
540
|
}
|
|
450
541
|
|
|
451
542
|
/**
|
|
452
543
|
* Validates every string member before it is digested or written (F9).
|
|
453
544
|
* Identifier-shaped members are REFUSED on a constraint violation, never
|
|
454
|
-
* coerced — a coerced audit row is a wrong audit row (D5).
|
|
455
|
-
* `idempotencyKey` additionally fail the personal-data scan as a REFUSAL
|
|
545
|
+
* coerced — a coerced audit row is a wrong audit row (D5). The actor's ids
|
|
546
|
+
* and `idempotencyKey` additionally fail the personal-data scan as a REFUSAL
|
|
456
547
|
* (never a silent rewrite of an identifier field) rather than as a
|
|
457
548
|
* redaction, unlike `summary` / `failureReason`.
|
|
458
549
|
*/
|
|
459
|
-
function validateInput(input:
|
|
550
|
+
function validateInput(input: RecordAuditActionInput): ValidationResult {
|
|
460
551
|
if (!zWorkspaceId.safeParse(input.tenant.workspaceId).success) {
|
|
461
552
|
return {
|
|
462
553
|
ok: false,
|
|
463
554
|
message: "tenant.workspaceId is not a valid workspace id",
|
|
464
555
|
};
|
|
465
556
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
};
|
|
557
|
+
const { actor } = input;
|
|
558
|
+
const actorId = actor.type === "agent" ? actor.agentId : actor.userId;
|
|
559
|
+
const actorRefusal = actorIdRefusal(actorId, "actorId");
|
|
560
|
+
if (actorRefusal !== undefined) {
|
|
561
|
+
return { ok: false, message: actorRefusal };
|
|
471
562
|
}
|
|
472
|
-
|
|
563
|
+
const onBehalfOf = actor.type === "agent" ? actor.onBehalfOfUserId : undefined;
|
|
564
|
+
if (onBehalfOf !== undefined) {
|
|
565
|
+
const onBehalfOfRefusal = actorIdRefusal(onBehalfOf, "onBehalfOf");
|
|
566
|
+
if (onBehalfOfRefusal !== undefined) {
|
|
567
|
+
return { ok: false, message: onBehalfOfRefusal };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (!DOMAIN_RE.test(input.domain)) {
|
|
473
571
|
return {
|
|
474
572
|
ok: false,
|
|
475
|
-
message: "
|
|
573
|
+
message: "domain does not match the required identifier grammar",
|
|
476
574
|
};
|
|
477
575
|
}
|
|
478
576
|
if (!TOOL_RE.test(input.tool)) {
|
|
@@ -531,7 +629,9 @@ function validateInput(input: RecordAgentActionInput): ValidationResult {
|
|
|
531
629
|
ok: true,
|
|
532
630
|
value: {
|
|
533
631
|
workspaceId: input.tenant.workspaceId,
|
|
534
|
-
actorId
|
|
632
|
+
actorId,
|
|
633
|
+
actorType: actor.type,
|
|
634
|
+
onBehalfOf,
|
|
535
635
|
tool: input.tool,
|
|
536
636
|
targetType: input.targetType,
|
|
537
637
|
targetId: input.targetId,
|
|
@@ -541,18 +641,39 @@ function validateInput(input: RecordAgentActionInput): ValidationResult {
|
|
|
541
641
|
};
|
|
542
642
|
}
|
|
543
643
|
|
|
644
|
+
/**
|
|
645
|
+
* One fresh, process-independent audit idempotency key (#6231; moved here
|
|
646
|
+
* from `routes/magnets/index.ts` so every route writer shares it, #7636): a
|
|
647
|
+
* `crypto.randomUUID()` with its dashes removed and its 32 hex characters
|
|
648
|
+
* regrouped into runs of six joined by `_` (e.g.
|
|
649
|
+
* `e4a940_bf8d33_4c2281_4b1b27_4461cd_19`).
|
|
650
|
+
*
|
|
651
|
+
* The regrouping is load-bearing, not cosmetic. `recordAuditAction` REFUSES
|
|
652
|
+
* an `idempotencyKey` its personal-data scan flags — a refusal writes no row
|
|
653
|
+
* at all — and that scan's phone pattern treats `-` as a digit separator,
|
|
654
|
+
* so a raw dashed uuid whose hex happens to be digit-heavy reads as a phone
|
|
655
|
+
* number: measured over 200k uuids, 37% are refused. `_` is not one of that
|
|
656
|
+
* pattern's separators (and is inside the key's allowed charset), so
|
|
657
|
+
* grouping caps every digit run at six — under the phone pattern's 7-digit
|
|
658
|
+
* floor and the long-digit-run pattern's 9 — and no generated key can be
|
|
659
|
+
* refused. Entropy is untouched: the same 128 bits, re-spelled.
|
|
660
|
+
*/
|
|
661
|
+
export function freshAuditIdempotencyKey(): string {
|
|
662
|
+
return randomUUID().replace(/-/g, "").replace(/(.{6})(?=.)/g, "$1_");
|
|
663
|
+
}
|
|
664
|
+
|
|
544
665
|
// ─── The writer ─────────────────────────────────────────────────────────────
|
|
545
666
|
|
|
546
667
|
/**
|
|
547
|
-
* Writes exactly one `audit_logs` row for one
|
|
668
|
+
* Writes exactly one `audit_logs` row for one audited action. Never throws;
|
|
548
669
|
* never blocks the caller's action (see the file docblock heading). Not
|
|
549
670
|
* gated on the `commerce` license claim (see the file docblock) — gating
|
|
550
671
|
* stays upstream, at the caller's own claim check.
|
|
551
672
|
*/
|
|
552
|
-
export async function
|
|
553
|
-
input:
|
|
673
|
+
export async function recordAuditAction(
|
|
674
|
+
input: RecordAuditActionInput,
|
|
554
675
|
db: AuditWriteClient = prisma,
|
|
555
|
-
): Promise<
|
|
676
|
+
): Promise<RecordAuditActionResult> {
|
|
556
677
|
const validated = validateInput(input);
|
|
557
678
|
if (!validated.ok) {
|
|
558
679
|
return {
|
|
@@ -568,19 +689,21 @@ export async function recordAgentAction(
|
|
|
568
689
|
const {
|
|
569
690
|
workspaceId,
|
|
570
691
|
actorId,
|
|
692
|
+
actorType,
|
|
693
|
+
onBehalfOf,
|
|
571
694
|
tool,
|
|
572
695
|
targetType,
|
|
573
696
|
targetId,
|
|
574
697
|
redactedSummary,
|
|
575
698
|
redactedFailureReason,
|
|
576
699
|
} = validated.value;
|
|
577
|
-
const
|
|
578
|
-
const action = `agent.commerce.${tool}`;
|
|
700
|
+
const action = `${input.domain}.${tool}`;
|
|
579
701
|
|
|
580
702
|
const id = deriveAuditRowId({
|
|
581
703
|
workspaceId,
|
|
582
704
|
actorId,
|
|
583
705
|
actorType,
|
|
706
|
+
onBehalfOf,
|
|
584
707
|
action,
|
|
585
708
|
targetType,
|
|
586
709
|
targetId,
|
|
@@ -603,17 +726,18 @@ export async function recordAgentAction(
|
|
|
603
726
|
ok: false,
|
|
604
727
|
error: {
|
|
605
728
|
kind: "write_failed",
|
|
606
|
-
message: "
|
|
729
|
+
message: "audit row write failed",
|
|
607
730
|
persisted: false,
|
|
608
731
|
},
|
|
609
732
|
};
|
|
610
733
|
}
|
|
611
734
|
|
|
612
735
|
const metadata: AuditLogMetadata = {
|
|
613
|
-
v:
|
|
736
|
+
v: 2,
|
|
614
737
|
outcome: input.outcome,
|
|
615
738
|
tool,
|
|
616
739
|
idempotencyKey: input.idempotencyKey,
|
|
740
|
+
...(onBehalfOf !== undefined && { onBehalfOf }),
|
|
617
741
|
...(redactedSummary !== undefined && { summary: redactedSummary }),
|
|
618
742
|
...(redactedFailureReason !== undefined && {
|
|
619
743
|
failureReason: redactedFailureReason,
|
|
@@ -645,7 +769,7 @@ export async function recordAgentAction(
|
|
|
645
769
|
// no read is needed to report it.
|
|
646
770
|
logger.warn(
|
|
647
771
|
{ workspaceId, action, actorType, auditLogId: id },
|
|
648
|
-
"[audit-log] duplicate
|
|
772
|
+
"[audit-log] duplicate audit row suppressed by primary key",
|
|
649
773
|
);
|
|
650
774
|
return { ok: true, outcome: "duplicate", id: id as Id<"auditLog"> };
|
|
651
775
|
}
|
|
@@ -654,15 +778,122 @@ export async function recordAgentAction(
|
|
|
654
778
|
// argument IS the audit row (N7, D10).
|
|
655
779
|
logger.error(
|
|
656
780
|
{ workspaceId, action, actorType, errorCode: code, errorName: name },
|
|
657
|
-
"[audit-log]
|
|
781
|
+
"[audit-log] audit row write failed",
|
|
658
782
|
);
|
|
659
783
|
return {
|
|
660
784
|
ok: false,
|
|
661
785
|
error: {
|
|
662
786
|
kind: "write_failed",
|
|
663
|
-
message: "
|
|
787
|
+
message: "audit row write failed",
|
|
664
788
|
persisted: false,
|
|
665
789
|
},
|
|
666
790
|
};
|
|
667
791
|
}
|
|
668
792
|
}
|
|
793
|
+
|
|
794
|
+
// ─── Route helpers (#3591 D8) ───────────────────────────────────────────────
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The actor a route records: `request.actor`, which the auth preHandler sets
|
|
798
|
+
* on every authenticated request. A route reached without it (a harness that
|
|
799
|
+
* seats `request.tenant` directly) is filed as the tenant's own person, with a
|
|
800
|
+
* `warn` — an auth path that forgot to bind the actor must not pass silently.
|
|
801
|
+
*/
|
|
802
|
+
export function auditActorFor(
|
|
803
|
+
actor: AuditActor | undefined,
|
|
804
|
+
tenant: TenantContext,
|
|
805
|
+
): AuditActor {
|
|
806
|
+
if (actor) return actor;
|
|
807
|
+
logger.warn(
|
|
808
|
+
{ workspaceId: tenant.workspaceId },
|
|
809
|
+
"[audit-log] no request actor; filing the tenant's person",
|
|
810
|
+
);
|
|
811
|
+
return { type: "user", userId: tenant.userId };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** A service call's result, as far as the audit row needs it. */
|
|
815
|
+
export type AuditedResult = { ok: true } | { ok: false; error: { kind: string } };
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Who a route says acted, as far as `recordAgentWrite` reads it: the request
|
|
819
|
+
* itself (`request.actor`, and `request.agent` on the agent path), or — the
|
|
820
|
+
* older call shape — `request.actor` alone.
|
|
821
|
+
*/
|
|
822
|
+
export type AgentWriteCaller =
|
|
823
|
+
| AuditActor
|
|
824
|
+
| undefined
|
|
825
|
+
| { actor?: AuditActor; agent?: { agentId: Id<"agent"> } };
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* #3591 D8 — one row for an agent's inbox write, recorded after the write
|
|
829
|
+
* returns: `succeeded` when it did, else `failed` with the service's error
|
|
830
|
+
* kind. A person's write records nothing (spec § 2.3: a person's inbox
|
|
831
|
+
* actions are not audited). A failed audit write is logged at `error` once —
|
|
832
|
+
* by the writer for a failed insert, here for a refused row — and returns
|
|
833
|
+
* normally, so it never changes the caller's response.
|
|
834
|
+
*
|
|
835
|
+
* Pass the request, not `request.actor` alone, so an agent write cannot go
|
|
836
|
+
* unrecorded in silence: a request with `request.agent` but no
|
|
837
|
+
* `request.actor` (an auth path that forgot to bind the actor) is logged at
|
|
838
|
+
* `error` and still recorded, from `request.agent`. Its person is
|
|
839
|
+
* `tenant.userId`: on the agent path the tenant guard grants the workspace
|
|
840
|
+
* through the agent's responsible membership, so the tenant's person IS the
|
|
841
|
+
* person the agent acts for — read from the grant, not guessed.
|
|
842
|
+
*/
|
|
843
|
+
export async function recordAgentWrite(
|
|
844
|
+
caller: AgentWriteCaller,
|
|
845
|
+
input: Omit<
|
|
846
|
+
RecordAuditActionInput,
|
|
847
|
+
"actor" | "outcome" | "failureReason" | "idempotencyKey"
|
|
848
|
+
>,
|
|
849
|
+
result: AuditedResult,
|
|
850
|
+
db: AuditWriteClient = prisma,
|
|
851
|
+
): Promise<void> {
|
|
852
|
+
const actor = agentWriteActor(caller, input);
|
|
853
|
+
if (actor?.type !== "agent") return;
|
|
854
|
+
const audit = await recordAuditAction(
|
|
855
|
+
{
|
|
856
|
+
...input,
|
|
857
|
+
actor,
|
|
858
|
+
idempotencyKey: freshAuditIdempotencyKey(),
|
|
859
|
+
...(result.ok
|
|
860
|
+
? { outcome: "succeeded" as const }
|
|
861
|
+
: { outcome: "failed" as const, failureReason: result.error.kind }),
|
|
862
|
+
},
|
|
863
|
+
db,
|
|
864
|
+
);
|
|
865
|
+
if (!audit.ok && audit.error.kind === "invalid_input") {
|
|
866
|
+
logger.error(
|
|
867
|
+
{
|
|
868
|
+
workspaceId: input.tenant.workspaceId,
|
|
869
|
+
action: `${input.domain}.${input.tool}`,
|
|
870
|
+
actorId: actor.agentId,
|
|
871
|
+
error: audit.error,
|
|
872
|
+
},
|
|
873
|
+
"[audit-log] agent write audit refused",
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** The actor `recordAgentWrite` files — see its docblock for the fallback. */
|
|
879
|
+
function agentWriteActor(
|
|
880
|
+
caller: AgentWriteCaller,
|
|
881
|
+
input: { tenant: TenantContext; domain: string; tool: string },
|
|
882
|
+
): AuditActor | undefined {
|
|
883
|
+
if (caller === undefined || "type" in caller) return caller;
|
|
884
|
+
if (caller.actor) return caller.actor;
|
|
885
|
+
if (!caller.agent) return undefined;
|
|
886
|
+
logger.error(
|
|
887
|
+
{
|
|
888
|
+
workspaceId: input.tenant.workspaceId,
|
|
889
|
+
action: `${input.domain}.${input.tool}`,
|
|
890
|
+
actorId: caller.agent.agentId,
|
|
891
|
+
},
|
|
892
|
+
"[audit-log] agent write without request.actor; recording from request.agent",
|
|
893
|
+
);
|
|
894
|
+
return {
|
|
895
|
+
type: "agent",
|
|
896
|
+
agentId: caller.agent.agentId,
|
|
897
|
+
onBehalfOfUserId: input.tenant.userId,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
@@ -43,7 +43,9 @@ import {
|
|
|
43
43
|
resolveDefaultWorkspaceRow as resolveDefaultWorkspaceRowShared,
|
|
44
44
|
} from "@working-theory/database/tenancy";
|
|
45
45
|
import type { Id } from "@working-theory/ids";
|
|
46
|
+
import { lowerRole } from "@working-theory/validation";
|
|
46
47
|
|
|
48
|
+
import type { AgentGrant } from "../lib/agent-store.js";
|
|
47
49
|
import { prisma } from "../lib/prisma.js";
|
|
48
50
|
|
|
49
51
|
import { findVerifiedEmail } from "./verified-email.js";
|
|
@@ -204,6 +206,40 @@ export async function resolveDefaultTenantForOperator(
|
|
|
204
206
|
return (await isPinnedOperator(userId)) ? resolveDefaultTenant(userId) : null;
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The base-build grant for an AGENT (#3591 spec D5, D7). The same steps as
|
|
211
|
+
* `resolveDefaultTenantForOperator` — the pin check, then the default
|
|
212
|
+
* resolution and its one Membership read — so an agent and its person's
|
|
213
|
+
* session drive the same reads. On top of them: the resolved workspace must
|
|
214
|
+
* be the one the agent is bound to, and the Membership the read found must be
|
|
215
|
+
* the one the agent was created under. The role is then clamped to the
|
|
216
|
+
* agent's ceiling (`lowerRole`), so the operator's agent acts as a member.
|
|
217
|
+
*
|
|
218
|
+
* The membership id is compared here rather than carried on `TenantContext`,
|
|
219
|
+
* which stays one shape for every caller. On a base build only the pinned,
|
|
220
|
+
* verified operator holds a membership this admits, so only the operator's
|
|
221
|
+
* agents, bound to the default workspace, ever pass.
|
|
222
|
+
*/
|
|
223
|
+
export async function resolveDefaultTenantForAgent(
|
|
224
|
+
userId: Id<"user">,
|
|
225
|
+
agent: AgentGrant,
|
|
226
|
+
): Promise<TenantContext | null> {
|
|
227
|
+
if (!(await isPinnedOperator(userId))) return null;
|
|
228
|
+
const granted = await resolveDefaultGrant(userId);
|
|
229
|
+
if (
|
|
230
|
+
!granted ||
|
|
231
|
+
granted.tenant.workspaceId !== agent.workspaceId ||
|
|
232
|
+
granted.tenant.organizationId !== agent.organizationId ||
|
|
233
|
+
granted.membershipId !== agent.responsibleMembershipId
|
|
234
|
+
) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
...granted.tenant,
|
|
239
|
+
role: lowerRole(granted.tenant.role, agent.permissionLevel),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
207
243
|
/**
|
|
208
244
|
* The one pin-match predicate in `apps/api` (#7523 D2): `true` only when
|
|
209
245
|
* `AILK_OPERATOR_EMAIL` is set and the caller's VERIFIED `User.email` equals
|
|
@@ -297,6 +333,17 @@ export async function resolveDefaultWorkspaceId(): Promise<Id<"workspace"> | nul
|
|
|
297
333
|
export async function resolveDefaultTenant(
|
|
298
334
|
userId: Id<"user">,
|
|
299
335
|
): Promise<TenantContext | null> {
|
|
336
|
+
return (await resolveDefaultGrant(userId))?.tenant ?? null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* `resolveDefaultTenant`'s body, also handing back the id of the Membership
|
|
341
|
+
* it read, which the agent path compares (#3591 D5). One read serves both:
|
|
342
|
+
* its `select` is widened from `{ role }` to `{ id, role }`.
|
|
343
|
+
*/
|
|
344
|
+
async function resolveDefaultGrant(
|
|
345
|
+
userId: Id<"user">,
|
|
346
|
+
): Promise<{ tenant: TenantContext; membershipId: string } | null> {
|
|
300
347
|
const workspace = await resolveDefaultWorkspaceRow();
|
|
301
348
|
if (!workspace) {
|
|
302
349
|
return null;
|
|
@@ -309,15 +356,18 @@ export async function resolveDefaultTenant(
|
|
|
309
356
|
userId,
|
|
310
357
|
},
|
|
311
358
|
},
|
|
312
|
-
select: { role: true },
|
|
359
|
+
select: { id: true, role: true },
|
|
313
360
|
});
|
|
314
361
|
if (!membership) {
|
|
315
362
|
return null;
|
|
316
363
|
}
|
|
317
364
|
return {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
365
|
+
tenant: {
|
|
366
|
+
userId,
|
|
367
|
+
organizationId: workspace.organizationId as Id<"organization">,
|
|
368
|
+
workspaceId: workspace.id as Id<"workspace">,
|
|
369
|
+
role: membership.role as MembershipRole,
|
|
370
|
+
},
|
|
371
|
+
membershipId: membership.id,
|
|
322
372
|
};
|
|
323
373
|
}
|