@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.
- package/CHANGELOG.md +71 -0
- package/README.md +3 -3
- 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 +35 -607
- 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 +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- 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 +1 -1
- package/dist/package.js.map +1 -1
- 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.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 +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- 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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-subscription replay buffer used by the dispatcher to support
|
|
3
|
+
* short-disconnect resume. When a client reconnects with a
|
|
4
|
+
* `lastEventId`, the dispatcher walks the buffer from the matching
|
|
5
|
+
* cursor and re-emits every event the client missed.
|
|
6
|
+
*
|
|
7
|
+
* Defaults (configurable per-server):
|
|
8
|
+
* - `maxEvents`: 1000 (per subscription)
|
|
9
|
+
* - `ttlMs`: 5 minutes
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ServerEventFrame } from '@graphorin/protocol';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Public configuration accepted by {@link createReplayBuffer}.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
export interface ReplayBufferOptions {
|
|
22
|
+
readonly maxEvents?: number;
|
|
23
|
+
readonly ttlMs?: number;
|
|
24
|
+
readonly now?: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Snapshot returned by {@link ReplayBuffer.replay}.
|
|
29
|
+
*
|
|
30
|
+
* @stable
|
|
31
|
+
*/
|
|
32
|
+
export interface ReplayBufferSlice {
|
|
33
|
+
readonly events: ReadonlyArray<ServerEventFrame>;
|
|
34
|
+
readonly droppedCount: number;
|
|
35
|
+
readonly nextEventIdHint: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Occupancy snapshot returned by {@link ReplayBuffer.stats}.
|
|
40
|
+
*
|
|
41
|
+
* @stable
|
|
42
|
+
*/
|
|
43
|
+
export interface ReplayBufferStats {
|
|
44
|
+
/** Number of subjects currently holding at least one buffered event. */
|
|
45
|
+
readonly subjects: number;
|
|
46
|
+
/** Total buffered events across all subjects. */
|
|
47
|
+
readonly events: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-subject replay buffer. Stores up to `maxEvents` per subject
|
|
52
|
+
* with a TTL; thread-safe under the single-writer Node event loop
|
|
53
|
+
* model.
|
|
54
|
+
*
|
|
55
|
+
* @stable
|
|
56
|
+
*/
|
|
57
|
+
export interface ReplayBuffer {
|
|
58
|
+
push(subject: string, event: ServerEventFrame): void;
|
|
59
|
+
replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice;
|
|
60
|
+
size(subject: string): number;
|
|
61
|
+
forget(subject: string): void;
|
|
62
|
+
prune(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Occupancy snapshot (W-028). OPTIONAL so external implementations
|
|
65
|
+
* of this `@stable` interface keep compiling; `createReplayBuffer`
|
|
66
|
+
* always provides it. When absent, the `/v1/metrics` replay-buffer
|
|
67
|
+
* gauge degrades to `0`.
|
|
68
|
+
*/
|
|
69
|
+
stats?(): ReplayBufferStats;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface BufferedEvent {
|
|
73
|
+
readonly event: ServerEventFrame;
|
|
74
|
+
readonly bufferedAt: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build an in-memory replay buffer.
|
|
79
|
+
*
|
|
80
|
+
* @stable
|
|
81
|
+
*/
|
|
82
|
+
export function createReplayBuffer(options: ReplayBufferOptions = {}): ReplayBuffer {
|
|
83
|
+
const maxEvents = options.maxEvents ?? 1_000;
|
|
84
|
+
const ttlMs = options.ttlMs ?? 5 * 60_000;
|
|
85
|
+
const now = options.now ?? Date.now;
|
|
86
|
+
const buffers = new Map<string, BufferedEvent[]>();
|
|
87
|
+
const dropped = new Map<string, number>();
|
|
88
|
+
|
|
89
|
+
function pruneSubject(subject: string): void {
|
|
90
|
+
const list = buffers.get(subject);
|
|
91
|
+
if (list === undefined || list.length === 0) return;
|
|
92
|
+
const cutoff = now() - ttlMs;
|
|
93
|
+
let cutoffIndex = 0;
|
|
94
|
+
while (cutoffIndex < list.length) {
|
|
95
|
+
const entry = list[cutoffIndex];
|
|
96
|
+
if (entry === undefined || entry.bufferedAt >= cutoff) break;
|
|
97
|
+
cutoffIndex += 1;
|
|
98
|
+
}
|
|
99
|
+
if (cutoffIndex > 0) {
|
|
100
|
+
list.splice(0, cutoffIndex);
|
|
101
|
+
dropped.set(subject, (dropped.get(subject) ?? 0) + cutoffIndex);
|
|
102
|
+
}
|
|
103
|
+
if (list.length === 0) {
|
|
104
|
+
// W-028: release the whole subject, including its dropped-count
|
|
105
|
+
// entry - otherwise every finished run leaves a Map entry behind
|
|
106
|
+
// forever. A resume with a stale cursor still signals the gap:
|
|
107
|
+
// `replay()` floors droppedCount at 1 when the cursor misses.
|
|
108
|
+
buffers.delete(subject);
|
|
109
|
+
dropped.delete(subject);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function push(subject: string, event: ServerEventFrame): void {
|
|
114
|
+
pruneSubject(subject);
|
|
115
|
+
let list = buffers.get(subject);
|
|
116
|
+
if (list === undefined) {
|
|
117
|
+
list = [];
|
|
118
|
+
buffers.set(subject, list);
|
|
119
|
+
}
|
|
120
|
+
list.push({ event, bufferedAt: now() });
|
|
121
|
+
if (list.length > maxEvents) {
|
|
122
|
+
const overflow = list.length - maxEvents;
|
|
123
|
+
list.splice(0, overflow);
|
|
124
|
+
dropped.set(subject, (dropped.get(subject) ?? 0) + overflow);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function replay(subject: string, sinceEventId: string | undefined): ReplayBufferSlice {
|
|
129
|
+
pruneSubject(subject);
|
|
130
|
+
const list = buffers.get(subject) ?? [];
|
|
131
|
+
const droppedCount = dropped.get(subject) ?? 0;
|
|
132
|
+
const last = list.at(-1);
|
|
133
|
+
if (sinceEventId === undefined) {
|
|
134
|
+
const slice: ReplayBufferSlice = {
|
|
135
|
+
events: list.map((entry) => entry.event),
|
|
136
|
+
droppedCount,
|
|
137
|
+
nextEventIdHint: last?.event.eventId,
|
|
138
|
+
};
|
|
139
|
+
return slice;
|
|
140
|
+
}
|
|
141
|
+
const startIndex = list.findIndex((entry) => entry.event.eventId === sinceEventId);
|
|
142
|
+
if (startIndex < 0) {
|
|
143
|
+
// Cursor predates the buffer; return everything we still have +
|
|
144
|
+
// signal the gap via `droppedCount`. The miss itself PROVES at
|
|
145
|
+
// least one event was dropped, so floor the count at 1 even
|
|
146
|
+
// after the dropped-map entry was released by pruning (W-028) -
|
|
147
|
+
// the dispatcher must still send the replay-marker.
|
|
148
|
+
return {
|
|
149
|
+
events: list.map((entry) => entry.event),
|
|
150
|
+
droppedCount: Math.max(droppedCount, 1),
|
|
151
|
+
nextEventIdHint: last?.event.eventId,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const replayed = list.slice(startIndex + 1).map((entry) => entry.event);
|
|
155
|
+
return {
|
|
156
|
+
events: replayed,
|
|
157
|
+
droppedCount: 0,
|
|
158
|
+
nextEventIdHint: replayed.at(-1)?.eventId,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
push,
|
|
164
|
+
replay,
|
|
165
|
+
size(subject: string): number {
|
|
166
|
+
pruneSubject(subject);
|
|
167
|
+
return buffers.get(subject)?.length ?? 0;
|
|
168
|
+
},
|
|
169
|
+
forget(subject: string): void {
|
|
170
|
+
buffers.delete(subject);
|
|
171
|
+
dropped.delete(subject);
|
|
172
|
+
},
|
|
173
|
+
prune(): void {
|
|
174
|
+
for (const subject of [...buffers.keys()]) pruneSubject(subject);
|
|
175
|
+
},
|
|
176
|
+
stats(): ReplayBufferStats {
|
|
177
|
+
let events = 0;
|
|
178
|
+
for (const list of buffers.values()) events += list.length;
|
|
179
|
+
return { subjects: buffers.size, events };
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* W-028: schedule a periodic {@link ReplayBuffer.prune} sweep. Without
|
|
186
|
+
* it TTL expiry only ran lazily inside `push`/`replay`/`size` FOR THE
|
|
187
|
+
* SAME SUBJECT, so every finished run-subject (a fresh runId per run)
|
|
188
|
+
* retained up to `maxEvents` full payloads forever on a long-living
|
|
189
|
+
* server. Mirrors `scheduleRunPruning` (IP-16): `unref`-ed timer,
|
|
190
|
+
* returns a stop function. The sweep applies only the already
|
|
191
|
+
* documented TTL - replay semantics inside the TTL window are
|
|
192
|
+
* unchanged (an immediate `forget` on run completion would break
|
|
193
|
+
* short-disconnect resume of terminal events).
|
|
194
|
+
*
|
|
195
|
+
* @stable
|
|
196
|
+
*/
|
|
197
|
+
export function scheduleReplayBufferPruning(
|
|
198
|
+
buffer: ReplayBuffer,
|
|
199
|
+
opts: { readonly intervalMs?: number } = {},
|
|
200
|
+
): () => void {
|
|
201
|
+
const intervalMs = opts.intervalMs ?? 60_000;
|
|
202
|
+
const timer = setInterval(() => {
|
|
203
|
+
buffer.prune();
|
|
204
|
+
}, intervalMs);
|
|
205
|
+
if (typeof (timer as { unref?: () => void }).unref === 'function') {
|
|
206
|
+
(timer as { unref: () => void }).unref();
|
|
207
|
+
}
|
|
208
|
+
return () => clearInterval(timer);
|
|
209
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict subject grammar enforced by the WS dispatcher. Subjects are
|
|
3
|
+
* the multiplexing primitive over a single WS connection: every
|
|
4
|
+
* subscription is keyed by a subject, every event carries the
|
|
5
|
+
* subject it was emitted into, and the per-subject scope check
|
|
6
|
+
* happens at subscription time.
|
|
7
|
+
*
|
|
8
|
+
* Wildcards are deferred to v0.2+ - the dispatcher rejects any
|
|
9
|
+
* subject containing `'*'` or `'#'` so the ACL surface stays
|
|
10
|
+
* tractable.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ParsedScope } from '@graphorin/security/auth';
|
|
16
|
+
import { parseScope, scopeMatches } from '@graphorin/security/auth';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discriminated union of every recognised subject form. Surfaced on
|
|
20
|
+
* audit log entries + diagnostics; the wire still carries the raw
|
|
21
|
+
* string.
|
|
22
|
+
*
|
|
23
|
+
* @stable
|
|
24
|
+
*/
|
|
25
|
+
export type ParsedSubject =
|
|
26
|
+
| { readonly kind: 'session-events'; readonly sessionId: string }
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'session-run-events';
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
readonly runId: string;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'agent-run-events';
|
|
34
|
+
readonly agentId: string;
|
|
35
|
+
readonly runId: string;
|
|
36
|
+
}
|
|
37
|
+
| { readonly kind: 'workflow-events'; readonly workflowId: string }
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: 'workflow-run-events';
|
|
40
|
+
readonly workflowId: string;
|
|
41
|
+
readonly runId: string;
|
|
42
|
+
}
|
|
43
|
+
| { readonly kind: 'memory-conflicts' }
|
|
44
|
+
| { readonly kind: 'audit-events' };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Result of {@link tryParseSubject}.
|
|
48
|
+
*
|
|
49
|
+
* @stable
|
|
50
|
+
*/
|
|
51
|
+
export type ParseSubjectResult =
|
|
52
|
+
| { readonly ok: true; readonly subject: ParsedSubject }
|
|
53
|
+
| {
|
|
54
|
+
readonly ok: false;
|
|
55
|
+
readonly reason: 'wildcard-not-supported' | 'unknown-subject' | 'malformed';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const SESSION_EVENTS = /^session:([A-Za-z0-9_-]+)\/events$/;
|
|
59
|
+
const SESSION_RUN_EVENTS = /^session:([A-Za-z0-9_-]+)\/runs\/([A-Za-z0-9_-]+)\/events$/;
|
|
60
|
+
const AGENT_RUN_EVENTS = /^agent:([A-Za-z0-9_-]+)\/runs\/([A-Za-z0-9_-]+)\/events$/;
|
|
61
|
+
const WORKFLOW_EVENTS = /^workflow:([A-Za-z0-9_-]+)\/events$/;
|
|
62
|
+
const WORKFLOW_RUN_EVENTS = /^workflow:([A-Za-z0-9_-]+)\/runs\/([A-Za-z0-9_-]+)\/events$/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse a subject string into the {@link ParsedSubject} discriminator.
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export function tryParseSubject(raw: string): ParseSubjectResult {
|
|
70
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
71
|
+
return { ok: false, reason: 'malformed' };
|
|
72
|
+
}
|
|
73
|
+
if (raw.includes('*') || raw.includes('#')) {
|
|
74
|
+
return { ok: false, reason: 'wildcard-not-supported' };
|
|
75
|
+
}
|
|
76
|
+
if (raw === 'memory/conflicts') {
|
|
77
|
+
return { ok: true, subject: { kind: 'memory-conflicts' } };
|
|
78
|
+
}
|
|
79
|
+
if (raw === 'audit/events') {
|
|
80
|
+
return { ok: true, subject: { kind: 'audit-events' } };
|
|
81
|
+
}
|
|
82
|
+
let m = raw.match(SESSION_RUN_EVENTS);
|
|
83
|
+
if (m !== null) {
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
subject: { kind: 'session-run-events', sessionId: m[1] ?? '', runId: m[2] ?? '' },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
m = raw.match(AGENT_RUN_EVENTS);
|
|
90
|
+
if (m !== null) {
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
subject: { kind: 'agent-run-events', agentId: m[1] ?? '', runId: m[2] ?? '' },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
m = raw.match(SESSION_EVENTS);
|
|
97
|
+
if (m !== null) {
|
|
98
|
+
return { ok: true, subject: { kind: 'session-events', sessionId: m[1] ?? '' } };
|
|
99
|
+
}
|
|
100
|
+
m = raw.match(WORKFLOW_RUN_EVENTS);
|
|
101
|
+
if (m !== null) {
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
subject: { kind: 'workflow-run-events', workflowId: m[1] ?? '', runId: m[2] ?? '' },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
m = raw.match(WORKFLOW_EVENTS);
|
|
108
|
+
if (m !== null) {
|
|
109
|
+
return { ok: true, subject: { kind: 'workflow-events', workflowId: m[1] ?? '' } };
|
|
110
|
+
}
|
|
111
|
+
return { ok: false, reason: 'unknown-subject' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Required scope literal for every subject kind, expressed as a
|
|
116
|
+
* `ParsedScope`. The matcher `scopeMatches(granted, required)` uses
|
|
117
|
+
* the standard wildcard rules from `@graphorin/security/auth`
|
|
118
|
+
* (e.g. `agents:*` matches `agents:invoke:foo`).
|
|
119
|
+
*
|
|
120
|
+
* @stable
|
|
121
|
+
*/
|
|
122
|
+
export function requiredScopeFor(subject: ParsedSubject): ParsedScope {
|
|
123
|
+
switch (subject.kind) {
|
|
124
|
+
// periphery-10: session streams are READ-ONLY, so they gate on
|
|
125
|
+
// `sessions:read:<sessionId>` - consistent with the SSE route's
|
|
126
|
+
// `sessions:read` requirement (which the old `agents:invoke:<x>`
|
|
127
|
+
// requirement silently stacked on top of), and the resource slot
|
|
128
|
+
// is a sessionId under the sessions family instead of overloading
|
|
129
|
+
// `agents:invoke` (whose slot is an agentId everywhere else).
|
|
130
|
+
case 'session-events':
|
|
131
|
+
return parseScope(`sessions:read:${subject.sessionId}`);
|
|
132
|
+
case 'session-run-events':
|
|
133
|
+
return parseScope(`sessions:read:${subject.sessionId}`);
|
|
134
|
+
case 'agent-run-events':
|
|
135
|
+
return parseScope(`agents:read:${subject.agentId}`);
|
|
136
|
+
case 'workflow-events':
|
|
137
|
+
return parseScope(`workflows:read:${subject.workflowId}`);
|
|
138
|
+
case 'workflow-run-events':
|
|
139
|
+
return parseScope(`workflows:read:${subject.workflowId}`);
|
|
140
|
+
case 'memory-conflicts':
|
|
141
|
+
return parseScope('memory:read');
|
|
142
|
+
case 'audit-events':
|
|
143
|
+
return parseScope('audit:read');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compatibility shim - re-exports `scopeMatches` so consumers don't
|
|
149
|
+
* have to learn the security package's surface.
|
|
150
|
+
*
|
|
151
|
+
* @stable
|
|
152
|
+
*/
|
|
153
|
+
export function isSubjectAllowed(
|
|
154
|
+
granted: ReadonlyArray<ParsedScope>,
|
|
155
|
+
subject: ParsedSubject,
|
|
156
|
+
): boolean {
|
|
157
|
+
const required = requiredScopeFor(subject);
|
|
158
|
+
for (const scope of granted) {
|
|
159
|
+
if (scopeMatches(scope, required)) return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
package/src/ws/ticket.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory single-use ticket store for browser WebSocket clients.
|
|
3
|
+
* The browser WebSocket API does not allow custom headers, so the
|
|
4
|
+
* server issues a short-lived ticket via `POST /v1/session/ws-ticket`
|
|
5
|
+
* (HTTP-authenticated); the client then attaches the value as a
|
|
6
|
+
* second `Sec-WebSocket-Protocol` token (`ticket.<value>`) on the
|
|
7
|
+
* upgrade request.
|
|
8
|
+
*
|
|
9
|
+
* Tickets:
|
|
10
|
+
* - default 5-minute TTL,
|
|
11
|
+
* - single-use (the first valid `consume()` call marks the ticket
|
|
12
|
+
* consumed; every subsequent attempt rejects),
|
|
13
|
+
* - in-memory only (server restart invalidates everything; CLI /
|
|
14
|
+
* SDK clients use HTTP bearer instead so the lost ticket window
|
|
15
|
+
* does not affect them).
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import type { ParsedScope } from '@graphorin/security/auth';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stable shape returned by {@link WsTicketStore.issue}.
|
|
25
|
+
*
|
|
26
|
+
* @stable
|
|
27
|
+
*/
|
|
28
|
+
export interface WsTicket {
|
|
29
|
+
readonly value: string;
|
|
30
|
+
readonly expiresAt: number;
|
|
31
|
+
readonly issuedAt: number;
|
|
32
|
+
readonly tokenId: string;
|
|
33
|
+
readonly scopes: ReadonlyArray<ParsedScope>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stable result of {@link WsTicketStore.consume}.
|
|
38
|
+
*
|
|
39
|
+
* @stable
|
|
40
|
+
*/
|
|
41
|
+
export type WsTicketConsumeResult =
|
|
42
|
+
| { readonly ok: true; readonly ticket: WsTicket }
|
|
43
|
+
| { readonly ok: false; readonly reason: 'unknown' | 'consumed' | 'expired' };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Options accepted by {@link createWsTicketStore}.
|
|
47
|
+
*
|
|
48
|
+
* @stable
|
|
49
|
+
*/
|
|
50
|
+
export interface WsTicketStoreOptions {
|
|
51
|
+
/** Default `300_000` (5 min). */
|
|
52
|
+
readonly ttlMs?: number;
|
|
53
|
+
/** Cap on the number of unconsumed tickets retained. Default `1_000`. */
|
|
54
|
+
readonly maxOutstanding?: number;
|
|
55
|
+
readonly now?: () => number;
|
|
56
|
+
/**
|
|
57
|
+
* Random-bytes generator. Tests pass a deterministic source so
|
|
58
|
+
* ticket values are reproducible.
|
|
59
|
+
*/
|
|
60
|
+
readonly randomBytes?: (length: number) => Uint8Array;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pluggable in-memory ticket store used by the WS upgrade handler +
|
|
65
|
+
* the `POST /v1/session/ws-ticket` route.
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export interface WsTicketStore {
|
|
70
|
+
readonly ttlMs: number;
|
|
71
|
+
issue(input: { readonly tokenId: string; readonly scopes: ReadonlyArray<ParsedScope> }): WsTicket;
|
|
72
|
+
consume(value: string): WsTicketConsumeResult;
|
|
73
|
+
/** Drop expired entries; called on every `consume()`. */
|
|
74
|
+
prune(): number;
|
|
75
|
+
size(): number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface InternalEntry {
|
|
79
|
+
readonly ticket: WsTicket;
|
|
80
|
+
consumed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the default in-memory ticket store. Production deployments
|
|
85
|
+
* use exactly one store per process (multiple processes would each
|
|
86
|
+
* issue their own tickets - there is no shared state because the
|
|
87
|
+
* single-user-per-process default applies).
|
|
88
|
+
*
|
|
89
|
+
* @stable
|
|
90
|
+
*/
|
|
91
|
+
export function createWsTicketStore(options: WsTicketStoreOptions = {}): WsTicketStore {
|
|
92
|
+
const ttlMs = options.ttlMs ?? 5 * 60_000;
|
|
93
|
+
const maxOutstanding = options.maxOutstanding ?? 1_000;
|
|
94
|
+
const now = options.now ?? Date.now;
|
|
95
|
+
const random = options.randomBytes ?? ((n: number) => randomBytes(n));
|
|
96
|
+
const entries = new Map<string, InternalEntry>();
|
|
97
|
+
|
|
98
|
+
function evictOldest(): void {
|
|
99
|
+
const oldest = entries.keys().next().value;
|
|
100
|
+
if (oldest !== undefined) entries.delete(oldest);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function prune(): number {
|
|
104
|
+
const cutoff = now();
|
|
105
|
+
let removed = 0;
|
|
106
|
+
for (const [value, entry] of entries) {
|
|
107
|
+
if (entry.ticket.expiresAt <= cutoff) {
|
|
108
|
+
entries.delete(value);
|
|
109
|
+
removed += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return removed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function issue(input: {
|
|
116
|
+
readonly tokenId: string;
|
|
117
|
+
readonly scopes: ReadonlyArray<ParsedScope>;
|
|
118
|
+
}): WsTicket {
|
|
119
|
+
prune();
|
|
120
|
+
while (entries.size >= maxOutstanding) {
|
|
121
|
+
evictOldest();
|
|
122
|
+
}
|
|
123
|
+
const issuedAt = now();
|
|
124
|
+
const value = encodeTicket(random(24));
|
|
125
|
+
const ticket: WsTicket = {
|
|
126
|
+
value,
|
|
127
|
+
issuedAt,
|
|
128
|
+
expiresAt: issuedAt + ttlMs,
|
|
129
|
+
tokenId: input.tokenId,
|
|
130
|
+
scopes: Object.freeze(input.scopes.slice()),
|
|
131
|
+
};
|
|
132
|
+
entries.set(value, { ticket, consumed: false });
|
|
133
|
+
return ticket;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function consume(value: string): WsTicketConsumeResult {
|
|
137
|
+
const entry = entries.get(value);
|
|
138
|
+
if (entry === undefined) {
|
|
139
|
+
prune();
|
|
140
|
+
return { ok: false, reason: 'unknown' };
|
|
141
|
+
}
|
|
142
|
+
if (entry.ticket.expiresAt <= now()) {
|
|
143
|
+
entries.delete(value);
|
|
144
|
+
prune();
|
|
145
|
+
return { ok: false, reason: 'expired' };
|
|
146
|
+
}
|
|
147
|
+
if (entry.consumed) {
|
|
148
|
+
prune();
|
|
149
|
+
return { ok: false, reason: 'consumed' };
|
|
150
|
+
}
|
|
151
|
+
entry.consumed = true;
|
|
152
|
+
prune();
|
|
153
|
+
return { ok: true, ticket: entry.ticket };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
ttlMs,
|
|
158
|
+
issue,
|
|
159
|
+
consume,
|
|
160
|
+
prune,
|
|
161
|
+
size: () => entries.size,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function encodeTicket(bytes: Uint8Array): string {
|
|
166
|
+
// URL-safe base64 - Buffer.from is available in every supported
|
|
167
|
+
// Node runtime; the implementation never touches the value once
|
|
168
|
+
// encoded so the choice is purely cosmetic.
|
|
169
|
+
return Buffer.from(bytes)
|
|
170
|
+
.toString('base64')
|
|
171
|
+
.replace(/\+/g, '-')
|
|
172
|
+
.replace(/\//g, '_')
|
|
173
|
+
.replace(/=+$/g, '');
|
|
174
|
+
}
|