@graphorin/server 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +3 -3
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -607
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- package/dist/routes/index.js +0 -12
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST `Idempotency-Key` middleware per IETF
|
|
3
|
+
* draft-ietf-httpapi-idempotency-key-header-07. Layers an LRU
|
|
4
|
+
* read-cache over a durable {@link IdempotencyStore} so:
|
|
5
|
+
*
|
|
6
|
+
* - Replays of a successful request return the cached response body
|
|
7
|
+
* AND the original status code, with a synthetic
|
|
8
|
+
* `Idempotency-Replayed: true` header so clients can distinguish
|
|
9
|
+
* the cached path.
|
|
10
|
+
* - Replays of a key with a different request body return `409
|
|
11
|
+
* Conflict` and a typed error body (per ADR-036 / DEC-142).
|
|
12
|
+
* - 5xx + 408 + 429 + 503 responses are NOT cached so transient
|
|
13
|
+
* errors do not pin the operator into a non-recoverable state.
|
|
14
|
+
*
|
|
15
|
+
* Streaming endpoints (`/v1/agents/:id/stream`,
|
|
16
|
+
* `/v1/workflows/:id/execute` when invoked with the `stream=true`
|
|
17
|
+
* query) cache only the initial 202 + `runId` envelope; the actual
|
|
18
|
+
* event stream is replayed via the WebSocket layer (Phase 14b).
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { IdempotencyRecord, IdempotencyStore } from '@graphorin/store-sqlite';
|
|
24
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
25
|
+
|
|
26
|
+
import type { ServerConfigSpec } from '../config.js';
|
|
27
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
28
|
+
import { fingerprintRequest } from '../internal/json.js';
|
|
29
|
+
import { SERVER_METRIC_NAMES } from '../metrics/catalog.js';
|
|
30
|
+
import type { MetricRegistry } from '../metrics/registry.js';
|
|
31
|
+
|
|
32
|
+
const HEADER_NAME = 'idempotency-key';
|
|
33
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
|
34
|
+
// Status codes the IETF draft + Stripe convention exclude from
|
|
35
|
+
// idempotent caching (always retry-eligible).
|
|
36
|
+
const NON_CACHEABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
37
|
+
const KEY_RE = /^[A-Za-z0-9_\-:]{8,255}$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options accepted by {@link createIdempotencyMiddleware}.
|
|
41
|
+
*
|
|
42
|
+
* @stable
|
|
43
|
+
*/
|
|
44
|
+
export interface IdempotencyMiddlewareOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Paths whose responses are NEVER cached or replayed (IP-6) - the
|
|
47
|
+
* middleware passes straight through. Used for secret-bearing
|
|
48
|
+
* endpoints (`POST /v1/tokens` returns a raw token; caching it would
|
|
49
|
+
* persist the secret plaintext in durable SQLite for the TTL).
|
|
50
|
+
*/
|
|
51
|
+
readonly excludeResponseCachePaths?: ReadonlyArray<string>;
|
|
52
|
+
readonly store: IdempotencyStore;
|
|
53
|
+
readonly config: ServerConfigSpec['server']['idempotency'];
|
|
54
|
+
/** Wall-clock provider; tests inject a deterministic generator. */
|
|
55
|
+
readonly now?: () => number;
|
|
56
|
+
/**
|
|
57
|
+
* IP-15: when supplied, the middleware publishes a live
|
|
58
|
+
* `graphorin_idempotency_cache_hit_ratio` gauge (replays / replays+executes)
|
|
59
|
+
* instead of leaving the registered metric a permanently-empty series.
|
|
60
|
+
*/
|
|
61
|
+
readonly metricRegistry?: MetricRegistry;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface CacheEntry {
|
|
65
|
+
readonly record: IdempotencyRecord;
|
|
66
|
+
readonly cachedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @stable
|
|
71
|
+
*/
|
|
72
|
+
export function createIdempotencyMiddleware(
|
|
73
|
+
options: IdempotencyMiddlewareOptions,
|
|
74
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
75
|
+
const { store, config } = options;
|
|
76
|
+
if (!config.enabled) {
|
|
77
|
+
return async (_, next) => {
|
|
78
|
+
await next();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const lru = new SimpleLru<string, CacheEntry>(config.lruCacheSize);
|
|
83
|
+
|
|
84
|
+
// periphery-08: keys whose execution is currently in flight - a
|
|
85
|
+
// concurrent duplicate is rejected (409) instead of double-executing.
|
|
86
|
+
const inFlight = new Set<string>();
|
|
87
|
+
|
|
88
|
+
const excluded = new Set(options.excludeResponseCachePaths ?? []);
|
|
89
|
+
|
|
90
|
+
// IP-15: publish the cache hit ratio as a live gauge. A "hit" is a replay
|
|
91
|
+
// served from a stored record; a "miss" is a keyed request that executed
|
|
92
|
+
// fresh. Conflicts (409) are abnormal and excluded from the ratio.
|
|
93
|
+
const registry = options.metricRegistry;
|
|
94
|
+
let hits = 0;
|
|
95
|
+
let lookups = 0;
|
|
96
|
+
const recordCacheOutcome = (hit: boolean): void => {
|
|
97
|
+
if (registry === undefined) return;
|
|
98
|
+
lookups += 1;
|
|
99
|
+
if (hit) hits += 1;
|
|
100
|
+
registry.set(SERVER_METRIC_NAMES.idempotencyCacheHitRatio, hits / lookups);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return async (c, next) => {
|
|
104
|
+
if (SAFE_METHODS.has(c.req.method.toUpperCase())) {
|
|
105
|
+
await next();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// IP-6: secret-bearing endpoints opt out of response caching
|
|
109
|
+
// entirely - repeats re-execute and no body is ever persisted.
|
|
110
|
+
if (excluded.has(c.req.path)) {
|
|
111
|
+
await next();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const key = c.req.header(HEADER_NAME)?.trim();
|
|
115
|
+
if (key === undefined || key.length === 0) {
|
|
116
|
+
if (config.requireKey === 'enforce') {
|
|
117
|
+
return c.json(
|
|
118
|
+
{
|
|
119
|
+
error: 'idempotency-key-required',
|
|
120
|
+
message: 'Idempotency-Key header is required for this endpoint.',
|
|
121
|
+
hint: "Set 'Idempotency-Key: <uuid>' on the request.",
|
|
122
|
+
},
|
|
123
|
+
400,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// 'warn' / 'off' - pass-through with a hint header.
|
|
127
|
+
if (config.requireKey === 'warn') {
|
|
128
|
+
c.header('Idempotency-Status', 'header-missing');
|
|
129
|
+
}
|
|
130
|
+
await next();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!KEY_RE.test(key)) {
|
|
134
|
+
return c.json(
|
|
135
|
+
{
|
|
136
|
+
error: 'idempotency-key-required',
|
|
137
|
+
message: 'Idempotency-Key must be 8-255 chars of [A-Za-z0-9_:-].',
|
|
138
|
+
},
|
|
139
|
+
400,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
c.set('state', { ...c.get('state'), idempotencyKey: key });
|
|
143
|
+
const fingerprint = await computeFingerprint(c);
|
|
144
|
+
const cached = lru.get(key);
|
|
145
|
+
let record: IdempotencyRecord | null;
|
|
146
|
+
if (cached !== undefined && now() - cached.cachedAt < 5 * 60 * 1000) {
|
|
147
|
+
record = cached.record;
|
|
148
|
+
} else {
|
|
149
|
+
record = await store.get(key);
|
|
150
|
+
if (record !== null) {
|
|
151
|
+
lru.set(key, { record, cachedAt: now() });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// IP-6: the verified principal the record is bound to. Replays are
|
|
155
|
+
// served only to the SAME principal that originally executed the
|
|
156
|
+
// request (who passed the route's scope checks at execute time) -
|
|
157
|
+
// a different token cannot fetch someone else's cached response,
|
|
158
|
+
// closing the scope-bypass the pre-router mount opened.
|
|
159
|
+
const auth = c.get('state').auth;
|
|
160
|
+
const principal = auth.kind === 'token' ? auth.token.tokenId : 'anonymous';
|
|
161
|
+
if (record !== null) {
|
|
162
|
+
if (record.scope !== undefined && record.scope !== principal) {
|
|
163
|
+
return c.json(
|
|
164
|
+
{
|
|
165
|
+
error: 'idempotency-conflict',
|
|
166
|
+
message: `Idempotency-Key '${key}' is bound to a different principal.`,
|
|
167
|
+
},
|
|
168
|
+
409,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (config.checkBodyFingerprint && record.requestHash !== fingerprint) {
|
|
172
|
+
return c.json(
|
|
173
|
+
{
|
|
174
|
+
error: 'idempotency-conflict',
|
|
175
|
+
message: `Idempotency-Key '${key}' was previously used with a different request body.`,
|
|
176
|
+
},
|
|
177
|
+
409,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (record.expiresAt > now()) {
|
|
181
|
+
c.set('state', { ...c.get('state'), idempotencyReplay: true });
|
|
182
|
+
const headers = record.responseHeaders ?? {};
|
|
183
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
184
|
+
c.header(name, value);
|
|
185
|
+
}
|
|
186
|
+
c.header('Idempotency-Replayed', 'true');
|
|
187
|
+
recordCacheOutcome(true);
|
|
188
|
+
return new Response(JSON.stringify(record.response), {
|
|
189
|
+
status: record.statusCode,
|
|
190
|
+
headers: {
|
|
191
|
+
...headers,
|
|
192
|
+
'Content-Type': 'application/json',
|
|
193
|
+
'Idempotency-Replayed': 'true',
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// Expired - fall through to re-execute.
|
|
198
|
+
lru.delete(key);
|
|
199
|
+
await store.delete(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// periphery-08: the record is only written AFTER next(), so two
|
|
203
|
+
// concurrent requests with the same key would both miss the cache
|
|
204
|
+
// and both execute (a double agent run). Track in-flight keyed
|
|
205
|
+
// executions in-process (the server is single-process by design)
|
|
206
|
+
// and reject concurrent duplicates per
|
|
207
|
+
// draft-ietf-httpapi-idempotency-key-header: 409 + Retry-After.
|
|
208
|
+
// The key stays in-flight until the record is CACHED (or found
|
|
209
|
+
// uncacheable), so a duplicate never slips in between execution
|
|
210
|
+
// and the write.
|
|
211
|
+
if (inFlight.has(key)) {
|
|
212
|
+
c.header('Retry-After', '1');
|
|
213
|
+
return c.json(
|
|
214
|
+
{
|
|
215
|
+
error: 'idempotency-in-flight',
|
|
216
|
+
message: `A request with Idempotency-Key '${key}' is currently being processed.`,
|
|
217
|
+
},
|
|
218
|
+
409,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
inFlight.add(key);
|
|
222
|
+
try {
|
|
223
|
+
// A keyed request that reaches execution is a cache miss.
|
|
224
|
+
recordCacheOutcome(false);
|
|
225
|
+
await next();
|
|
226
|
+
|
|
227
|
+
const status = c.res.status;
|
|
228
|
+
if (NON_CACHEABLE_STATUS.has(status)) {
|
|
229
|
+
// Retry-eligible - leave nothing in the cache.
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const responseBody = await captureJsonResponse(c);
|
|
233
|
+
if (responseBody === null) {
|
|
234
|
+
// Non-JSON or empty body - skip caching to keep the schema
|
|
235
|
+
// simple; clients that need response replay can use bodied
|
|
236
|
+
// POSTs.
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const responseHeaders = collectResponseHeaders(c);
|
|
240
|
+
const record_: IdempotencyRecord = {
|
|
241
|
+
key,
|
|
242
|
+
requestHash: fingerprint,
|
|
243
|
+
statusCode: status,
|
|
244
|
+
response: responseBody,
|
|
245
|
+
...(responseHeaders !== undefined ? { responseHeaders } : {}),
|
|
246
|
+
// IP-6: bind the record to the executing principal.
|
|
247
|
+
scope: principal,
|
|
248
|
+
createdAt: now(),
|
|
249
|
+
expiresAt: now() + config.ttlSeconds * 1000,
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
await store.put(record_);
|
|
253
|
+
lru.set(key, { record: record_, cachedAt: now() });
|
|
254
|
+
} catch {
|
|
255
|
+
// Best-effort cache. Persistence failures must not break the
|
|
256
|
+
// hot path - operators see them via the audit log + logger.
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
inFlight.delete(key);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function computeFingerprint(c: Context<{ Variables: ServerVariables }>): Promise<string> {
|
|
265
|
+
let body: unknown = null;
|
|
266
|
+
const ct = c.req.header('content-type');
|
|
267
|
+
if (ct?.toLowerCase().includes('application/json')) {
|
|
268
|
+
try {
|
|
269
|
+
body = await c.req.json();
|
|
270
|
+
} catch {
|
|
271
|
+
body = await c.req.text();
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
body = await c.req.text();
|
|
275
|
+
}
|
|
276
|
+
return fingerprintRequest(c.req.method, c.req.path, body);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function captureJsonResponse(
|
|
280
|
+
c: Context<{ Variables: ServerVariables }>,
|
|
281
|
+
): Promise<unknown | null> {
|
|
282
|
+
if (c.res === undefined) return null;
|
|
283
|
+
const contentType = c.res.headers.get('content-type') ?? '';
|
|
284
|
+
if (!contentType.toLowerCase().includes('application/json')) return null;
|
|
285
|
+
try {
|
|
286
|
+
const cloned = c.res.clone();
|
|
287
|
+
const text = await cloned.text();
|
|
288
|
+
if (text.length === 0) return null;
|
|
289
|
+
return JSON.parse(text);
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function collectResponseHeaders(
|
|
296
|
+
c: Context<{ Variables: ServerVariables }>,
|
|
297
|
+
): Readonly<Record<string, string>> | undefined {
|
|
298
|
+
if (c.res === undefined) return undefined;
|
|
299
|
+
const out: Record<string, string> = {};
|
|
300
|
+
c.res.headers.forEach((value, name) => {
|
|
301
|
+
if (name.toLowerCase() === 'content-length') return;
|
|
302
|
+
out[name] = value;
|
|
303
|
+
});
|
|
304
|
+
return Object.keys(out).length === 0 ? undefined : Object.freeze(out);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
class SimpleLru<K, V> {
|
|
308
|
+
readonly #capacity: number;
|
|
309
|
+
readonly #map: Map<K, V> = new Map();
|
|
310
|
+
|
|
311
|
+
constructor(capacity: number) {
|
|
312
|
+
this.#capacity = Math.max(1, capacity);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
get(key: K): V | undefined {
|
|
316
|
+
const value = this.#map.get(key);
|
|
317
|
+
if (value === undefined) return undefined;
|
|
318
|
+
this.#map.delete(key);
|
|
319
|
+
this.#map.set(key, value);
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
set(key: K, value: V): void {
|
|
324
|
+
if (this.#map.has(key)) this.#map.delete(key);
|
|
325
|
+
this.#map.set(key, value);
|
|
326
|
+
while (this.#map.size > this.#capacity) {
|
|
327
|
+
const oldestKey = this.#map.keys().next().value as K | undefined;
|
|
328
|
+
if (oldestKey === undefined) break;
|
|
329
|
+
this.#map.delete(oldestKey);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
delete(key: K): boolean {
|
|
334
|
+
return this.#map.delete(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware barrel for `@graphorin/server`. Every entry is a Hono
|
|
3
|
+
* `MiddlewareHandler`; they are composed by the `createServer({...})`
|
|
4
|
+
* factory in a fixed order:
|
|
5
|
+
*
|
|
6
|
+
* request-state -> cors -> request audit metadata -> rate-limit ->
|
|
7
|
+
* csrf -> auth -> scope (per-route) -> idempotency (per-route) ->
|
|
8
|
+
* audit -> handler
|
|
9
|
+
*
|
|
10
|
+
* Operators that build a custom Hono app (or wrap the framework one)
|
|
11
|
+
* can re-import these factories directly through this barrel.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
type AuditErrorSink,
|
|
18
|
+
type AuditMiddlewareOptions,
|
|
19
|
+
createAuditMiddleware,
|
|
20
|
+
HTTP_REQUEST_AUDIT_ACTION,
|
|
21
|
+
} from './audit.js';
|
|
22
|
+
export {
|
|
23
|
+
type AuthMiddlewareOptions,
|
|
24
|
+
createAnonymousAuthMiddleware,
|
|
25
|
+
createAuthMiddleware,
|
|
26
|
+
} from './auth.js';
|
|
27
|
+
export { createCorsMiddleware } from './cors.js';
|
|
28
|
+
export { createCsrfMiddleware } from './csrf.js';
|
|
29
|
+
export {
|
|
30
|
+
createIdempotencyMiddleware,
|
|
31
|
+
type IdempotencyMiddlewareOptions,
|
|
32
|
+
} from './idempotency.js';
|
|
33
|
+
export { createRateLimitMiddleware } from './rate-limit.js';
|
|
34
|
+
export {
|
|
35
|
+
createRequestStateMiddleware,
|
|
36
|
+
type RequestStateMiddlewareOptions,
|
|
37
|
+
} from './request-state.js';
|
|
38
|
+
export { createScopeMiddleware, type ScopeRequirement } from './scope.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-IP sliding-window rate limit. Defends against brute-force
|
|
3
|
+
* authentication probes and naive DoS scenarios. The limit is
|
|
4
|
+
* deliberately conservative - operators with multi-tenant traffic
|
|
5
|
+
* should layer a dedicated reverse proxy on top.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MiddlewareHandler } from 'hono';
|
|
11
|
+
|
|
12
|
+
import type { ServerConfigSpec } from '../config.js';
|
|
13
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
14
|
+
|
|
15
|
+
interface Window {
|
|
16
|
+
count: number;
|
|
17
|
+
start: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @stable
|
|
22
|
+
*/
|
|
23
|
+
export function createRateLimitMiddleware(
|
|
24
|
+
config: ServerConfigSpec['server']['rateLimit'],
|
|
25
|
+
options: { readonly now?: () => number } = {},
|
|
26
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
27
|
+
if (!config.enabled) {
|
|
28
|
+
return async (_, next) => {
|
|
29
|
+
await next();
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const windows = new Map<string, Window>();
|
|
33
|
+
const now = options.now ?? Date.now;
|
|
34
|
+
// IP-10: bound the window map - expired entries are swept once the
|
|
35
|
+
// map crosses the cap so an attacker rotating spoofed XFF values
|
|
36
|
+
// (trustProxy=true deployments) cannot grow it without bound.
|
|
37
|
+
const SWEEP_THRESHOLD = 10_000;
|
|
38
|
+
const sweep = (ts: number): void => {
|
|
39
|
+
if (windows.size < SWEEP_THRESHOLD) return;
|
|
40
|
+
for (const [key, win] of windows) {
|
|
41
|
+
if (ts - win.start >= config.windowMs) windows.delete(key);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return async (c, next) => {
|
|
46
|
+
const ip = c.get('state').clientIp ?? 'anonymous';
|
|
47
|
+
const ts = now();
|
|
48
|
+
sweep(ts);
|
|
49
|
+
const window = windows.get(ip) ?? { count: 0, start: ts };
|
|
50
|
+
if (ts - window.start >= config.windowMs) {
|
|
51
|
+
window.start = ts;
|
|
52
|
+
window.count = 0;
|
|
53
|
+
}
|
|
54
|
+
window.count += 1;
|
|
55
|
+
windows.set(ip, window);
|
|
56
|
+
const remaining = Math.max(0, config.perIpRequests - window.count);
|
|
57
|
+
c.header('X-RateLimit-Limit', config.perIpRequests.toString());
|
|
58
|
+
c.header('X-RateLimit-Remaining', remaining.toString());
|
|
59
|
+
c.header(
|
|
60
|
+
'X-RateLimit-Reset',
|
|
61
|
+
Math.max(0, Math.ceil((window.start + config.windowMs - ts) / 1000)).toString(),
|
|
62
|
+
);
|
|
63
|
+
if (window.count > config.perIpRequests) {
|
|
64
|
+
const retryAfter = Math.max(0, Math.ceil((window.start + config.windowMs - ts) / 1000));
|
|
65
|
+
return c.json({ error: 'rate-limit-exceeded', message: 'Too many requests.' }, 429, {
|
|
66
|
+
'Retry-After': retryAfter.toString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
await next();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstraps the per-request `c.var.state` slot. Every server-owned
|
|
3
|
+
* middleware downstream reads from this object; defaults are kept on
|
|
4
|
+
* a single line per field so the structure is auditable at a glance.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MiddlewareHandler } from 'hono';
|
|
10
|
+
|
|
11
|
+
import type { ServerRequestState, ServerVariables } from '../internal/context.js';
|
|
12
|
+
import { newRequestId } from '../internal/ids.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
export interface RequestStateMiddlewareOptions {
|
|
18
|
+
/** Override the wall clock; tests inject a fixed value. */
|
|
19
|
+
readonly now?: () => number;
|
|
20
|
+
/** Override the request id generator. */
|
|
21
|
+
readonly newRequestId?: () => string;
|
|
22
|
+
/** Trust `X-Forwarded-For`/`X-Real-IP` for the client IP. Default false. */
|
|
23
|
+
readonly trustProxy?: boolean;
|
|
24
|
+
/** Echo the request id back to the client. Default `'X-Request-Id'`. */
|
|
25
|
+
readonly responseHeader?: string | false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clientIp(
|
|
29
|
+
headerLookup: (name: string) => string | undefined,
|
|
30
|
+
trustProxy: boolean,
|
|
31
|
+
): string | undefined {
|
|
32
|
+
if (trustProxy) {
|
|
33
|
+
const xff = headerLookup('x-forwarded-for');
|
|
34
|
+
if (xff !== undefined && xff.length > 0) {
|
|
35
|
+
const [first] = xff.split(',');
|
|
36
|
+
if (first !== undefined) return first.trim();
|
|
37
|
+
}
|
|
38
|
+
const xri = headerLookup('x-real-ip');
|
|
39
|
+
if (xri !== undefined && xri.length > 0) return xri.trim();
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @stable
|
|
46
|
+
*/
|
|
47
|
+
export function createRequestStateMiddleware(
|
|
48
|
+
options: RequestStateMiddlewareOptions = {},
|
|
49
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
50
|
+
const now = options.now ?? Date.now;
|
|
51
|
+
const idFactory = options.newRequestId ?? newRequestId;
|
|
52
|
+
const trustProxy = options.trustProxy ?? false;
|
|
53
|
+
const responseHeader =
|
|
54
|
+
options.responseHeader === undefined ? 'X-Request-Id' : options.responseHeader;
|
|
55
|
+
|
|
56
|
+
return async (c, next) => {
|
|
57
|
+
const headerLookup = (name: string): string | undefined => c.req.header(name);
|
|
58
|
+
const requestId = headerLookup('x-request-id')?.trim() || idFactory();
|
|
59
|
+
// IP-10: with the default trustProxy=false the proxy headers are
|
|
60
|
+
// ignored - the client IP comes from the SOCKET, so per-IP rate
|
|
61
|
+
// limits and lockouts actually key per client instead of dumping
|
|
62
|
+
// every request into one shared 'anonymous' bucket.
|
|
63
|
+
const socketAddress = (
|
|
64
|
+
c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined
|
|
65
|
+
)?.incoming?.socket?.remoteAddress;
|
|
66
|
+
const ip = clientIp(headerLookup, trustProxy) ?? socketAddress;
|
|
67
|
+
const state: ServerRequestState = {
|
|
68
|
+
requestId,
|
|
69
|
+
receivedAt: now(),
|
|
70
|
+
clientIp: ip,
|
|
71
|
+
auth: { kind: 'unauthenticated' },
|
|
72
|
+
};
|
|
73
|
+
c.set('state', state);
|
|
74
|
+
if (responseHeader !== false) {
|
|
75
|
+
c.header(responseHeader, requestId);
|
|
76
|
+
}
|
|
77
|
+
await next();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-route scope enforcement. Reads the verified token populated by
|
|
3
|
+
* the auth middleware and short-circuits with `403` when the granted
|
|
4
|
+
* set does not match the required scope.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type ParsedScope,
|
|
11
|
+
parseScope,
|
|
12
|
+
scopeMatches,
|
|
13
|
+
tryParseScope,
|
|
14
|
+
} from '@graphorin/security/auth';
|
|
15
|
+
import type { MiddlewareHandler } from 'hono';
|
|
16
|
+
|
|
17
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Required-scope spec accepted by {@link createScopeMiddleware}. Either
|
|
21
|
+
* a single string (`'agents:invoke'`), a parsed scope, or a function
|
|
22
|
+
* that derives the required scope from the request (e.g. to insert the
|
|
23
|
+
* `:id` segment lazily).
|
|
24
|
+
*
|
|
25
|
+
* @stable
|
|
26
|
+
*/
|
|
27
|
+
export type ScopeRequirement =
|
|
28
|
+
| string
|
|
29
|
+
| ParsedScope
|
|
30
|
+
| ((path: string, params: Record<string, string>) => string | ParsedScope);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @stable
|
|
34
|
+
*/
|
|
35
|
+
export function createScopeMiddleware(
|
|
36
|
+
requirement: ScopeRequirement,
|
|
37
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
38
|
+
return async (c, next) => {
|
|
39
|
+
const auth = c.get('state').auth;
|
|
40
|
+
if (auth.kind === 'anonymous') {
|
|
41
|
+
// IP-13: auth is disabled server-wide (auth.kind='none'). There is no
|
|
42
|
+
// token to scope-check - the trusted-loopback operator is allowed
|
|
43
|
+
// through every gate.
|
|
44
|
+
await next();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (auth.kind === 'unauthenticated') {
|
|
48
|
+
return c.json(
|
|
49
|
+
{
|
|
50
|
+
error: 'auth-required',
|
|
51
|
+
message: 'Authentication required for this endpoint.',
|
|
52
|
+
},
|
|
53
|
+
401,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const required = resolveRequirement(
|
|
57
|
+
requirement,
|
|
58
|
+
c.req.path,
|
|
59
|
+
c.req.param() as Record<string, string>,
|
|
60
|
+
);
|
|
61
|
+
// W-107: a dynamic requirement built from an arbitrary URL segment
|
|
62
|
+
// may not parse (target charset/length limits). An unparseable
|
|
63
|
+
// requirement can never be granted - answer 403, never throw a 500.
|
|
64
|
+
if (required === undefined) {
|
|
65
|
+
return c.json(
|
|
66
|
+
{
|
|
67
|
+
error: 'scope-denied',
|
|
68
|
+
message: 'Token lacks the required scope (unparseable resource id).',
|
|
69
|
+
},
|
|
70
|
+
403,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const granted = auth.grantedScopes;
|
|
74
|
+
let allowed = false;
|
|
75
|
+
for (const scope of granted) {
|
|
76
|
+
if (scopeMatches(scope, required)) {
|
|
77
|
+
allowed = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!allowed) {
|
|
82
|
+
return c.json(
|
|
83
|
+
{
|
|
84
|
+
error: 'scope-denied',
|
|
85
|
+
message: `Token lacks required scope '${required.raw}'.`,
|
|
86
|
+
hint: `Mint a token with the '${required.raw}' (or admin:*) scope.`,
|
|
87
|
+
},
|
|
88
|
+
403,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
await next();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* W-107: authentication-only gate. Lets any authenticated principal
|
|
97
|
+
* (token or the anonymous trusted-loopback operator) through and
|
|
98
|
+
* rejects only `unauthenticated` with 401. Used where possession of a
|
|
99
|
+
* valid principal is the whole requirement - e.g. the ws-ticket mint,
|
|
100
|
+
* which adds no rights of its own (the ticket carries the principal's
|
|
101
|
+
* scopes and every subscribe is per-subject gated).
|
|
102
|
+
*
|
|
103
|
+
* @stable
|
|
104
|
+
*/
|
|
105
|
+
export function createAuthenticatedMiddleware(): MiddlewareHandler<{
|
|
106
|
+
Variables: ServerVariables;
|
|
107
|
+
}> {
|
|
108
|
+
return async (c, next) => {
|
|
109
|
+
const auth = c.get('state').auth;
|
|
110
|
+
if (auth.kind === 'unauthenticated') {
|
|
111
|
+
return c.json(
|
|
112
|
+
{
|
|
113
|
+
error: 'auth-required',
|
|
114
|
+
message: 'Authentication required for this endpoint.',
|
|
115
|
+
},
|
|
116
|
+
401,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
await next();
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* W-107: imperative scope check for handlers that must resolve the
|
|
125
|
+
* resource BEFORE the requirement is known (per-resource run control:
|
|
126
|
+
* snapshot first, then require the owning agent's/workflow's scope).
|
|
127
|
+
* Mirrors the middleware exactly: anonymous allows, unauthenticated
|
|
128
|
+
* denies, an unparseable requirement denies.
|
|
129
|
+
*
|
|
130
|
+
* @stable
|
|
131
|
+
*/
|
|
132
|
+
export function checkScope(
|
|
133
|
+
auth: import('../internal/context.js').AuthState,
|
|
134
|
+
required: string | ParsedScope,
|
|
135
|
+
): boolean {
|
|
136
|
+
if (auth.kind === 'anonymous') return true;
|
|
137
|
+
if (auth.kind === 'unauthenticated') return false;
|
|
138
|
+
const parsed = typeof required === 'string' ? tryParseScope(required) : required;
|
|
139
|
+
if (parsed === undefined) return false;
|
|
140
|
+
for (const scope of auth.grantedScopes) {
|
|
141
|
+
if (scopeMatches(scope, parsed)) return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveRequirement(
|
|
147
|
+
requirement: ScopeRequirement,
|
|
148
|
+
path: string,
|
|
149
|
+
params: Record<string, string>,
|
|
150
|
+
): ParsedScope | undefined {
|
|
151
|
+
// W-107: dynamic requirements interpolate raw URL segments - use the
|
|
152
|
+
// non-throwing parser so `GET /sessions/<junk>` yields 403, not 500.
|
|
153
|
+
if (typeof requirement === 'function') {
|
|
154
|
+
const next = requirement(path, params);
|
|
155
|
+
return typeof next === 'string' ? tryParseScope(next) : next;
|
|
156
|
+
}
|
|
157
|
+
return typeof requirement === 'string' ? tryParseScope(requirement) : requirement;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `parseScope` stays imported for API parity with older call sites.
|
|
161
|
+
void parseScope;
|