@graphorin/server 0.6.1 → 0.7.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 (181) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +35 -607
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +8 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +19 -24
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +1 -1
  59. package/dist/package.js.map +1 -1
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.js +19 -12
  99. package/dist/ws/dispatcher.js.map +1 -1
  100. package/dist/ws/index.d.ts +2 -2
  101. package/dist/ws/index.js +3 -3
  102. package/dist/ws/replay-buffer.d.ts +35 -1
  103. package/dist/ws/replay-buffer.d.ts.map +1 -1
  104. package/dist/ws/replay-buffer.js +35 -3
  105. package/dist/ws/replay-buffer.js.map +1 -1
  106. package/dist/ws/upgrade.d.ts.map +1 -1
  107. package/dist/ws/upgrade.js +24 -18
  108. package/dist/ws/upgrade.js.map +1 -1
  109. package/package.json +30 -29
  110. package/src/app-audit-binding.ts +227 -0
  111. package/src/app-daemons.ts +73 -0
  112. package/src/app-lifecycle.ts +476 -0
  113. package/src/app-metrics.ts +144 -0
  114. package/src/app-middleware.ts +149 -0
  115. package/src/app-routes.ts +305 -0
  116. package/src/app-ws.ts +111 -0
  117. package/src/app.ts +317 -0
  118. package/src/commentary/audit-bridge.ts +135 -0
  119. package/src/commentary/built-in-patterns.ts +79 -0
  120. package/src/commentary/index.ts +32 -0
  121. package/src/commentary/sanitizer.ts +238 -0
  122. package/src/commentary/types.ts +130 -0
  123. package/src/config.ts +472 -0
  124. package/src/consolidator/daemon.ts +141 -0
  125. package/src/consolidator/index.ts +14 -0
  126. package/src/errors/index.ts +247 -0
  127. package/src/health/checks.ts +322 -0
  128. package/src/health/index.ts +34 -0
  129. package/src/health/routes.ts +154 -0
  130. package/src/index.ts +243 -0
  131. package/src/internal/context.ts +77 -0
  132. package/src/internal/ids.ts +17 -0
  133. package/src/internal/json.ts +47 -0
  134. package/src/internal/wire-error.ts +44 -0
  135. package/src/lifecycle/hooks.ts +66 -0
  136. package/src/lifecycle/index.ts +16 -0
  137. package/src/lifecycle/pre-bind.ts +138 -0
  138. package/src/metrics/catalog.ts +112 -0
  139. package/src/metrics/index.ts +12 -0
  140. package/src/metrics/registry.ts +337 -0
  141. package/src/metrics/tools-bridge.ts +124 -0
  142. package/src/middleware/audit.ts +114 -0
  143. package/src/middleware/auth.ts +195 -0
  144. package/src/middleware/cors.ts +72 -0
  145. package/src/middleware/csrf.ts +98 -0
  146. package/src/middleware/idempotency.ts +336 -0
  147. package/src/middleware/index.ts +38 -0
  148. package/src/middleware/rate-limit.ts +71 -0
  149. package/src/middleware/request-state.ts +79 -0
  150. package/src/middleware/scope.ts +161 -0
  151. package/src/registry/index.ts +278 -0
  152. package/src/replay/index.ts +14 -0
  153. package/src/replay/routes.ts +255 -0
  154. package/src/routes/agents.ts +363 -0
  155. package/src/routes/audit.ts +207 -0
  156. package/src/routes/auth.ts +80 -0
  157. package/src/routes/health.ts +42 -0
  158. package/src/routes/index.ts +16 -0
  159. package/src/routes/mcp.ts +97 -0
  160. package/src/routes/memory.ts +152 -0
  161. package/src/routes/sessions.ts +250 -0
  162. package/src/routes/skills.ts +91 -0
  163. package/src/routes/tokens.ts +166 -0
  164. package/src/routes/workflows.ts +616 -0
  165. package/src/runtime/retention.ts +284 -0
  166. package/src/runtime/run-state.ts +422 -0
  167. package/src/sse/events.ts +303 -0
  168. package/src/sse/index.ts +7 -0
  169. package/src/tools-audit-bridge.ts +120 -0
  170. package/src/triggers/daemon.ts +198 -0
  171. package/src/triggers/index.ts +16 -0
  172. package/src/triggers/routes.ts +139 -0
  173. package/src/workflows/timer-daemon.ts +102 -0
  174. package/src/ws/dispatcher.ts +537 -0
  175. package/src/ws/index.ts +45 -0
  176. package/src/ws/replay-buffer.ts +209 -0
  177. package/src/ws/subjects.ts +162 -0
  178. package/src/ws/ticket.ts +174 -0
  179. package/src/ws/upgrade.ts +507 -0
  180. package/dist/lifecycle/index.js +0 -3
  181. package/dist/routes/index.js +0 -12
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Registry plumbing that lets the server route handlers locate user-
3
+ * defined agents, workflows, sessions, memory, skills, and MCP
4
+ * server bindings without taking a hard peer dependency on every
5
+ * sibling package.
6
+ *
7
+ * Every entry is keyed by string id; lookups never throw - callers
8
+ * receive `undefined` and the route handler decides how to surface
9
+ * the miss (typically a 404 with a typed error body).
10
+ *
11
+ * W-053: compatibility of `ServerAgentLike` / `ServerWorkflowLike` with
12
+ * the real `Agent` / `Workflow` surfaces is gated at compile time by
13
+ * `tests/registry-contract.types.test.ts` - a drift in either direction
14
+ * fails the server package's typecheck, not a user's runtime. When a
15
+ * mismatch appears, fix THESE structural types (they are the copy), not
16
+ * the source interfaces.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+
21
+ /**
22
+ * Minimal shape the server needs from an `Agent`. Compatible with
23
+ * the `Agent` interface from `@graphorin/agent` but kept
24
+ * structurally so we avoid the peer dependency.
25
+ *
26
+ * @stable
27
+ */
28
+ export interface ServerAgentLike {
29
+ readonly id: string;
30
+ run(
31
+ input: unknown,
32
+ options?: {
33
+ readonly signal?: AbortSignal;
34
+ readonly sessionId?: string;
35
+ readonly userId?: string;
36
+ },
37
+ ): Promise<unknown>;
38
+ /**
39
+ * Streaming surface (IP-2). `@graphorin/agent` agents satisfy this
40
+ * structurally; `POST /agents/:id/stream` consumes it and emits
41
+ * every event onto the run's WS subject. Optional so plain
42
+ * run-only fixtures keep working (they emit a single terminal frame).
43
+ */
44
+ stream?(
45
+ input: unknown,
46
+ options?: {
47
+ readonly signal?: AbortSignal;
48
+ readonly sessionId?: string;
49
+ readonly userId?: string;
50
+ },
51
+ ): AsyncIterable<unknown>;
52
+ }
53
+
54
+ /**
55
+ * Minimal shape the server needs from a `Workflow`. Mirrors the
56
+ * `Workflow` surface from `@graphorin/workflow`.
57
+ *
58
+ * @stable
59
+ */
60
+ export interface ServerWorkflowLike {
61
+ readonly name: string;
62
+ execute(
63
+ input: unknown,
64
+ options?: { readonly signal?: AbortSignal; readonly threadId?: string },
65
+ ): AsyncIterable<unknown>;
66
+ resume?(
67
+ threadId: string,
68
+ directive?: { readonly resume?: unknown },
69
+ opts?: { readonly signal?: AbortSignal },
70
+ ): AsyncIterable<unknown>;
71
+ /** W-119: replay a failed/aborted thread (`POST /:id/retry`). */
72
+ retry?(threadId: string, opts?: { readonly signal?: AbortSignal }): AsyncIterable<unknown>;
73
+ /** W-119: fire a due durable timer (`POST /:id/tick`). */
74
+ tick?(
75
+ threadId: string,
76
+ opts?: { readonly now?: number },
77
+ ): Promise<{ readonly fired: boolean; readonly nextWakeAt: number | null }>;
78
+ /**
79
+ * W-119: resolve a NAMED awakeable/approval (`POST /:id/resume` with
80
+ * `name`) - `approve` is the same primitive under the hood.
81
+ */
82
+ resolveAwakeable?(
83
+ threadId: string,
84
+ name: string,
85
+ value?: unknown,
86
+ opts?: { readonly signal?: AbortSignal },
87
+ ): AsyncIterable<unknown>;
88
+ /** W-119: fork a new thread from a checkpoint (`POST /:id/fork`). */
89
+ fork?(threadId: string, fromCheckpointId: string): Promise<{ readonly newThreadId: string }>;
90
+ getState?(threadId: string): Promise<unknown>;
91
+ listCheckpoints?(threadId: string): Promise<ReadonlyArray<unknown>>;
92
+ /** W-005: per-thread checkpoint erasure (`DELETE /:id/threads/:threadId`). */
93
+ deleteThread?(threadId: string): Promise<void>;
94
+ }
95
+
96
+ /**
97
+ * Snapshot record returned by {@link AgentRegistry.list}.
98
+ *
99
+ * @stable
100
+ */
101
+ export interface AgentSummary {
102
+ readonly id: string;
103
+ readonly description?: string;
104
+ readonly tags?: ReadonlyArray<string>;
105
+ }
106
+
107
+ /**
108
+ * Snapshot record returned by {@link WorkflowRegistry.list}.
109
+ *
110
+ * @stable
111
+ */
112
+ export interface WorkflowSummary {
113
+ readonly id: string;
114
+ readonly description?: string;
115
+ readonly tags?: ReadonlyArray<string>;
116
+ }
117
+
118
+ /**
119
+ * Internal registry entry - pairs the user-supplied object with the
120
+ * metadata routes serve to clients.
121
+ *
122
+ * @internal
123
+ */
124
+ interface AgentEntry {
125
+ readonly id: string;
126
+ readonly agent: ServerAgentLike;
127
+ readonly description?: string;
128
+ readonly tags?: ReadonlyArray<string>;
129
+ }
130
+
131
+ /**
132
+ * @internal
133
+ */
134
+ interface WorkflowEntry {
135
+ readonly id: string;
136
+ readonly workflow: ServerWorkflowLike;
137
+ readonly description?: string;
138
+ readonly tags?: ReadonlyArray<string>;
139
+ }
140
+
141
+ /**
142
+ * Registration descriptor accepted by {@link AgentRegistry.register}.
143
+ *
144
+ * @stable
145
+ */
146
+ export interface AgentRegistration {
147
+ readonly id: string;
148
+ readonly agent: ServerAgentLike;
149
+ readonly description?: string;
150
+ readonly tags?: ReadonlyArray<string>;
151
+ }
152
+
153
+ /**
154
+ * Registration descriptor accepted by {@link WorkflowRegistry.register}.
155
+ *
156
+ * @stable
157
+ */
158
+ export interface WorkflowRegistration {
159
+ readonly id: string;
160
+ readonly workflow: ServerWorkflowLike;
161
+ readonly description?: string;
162
+ readonly tags?: ReadonlyArray<string>;
163
+ }
164
+
165
+ /**
166
+ * Read/write registry for agents the server should expose. Every
167
+ * mutation is synchronous and the lookup is `O(1)`.
168
+ *
169
+ * The class is intentionally tiny - extension points (e.g. lazy
170
+ * factory loading, per-tenant scoping) live in higher-level packages
171
+ * and consume this surface as a primitive.
172
+ *
173
+ * @stable
174
+ */
175
+ export class AgentRegistry {
176
+ readonly #entries: Map<string, AgentEntry> = new Map();
177
+
178
+ register(entry: AgentRegistration): void {
179
+ this.#entries.set(entry.id, {
180
+ id: entry.id,
181
+ agent: entry.agent,
182
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
183
+ ...(entry.tags !== undefined ? { tags: Object.freeze(entry.tags.slice()) } : {}),
184
+ });
185
+ }
186
+
187
+ unregister(id: string): boolean {
188
+ return this.#entries.delete(id);
189
+ }
190
+
191
+ get(id: string): ServerAgentLike | undefined {
192
+ return this.#entries.get(id)?.agent;
193
+ }
194
+
195
+ has(id: string): boolean {
196
+ return this.#entries.has(id);
197
+ }
198
+
199
+ list(): ReadonlyArray<AgentSummary> {
200
+ return Object.freeze(
201
+ [...this.#entries.values()].map((entry) =>
202
+ Object.freeze({
203
+ id: entry.id,
204
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
205
+ ...(entry.tags !== undefined ? { tags: entry.tags } : {}),
206
+ }),
207
+ ),
208
+ );
209
+ }
210
+
211
+ describe(id: string): AgentSummary | undefined {
212
+ const entry = this.#entries.get(id);
213
+ if (entry === undefined) return undefined;
214
+ return Object.freeze({
215
+ id: entry.id,
216
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
217
+ ...(entry.tags !== undefined ? { tags: entry.tags } : {}),
218
+ });
219
+ }
220
+
221
+ size(): number {
222
+ return this.#entries.size;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * @stable
228
+ */
229
+ export class WorkflowRegistry {
230
+ readonly #entries: Map<string, WorkflowEntry> = new Map();
231
+
232
+ register(entry: WorkflowRegistration): void {
233
+ this.#entries.set(entry.id, {
234
+ id: entry.id,
235
+ workflow: entry.workflow,
236
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
237
+ ...(entry.tags !== undefined ? { tags: Object.freeze(entry.tags.slice()) } : {}),
238
+ });
239
+ }
240
+
241
+ unregister(id: string): boolean {
242
+ return this.#entries.delete(id);
243
+ }
244
+
245
+ get(id: string): ServerWorkflowLike | undefined {
246
+ return this.#entries.get(id)?.workflow;
247
+ }
248
+
249
+ has(id: string): boolean {
250
+ return this.#entries.has(id);
251
+ }
252
+
253
+ list(): ReadonlyArray<WorkflowSummary> {
254
+ return Object.freeze(
255
+ [...this.#entries.values()].map((entry) =>
256
+ Object.freeze({
257
+ id: entry.id,
258
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
259
+ ...(entry.tags !== undefined ? { tags: entry.tags } : {}),
260
+ }),
261
+ ),
262
+ );
263
+ }
264
+
265
+ describe(id: string): WorkflowSummary | undefined {
266
+ const entry = this.#entries.get(id);
267
+ if (entry === undefined) return undefined;
268
+ return Object.freeze({
269
+ id: entry.id,
270
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
271
+ ...(entry.tags !== undefined ? { tags: entry.tags } : {}),
272
+ });
273
+ }
274
+
275
+ size(): number {
276
+ return this.#entries.size;
277
+ }
278
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@graphorin/server/replay` - Phase 14c scope-enforced replay
3
+ * endpoints + audit integration.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ export {
9
+ createReplayRoutes,
10
+ type ReplayApi,
11
+ type ReplayMode,
12
+ type ReplayResponse,
13
+ type ReplayRoutesDeps,
14
+ } from './routes.js';
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Replay endpoints with scope enforcement.
3
+ *
4
+ * POST /v1/runs/:runId/replay
5
+ * POST /v1/sessions/:id/replay
6
+ *
7
+ * Scope rules:
8
+ * - `raw: false` (default) → require `traces:read:sanitized`.
9
+ * - `raw: true` → require `traces:read:raw` (admin).
10
+ *
11
+ * Every successful invocation appends a `trace.replay.accessed` audit
12
+ * entry. The audit write is best-effort: a missing `AuditDb` does not
13
+ * block the replay.
14
+ *
15
+ * The actual replay stream is consumer-supplied - the framework
16
+ * accepts a `ReplayApi` from the operator that knows how to load
17
+ * trace events for a `runId` / `sessionId`. Phase 14c only owns the
18
+ * scope check + audit + transport.
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import { type AuditDb, appendAudit } from '@graphorin/security/audit';
24
+ import { type ParsedScope, parseScope, scopeMatches } from '@graphorin/security/auth';
25
+ import { Hono } from 'hono';
26
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
27
+ import { z } from 'zod';
28
+
29
+ import type { AuthState, ServerVariables } from '../internal/context.js';
30
+
31
+ /** @stable */
32
+ export type ReplayMode = 'sanitized' | 'raw';
33
+
34
+ /** @stable */
35
+ export interface ReplayApi {
36
+ loadRunReplay(input: {
37
+ readonly runId: string;
38
+ readonly mode: ReplayMode;
39
+ readonly fromMessageId?: string;
40
+ readonly provider?: string;
41
+ }): Promise<ReplayResponse>;
42
+ loadSessionReplay(input: {
43
+ readonly sessionId: string;
44
+ readonly mode: ReplayMode;
45
+ readonly fromMessageId?: string;
46
+ readonly provider?: string;
47
+ }): Promise<ReplayResponse>;
48
+ }
49
+
50
+ /** @stable */
51
+ export interface ReplayResponse {
52
+ readonly events: ReadonlyArray<unknown>;
53
+ readonly truncated?: boolean;
54
+ readonly nextCursor?: string;
55
+ }
56
+
57
+ const SCOPE_SANITIZED: ParsedScope = parseScope('traces:read:sanitized');
58
+ const SCOPE_RAW: ParsedScope = parseScope('traces:read:raw');
59
+
60
+ const ReplayRequestSchema = z
61
+ .object({
62
+ raw: z.boolean().optional(),
63
+ fromMessageId: z.string().min(1).optional(),
64
+ provider: z.string().min(1).optional(),
65
+ })
66
+ .strict()
67
+ .default({});
68
+
69
+ /**
70
+ * @stable
71
+ */
72
+ export interface ReplayRoutesDeps {
73
+ readonly replay: ReplayApi;
74
+ readonly auditDb?: AuditDb;
75
+ readonly now?: () => number;
76
+ }
77
+
78
+ /**
79
+ * @stable
80
+ */
81
+ export function createReplayRoutes(deps: ReplayRoutesDeps): Hono<{ Variables: ServerVariables }> {
82
+ const app = new Hono<{ Variables: ServerVariables }>();
83
+ const now = deps.now ?? Date.now;
84
+
85
+ app.post('/runs/:runId/replay', async (c) => {
86
+ const runId = c.req.param('runId');
87
+ const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));
88
+ if (!body.success) {
89
+ return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);
90
+ }
91
+ const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';
92
+ const auth = c.get('state').auth;
93
+ const scopeError = enforceReplayScope(auth, mode);
94
+ if (scopeError !== undefined) {
95
+ await maybeAppendAudit(deps.auditDb, {
96
+ actor: auditActor(auth),
97
+ action: 'replay:skipped',
98
+ target: `run:${runId}`,
99
+ decision: 'denied',
100
+ ts: now(),
101
+ metadata: { mode, reason: scopeError.code },
102
+ });
103
+ return c.json(scopeError.body, scopeError.status);
104
+ }
105
+ const replay = await deps.replay.loadRunReplay({
106
+ runId,
107
+ mode,
108
+ ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),
109
+ ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),
110
+ });
111
+ await maybeAppendAudit(deps.auditDb, {
112
+ actor: auditActor(auth),
113
+ action: 'replay:accessed',
114
+ target: `run:${runId}`,
115
+ decision: 'success',
116
+ ts: now(),
117
+ metadata: { mode, eventCount: replay.events.length },
118
+ });
119
+ return c.json(serializeReplay(replay, mode));
120
+ });
121
+
122
+ app.post('/sessions/:id/replay', async (c) => {
123
+ const sessionId = c.req.param('id');
124
+ const body = ReplayRequestSchema.safeParse(await safelyParseJson(c));
125
+ if (!body.success) {
126
+ return c.json(buildValidationError('Invalid replay body.', body.error.issues), 400);
127
+ }
128
+ const mode: ReplayMode = body.data.raw === true ? 'raw' : 'sanitized';
129
+ const auth = c.get('state').auth;
130
+ const scopeError = enforceReplayScope(auth, mode);
131
+ if (scopeError !== undefined) {
132
+ await maybeAppendAudit(deps.auditDb, {
133
+ actor: auditActor(auth),
134
+ action: 'replay:skipped',
135
+ target: `session:${sessionId}`,
136
+ decision: 'denied',
137
+ ts: now(),
138
+ metadata: { mode, reason: scopeError.code },
139
+ });
140
+ return c.json(scopeError.body, scopeError.status);
141
+ }
142
+ const replay = await deps.replay.loadSessionReplay({
143
+ sessionId,
144
+ mode,
145
+ ...(body.data.fromMessageId !== undefined ? { fromMessageId: body.data.fromMessageId } : {}),
146
+ ...(body.data.provider !== undefined ? { provider: body.data.provider } : {}),
147
+ });
148
+ await maybeAppendAudit(deps.auditDb, {
149
+ actor: auditActor(auth),
150
+ action: 'replay:accessed',
151
+ target: `session:${sessionId}`,
152
+ decision: 'success',
153
+ ts: now(),
154
+ metadata: { mode, eventCount: replay.events.length },
155
+ });
156
+ return c.json(serializeReplay(replay, mode));
157
+ });
158
+
159
+ return app;
160
+ }
161
+
162
+ interface ScopeRefusal {
163
+ readonly code: 'auth-required' | 'scope-denied';
164
+ readonly status: ContentfulStatusCode;
165
+ readonly body: { readonly error: string; readonly message: string; readonly hint?: string };
166
+ }
167
+
168
+ function enforceReplayScope(auth: AuthState, mode: ReplayMode): ScopeRefusal | undefined {
169
+ // IP-13: both a verified token and the no-auth anonymous principal carry a
170
+ // `grantedScopes` set; only a genuinely unauthenticated request is refused.
171
+ if (auth.kind === 'unauthenticated') {
172
+ return {
173
+ code: 'auth-required',
174
+ status: 401,
175
+ body: {
176
+ error: 'auth-required',
177
+ message: 'Authentication required for replay endpoints.',
178
+ },
179
+ };
180
+ }
181
+ const required = mode === 'raw' ? SCOPE_RAW : SCOPE_SANITIZED;
182
+ for (const scope of auth.grantedScopes) {
183
+ if (scopeMatches(scope, required)) return undefined;
184
+ }
185
+ return {
186
+ code: 'scope-denied',
187
+ status: 403,
188
+ body: {
189
+ error: 'scope-denied',
190
+ message: `Token lacks required scope '${required.raw}'.`,
191
+ hint: `Mint a token with the '${required.raw}' (or admin:*) scope.`,
192
+ },
193
+ };
194
+ }
195
+
196
+ function auditActor(auth: AuthState): {
197
+ readonly kind: 'token' | 'system';
198
+ readonly id: string;
199
+ readonly label?: string;
200
+ } {
201
+ if (auth.kind === 'token') {
202
+ return {
203
+ kind: 'token',
204
+ id: auth.token.tokenId,
205
+ ...(auth.token.label !== undefined ? { label: auth.token.label } : {}),
206
+ };
207
+ }
208
+ // IP-13: attribute no-auth replays to the synthetic 'anonymous' principal.
209
+ return { kind: 'system', id: auth.kind === 'anonymous' ? 'anonymous' : 'unauthenticated' };
210
+ }
211
+
212
+ async function maybeAppendAudit(
213
+ auditDb: AuditDb | undefined,
214
+ entry: Parameters<typeof appendAudit>[1],
215
+ ): Promise<void> {
216
+ if (auditDb === undefined) return;
217
+ try {
218
+ await appendAudit(auditDb, entry);
219
+ } catch {
220
+ // Best-effort - replay must not be blocked by audit failures.
221
+ }
222
+ }
223
+
224
+ function serializeReplay(replay: ReplayResponse, mode: ReplayMode): Record<string, unknown> {
225
+ return {
226
+ mode,
227
+ events: [...replay.events],
228
+ ...(replay.truncated === true ? { truncated: true } : {}),
229
+ ...(replay.nextCursor !== undefined ? { nextCursor: replay.nextCursor } : {}),
230
+ };
231
+ }
232
+
233
+ function buildValidationError(
234
+ message: string,
235
+ issues: ReadonlyArray<{
236
+ readonly path: ReadonlyArray<string | number>;
237
+ readonly message: string;
238
+ }>,
239
+ ): Record<string, unknown> {
240
+ return {
241
+ error: 'config-invalid',
242
+ message,
243
+ issues: issues.map((i) => ({ path: i.path, message: i.message })),
244
+ };
245
+ }
246
+
247
+ async function safelyParseJson(c: {
248
+ readonly req: { readonly json: () => Promise<unknown> };
249
+ }): Promise<unknown> {
250
+ try {
251
+ return await c.req.json();
252
+ } catch {
253
+ return {};
254
+ }
255
+ }