@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W-051: bridge the `@graphorin/tools` module-level counter registry
|
|
3
|
+
* (which `@graphorin/mcp` also writes) into the server's
|
|
4
|
+
* {@link MetricRegistry}, so `tool.*` / `mcp.*` telemetry - including
|
|
5
|
+
* the shadow-mode `tool.dataflow.*` counters - shows up on
|
|
6
|
+
* `/v1/metrics` without hand-wiring.
|
|
7
|
+
*
|
|
8
|
+
* Called from `refreshLiveMetrics` on every scrape. Counters are
|
|
9
|
+
* delta-synced (the tools registry is cumulative per process; the
|
|
10
|
+
* Prometheus registry increments by the positive difference, so
|
|
11
|
+
* repeated scrapes never double-count); gauges are set absolutely -
|
|
12
|
+
* the `kinds` field on the snapshot (added for exactly this bridge)
|
|
13
|
+
* tells the two apart. Histograms are deliberately NOT bridged yet:
|
|
14
|
+
* summary bridging needs per-key consumed-observation offsets, and the
|
|
15
|
+
* raw observation lists stay available through `snapshotCounters()`
|
|
16
|
+
* for library users (documented in the observability guide).
|
|
17
|
+
*
|
|
18
|
+
* Naming: `tool.executor.retry.total{toolName=x}` becomes
|
|
19
|
+
* `graphorin_tool_executor_retry_total{toolName="x"}` - `[.-]` map to
|
|
20
|
+
* `_` (the Prometheus name grammar forbids dots), label values pass
|
|
21
|
+
* through the same sanitizer as trigger ids.
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type CounterSnapshot, snapshotCounters } from '@graphorin/tools/audit';
|
|
27
|
+
import type { MetricRegistry } from './registry.js';
|
|
28
|
+
|
|
29
|
+
const BRIDGED_PREFIXES = ['tool.', 'mcp.'] as const;
|
|
30
|
+
const PROM_LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sync the tools-package counter snapshot into `registry`. Idempotent
|
|
34
|
+
* per value: scraping twice without new increments changes nothing.
|
|
35
|
+
*
|
|
36
|
+
* @stable
|
|
37
|
+
*/
|
|
38
|
+
export function syncToolCounters(
|
|
39
|
+
registry: MetricRegistry,
|
|
40
|
+
snapshot: CounterSnapshot = snapshotCounters(),
|
|
41
|
+
): void {
|
|
42
|
+
for (const [key, value] of Object.entries(snapshot.counters)) {
|
|
43
|
+
const parsed = parseCounterKey(key);
|
|
44
|
+
if (parsed === undefined) continue;
|
|
45
|
+
if (!BRIDGED_PREFIXES.some((prefix) => parsed.name.startsWith(prefix))) continue;
|
|
46
|
+
const kind = snapshot.kinds[key] ?? 'counter';
|
|
47
|
+
const metricName = toPrometheusName(parsed.name);
|
|
48
|
+
try {
|
|
49
|
+
if (kind === 'gauge') {
|
|
50
|
+
registry.registerGauge(metricName, `Bridged from tools counter '${parsed.name}'.`);
|
|
51
|
+
registry.set(metricName, value, parsed.labels);
|
|
52
|
+
} else {
|
|
53
|
+
registry.registerCounter(metricName, `Bridged from tools counter '${parsed.name}'.`);
|
|
54
|
+
const current = readBridgedValue(registry, metricName, parsed.labels);
|
|
55
|
+
const delta = value - current;
|
|
56
|
+
if (delta > 0) registry.inc(metricName, parsed.labels, delta);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Best-effort: one malformed key (or a name registered by other
|
|
60
|
+
// code with a conflicting kind) must never fail the scrape.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parsed `name{k=v,...}` key in the tools `buildKey` format. */
|
|
66
|
+
interface ParsedCounterKey {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly labels: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCounterKey(key: string): ParsedCounterKey | undefined {
|
|
72
|
+
const brace = key.indexOf('{');
|
|
73
|
+
if (brace === -1) return { name: key, labels: {} };
|
|
74
|
+
if (!key.endsWith('}')) return undefined;
|
|
75
|
+
const name = key.slice(0, brace);
|
|
76
|
+
const body = key.slice(brace + 1, -1);
|
|
77
|
+
const labels: Record<string, string> = {};
|
|
78
|
+
for (const pair of body.split(',')) {
|
|
79
|
+
const eq = pair.indexOf('=');
|
|
80
|
+
if (eq <= 0) return undefined;
|
|
81
|
+
const labelName = pair.slice(0, eq);
|
|
82
|
+
if (!PROM_LABEL_NAME_RE.test(labelName)) return undefined;
|
|
83
|
+
labels[labelName] = sanitizeLabelValue(pair.slice(eq + 1));
|
|
84
|
+
}
|
|
85
|
+
return { name, labels };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toPrometheusName(name: string): string {
|
|
89
|
+
return `graphorin_${name.replace(/[.-]/g, '_')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Same policy as the trigger-id sanitizer in `app-metrics.ts`: bound
|
|
94
|
+
* the character set and the length so server identities / tool names
|
|
95
|
+
* cannot explode label cardinality with UTF-8 sequences.
|
|
96
|
+
*/
|
|
97
|
+
function sanitizeLabelValue(value: string): string {
|
|
98
|
+
return value.replace(/[^A-Za-z0-9_:.\-/]/g, '_').slice(0, 200);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBridgedValue(
|
|
102
|
+
registry: MetricRegistry,
|
|
103
|
+
name: string,
|
|
104
|
+
labels: Record<string, string>,
|
|
105
|
+
): number {
|
|
106
|
+
const entries = registry.snapshot().counters[name] ?? [];
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (labelsEqual(entry.labels, labels)) return entry.value;
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function labelsEqual(
|
|
114
|
+
a: Record<string, string | number | boolean>,
|
|
115
|
+
b: Record<string, string>,
|
|
116
|
+
): boolean {
|
|
117
|
+
const aKeys = Object.keys(a);
|
|
118
|
+
const bKeys = Object.keys(b);
|
|
119
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
120
|
+
for (const k of bKeys) {
|
|
121
|
+
if (String(a[k]) !== b[k]) return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit middleware. Forwards every authenticated request through
|
|
3
|
+
* `appendAudit` (`@graphorin/security/audit`) so the operator gets a
|
|
4
|
+
* tamper-evident chain of every state-changing call. Append failures
|
|
5
|
+
* never break the request hot path; they are recorded on a best-
|
|
6
|
+
* effort error sink.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AuditDb,
|
|
13
|
+
type AuditDecision,
|
|
14
|
+
type AuditEntryInput,
|
|
15
|
+
appendAudit,
|
|
16
|
+
} from '@graphorin/security/audit';
|
|
17
|
+
import type { MiddlewareHandler } from 'hono';
|
|
18
|
+
|
|
19
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical action discriminator emitted on every authenticated REST
|
|
23
|
+
* request per the Phase 14a spec (`§ Audit middleware`). Exposed as
|
|
24
|
+
* a constant so consumers (downstream filters, dashboards) can grep
|
|
25
|
+
* for it without restating the literal everywhere.
|
|
26
|
+
*
|
|
27
|
+
* @stable
|
|
28
|
+
*/
|
|
29
|
+
export const HTTP_REQUEST_AUDIT_ACTION = 'http:request' as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Optional telemetry sink. The default ignores errors; production
|
|
33
|
+
* deployments wire this into their structured logger.
|
|
34
|
+
*
|
|
35
|
+
* @stable
|
|
36
|
+
*/
|
|
37
|
+
export type AuditErrorSink = (err: unknown, entry: Omit<AuditEntryInput, 'ts'>) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @stable
|
|
41
|
+
*/
|
|
42
|
+
export interface AuditMiddlewareOptions {
|
|
43
|
+
readonly auditDb: AuditDb;
|
|
44
|
+
/** Optional override for the time source. */
|
|
45
|
+
readonly now?: () => number;
|
|
46
|
+
/** Optional error sink. Defaults to swallow. */
|
|
47
|
+
readonly onError?: AuditErrorSink;
|
|
48
|
+
/**
|
|
49
|
+
* When `true` (the default), include the path + method + status on
|
|
50
|
+
* the audit entry's metadata. Disable for compliance-strict
|
|
51
|
+
* deployments that already log the request envelope elsewhere.
|
|
52
|
+
*/
|
|
53
|
+
readonly recordMetadata?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decisionForStatus(status: number): AuditDecision {
|
|
57
|
+
if (status >= 500) return 'error';
|
|
58
|
+
if (status === 404) return 'not-found';
|
|
59
|
+
if (status >= 400) return 'denied';
|
|
60
|
+
return 'success';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @stable
|
|
65
|
+
*/
|
|
66
|
+
export function createAuditMiddleware(
|
|
67
|
+
options: AuditMiddlewareOptions,
|
|
68
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
69
|
+
const now = options.now ?? Date.now;
|
|
70
|
+
const onError = options.onError ?? (() => {});
|
|
71
|
+
const recordMetadata = options.recordMetadata ?? true;
|
|
72
|
+
|
|
73
|
+
return async (c, next) => {
|
|
74
|
+
const start = now();
|
|
75
|
+
await next();
|
|
76
|
+
const status = c.res?.status ?? 0;
|
|
77
|
+
const auth = c.get('state').auth;
|
|
78
|
+
if (auth.kind !== 'token') {
|
|
79
|
+
// Anonymous calls (health, etc.) are not audited.
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const token = auth.token;
|
|
83
|
+
const entry: AuditEntryInput = {
|
|
84
|
+
ts: now(),
|
|
85
|
+
actor: {
|
|
86
|
+
kind: 'token',
|
|
87
|
+
id: token.tokenId,
|
|
88
|
+
...(token.label !== undefined ? { label: token.label } : {}),
|
|
89
|
+
},
|
|
90
|
+
action: HTTP_REQUEST_AUDIT_ACTION,
|
|
91
|
+
target: c.req.path,
|
|
92
|
+
decision: decisionForStatus(status),
|
|
93
|
+
...(recordMetadata
|
|
94
|
+
? {
|
|
95
|
+
metadata: {
|
|
96
|
+
method: c.req.method,
|
|
97
|
+
status,
|
|
98
|
+
durationMs: now() - start,
|
|
99
|
+
requestId: c.get('state').requestId,
|
|
100
|
+
...(c.get('state').idempotencyKey !== undefined
|
|
101
|
+
? { idempotencyKey: c.get('state').idempotencyKey }
|
|
102
|
+
: {}),
|
|
103
|
+
...(c.get('state').idempotencyReplay === true ? { idempotencyReplayed: true } : {}),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
await appendAudit(options.auditDb, entry);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
onError(err, entry);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-token authentication middleware. Wraps the
|
|
3
|
+
* `@graphorin/security` token verifier so handlers see
|
|
4
|
+
* a stable {@link import('../internal/context.js').AuthState}
|
|
5
|
+
* structure on `c.var.state.auth`.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type ParsedScope,
|
|
12
|
+
parseScope,
|
|
13
|
+
type TokenVerifier,
|
|
14
|
+
tryParseScope,
|
|
15
|
+
} from '@graphorin/security/auth';
|
|
16
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
17
|
+
|
|
18
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* IP-13: the scope set granted to every request when `auth.kind = 'none'`.
|
|
22
|
+
* `admin:*` matches every required scope (see `@graphorin/security/auth`
|
|
23
|
+
* `scopeMatches`), so the trusted-loopback operator is uniformly authorized
|
|
24
|
+
* without special-casing each route.
|
|
25
|
+
*/
|
|
26
|
+
const ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope> = Object.freeze([parseScope('admin:*')]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps
|
|
30
|
+
* `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the
|
|
31
|
+
* scope middleware, SSE handler and replay routes all treat the request as a
|
|
32
|
+
* fully-authorized principal. This is the documented trusted-loopback /
|
|
33
|
+
* single-operator mode - never mount it on a non-loopback deployment without
|
|
34
|
+
* understanding that every endpoint becomes open.
|
|
35
|
+
*
|
|
36
|
+
* @stable
|
|
37
|
+
*/
|
|
38
|
+
export function createAnonymousAuthMiddleware(): MiddlewareHandler<{
|
|
39
|
+
Variables: ServerVariables;
|
|
40
|
+
}> {
|
|
41
|
+
return async (c, next: Next) => {
|
|
42
|
+
c.set('state', {
|
|
43
|
+
...c.get('state'),
|
|
44
|
+
auth: { kind: 'anonymous' as const, grantedScopes: ANONYMOUS_GRANTED_SCOPES },
|
|
45
|
+
});
|
|
46
|
+
await next();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options accepted by {@link createAuthMiddleware}. Tests inject a
|
|
52
|
+
* stub verifier; production wiring uses the verifier built during the
|
|
53
|
+
* server's pre-bind step.
|
|
54
|
+
*
|
|
55
|
+
* @stable
|
|
56
|
+
*/
|
|
57
|
+
export interface AuthMiddlewareOptions {
|
|
58
|
+
readonly verifier: TokenVerifier;
|
|
59
|
+
/**
|
|
60
|
+
* Whether to allow unauthenticated requests through. Used by
|
|
61
|
+
* `health` and (when explicitly opted-in) by the public read
|
|
62
|
+
* endpoints. When `false` (the default), missing / malformed /
|
|
63
|
+
* invalid tokens short-circuit the request with `401`.
|
|
64
|
+
*/
|
|
65
|
+
readonly allowAnonymous?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HEADER_NAME = 'authorization';
|
|
69
|
+
const BEARER_PREFIX = 'bearer ';
|
|
70
|
+
|
|
71
|
+
function extractBearer(c: Context): string | undefined {
|
|
72
|
+
const raw = c.req.header(HEADER_NAME);
|
|
73
|
+
if (raw === undefined || raw === null) return undefined;
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (trimmed.length < BEARER_PREFIX.length) return undefined;
|
|
76
|
+
if (trimmed.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return undefined;
|
|
77
|
+
return trimmed.slice(BEARER_PREFIX.length).trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function reasonToStatus(reason: string): {
|
|
81
|
+
status: number;
|
|
82
|
+
body: { error: string; message: string; hint?: string };
|
|
83
|
+
} {
|
|
84
|
+
switch (reason) {
|
|
85
|
+
case 'malformed':
|
|
86
|
+
return { status: 401, body: { error: 'auth-invalid', message: 'Malformed bearer token.' } };
|
|
87
|
+
case 'unknown-token':
|
|
88
|
+
return { status: 401, body: { error: 'auth-invalid', message: 'Unknown bearer token.' } };
|
|
89
|
+
case 'revoked':
|
|
90
|
+
return {
|
|
91
|
+
status: 401,
|
|
92
|
+
body: { error: 'auth-revoked', message: 'Bearer token has been revoked.' },
|
|
93
|
+
};
|
|
94
|
+
case 'expired':
|
|
95
|
+
return { status: 401, body: { error: 'auth-expired', message: 'Bearer token has expired.' } };
|
|
96
|
+
case 'ip-locked-out':
|
|
97
|
+
return {
|
|
98
|
+
status: 429,
|
|
99
|
+
body: {
|
|
100
|
+
error: 'auth-locked-out',
|
|
101
|
+
message: 'Too many failed authentications from this address.',
|
|
102
|
+
hint: 'Wait for the lockout window to elapse before retrying.',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
case 'token-locked-out':
|
|
106
|
+
return {
|
|
107
|
+
status: 429,
|
|
108
|
+
body: {
|
|
109
|
+
error: 'auth-locked-out',
|
|
110
|
+
message: 'Bearer token is locked out after repeated failures.',
|
|
111
|
+
hint: 'Operator must rotate the token.',
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
default:
|
|
115
|
+
return {
|
|
116
|
+
status: 401,
|
|
117
|
+
body: { error: 'auth-invalid', message: `Authentication failed: ${reason}.` },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build the bearer-token middleware. The middleware always sets
|
|
124
|
+
* `c.var.state.auth`, even on the unauthenticated branch, so
|
|
125
|
+
* downstream code can pattern-match the discriminated union without
|
|
126
|
+
* a separate "is anonymous?" check.
|
|
127
|
+
*
|
|
128
|
+
* @stable
|
|
129
|
+
*/
|
|
130
|
+
export function createAuthMiddleware(
|
|
131
|
+
options: AuthMiddlewareOptions,
|
|
132
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
133
|
+
const allowAnonymous = options.allowAnonymous ?? false;
|
|
134
|
+
return async (c, next: Next) => {
|
|
135
|
+
const token = extractBearer(c);
|
|
136
|
+
// IP-11: the request-state middleware is the ONLY IP authority -
|
|
137
|
+
// it resolves the client IP per the configured trustProxy policy
|
|
138
|
+
// (proxy headers when trusted, else the socket address). The old
|
|
139
|
+
// fallback read attacker-controlled X-Real-IP unconditionally,
|
|
140
|
+
// letting header rotation evade per-IP lockout and a spoofed
|
|
141
|
+
// victim IP trigger a lockout against them.
|
|
142
|
+
const ip = c.get('state').clientIp;
|
|
143
|
+
if (token === undefined) {
|
|
144
|
+
if (allowAnonymous) {
|
|
145
|
+
c.set('state', { ...c.get('state'), auth: { kind: 'unauthenticated' as const } });
|
|
146
|
+
await next();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
return c.json(
|
|
150
|
+
{
|
|
151
|
+
error: 'auth-required',
|
|
152
|
+
message: 'Bearer token required.',
|
|
153
|
+
hint: "Set 'Authorization: Bearer <token>'.",
|
|
154
|
+
},
|
|
155
|
+
401,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const result = await options.verifier.verify(token, ip !== undefined ? { ip } : {});
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
const mapped = reasonToStatus(result.reason);
|
|
161
|
+
const headers: Record<string, string> = {};
|
|
162
|
+
if (result.retryAfterMs !== undefined) {
|
|
163
|
+
headers['Retry-After'] = Math.ceil(result.retryAfterMs / 1000).toString();
|
|
164
|
+
}
|
|
165
|
+
return c.json(mapped.body, mapped.status as 401 | 429, headers);
|
|
166
|
+
}
|
|
167
|
+
const grantedScopes: ParsedScope[] = [];
|
|
168
|
+
for (const scope of result.token.scopes) {
|
|
169
|
+
grantedScopes.push(scope);
|
|
170
|
+
}
|
|
171
|
+
// Defensive: any string scopes coming from the store also parse here
|
|
172
|
+
// even though the verifier itself parses them - see scope.ts.
|
|
173
|
+
void tryParseScope;
|
|
174
|
+
c.set('state', {
|
|
175
|
+
...c.get('state'),
|
|
176
|
+
auth: {
|
|
177
|
+
kind: 'token' as const,
|
|
178
|
+
token: result.token,
|
|
179
|
+
grantedScopes: Object.freeze(grantedScopes),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
// Mirror the verified token onto the documented `c.var.token`
|
|
183
|
+
// slot so route handlers can read it without unwrapping the auth
|
|
184
|
+
// discriminator. The shape is the one called out in the Phase
|
|
185
|
+
// 14a spec: { id, label, scopes, env, expiresAt? }.
|
|
186
|
+
c.set('token', {
|
|
187
|
+
id: result.token.tokenId,
|
|
188
|
+
label: result.token.label,
|
|
189
|
+
scopes: Object.freeze(grantedScopes.slice()),
|
|
190
|
+
env: result.token.env,
|
|
191
|
+
expiresAt: result.token.expiresAt,
|
|
192
|
+
});
|
|
193
|
+
await next();
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deny-by-default CORS middleware. The framework refuses to echo back
|
|
3
|
+
* arbitrary origins; allowlist semantics match the threat-model
|
|
4
|
+
* checklist in the runtime architecture.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MiddlewareHandler } from 'hono';
|
|
10
|
+
|
|
11
|
+
import type { ServerConfigSpec } from '../config.js';
|
|
12
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
export function createCorsMiddleware(
|
|
18
|
+
config: ServerConfigSpec['server']['cors'],
|
|
19
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
20
|
+
const allowSet = new Set(config.allowOrigins);
|
|
21
|
+
const allowAny = allowSet.has('*');
|
|
22
|
+
const allowMethods = config.allowMethods.join(', ');
|
|
23
|
+
const allowHeaders = config.allowHeaders.join(', ');
|
|
24
|
+
const maxAge = config.maxAgeSeconds.toString();
|
|
25
|
+
|
|
26
|
+
return async (c, next) => {
|
|
27
|
+
const origin = c.req.header('origin');
|
|
28
|
+
const isPreflight =
|
|
29
|
+
c.req.method === 'OPTIONS' && c.req.header('access-control-request-method') !== undefined;
|
|
30
|
+
if (origin === undefined) {
|
|
31
|
+
// Same-origin request - proceed without CORS headers.
|
|
32
|
+
if (isPreflight) {
|
|
33
|
+
return c.body(null, 204);
|
|
34
|
+
}
|
|
35
|
+
await next();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const allowed = allowAny || allowSet.has(origin);
|
|
39
|
+
if (!allowed) {
|
|
40
|
+
if (isPreflight) {
|
|
41
|
+
return c.body(null, 204);
|
|
42
|
+
}
|
|
43
|
+
// Pass the request through but withhold the CORS allow header so
|
|
44
|
+
// the browser blocks it; do NOT short-circuit the request - the
|
|
45
|
+
// server-to-server flow must still receive a normal response.
|
|
46
|
+
await next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (allowAny && config.allowCredentials) {
|
|
51
|
+
// Specs forbid `Access-Control-Allow-Origin: *` with credentials;
|
|
52
|
+
// mirror the request origin instead.
|
|
53
|
+
c.header('Access-Control-Allow-Origin', origin);
|
|
54
|
+
c.header('Vary', 'Origin');
|
|
55
|
+
} else if (allowAny) {
|
|
56
|
+
c.header('Access-Control-Allow-Origin', '*');
|
|
57
|
+
} else {
|
|
58
|
+
c.header('Access-Control-Allow-Origin', origin);
|
|
59
|
+
c.header('Vary', 'Origin');
|
|
60
|
+
}
|
|
61
|
+
if (config.allowCredentials) {
|
|
62
|
+
c.header('Access-Control-Allow-Credentials', 'true');
|
|
63
|
+
}
|
|
64
|
+
if (isPreflight) {
|
|
65
|
+
c.header('Access-Control-Allow-Methods', allowMethods);
|
|
66
|
+
c.header('Access-Control-Allow-Headers', allowHeaders);
|
|
67
|
+
c.header('Access-Control-Max-Age', maxAge);
|
|
68
|
+
return c.body(null, 204);
|
|
69
|
+
}
|
|
70
|
+
await next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Double-submit CSRF middleware. Targets browser flows: when a
|
|
3
|
+
* request carries a session-cookie style credential we require the
|
|
4
|
+
* client to echo the value of the `graphorin_csrf` cookie back in
|
|
5
|
+
* the `X-CSRF-Token` header. Bearer-token (Authorization header)
|
|
6
|
+
* requests are exempt because they are not vulnerable to the
|
|
7
|
+
* confused-deputy attack the cookie pattern guards against.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Buffer } from 'node:buffer';
|
|
13
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
import type { MiddlewareHandler } from 'hono';
|
|
16
|
+
|
|
17
|
+
import type { ServerConfigSpec } from '../config.js';
|
|
18
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @stable
|
|
22
|
+
*/
|
|
23
|
+
export function createCsrfMiddleware(
|
|
24
|
+
config: ServerConfigSpec['server']['csrf'],
|
|
25
|
+
): MiddlewareHandler<{ Variables: ServerVariables }> {
|
|
26
|
+
if (!config.enabled) {
|
|
27
|
+
return async (_, next) => {
|
|
28
|
+
await next();
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const safeMethods = new Set(config.safeMethods.map((m) => m.toUpperCase()));
|
|
32
|
+
|
|
33
|
+
return async (c, next) => {
|
|
34
|
+
const method = c.req.method.toUpperCase();
|
|
35
|
+
const cookieHeader = c.req.header('cookie');
|
|
36
|
+
const cookies = parseCookies(cookieHeader);
|
|
37
|
+
const cookieValue = cookies.get(config.cookieName);
|
|
38
|
+
const authHeader = c.req.header('authorization');
|
|
39
|
+
const isBearer = authHeader !== undefined && /^bearer\s+/i.test(authHeader);
|
|
40
|
+
|
|
41
|
+
if (safeMethods.has(method) || isBearer) {
|
|
42
|
+
// Always issue a fresh CSRF cookie on browser flows so SPAs
|
|
43
|
+
// can read it on first load. Skip the cookie issuance for
|
|
44
|
+
// bearer-only callers - they don't need it.
|
|
45
|
+
if (!isBearer && cookieValue === undefined) {
|
|
46
|
+
const token = randomBytes(24).toString('base64url');
|
|
47
|
+
// IP-12: the CSRF token must be readable by the SPA for the
|
|
48
|
+
// double-submit echo, so the cookie carries NO HttpOnly attribute -
|
|
49
|
+
// RFC 6265 enables HttpOnly on its mere presence, even `HttpOnly=false`.
|
|
50
|
+
c.header('Set-Cookie', `${config.cookieName}=${token}; Path=/; SameSite=Strict; Secure`);
|
|
51
|
+
}
|
|
52
|
+
await next();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (cookieValue === undefined) {
|
|
57
|
+
return c.json(
|
|
58
|
+
{ error: 'csrf-denied', message: 'CSRF cookie missing on state-changing request.' },
|
|
59
|
+
403,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const headerValue = c.req.header(config.headerName);
|
|
63
|
+
if (headerValue === undefined) {
|
|
64
|
+
return c.json(
|
|
65
|
+
{ error: 'csrf-denied', message: `CSRF header '${config.headerName}' missing.` },
|
|
66
|
+
403,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!constantTimeEqual(cookieValue, headerValue)) {
|
|
70
|
+
return c.json({ error: 'csrf-denied', message: 'CSRF token mismatch.' }, 403);
|
|
71
|
+
}
|
|
72
|
+
await next();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseCookies(header: string | undefined): Map<string, string> {
|
|
77
|
+
const out = new Map<string, string>();
|
|
78
|
+
if (header === undefined) return out;
|
|
79
|
+
const parts = header.split(';');
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
const trimmed = part.trim();
|
|
82
|
+
if (trimmed.length === 0) continue;
|
|
83
|
+
const eq = trimmed.indexOf('=');
|
|
84
|
+
if (eq < 0) continue;
|
|
85
|
+
const name = trimmed.slice(0, eq).trim();
|
|
86
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
87
|
+
out.set(name, value);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
93
|
+
if (a.length !== b.length) return false;
|
|
94
|
+
const aBuf = Buffer.from(a, 'utf8');
|
|
95
|
+
const bBuf = Buffer.from(b, 'utf8');
|
|
96
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
97
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
98
|
+
}
|