@graphorin/server 0.6.0 → 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.
- package/CHANGELOG.md +85 -0
- package/README.md +6 -6
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +40 -611
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -26
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.d.ts.map +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +26 -19
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +31 -30
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- 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
|
+
}
|