@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.
Files changed (182) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +6 -6
  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 +40 -611
  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 +9 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -26
  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 +6 -0
  59. package/dist/package.js.map +1 -0
  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.d.ts.map +1 -1
  99. package/dist/ws/dispatcher.js +19 -12
  100. package/dist/ws/dispatcher.js.map +1 -1
  101. package/dist/ws/index.d.ts +2 -2
  102. package/dist/ws/index.js +3 -3
  103. package/dist/ws/replay-buffer.d.ts +35 -1
  104. package/dist/ws/replay-buffer.d.ts.map +1 -1
  105. package/dist/ws/replay-buffer.js +35 -3
  106. package/dist/ws/replay-buffer.js.map +1 -1
  107. package/dist/ws/upgrade.d.ts.map +1 -1
  108. package/dist/ws/upgrade.js +26 -19
  109. package/dist/ws/upgrade.js.map +1 -1
  110. package/package.json +31 -30
  111. package/src/app-audit-binding.ts +227 -0
  112. package/src/app-daemons.ts +73 -0
  113. package/src/app-lifecycle.ts +476 -0
  114. package/src/app-metrics.ts +144 -0
  115. package/src/app-middleware.ts +149 -0
  116. package/src/app-routes.ts +305 -0
  117. package/src/app-ws.ts +111 -0
  118. package/src/app.ts +317 -0
  119. package/src/commentary/audit-bridge.ts +135 -0
  120. package/src/commentary/built-in-patterns.ts +79 -0
  121. package/src/commentary/index.ts +32 -0
  122. package/src/commentary/sanitizer.ts +238 -0
  123. package/src/commentary/types.ts +130 -0
  124. package/src/config.ts +472 -0
  125. package/src/consolidator/daemon.ts +141 -0
  126. package/src/consolidator/index.ts +14 -0
  127. package/src/errors/index.ts +247 -0
  128. package/src/health/checks.ts +322 -0
  129. package/src/health/index.ts +34 -0
  130. package/src/health/routes.ts +154 -0
  131. package/src/index.ts +243 -0
  132. package/src/internal/context.ts +77 -0
  133. package/src/internal/ids.ts +17 -0
  134. package/src/internal/json.ts +47 -0
  135. package/src/internal/wire-error.ts +44 -0
  136. package/src/lifecycle/hooks.ts +66 -0
  137. package/src/lifecycle/index.ts +16 -0
  138. package/src/lifecycle/pre-bind.ts +138 -0
  139. package/src/metrics/catalog.ts +112 -0
  140. package/src/metrics/index.ts +12 -0
  141. package/src/metrics/registry.ts +337 -0
  142. package/src/metrics/tools-bridge.ts +124 -0
  143. package/src/middleware/audit.ts +114 -0
  144. package/src/middleware/auth.ts +195 -0
  145. package/src/middleware/cors.ts +72 -0
  146. package/src/middleware/csrf.ts +98 -0
  147. package/src/middleware/idempotency.ts +336 -0
  148. package/src/middleware/index.ts +38 -0
  149. package/src/middleware/rate-limit.ts +71 -0
  150. package/src/middleware/request-state.ts +79 -0
  151. package/src/middleware/scope.ts +161 -0
  152. package/src/registry/index.ts +278 -0
  153. package/src/replay/index.ts +14 -0
  154. package/src/replay/routes.ts +255 -0
  155. package/src/routes/agents.ts +363 -0
  156. package/src/routes/audit.ts +207 -0
  157. package/src/routes/auth.ts +80 -0
  158. package/src/routes/health.ts +42 -0
  159. package/src/routes/index.ts +16 -0
  160. package/src/routes/mcp.ts +97 -0
  161. package/src/routes/memory.ts +152 -0
  162. package/src/routes/sessions.ts +250 -0
  163. package/src/routes/skills.ts +91 -0
  164. package/src/routes/tokens.ts +166 -0
  165. package/src/routes/workflows.ts +616 -0
  166. package/src/runtime/retention.ts +284 -0
  167. package/src/runtime/run-state.ts +422 -0
  168. package/src/sse/events.ts +303 -0
  169. package/src/sse/index.ts +7 -0
  170. package/src/tools-audit-bridge.ts +120 -0
  171. package/src/triggers/daemon.ts +198 -0
  172. package/src/triggers/index.ts +16 -0
  173. package/src/triggers/routes.ts +139 -0
  174. package/src/workflows/timer-daemon.ts +102 -0
  175. package/src/ws/dispatcher.ts +537 -0
  176. package/src/ws/index.ts +45 -0
  177. package/src/ws/replay-buffer.ts +209 -0
  178. package/src/ws/subjects.ts +162 -0
  179. package/src/ws/ticket.ts +174 -0
  180. package/src/ws/upgrade.ts +507 -0
  181. package/dist/lifecycle/index.js +0 -3
  182. package/dist/routes/index.js +0 -12
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Middleware wiring for `createServer({...})`.
3
+ *
4
+ * Two attachment points, called in this order by the composition:
5
+ *
6
+ * 1. {@link attachGlobalMiddleware} - request-state / CORS / CSRF /
7
+ * rate-limit, mounted on `*` at app construction time.
8
+ * 2. {@link attachProtectedMiddleware} - auth + audit + idempotency
9
+ * for the authenticated subtree, mounted by `mountRoutes` after
10
+ * the unauthenticated health / metrics endpoints.
11
+ *
12
+ * The relative order of these layers is part of the documented
13
+ * contract - do not reorder.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import type { TokenVerifier } from '@graphorin/security';
19
+ import type { AuditDb } from '@graphorin/security/audit';
20
+ import type { GraphorinSqliteStore } from '@graphorin/store-sqlite';
21
+ import type { Hono } from 'hono';
22
+ import type { ServerConfigSpec } from './config.js';
23
+ import type { ServerVariables } from './internal/context.js';
24
+ import type { MetricRegistry } from './metrics/registry.js';
25
+ import {
26
+ createAnonymousAuthMiddleware,
27
+ createAuditMiddleware,
28
+ createAuthMiddleware,
29
+ createCorsMiddleware,
30
+ createCsrfMiddleware,
31
+ createIdempotencyMiddleware,
32
+ createRateLimitMiddleware,
33
+ createRequestStateMiddleware,
34
+ } from './middleware/index.js';
35
+
36
+ /**
37
+ * Global middleware in the documented order. Per-route scope +
38
+ * idempotency layers are mounted by the route factories so the
39
+ * composition stays declarative + auditable.
40
+ */
41
+ export function attachGlobalMiddleware(
42
+ app: Hono<{ Variables: ServerVariables }>,
43
+ config: ServerConfigSpec,
44
+ now: () => number,
45
+ ): void {
46
+ app.use(
47
+ '*',
48
+ createRequestStateMiddleware({
49
+ now,
50
+ ...(config.server.trustProxy ? { trustProxy: true } : {}),
51
+ }),
52
+ );
53
+ app.use('*', createCorsMiddleware(config.server.cors));
54
+ app.use('*', createCsrfMiddleware(config.server.csrf));
55
+ app.use('*', createRateLimitMiddleware(config.server.rateLimit, { now }));
56
+ }
57
+
58
+ /** Dependencies consumed by {@link attachProtectedMiddleware}. */
59
+ export interface ProtectedMiddlewareContext {
60
+ readonly now: () => number;
61
+ readonly store: GraphorinSqliteStore;
62
+ readonly metricRegistry: MetricRegistry;
63
+ readonly verifier?: TokenVerifier | undefined;
64
+ readonly auditDb?: AuditDb | undefined;
65
+ }
66
+
67
+ /**
68
+ * Authenticated subtree begins here. The health endpoint above is
69
+ * intentionally outside the auth boundary so liveness probes work
70
+ * before the token verifier is wired. The WebSocket upgrade path is
71
+ * also exempt: the upgrade handler in `ws/upgrade.ts` performs its
72
+ * own bearer + ticket validation inline (the HTTP auth middleware
73
+ * would otherwise short-circuit the upgrade with a 401 response
74
+ * before the handler can negotiate the subprotocol).
75
+ */
76
+ export function attachProtectedMiddleware(
77
+ app: Hono<{ Variables: ServerVariables }>,
78
+ config: ServerConfigSpec,
79
+ ctx: ProtectedMiddlewareContext,
80
+ ): { readonly anonymousAuth: boolean } {
81
+ const base = config.server.basePath;
82
+ const wsUpgradePath = config.server.ws.enabled ? `${base}${config.server.ws.path}` : undefined;
83
+ const metricsPath =
84
+ config.metrics.enabled && !config.metrics.requireAuth
85
+ ? `${base}${config.metrics.path}`
86
+ : undefined;
87
+ function shouldSkipAuth(path: string): boolean {
88
+ if (path === `${base}/health` || path === `${base}/health/`) return true;
89
+ if (wsUpgradePath !== undefined) {
90
+ if (path === wsUpgradePath || path === `${wsUpgradePath}/`) return true;
91
+ }
92
+ if (metricsPath !== undefined) {
93
+ if (path === metricsPath || path === `${metricsPath}/`) return true;
94
+ }
95
+ return false;
96
+ }
97
+ // IP-13: in the no-auth loopback mode (`auth.kind='none'`) there is no
98
+ // verifier, but the authenticated subtree must still be reachable - install
99
+ // an anonymous middleware that stamps a fully-authorized principal so the
100
+ // domain routes serve instead of every one of them returning 401.
101
+ const anonymousAuth = ctx.verifier === undefined && config.auth.kind === 'none';
102
+ if (ctx.verifier !== undefined || anonymousAuth) {
103
+ const authMw =
104
+ ctx.verifier !== undefined
105
+ ? createAuthMiddleware({ verifier: ctx.verifier })
106
+ : createAnonymousAuthMiddleware();
107
+ app.use(`${base}/*`, async (c, next) => {
108
+ if (shouldSkipAuth(c.req.path)) {
109
+ await next();
110
+ return;
111
+ }
112
+ return authMw(c, next);
113
+ });
114
+ if (ctx.auditDb !== undefined) {
115
+ const auditMw = createAuditMiddleware({ auditDb: ctx.auditDb, now: ctx.now });
116
+ app.use(`${base}/*`, async (c, next) => {
117
+ if (shouldSkipAuth(c.req.path)) {
118
+ await next();
119
+ return;
120
+ }
121
+ return auditMw(c, next);
122
+ });
123
+ }
124
+ }
125
+
126
+ // Idempotency middleware - applied to side-effecting endpoints. We
127
+ // mount it once on the authenticated subtree so handlers don't have
128
+ // to duplicate the configuration per-route.
129
+ if (config.server.idempotency.enabled) {
130
+ const idempotencyMw = createIdempotencyMiddleware({
131
+ store: ctx.store.idempotency,
132
+ config: config.server.idempotency,
133
+ now: ctx.now,
134
+ // IP-6: token minting returns a raw secret - never cache it.
135
+ excludeResponseCachePaths: [`${base}/tokens`],
136
+ // IP-15: publish the live cache hit ratio gauge.
137
+ metricRegistry: ctx.metricRegistry,
138
+ });
139
+ app.use(`${base}/*`, async (c, next) => {
140
+ if (shouldSkipAuth(c.req.path)) {
141
+ await next();
142
+ return;
143
+ }
144
+ return idempotencyMw(c, next);
145
+ });
146
+ }
147
+
148
+ return { anonymousAuth };
149
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * HTTP route mounting for `createServer({...})` - health, metrics,
3
+ * the authenticated middleware boundary, every REST domain surface,
4
+ * the WebSocket upgrade, and SSE. Mount order is part of the
5
+ * documented contract (unauthenticated health/metrics first, then the
6
+ * auth boundary, then the domain routes) - do not reorder.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import type { TokenVerifier } from '@graphorin/security';
12
+ import type { AuditDb } from '@graphorin/security/audit';
13
+ import type { GraphorinSqliteStore } from '@graphorin/store-sqlite';
14
+ import type { createNodeWebSocket } from '@hono/node-ws';
15
+ import type { Hono } from 'hono';
16
+ import { refreshLiveMetrics } from './app-metrics.js';
17
+ import { attachProtectedMiddleware } from './app-middleware.js';
18
+ import type { ServerConfigSpec } from './config.js';
19
+ import type { ConsolidatorDaemon } from './consolidator/daemon.js';
20
+ import {
21
+ createExtendedHealthRoutes,
22
+ createMetricsRoutes,
23
+ createSecretsHealthRoutes,
24
+ type HealthCheckOptions,
25
+ } from './health/index.js';
26
+ import type { ServerVariables } from './internal/context.js';
27
+ import type { MetricRegistry } from './metrics/registry.js';
28
+ import type { AgentRegistry, WorkflowRegistry } from './registry/index.js';
29
+ import { createReplayRoutes, type ReplayApi } from './replay/index.js';
30
+ import {
31
+ type AuditApi,
32
+ createAgentRoutes,
33
+ createAuditRoutes,
34
+ createAuthRoutes,
35
+ createMcpRoutes,
36
+ createMemoryRoutes,
37
+ createRunRoutes,
38
+ createSessionRoutes,
39
+ createSkillsRoutes,
40
+ createTokensRoutes,
41
+ createWorkflowRoutes,
42
+ type McpApi,
43
+ type MemoryApi,
44
+ type SessionApi,
45
+ type SkillsApi,
46
+ } from './routes/index.js';
47
+ import type { RunStateTracker } from './runtime/run-state.js';
48
+ import { createSseRoutes } from './sse/index.js';
49
+ import type { TriggersDaemon } from './triggers/daemon.js';
50
+ import { createTriggersRoutes } from './triggers/routes.js';
51
+ import type { WorkflowTimerDaemon } from './workflows/timer-daemon.js';
52
+ import type { WsDispatcher, WsTicketStore } from './ws/index.js';
53
+ import { createWsUpgradeEvents } from './ws/upgrade.js';
54
+
55
+ export interface MountRoutesContext {
56
+ readonly version: string;
57
+ readonly startedAt: number;
58
+ readonly now: () => number;
59
+ readonly agents: AgentRegistry;
60
+ readonly workflows: WorkflowRegistry;
61
+ readonly runs: RunStateTracker;
62
+ readonly store: GraphorinSqliteStore;
63
+ readonly metricRegistry: MetricRegistry;
64
+ readonly sessions?: SessionApi;
65
+ readonly memory?: MemoryApi;
66
+ readonly skills?: SkillsApi;
67
+ readonly mcp?: McpApi;
68
+ readonly audit?: AuditApi;
69
+ readonly replay?: ReplayApi;
70
+ readonly healthProbes?: () => HealthCheckOptions | Promise<HealthCheckOptions>;
71
+ readonly verifier?: TokenVerifier;
72
+ readonly auditDb?: AuditDb;
73
+ readonly pepper?: import('@graphorin/security').SecretValue;
74
+ readonly wsDispatcher?: WsDispatcher;
75
+ readonly wsTickets?: WsTicketStore;
76
+ readonly wsAdapter?: ReturnType<typeof createNodeWebSocket>;
77
+ readonly triggersDaemon?: TriggersDaemon;
78
+ readonly consolidatorDaemon?: ConsolidatorDaemon;
79
+ readonly workflowTimerDaemon?: WorkflowTimerDaemon;
80
+ }
81
+
82
+ /** IP-23: is `host` a loopback interface (so an open /metrics is not exposed)? */
83
+ function isLoopbackHost(host: string): boolean {
84
+ const h = host.trim().toLowerCase();
85
+ return h === '127.0.0.1' || h === '::1' || h === '[::1]' || h === 'localhost';
86
+ }
87
+
88
+ export function mountRoutes(
89
+ app: Hono<{ Variables: ServerVariables }>,
90
+ config: ServerConfigSpec,
91
+ ctx: MountRoutesContext,
92
+ ): void {
93
+ const base = config.server.basePath;
94
+ const probes =
95
+ ctx.healthProbes ??
96
+ (() =>
97
+ buildDefaultHealthProbes(
98
+ ctx.store,
99
+ ctx.triggersDaemon,
100
+ ctx.consolidatorDaemon,
101
+ ctx.wsDispatcher,
102
+ config,
103
+ ctx.workflowTimerDaemon,
104
+ ));
105
+ const health = createExtendedHealthRoutes({
106
+ version: ctx.version,
107
+ startedAt: ctx.startedAt,
108
+ now: ctx.now,
109
+ probes,
110
+ });
111
+ app.route(`${base}/health`, health);
112
+
113
+ // IP-13: `auth.kind='none'` disables authentication on every route. It is the
114
+ // documented trusted-loopback / single-operator mode, but binding a
115
+ // non-loopback host with auth off exposes full admin access (including the WS
116
+ // stream) to anyone who can reach it - warn loudly rather than silently.
117
+ if (config.auth.kind === 'none' && !isLoopbackHost(config.server.host)) {
118
+ console.warn(
119
+ `[graphorin/server] WARN: auth.kind='none' disables authentication on every endpoint, ` +
120
+ `but the server binds the non-loopback host '${config.server.host}'. Anyone who can reach ` +
121
+ `it has full admin access - use auth.kind='token' for non-loopback deployments or bind a ` +
122
+ `loopback host.`,
123
+ );
124
+ }
125
+
126
+ if (config.metrics.enabled) {
127
+ // IP-23: an unauthenticated /metrics endpoint leaks operational intel
128
+ // (trigger ids in labels, consolidator budgets). It is fine on a loopback
129
+ // host, but binding a non-loopback host with auth off silently exposes it.
130
+ if (!config.metrics.requireAuth && !isLoopbackHost(config.server.host)) {
131
+ console.warn(
132
+ `[graphorin/server] WARN: /metrics is unauthenticated (metrics.requireAuth=false) and ` +
133
+ `the server binds the non-loopback host '${config.server.host}'. The exposition leaks ` +
134
+ `operational detail to anyone who can reach it - set metrics.requireAuth=true or bind a ` +
135
+ `loopback host.`,
136
+ );
137
+ }
138
+ const metricsRoute = createMetricsRoutes({
139
+ registry: ctx.metricRegistry,
140
+ requireAuth: config.metrics.requireAuth,
141
+ refresh: () =>
142
+ refreshLiveMetrics({
143
+ registry: ctx.metricRegistry,
144
+ store: ctx.store,
145
+ runs: ctx.runs,
146
+ startedAt: ctx.startedAt,
147
+ now: ctx.now,
148
+ ...(ctx.triggersDaemon !== undefined ? { triggersDaemon: ctx.triggersDaemon } : {}),
149
+ ...(ctx.consolidatorDaemon !== undefined
150
+ ? { consolidatorDaemon: ctx.consolidatorDaemon }
151
+ : {}),
152
+ ...(ctx.wsDispatcher !== undefined ? { wsDispatcher: ctx.wsDispatcher } : {}),
153
+ }),
154
+ });
155
+ app.route(`${base}${config.metrics.path}`, metricsRoute);
156
+ }
157
+
158
+ // Auth + audit + idempotency for everything below this line - see
159
+ // `app-middleware.ts` for the boundary semantics and skip list.
160
+ const { anonymousAuth } = attachProtectedMiddleware(app, config, ctx);
161
+
162
+ // Mounted AFTER the auth middleware so the scope check has a
163
+ // verified token to inspect; the unauthenticated `/v1/health` GET
164
+ // continues to serve the rollup.
165
+ app.route(`${base}/health/secrets`, createSecretsHealthRoutes());
166
+
167
+ app.route(
168
+ `${base}/agents`,
169
+ createAgentRoutes({
170
+ agents: ctx.agents,
171
+ runs: ctx.runs,
172
+ // IP-2: the streaming dispatcher reaches the route layer.
173
+ ...(ctx.wsDispatcher !== undefined ? { dispatcher: ctx.wsDispatcher } : {}),
174
+ }),
175
+ );
176
+ app.route(`${base}/runs`, createRunRoutes({ agents: ctx.agents, runs: ctx.runs }));
177
+ app.route(
178
+ `${base}/workflows`,
179
+ createWorkflowRoutes({
180
+ workflows: ctx.workflows,
181
+ runs: ctx.runs,
182
+ ...(ctx.wsDispatcher !== undefined ? { dispatcher: ctx.wsDispatcher } : {}),
183
+ }),
184
+ );
185
+ if (ctx.sessions !== undefined) {
186
+ app.route(`${base}/sessions`, createSessionRoutes({ sessions: ctx.sessions }));
187
+ }
188
+ if (ctx.memory !== undefined) {
189
+ app.route(`${base}/memory`, createMemoryRoutes({ memory: ctx.memory }));
190
+ }
191
+ if (ctx.skills !== undefined) {
192
+ app.route(`${base}/skills`, createSkillsRoutes({ skills: ctx.skills }));
193
+ }
194
+ if (ctx.mcp !== undefined) {
195
+ app.route(`${base}/mcp`, createMcpRoutes({ mcp: ctx.mcp }));
196
+ }
197
+ if (ctx.audit !== undefined) {
198
+ app.route(`${base}/audit`, createAuditRoutes({ audit: ctx.audit }));
199
+ }
200
+ if (ctx.triggersDaemon !== undefined) {
201
+ app.route(`${base}/triggers`, createTriggersRoutes({ daemon: ctx.triggersDaemon }));
202
+ }
203
+ if (ctx.replay !== undefined) {
204
+ app.route(
205
+ `${base}`,
206
+ createReplayRoutes({
207
+ replay: ctx.replay,
208
+ ...(ctx.auditDb !== undefined ? { auditDb: ctx.auditDb } : {}),
209
+ now: ctx.now,
210
+ }),
211
+ );
212
+ }
213
+ if (config.auth.kind === 'token' && ctx.pepper !== undefined) {
214
+ app.route(
215
+ `${base}/tokens`,
216
+ createTokensRoutes({
217
+ tokenStore: ctx.store.authTokens,
218
+ pepper: ctx.pepper,
219
+ defaultEnv: 'live',
220
+ allowedEnvs: config.auth.tokenEnvironments,
221
+ }),
222
+ );
223
+ }
224
+ if (ctx.wsTickets !== undefined) {
225
+ app.route(`${base}`, createAuthRoutes({ tickets: ctx.wsTickets }));
226
+ }
227
+ // IP-13: mount the WS upgrade when a verifier is wired OR auth is disabled
228
+ // (`auth.kind='none'`). The old condition required a verifier, so
229
+ // `ws.enabled: true` under no-auth was silently ignored - the route never
230
+ // mounted and clients got a bare 404 with no explanation.
231
+ if (
232
+ ctx.wsDispatcher !== undefined &&
233
+ ctx.wsTickets !== undefined &&
234
+ ctx.wsAdapter !== undefined &&
235
+ (ctx.verifier !== undefined || anonymousAuth) &&
236
+ config.server.ws.enabled
237
+ ) {
238
+ const dispatcher = ctx.wsDispatcher;
239
+ const tickets = ctx.wsTickets;
240
+ const verifier = ctx.verifier;
241
+ const runs = ctx.runs;
242
+ app.get(
243
+ `${base}${config.server.ws.path}`,
244
+ ctx.wsAdapter.upgradeWebSocket((c) =>
245
+ createWsUpgradeEvents(c, {
246
+ dispatcher,
247
+ tickets,
248
+ ...(verifier !== undefined ? { verifier } : {}),
249
+ anonymous: anonymousAuth,
250
+ runs,
251
+ now: ctx.now,
252
+ }),
253
+ ),
254
+ );
255
+ }
256
+ if (ctx.wsDispatcher !== undefined && config.server.sse.enabled) {
257
+ app.route(
258
+ `${base}${config.server.sse.path}`,
259
+ createSseRoutes({
260
+ dispatcher: ctx.wsDispatcher,
261
+ keepAliveMs: config.server.sse.keepAliveMs,
262
+ // IP-9: bound the per-connection delivery queue.
263
+ perConnectionQueueLimit: config.server.stream.perConnectionQueueLimit,
264
+ now: ctx.now,
265
+ }),
266
+ );
267
+ }
268
+ }
269
+
270
+ function buildDefaultHealthProbes(
271
+ store: GraphorinSqliteStore,
272
+ triggersDaemon: TriggersDaemon | undefined,
273
+ consolidatorDaemon: ConsolidatorDaemon | undefined,
274
+ dispatcher: WsDispatcher | undefined,
275
+ config: ServerConfigSpec,
276
+ workflowTimerDaemon?: WorkflowTimerDaemon,
277
+ ): HealthCheckOptions {
278
+ const out: {
279
+ -readonly [K in keyof HealthCheckOptions]?: HealthCheckOptions[K];
280
+ } = {
281
+ store,
282
+ walWarnThresholdBytes: config.health.walWarnThresholdBytes,
283
+ encryptionEnabled: config.storage.encryption.enabled,
284
+ // IP-1: when this process built the encrypted store itself, the
285
+ // keyed open at boot proved the cipher peer - report the fact.
286
+ ...(config.storage.encryption.enabled ? { cipherPeerInstalled: true } : {}),
287
+ };
288
+ if (triggersDaemon !== undefined) out.triggers = triggersDaemon;
289
+ if (consolidatorDaemon !== undefined) out.consolidator = consolidatorDaemon;
290
+ if (workflowTimerDaemon !== undefined) out.workflowTimers = workflowTimerDaemon;
291
+ if (dispatcher !== undefined) {
292
+ const sizes = dispatcher.size();
293
+ // The dispatcher only exposes (subscribers, subscriptions); the
294
+ // per-subject replay-buffer occupancy is owned by each
295
+ // subscription, not the dispatcher itself. Surface the active
296
+ // subscription count as a usable proxy + the raw subscriber count
297
+ // so operators can correlate.
298
+ out.replayBuffer = {
299
+ eventsBuffered: sizes.subscriptions,
300
+ subscribers: sizes.subscribers,
301
+ subscriptions: sizes.subscriptions,
302
+ };
303
+ }
304
+ return Object.freeze(out as HealthCheckOptions);
305
+ }
package/src/app-ws.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * WebSocket layer construction for `createServer({...})` - dispatcher,
3
+ * single-use ticket store, the `@hono/node-ws` adapter, and the
4
+ * Graphorin-aware subprotocol negotiation policy. Built before the
5
+ * listener starts; the upgrade route itself is mounted in
6
+ * `app-routes.ts` and the adapter is injected into the Node server in
7
+ * `app-lifecycle.ts`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import { negotiateSubprotocol, SUBPROTOCOL_NAME } from '@graphorin/protocol';
13
+ import { createNodeWebSocket } from '@hono/node-ws';
14
+ import type { Hono } from 'hono';
15
+ import { createLateBoundCommentarySink, type LateBoundCommentarySink } from './commentary/index.js';
16
+ import type { ServerConfigSpec } from './config.js';
17
+ import type { ServerVariables } from './internal/context.js';
18
+ import {
19
+ createWsDispatcher,
20
+ createWsTicketStore,
21
+ type WsDispatcher,
22
+ type WsTicketStore,
23
+ } from './ws/index.js';
24
+
25
+ /**
26
+ * Everything the WebSocket layer contributes to the composed server.
27
+ * Every member is `undefined` when `server.ws.enabled` is false.
28
+ */
29
+ export interface WsLayer {
30
+ readonly dispatcher: WsDispatcher | undefined;
31
+ readonly tickets: WsTicketStore | undefined;
32
+ readonly wsAdapter: ReturnType<typeof createNodeWebSocket> | undefined;
33
+ readonly commentaryAuditSink: LateBoundCommentarySink | undefined;
34
+ }
35
+
36
+ /**
37
+ * Build the WS dispatcher + ticket store + node-ws adapter when
38
+ * `server.ws.enabled` is true.
39
+ */
40
+ export function buildWsLayer(
41
+ app: Hono<{ Variables: ServerVariables }>,
42
+ config: ServerConfigSpec,
43
+ now: () => number,
44
+ ): WsLayer {
45
+ let dispatcher: WsDispatcher | undefined;
46
+ let tickets: WsTicketStore | undefined;
47
+ let wsAdapter: ReturnType<typeof createNodeWebSocket> | undefined;
48
+ // IP-21: the dispatcher is built here, before the audit DB opens in start().
49
+ // Hand it a late-bound commentary sink now and install the audit-writing
50
+ // target once the DB exists - otherwise the sanitizer's documented decisions
51
+ // (wrapped/stripped frames with before/after digests) are dropped.
52
+ let commentaryAuditSink: LateBoundCommentarySink | undefined;
53
+ if (config.server.ws.enabled) {
54
+ commentaryAuditSink = createLateBoundCommentarySink();
55
+ dispatcher = createWsDispatcher({
56
+ commentary: {
57
+ policy: config.server.ws.commentarySanitization.policy,
58
+ applyToEvents: config.server.ws.commentarySanitization.applyToEvents,
59
+ sink: commentaryAuditSink,
60
+ },
61
+ replayBuffer: {
62
+ maxEvents: config.server.stream.replayBuffer.maxEvents,
63
+ ttlMs: config.server.stream.replayBuffer.ttlSeconds * 1000,
64
+ },
65
+ perConnectionQueueLimit: config.server.stream.perConnectionQueueLimit,
66
+ now,
67
+ });
68
+ tickets = createWsTicketStore({
69
+ ttlMs: config.server.ws.ticketTtlMs,
70
+ now,
71
+ });
72
+ wsAdapter = createNodeWebSocket({ app });
73
+ // The WS server inside @hono/node-ws is created without any
74
+ // subprotocol-negotiation policy; without one, the `ws` library
75
+ // never echoes back `Sec-WebSocket-Protocol` and clients that
76
+ // advertised a subprotocol close the connection immediately.
77
+ // Mutate the options to install a Graphorin-aware policy that
78
+ // selects `graphorin.protocol.v1` when the client offered it.
79
+ const wssOptions = (
80
+ wsAdapter.wss as unknown as {
81
+ options: {
82
+ handleProtocols?: (
83
+ protocols: Set<string>,
84
+ request: import('node:http').IncomingMessage,
85
+ ) => string | false;
86
+ };
87
+ }
88
+ ).options;
89
+ wssOptions.handleProtocols = (protocols) => {
90
+ const negotiated = negotiateSubprotocol(Array.from(protocols));
91
+ if (negotiated !== null) return negotiated;
92
+ // Browser ticket flow: the `WebSocket` constructor cannot set an
93
+ // `Authorization` header, so the browser client offers two
94
+ // subprotocol tokens - the canonical `graphorin.protocol.v1`
95
+ // name plus a `ticket.<value>` token (see the wire contract in
96
+ // `@graphorin/protocol`'s `subprotocol.ts`:
97
+ // `SUBPROTOCOL_NAME` / `TICKET_SUBPROTOCOL_PREFIX` /
98
+ // `parseTicketSubprotocol`). The server MUST echo back exactly
99
+ // the canonical name (never the `ticket.*` token) so the
100
+ // handshake's `Sec-WebSocket-Protocol` response stays valid; the
101
+ // ticket value is consumed separately by `createWsUpgradeEvents`
102
+ // via `parseTicketSubprotocol` and exchanged through the
103
+ // single-use `WsTicketStore`.
104
+ for (const candidate of protocols) {
105
+ if (candidate === SUBPROTOCOL_NAME) return SUBPROTOCOL_NAME;
106
+ }
107
+ return false;
108
+ };
109
+ }
110
+ return { dispatcher, tickets, wsAdapter, commentaryAuditSink };
111
+ }