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.
Files changed (56) hide show
  1. package/dist/module-architecture.js +27 -0
  2. package/package.json +3 -3
  3. package/templates/.env.example +23 -9
  4. package/templates/apps/api/CLAUDE.md +21 -17
  5. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
  6. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
  7. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
  8. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
  9. package/templates/apps/api/src/config/index.ts +35 -20
  10. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
  11. package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
  12. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
  13. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
  14. package/templates/apps/api/src/lib/agent-store.ts +521 -0
  15. package/templates/apps/api/src/lib/spoke-store.ts +13 -2
  16. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
  17. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
  18. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
  19. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
  20. package/templates/apps/api/src/middleware/auth.ts +219 -5
  21. package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
  22. package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
  23. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
  24. package/templates/apps/api/src/routes/leads/index.ts +14 -6
  25. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
  26. package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
  27. package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
  28. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
  29. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
  30. package/templates/apps/api/src/server.ts +27 -0
  31. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
  32. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
  33. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
  34. package/templates/apps/api/src/services/audit-log.ts +278 -47
  35. package/templates/apps/api/src/services/default-tenant.ts +55 -5
  36. package/templates/apps/api/src/services/leads-inbox.ts +12 -21
  37. package/templates/apps/api/src/services/project-listings.ts +1 -21
  38. package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
  39. package/templates/apps/mcp/CLAUDE.md +44 -5
  40. package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
  41. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
  42. package/templates/apps/mcp/src/http-transport.ts +107 -1
  43. package/templates/apps/mcp/src/workspace-server.ts +193 -0
  44. package/templates/apps/web/.env.example +64 -37
  45. package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
  46. package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
  47. package/templates/apps/web/lib/relay.ts +279 -0
  48. package/templates/apps/web/middleware.ts +5 -4
  49. package/templates/apps/web/next.config.mjs +6 -0
  50. package/templates/database/CHANGELOG.md +25 -0
  51. package/templates/database/__tests__/agents.db.test.ts +235 -0
  52. package/templates/database/__tests__/build.test.ts +131 -0
  53. package/templates/database/auth/schema.prisma +61 -0
  54. package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
  55. package/templates/database/package.json +3 -3
  56. package/versions.json +34 -34
@@ -1,10 +1,19 @@
1
1
  /**
2
2
  * @file audit-log.ts
3
- * @description The agent-action audit writer (#6040, REL-017 row 5 / FEAT-084
4
- * SUB-096) — `recordAgentAction`, the first production write site for
5
- * `ops.AuditLog`. Writes exactly one row per agent action: tenant, actor,
6
- * tool, and outcome, with personal data redacted before it can reach the
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 recordAgentAction(input)` as a bare statement compiles
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). Only
93
- * `"agent"` is ever written by this file — `"user"`/`"system"` are exported
94
- * so a future writer for those actors (#3591) shares one union. */
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
- export type AgentActionOutcome =
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: 1;
108
- outcome: AgentActionOutcome;
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 RecordAgentActionInput {
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, `/^[A-Za-z0-9_:.-]+$/`, then personal-data-scanned. */
154
- actorId: string;
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: AgentActionOutcome;
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 RecordAgentActionResult =
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: AgentActionOutcome;
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 (`"agent.commerce." + tool`). */
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
- /** `actorId`'s shape is open (Q2) — an opaque, non-registry identifier. */
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 `actorId` and
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 (`actorId`, `idempotencyKey`). */
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: redactPersonalData(text) };
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). `actorId` and
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: RecordAgentActionInput): ValidationResult {
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
- if (!ACTOR_ID_RE.test(input.actorId)) {
467
- return {
468
- ok: false,
469
- message: "actorId does not match the required identifier grammar",
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
- if (personalDataDetected(input.actorId)) {
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: "actorId appears to carry personal data",
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: input.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 agent action. Never throws;
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 recordAgentAction(
553
- input: RecordAgentActionInput,
673
+ export async function recordAuditAction(
674
+ input: RecordAuditActionInput,
554
675
  db: AuditWriteClient = prisma,
555
- ): Promise<RecordAgentActionResult> {
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 actorType: AuditActorType = "agent";
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: "agent action row write failed",
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: 1,
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 agent action row suppressed by primary key",
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] agent action row write failed",
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: "agent action row write failed",
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
- userId,
319
- organizationId: workspace.organizationId as Id<"organization">,
320
- workspaceId: workspace.id as Id<"workspace">,
321
- role: membership.role as MembershipRole,
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
  }