@graphorin/server 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +6 -6
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +40 -611
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +9 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -26
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +6 -0
  59. package/dist/package.js.map +1 -0
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.d.ts.map +1 -1
  99. package/dist/ws/dispatcher.js +19 -12
  100. package/dist/ws/dispatcher.js.map +1 -1
  101. package/dist/ws/index.d.ts +2 -2
  102. package/dist/ws/index.js +3 -3
  103. package/dist/ws/replay-buffer.d.ts +35 -1
  104. package/dist/ws/replay-buffer.d.ts.map +1 -1
  105. package/dist/ws/replay-buffer.js +35 -3
  106. package/dist/ws/replay-buffer.js.map +1 -1
  107. package/dist/ws/upgrade.d.ts.map +1 -1
  108. package/dist/ws/upgrade.js +26 -19
  109. package/dist/ws/upgrade.js.map +1 -1
  110. package/package.json +31 -30
  111. package/src/app-audit-binding.ts +227 -0
  112. package/src/app-daemons.ts +73 -0
  113. package/src/app-lifecycle.ts +476 -0
  114. package/src/app-metrics.ts +144 -0
  115. package/src/app-middleware.ts +149 -0
  116. package/src/app-routes.ts +305 -0
  117. package/src/app-ws.ts +111 -0
  118. package/src/app.ts +317 -0
  119. package/src/commentary/audit-bridge.ts +135 -0
  120. package/src/commentary/built-in-patterns.ts +79 -0
  121. package/src/commentary/index.ts +32 -0
  122. package/src/commentary/sanitizer.ts +238 -0
  123. package/src/commentary/types.ts +130 -0
  124. package/src/config.ts +472 -0
  125. package/src/consolidator/daemon.ts +141 -0
  126. package/src/consolidator/index.ts +14 -0
  127. package/src/errors/index.ts +247 -0
  128. package/src/health/checks.ts +322 -0
  129. package/src/health/index.ts +34 -0
  130. package/src/health/routes.ts +154 -0
  131. package/src/index.ts +243 -0
  132. package/src/internal/context.ts +77 -0
  133. package/src/internal/ids.ts +17 -0
  134. package/src/internal/json.ts +47 -0
  135. package/src/internal/wire-error.ts +44 -0
  136. package/src/lifecycle/hooks.ts +66 -0
  137. package/src/lifecycle/index.ts +16 -0
  138. package/src/lifecycle/pre-bind.ts +138 -0
  139. package/src/metrics/catalog.ts +112 -0
  140. package/src/metrics/index.ts +12 -0
  141. package/src/metrics/registry.ts +337 -0
  142. package/src/metrics/tools-bridge.ts +124 -0
  143. package/src/middleware/audit.ts +114 -0
  144. package/src/middleware/auth.ts +195 -0
  145. package/src/middleware/cors.ts +72 -0
  146. package/src/middleware/csrf.ts +98 -0
  147. package/src/middleware/idempotency.ts +336 -0
  148. package/src/middleware/index.ts +38 -0
  149. package/src/middleware/rate-limit.ts +71 -0
  150. package/src/middleware/request-state.ts +79 -0
  151. package/src/middleware/scope.ts +161 -0
  152. package/src/registry/index.ts +278 -0
  153. package/src/replay/index.ts +14 -0
  154. package/src/replay/routes.ts +255 -0
  155. package/src/routes/agents.ts +363 -0
  156. package/src/routes/audit.ts +207 -0
  157. package/src/routes/auth.ts +80 -0
  158. package/src/routes/health.ts +42 -0
  159. package/src/routes/index.ts +16 -0
  160. package/src/routes/mcp.ts +97 -0
  161. package/src/routes/memory.ts +152 -0
  162. package/src/routes/sessions.ts +250 -0
  163. package/src/routes/skills.ts +91 -0
  164. package/src/routes/tokens.ts +166 -0
  165. package/src/routes/workflows.ts +616 -0
  166. package/src/runtime/retention.ts +284 -0
  167. package/src/runtime/run-state.ts +422 -0
  168. package/src/sse/events.ts +303 -0
  169. package/src/sse/index.ts +7 -0
  170. package/src/tools-audit-bridge.ts +120 -0
  171. package/src/triggers/daemon.ts +198 -0
  172. package/src/triggers/index.ts +16 -0
  173. package/src/triggers/routes.ts +139 -0
  174. package/src/workflows/timer-daemon.ts +102 -0
  175. package/src/ws/dispatcher.ts +537 -0
  176. package/src/ws/index.ts +45 -0
  177. package/src/ws/replay-buffer.ts +209 -0
  178. package/src/ws/subjects.ts +162 -0
  179. package/src/ws/ticket.ts +174 -0
  180. package/src/ws/upgrade.ts +507 -0
  181. package/dist/lifecycle/index.js +0 -3
  182. package/dist/routes/index.js +0 -12
@@ -0,0 +1,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
+ }