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