@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,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
+ }
@@ -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
+ }