@graphorin/server 0.5.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 +7 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/app.d.ts +174 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +684 -0
- package/dist/app.js.map +1 -0
- package/dist/commentary/audit-bridge.d.ts +57 -0
- package/dist/commentary/audit-bridge.d.ts.map +1 -0
- package/dist/commentary/audit-bridge.js +92 -0
- package/dist/commentary/audit-bridge.js.map +1 -0
- package/dist/commentary/built-in-patterns.d.ts +24 -0
- package/dist/commentary/built-in-patterns.d.ts.map +1 -0
- package/dist/commentary/built-in-patterns.js +62 -0
- package/dist/commentary/built-in-patterns.js.map +1 -0
- package/dist/commentary/index.d.ts +5 -0
- package/dist/commentary/index.js +5 -0
- package/dist/commentary/sanitizer.d.ts +37 -0
- package/dist/commentary/sanitizer.d.ts.map +1 -0
- package/dist/commentary/sanitizer.js +182 -0
- package/dist/commentary/sanitizer.js.map +1 -0
- package/dist/commentary/types.d.ts +120 -0
- package/dist/commentary/types.d.ts.map +1 -0
- package/dist/config.d.ts +760 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +217 -0
- package/dist/config.js.map +1 -0
- package/dist/consolidator/daemon.d.ts +88 -0
- package/dist/consolidator/daemon.d.ts.map +1 -0
- package/dist/consolidator/daemon.js +54 -0
- package/dist/consolidator/daemon.js.map +1 -0
- package/dist/consolidator/index.d.ts +2 -0
- package/dist/consolidator/index.js +3 -0
- package/dist/errors/index.d.ts +104 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +131 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/health/checks.d.ts +120 -0
- package/dist/health/checks.d.ts.map +1 -0
- package/dist/health/checks.js +127 -0
- package/dist/health/checks.js.map +1 -0
- package/dist/health/index.d.ts +3 -0
- package/dist/health/index.js +4 -0
- package/dist/health/routes.d.ts +66 -0
- package/dist/health/routes.d.ts.map +1 -0
- package/dist/health/routes.js +96 -0
- package/dist/health/routes.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/context.d.ts +66 -0
- package/dist/internal/context.d.ts.map +1 -0
- package/dist/internal/ids.js +21 -0
- package/dist/internal/ids.js.map +1 -0
- package/dist/internal/json.js +46 -0
- package/dist/internal/json.js.map +1 -0
- package/dist/lifecycle/hooks.d.ts +57 -0
- package/dist/lifecycle/hooks.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +3 -0
- package/dist/lifecycle/pre-bind.d.ts +38 -0
- package/dist/lifecycle/pre-bind.d.ts.map +1 -0
- package/dist/lifecycle/pre-bind.js +62 -0
- package/dist/lifecycle/pre-bind.js.map +1 -0
- package/dist/metrics/catalog.d.ts +34 -0
- package/dist/metrics/catalog.d.ts.map +1 -0
- package/dist/metrics/catalog.js +61 -0
- package/dist/metrics/catalog.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/registry.d.ts +63 -0
- package/dist/metrics/registry.d.ts.map +1 -0
- package/dist/metrics/registry.js +222 -0
- package/dist/metrics/registry.js.map +1 -0
- package/dist/middleware/audit.d.ts +47 -0
- package/dist/middleware/audit.d.ts.map +1 -0
- package/dist/middleware/audit.js +71 -0
- package/dist/middleware/audit.js.map +1 -0
- package/dist/middleware/auth.d.ts +50 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +164 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/cors.d.ts +15 -0
- package/dist/middleware/cors.d.ts.map +1 -0
- package/dist/middleware/cors.js +45 -0
- package/dist/middleware/cors.js.map +1 -0
- package/dist/middleware/csrf.d.ts +15 -0
- package/dist/middleware/csrf.d.ts.map +1 -0
- package/dist/middleware/csrf.js +77 -0
- package/dist/middleware/csrf.js.map +1 -0
- package/dist/middleware/idempotency.d.ts +41 -0
- package/dist/middleware/idempotency.d.ts.map +1 -0
- package/dist/middleware/idempotency.js +198 -0
- package/dist/middleware/idempotency.js.map +1 -0
- package/dist/middleware/index.d.ts +9 -0
- package/dist/middleware/index.js +10 -0
- package/dist/middleware/rate-limit.d.ts +17 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +47 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/middleware/request-state.d.ts +27 -0
- package/dist/middleware/request-state.d.ts.map +1 -0
- package/dist/middleware/request-state.js +42 -0
- package/dist/middleware/request-state.js.map +1 -0
- package/dist/middleware/scope.d.ts +24 -0
- package/dist/middleware/scope.d.ts.map +1 -0
- package/dist/middleware/scope.js +50 -0
- package/dist/middleware/scope.js.map +1 -0
- package/dist/registry/index.d.ts +135 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +96 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/replay/index.d.ts +2 -0
- package/dist/replay/index.js +3 -0
- package/dist/replay/routes.d.ts +46 -0
- package/dist/replay/routes.d.ts.map +1 -0
- package/dist/replay/routes.js +189 -0
- package/dist/replay/routes.js.map +1 -0
- package/dist/routes/agents.d.ts +41 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +213 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/audit.d.ts +57 -0
- package/dist/routes/audit.d.ts.map +1 -0
- package/dist/routes/audit.js +116 -0
- package/dist/routes/audit.js.map +1 -0
- package/dist/routes/auth.d.ts +26 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +54 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/health.d.ts +22 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +33 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/index.d.ts +10 -0
- package/dist/routes/index.js +12 -0
- package/dist/routes/mcp.d.ts +32 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +61 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/memory.d.ts +141 -0
- package/dist/routes/memory.d.ts.map +1 -0
- package/dist/routes/memory.js +102 -0
- package/dist/routes/memory.js.map +1 -0
- package/dist/routes/sessions.d.ts +54 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +135 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/skills.d.ts +34 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +54 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tokens.d.ts +25 -0
- package/dist/routes/tokens.d.ts.map +1 -0
- package/dist/routes/tokens.js +87 -0
- package/dist/routes/tokens.js.map +1 -0
- package/dist/routes/workflows.d.ts +27 -0
- package/dist/routes/workflows.d.ts.map +1 -0
- package/dist/routes/workflows.js +220 -0
- package/dist/routes/workflows.js.map +1 -0
- package/dist/runtime/run-state.d.ts +158 -0
- package/dist/runtime/run-state.d.ts.map +1 -0
- package/dist/runtime/run-state.js +182 -0
- package/dist/runtime/run-state.js.map +1 -0
- package/dist/sse/events.d.ts +44 -0
- package/dist/sse/events.d.ts.map +1 -0
- package/dist/sse/events.js +200 -0
- package/dist/sse/events.js.map +1 -0
- package/dist/sse/index.d.ts +2 -0
- package/dist/sse/index.js +3 -0
- package/dist/triggers/daemon.d.ts +74 -0
- package/dist/triggers/daemon.d.ts.map +1 -0
- package/dist/triggers/daemon.js +114 -0
- package/dist/triggers/daemon.js.map +1 -0
- package/dist/triggers/index.d.ts +3 -0
- package/dist/triggers/index.js +4 -0
- package/dist/triggers/routes.d.ts +21 -0
- package/dist/triggers/routes.d.ts.map +1 -0
- package/dist/triggers/routes.js +117 -0
- package/dist/triggers/routes.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +169 -0
- package/dist/ws/dispatcher.d.ts.map +1 -0
- package/dist/ws/dispatcher.js +303 -0
- package/dist/ws/dispatcher.js.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.js +7 -0
- package/dist/ws/replay-buffer.d.ts +47 -0
- package/dist/ws/replay-buffer.d.ts.map +1 -0
- package/dist/ws/replay-buffer.js +88 -0
- package/dist/ws/replay-buffer.js.map +1 -0
- package/dist/ws/subjects.d.ts +71 -0
- package/dist/ws/subjects.d.ts.map +1 -0
- package/dist/ws/subjects.js +112 -0
- package/dist/ws/subjects.js.map +1 -0
- package/dist/ws/ticket.d.ts +74 -0
- package/dist/ws/ticket.d.ts.map +1 -0
- package/dist/ws/ticket.js +112 -0
- package/dist/ws/ticket.js.map +1 -0
- package/dist/ws/upgrade.d.ts +63 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/dist/ws/upgrade.js +324 -0
- package/dist/ws/upgrade.js.map +1 -0
- package/package.json +156 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
//#region src/metrics/registry.ts
|
|
2
|
+
const SUMMARY_SAMPLE_CAP = 1024;
|
|
3
|
+
const SUMMARY_QUANTILES = Object.freeze([.5, .95]);
|
|
4
|
+
/**
|
|
5
|
+
* Lightweight Prometheus registry. Each instance owns its metric
|
|
6
|
+
* catalogue + per-label samples; `render()` emits the canonical text
|
|
7
|
+
* exposition block.
|
|
8
|
+
*
|
|
9
|
+
* @stable
|
|
10
|
+
*/
|
|
11
|
+
var MetricRegistry = class {
|
|
12
|
+
#definitions = /* @__PURE__ */ new Map();
|
|
13
|
+
#counters = /* @__PURE__ */ new Map();
|
|
14
|
+
#gauges = /* @__PURE__ */ new Map();
|
|
15
|
+
#summaries = /* @__PURE__ */ new Map();
|
|
16
|
+
registerCounter(name, help, labelNames = []) {
|
|
17
|
+
this.#register(name, help, "counter", labelNames);
|
|
18
|
+
if (!this.#counters.has(name)) this.#counters.set(name, /* @__PURE__ */ new Map());
|
|
19
|
+
}
|
|
20
|
+
registerGauge(name, help, labelNames = []) {
|
|
21
|
+
this.#register(name, help, "gauge", labelNames);
|
|
22
|
+
if (!this.#gauges.has(name)) this.#gauges.set(name, /* @__PURE__ */ new Map());
|
|
23
|
+
}
|
|
24
|
+
registerSummary(name, help, labelNames = []) {
|
|
25
|
+
this.#register(name, help, "summary", labelNames);
|
|
26
|
+
if (!this.#summaries.has(name)) this.#summaries.set(name, /* @__PURE__ */ new Map());
|
|
27
|
+
}
|
|
28
|
+
inc(name, labels = {}, by = 1) {
|
|
29
|
+
this.#assertKind(name, "counter");
|
|
30
|
+
assertLabelNames(labels);
|
|
31
|
+
const bucket = this.#counters.get(name);
|
|
32
|
+
if (bucket === void 0) throw new Error(`MetricRegistry.inc: counter '${name}' is not registered`);
|
|
33
|
+
const key = serializeLabelKey(labels);
|
|
34
|
+
const entry = bucket.get(key) ?? {
|
|
35
|
+
labels: { ...labels },
|
|
36
|
+
value: 0
|
|
37
|
+
};
|
|
38
|
+
if (!Number.isFinite(by)) throw new Error(`MetricRegistry.inc(${name}): by must be finite`);
|
|
39
|
+
if (by < 0) throw new Error(`MetricRegistry.inc(${name}): counters never decrement`);
|
|
40
|
+
entry.value += by;
|
|
41
|
+
bucket.set(key, entry);
|
|
42
|
+
}
|
|
43
|
+
set(name, value, labels = {}) {
|
|
44
|
+
this.#assertKind(name, "gauge");
|
|
45
|
+
assertLabelNames(labels);
|
|
46
|
+
const bucket = this.#gauges.get(name);
|
|
47
|
+
if (bucket === void 0) throw new Error(`MetricRegistry.set: gauge '${name}' is not registered`);
|
|
48
|
+
const key = serializeLabelKey(labels);
|
|
49
|
+
bucket.set(key, {
|
|
50
|
+
labels: { ...labels },
|
|
51
|
+
value
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
observe(name, value, labels = {}) {
|
|
55
|
+
this.#assertKind(name, "summary");
|
|
56
|
+
assertLabelNames(labels);
|
|
57
|
+
const bucket = this.#summaries.get(name);
|
|
58
|
+
if (bucket === void 0) throw new Error(`MetricRegistry.observe: summary '${name}' is not registered`);
|
|
59
|
+
const key = serializeLabelKey(labels);
|
|
60
|
+
const entry = bucket.get(key) ?? {
|
|
61
|
+
labels: { ...labels },
|
|
62
|
+
count: 0,
|
|
63
|
+
sum: 0,
|
|
64
|
+
samples: []
|
|
65
|
+
};
|
|
66
|
+
entry.count += 1;
|
|
67
|
+
entry.sum += value;
|
|
68
|
+
entry.samples.push(value);
|
|
69
|
+
if (entry.samples.length > SUMMARY_SAMPLE_CAP) entry.samples.shift();
|
|
70
|
+
bucket.set(key, entry);
|
|
71
|
+
}
|
|
72
|
+
reset() {
|
|
73
|
+
this.#counters.clear();
|
|
74
|
+
this.#gauges.clear();
|
|
75
|
+
this.#summaries.clear();
|
|
76
|
+
for (const [name, def] of this.#definitions) switch (def.kind) {
|
|
77
|
+
case "counter":
|
|
78
|
+
this.#counters.set(name, /* @__PURE__ */ new Map());
|
|
79
|
+
break;
|
|
80
|
+
case "gauge":
|
|
81
|
+
this.#gauges.set(name, /* @__PURE__ */ new Map());
|
|
82
|
+
break;
|
|
83
|
+
case "summary":
|
|
84
|
+
this.#summaries.set(name, /* @__PURE__ */ new Map());
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Render the current snapshot in Prometheus text exposition
|
|
90
|
+
* format (v0.0.4). Never throws — incomplete sample buckets are
|
|
91
|
+
* skipped instead of failing the scrape.
|
|
92
|
+
*/
|
|
93
|
+
render() {
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (const def of this.#definitions.values()) {
|
|
96
|
+
lines.push(`# HELP ${def.name} ${escapeHelp(def.help)}`);
|
|
97
|
+
lines.push(`# TYPE ${def.name} ${def.kind}`);
|
|
98
|
+
switch (def.kind) {
|
|
99
|
+
case "counter": {
|
|
100
|
+
const bucket = this.#counters.get(def.name);
|
|
101
|
+
if (bucket !== void 0) for (const entry of bucket.values()) lines.push(formatSample(def.name, entry.labels, entry.value));
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "gauge": {
|
|
105
|
+
const bucket = this.#gauges.get(def.name);
|
|
106
|
+
if (bucket !== void 0) for (const entry of bucket.values()) lines.push(formatSample(def.name, entry.labels, entry.value));
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "summary": {
|
|
110
|
+
const bucket = this.#summaries.get(def.name);
|
|
111
|
+
if (bucket !== void 0) for (const entry of bucket.values()) {
|
|
112
|
+
for (const q of SUMMARY_QUANTILES) {
|
|
113
|
+
const value = computeQuantile(entry.samples, q);
|
|
114
|
+
if (value === void 0) continue;
|
|
115
|
+
lines.push(formatSample(def.name, {
|
|
116
|
+
...entry.labels,
|
|
117
|
+
quantile: q.toString()
|
|
118
|
+
}, value));
|
|
119
|
+
}
|
|
120
|
+
lines.push(formatSample(`${def.name}_sum`, entry.labels, entry.sum));
|
|
121
|
+
lines.push(formatSample(`${def.name}_count`, entry.labels, entry.count));
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return `${lines.join("\n")}\n`;
|
|
128
|
+
}
|
|
129
|
+
contentType() {
|
|
130
|
+
return "text/plain; version=0.0.4; charset=utf-8";
|
|
131
|
+
}
|
|
132
|
+
/** Snapshot for tests / assertions. */
|
|
133
|
+
snapshot() {
|
|
134
|
+
const counters = {};
|
|
135
|
+
for (const [name, bucket] of this.#counters) counters[name] = [...bucket.values()].map((entry) => ({
|
|
136
|
+
labels: entry.labels,
|
|
137
|
+
value: entry.value
|
|
138
|
+
}));
|
|
139
|
+
const gauges = {};
|
|
140
|
+
for (const [name, bucket] of this.#gauges) gauges[name] = [...bucket.values()].map((entry) => ({
|
|
141
|
+
labels: entry.labels,
|
|
142
|
+
value: entry.value
|
|
143
|
+
}));
|
|
144
|
+
const summaries = {};
|
|
145
|
+
for (const [name, bucket] of this.#summaries) summaries[name] = [...bucket.values()].map((entry) => ({
|
|
146
|
+
labels: entry.labels,
|
|
147
|
+
count: entry.count,
|
|
148
|
+
sum: entry.sum,
|
|
149
|
+
samples: [...entry.samples]
|
|
150
|
+
}));
|
|
151
|
+
return {
|
|
152
|
+
counters,
|
|
153
|
+
gauges,
|
|
154
|
+
summaries
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
#register(name, help, kind, labelNames) {
|
|
158
|
+
if (!METRIC_NAME_RE.test(name)) throw new Error(`MetricRegistry: invalid metric name '${name}'`);
|
|
159
|
+
const existing = this.#definitions.get(name);
|
|
160
|
+
if (existing !== void 0) {
|
|
161
|
+
if (existing.kind !== kind) throw new Error(`MetricRegistry: metric '${name}' already registered as '${existing.kind}', cannot redefine as '${kind}'`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.#definitions.set(name, {
|
|
165
|
+
name,
|
|
166
|
+
help,
|
|
167
|
+
kind,
|
|
168
|
+
labelNames: Object.freeze([...labelNames])
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
#assertKind(name, kind) {
|
|
172
|
+
const def = this.#definitions.get(name);
|
|
173
|
+
if (def === void 0) throw new Error(`MetricRegistry: unknown metric '${name}'`);
|
|
174
|
+
if (def.kind !== kind) throw new Error(`MetricRegistry: metric '${name}' is a ${def.kind}, not a ${kind}`);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const METRIC_NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
178
|
+
const LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
179
|
+
function assertLabelNames(labels) {
|
|
180
|
+
for (const key of Object.keys(labels)) if (!LABEL_NAME_RE.test(key)) throw new Error(`MetricRegistry: invalid label name '${key}'`);
|
|
181
|
+
}
|
|
182
|
+
function serializeLabelKey(labels) {
|
|
183
|
+
const keys = Object.keys(labels).sort();
|
|
184
|
+
if (keys.length === 0) return "";
|
|
185
|
+
const parts = [];
|
|
186
|
+
for (const k of keys) parts.push(`${k}=${String(labels[k])}`);
|
|
187
|
+
return parts.join("|");
|
|
188
|
+
}
|
|
189
|
+
function formatSample(name, labels, value) {
|
|
190
|
+
return `${name}${formatLabels(labels)} ${formatNumber(value)}`;
|
|
191
|
+
}
|
|
192
|
+
function formatLabels(labels) {
|
|
193
|
+
const keys = Object.keys(labels).sort();
|
|
194
|
+
if (keys.length === 0) return "";
|
|
195
|
+
const parts = [];
|
|
196
|
+
for (const k of keys) {
|
|
197
|
+
if (!LABEL_NAME_RE.test(k)) throw new Error(`MetricRegistry: invalid label name '${k}'`);
|
|
198
|
+
parts.push(`${k}="${escapeLabelValue(String(labels[k]))}"`);
|
|
199
|
+
}
|
|
200
|
+
return `{${parts.join(",")}}`;
|
|
201
|
+
}
|
|
202
|
+
function escapeLabelValue(value) {
|
|
203
|
+
return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
|
|
204
|
+
}
|
|
205
|
+
function escapeHelp(help) {
|
|
206
|
+
return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
|
|
207
|
+
}
|
|
208
|
+
function formatNumber(value) {
|
|
209
|
+
if (Number.isNaN(value)) return "NaN";
|
|
210
|
+
if (!Number.isFinite(value)) return value > 0 ? "+Inf" : "-Inf";
|
|
211
|
+
if (Number.isInteger(value)) return String(value);
|
|
212
|
+
return value.toString();
|
|
213
|
+
}
|
|
214
|
+
function computeQuantile(samples, q) {
|
|
215
|
+
if (samples.length === 0) return void 0;
|
|
216
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
217
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
export { MetricRegistry };
|
|
222
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","names":["#definitions","#counters","#gauges","#summaries","#register","#assertKind","lines: string[]","counters: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>","gauges: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>","summaries: Record<\n string,\n ReadonlyArray<{\n labels: LabelSet;\n count: number;\n sum: number;\n samples: ReadonlyArray<number>;\n }>\n >","parts: string[]"],"sources":["../../src/metrics/registry.ts"],"sourcesContent":["/**\n * Tiny Prometheus exposition layer. The framework intentionally does\n * not pull in `prom-client` so the bundle stays lean and so the\n * exposition is byte-stable across processes (`prom-client` adds\n * default counters that drift between Node releases).\n *\n * The supported metric kinds are the three the runtime spec calls\n * out: `counter`, `gauge`, and `summary` (with hard-coded p50/p95\n * quantiles). Sample rendering follows Prometheus text exposition\n * format v0.0.4 — every metric block is preceded by `# HELP` + `# TYPE`\n * and labels are quoted-escaped per the spec.\n *\n * @packageDocumentation\n */\n\n/** @stable */\nexport type MetricKind = 'counter' | 'gauge' | 'summary';\n\n/** @stable */\nexport type LabelSet = Readonly<Record<string, string | number | boolean>>;\n\ninterface MetricDefinition {\n readonly name: string;\n readonly help: string;\n readonly kind: MetricKind;\n readonly labelNames: ReadonlyArray<string>;\n}\n\ninterface CounterEntry {\n readonly labels: LabelSet;\n value: number;\n}\n\ninterface GaugeEntry {\n readonly labels: LabelSet;\n value: number;\n}\n\ninterface SummaryEntry {\n readonly labels: LabelSet;\n count: number;\n sum: number;\n /** Recent samples (capped) used to compute quantiles. */\n samples: number[];\n}\n\nconst SUMMARY_SAMPLE_CAP = 1024;\nconst SUMMARY_QUANTILES = Object.freeze([0.5, 0.95]);\n\n/**\n * Lightweight Prometheus registry. Each instance owns its metric\n * catalogue + per-label samples; `render()` emits the canonical text\n * exposition block.\n *\n * @stable\n */\nexport class MetricRegistry {\n readonly #definitions = new Map<string, MetricDefinition>();\n readonly #counters = new Map<string, Map<string, CounterEntry>>();\n readonly #gauges = new Map<string, Map<string, GaugeEntry>>();\n readonly #summaries = new Map<string, Map<string, SummaryEntry>>();\n\n registerCounter(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {\n this.#register(name, help, 'counter', labelNames);\n if (!this.#counters.has(name)) this.#counters.set(name, new Map());\n }\n\n registerGauge(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {\n this.#register(name, help, 'gauge', labelNames);\n if (!this.#gauges.has(name)) this.#gauges.set(name, new Map());\n }\n\n registerSummary(name: string, help: string, labelNames: ReadonlyArray<string> = []): void {\n this.#register(name, help, 'summary', labelNames);\n if (!this.#summaries.has(name)) this.#summaries.set(name, new Map());\n }\n\n inc(name: string, labels: LabelSet = {}, by = 1): void {\n this.#assertKind(name, 'counter');\n assertLabelNames(labels);\n const bucket = this.#counters.get(name);\n if (bucket === undefined) {\n throw new Error(`MetricRegistry.inc: counter '${name}' is not registered`);\n }\n const key = serializeLabelKey(labels);\n const entry = bucket.get(key) ?? { labels: { ...labels }, value: 0 };\n if (!Number.isFinite(by)) {\n throw new Error(`MetricRegistry.inc(${name}): by must be finite`);\n }\n if (by < 0) {\n throw new Error(`MetricRegistry.inc(${name}): counters never decrement`);\n }\n entry.value += by;\n bucket.set(key, entry);\n }\n\n set(name: string, value: number, labels: LabelSet = {}): void {\n this.#assertKind(name, 'gauge');\n assertLabelNames(labels);\n const bucket = this.#gauges.get(name);\n if (bucket === undefined) {\n throw new Error(`MetricRegistry.set: gauge '${name}' is not registered`);\n }\n const key = serializeLabelKey(labels);\n bucket.set(key, { labels: { ...labels }, value });\n }\n\n observe(name: string, value: number, labels: LabelSet = {}): void {\n this.#assertKind(name, 'summary');\n assertLabelNames(labels);\n const bucket = this.#summaries.get(name);\n if (bucket === undefined) {\n throw new Error(`MetricRegistry.observe: summary '${name}' is not registered`);\n }\n const key = serializeLabelKey(labels);\n const entry = bucket.get(key) ?? { labels: { ...labels }, count: 0, sum: 0, samples: [] };\n entry.count += 1;\n entry.sum += value;\n entry.samples.push(value);\n if (entry.samples.length > SUMMARY_SAMPLE_CAP) {\n entry.samples.shift();\n }\n bucket.set(key, entry);\n }\n\n reset(): void {\n this.#counters.clear();\n this.#gauges.clear();\n this.#summaries.clear();\n for (const [name, def] of this.#definitions) {\n switch (def.kind) {\n case 'counter':\n this.#counters.set(name, new Map());\n break;\n case 'gauge':\n this.#gauges.set(name, new Map());\n break;\n case 'summary':\n this.#summaries.set(name, new Map());\n break;\n }\n }\n }\n\n /**\n * Render the current snapshot in Prometheus text exposition\n * format (v0.0.4). Never throws — incomplete sample buckets are\n * skipped instead of failing the scrape.\n */\n render(): string {\n const lines: string[] = [];\n for (const def of this.#definitions.values()) {\n lines.push(`# HELP ${def.name} ${escapeHelp(def.help)}`);\n lines.push(`# TYPE ${def.name} ${def.kind}`);\n switch (def.kind) {\n case 'counter': {\n const bucket = this.#counters.get(def.name);\n if (bucket !== undefined) {\n for (const entry of bucket.values()) {\n lines.push(formatSample(def.name, entry.labels, entry.value));\n }\n }\n break;\n }\n case 'gauge': {\n const bucket = this.#gauges.get(def.name);\n if (bucket !== undefined) {\n for (const entry of bucket.values()) {\n lines.push(formatSample(def.name, entry.labels, entry.value));\n }\n }\n break;\n }\n case 'summary': {\n const bucket = this.#summaries.get(def.name);\n if (bucket !== undefined) {\n for (const entry of bucket.values()) {\n for (const q of SUMMARY_QUANTILES) {\n const value = computeQuantile(entry.samples, q);\n if (value === undefined) continue;\n lines.push(\n formatSample(def.name, { ...entry.labels, quantile: q.toString() }, value),\n );\n }\n lines.push(formatSample(`${def.name}_sum`, entry.labels, entry.sum));\n lines.push(formatSample(`${def.name}_count`, entry.labels, entry.count));\n }\n }\n break;\n }\n }\n }\n return `${lines.join('\\n')}\\n`;\n }\n\n contentType(): string {\n return 'text/plain; version=0.0.4; charset=utf-8';\n }\n\n /** Snapshot for tests / assertions. */\n snapshot(): {\n counters: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>;\n gauges: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>>;\n summaries: Record<\n string,\n ReadonlyArray<{\n labels: LabelSet;\n count: number;\n sum: number;\n samples: ReadonlyArray<number>;\n }>\n >;\n } {\n const counters: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>> = {};\n for (const [name, bucket] of this.#counters) {\n counters[name] = [...bucket.values()].map((entry) => ({\n labels: entry.labels,\n value: entry.value,\n }));\n }\n const gauges: Record<string, ReadonlyArray<{ labels: LabelSet; value: number }>> = {};\n for (const [name, bucket] of this.#gauges) {\n gauges[name] = [...bucket.values()].map((entry) => ({\n labels: entry.labels,\n value: entry.value,\n }));\n }\n const summaries: Record<\n string,\n ReadonlyArray<{\n labels: LabelSet;\n count: number;\n sum: number;\n samples: ReadonlyArray<number>;\n }>\n > = {};\n for (const [name, bucket] of this.#summaries) {\n summaries[name] = [...bucket.values()].map((entry) => ({\n labels: entry.labels,\n count: entry.count,\n sum: entry.sum,\n samples: [...entry.samples],\n }));\n }\n return { counters, gauges, summaries };\n }\n\n #register(name: string, help: string, kind: MetricKind, labelNames: ReadonlyArray<string>): void {\n if (!METRIC_NAME_RE.test(name)) {\n throw new Error(`MetricRegistry: invalid metric name '${name}'`);\n }\n const existing = this.#definitions.get(name);\n if (existing !== undefined) {\n if (existing.kind !== kind) {\n throw new Error(\n `MetricRegistry: metric '${name}' already registered as '${existing.kind}', cannot redefine as '${kind}'`,\n );\n }\n return;\n }\n this.#definitions.set(name, {\n name,\n help,\n kind,\n labelNames: Object.freeze([...labelNames]),\n });\n }\n\n #assertKind(name: string, kind: MetricKind): void {\n const def = this.#definitions.get(name);\n if (def === undefined) {\n throw new Error(`MetricRegistry: unknown metric '${name}'`);\n }\n if (def.kind !== kind) {\n throw new Error(`MetricRegistry: metric '${name}' is a ${def.kind}, not a ${kind}`);\n }\n }\n}\n\nconst METRIC_NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;\nconst LABEL_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\nfunction assertLabelNames(labels: LabelSet): void {\n for (const key of Object.keys(labels)) {\n if (!LABEL_NAME_RE.test(key)) {\n throw new Error(`MetricRegistry: invalid label name '${key}'`);\n }\n }\n}\n\nfunction serializeLabelKey(labels: LabelSet): string {\n const keys = Object.keys(labels).sort();\n if (keys.length === 0) return '';\n const parts: string[] = [];\n for (const k of keys) parts.push(`${k}=${String(labels[k])}`);\n return parts.join('|');\n}\n\nfunction formatSample(name: string, labels: LabelSet, value: number): string {\n const labelStr = formatLabels(labels);\n return `${name}${labelStr} ${formatNumber(value)}`;\n}\n\nfunction formatLabels(labels: LabelSet): string {\n const keys = Object.keys(labels).sort();\n if (keys.length === 0) return '';\n const parts: string[] = [];\n for (const k of keys) {\n if (!LABEL_NAME_RE.test(k)) {\n throw new Error(`MetricRegistry: invalid label name '${k}'`);\n }\n parts.push(`${k}=\"${escapeLabelValue(String(labels[k]))}\"`);\n }\n return `{${parts.join(',')}}`;\n}\n\nfunction escapeLabelValue(value: string): string {\n return value.replace(/\\\\/g, '\\\\\\\\').replace(/\\n/g, '\\\\n').replace(/\"/g, '\\\\\"');\n}\n\nfunction escapeHelp(help: string): string {\n return help.replace(/\\\\/g, '\\\\\\\\').replace(/\\n/g, '\\\\n');\n}\n\nfunction formatNumber(value: number): string {\n if (Number.isNaN(value)) return 'NaN';\n if (!Number.isFinite(value)) return value > 0 ? '+Inf' : '-Inf';\n if (Number.isInteger(value)) return String(value);\n return value.toString();\n}\n\nfunction computeQuantile(samples: ReadonlyArray<number>, q: number): number | undefined {\n if (samples.length === 0) return undefined;\n const sorted = [...samples].sort((a, b) => a - b);\n const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));\n return sorted[idx];\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ServerVariables } from "../internal/context.js";
|
|
2
|
+
import { AuditDb, AuditEntryInput } from "@graphorin/security/audit";
|
|
3
|
+
import { MiddlewareHandler } from "hono";
|
|
4
|
+
|
|
5
|
+
//#region src/middleware/audit.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canonical action discriminator emitted on every authenticated REST
|
|
9
|
+
* request per the Phase 14a spec (`§ Audit middleware`). Exposed as
|
|
10
|
+
* a constant so consumers (downstream filters, dashboards) can grep
|
|
11
|
+
* for it without restating the literal everywhere.
|
|
12
|
+
*
|
|
13
|
+
* @stable
|
|
14
|
+
*/
|
|
15
|
+
declare const HTTP_REQUEST_AUDIT_ACTION: "http:request";
|
|
16
|
+
/**
|
|
17
|
+
* Optional telemetry sink. The default ignores errors; production
|
|
18
|
+
* deployments wire this into their structured logger.
|
|
19
|
+
*
|
|
20
|
+
* @stable
|
|
21
|
+
*/
|
|
22
|
+
type AuditErrorSink = (err: unknown, entry: Omit<AuditEntryInput, 'ts'>) => void;
|
|
23
|
+
/**
|
|
24
|
+
* @stable
|
|
25
|
+
*/
|
|
26
|
+
interface AuditMiddlewareOptions {
|
|
27
|
+
readonly auditDb: AuditDb;
|
|
28
|
+
/** Optional override for the time source. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
/** Optional error sink. Defaults to swallow. */
|
|
31
|
+
readonly onError?: AuditErrorSink;
|
|
32
|
+
/**
|
|
33
|
+
* When `true` (the default), include the path + method + status on
|
|
34
|
+
* the audit entry's metadata. Disable for compliance-strict
|
|
35
|
+
* deployments that already log the request envelope elsewhere.
|
|
36
|
+
*/
|
|
37
|
+
readonly recordMetadata?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* @stable
|
|
41
|
+
*/
|
|
42
|
+
declare function createAuditMiddleware(options: AuditMiddlewareOptions): MiddlewareHandler<{
|
|
43
|
+
Variables: ServerVariables;
|
|
44
|
+
}>;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { AuditErrorSink, AuditMiddlewareOptions, HTTP_REQUEST_AUDIT_ACTION, createAuditMiddleware };
|
|
47
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.d.ts","names":[],"sources":["../../src/middleware/audit.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;cA4Ba;;;;;;;KAQD,cAAA,yBAAuC,KAAK;;;;UAKvC,sBAAA;oBACG;;;;qBAIC;;;;;;;;;;;iBAmBL,qBAAA,UACL,yBACR;aAA+B"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { appendAudit } from "@graphorin/security/audit";
|
|
2
|
+
|
|
3
|
+
//#region src/middleware/audit.ts
|
|
4
|
+
/**
|
|
5
|
+
* Audit middleware. Forwards every authenticated request through
|
|
6
|
+
* `appendAudit` (`@graphorin/security/audit`) so the operator gets a
|
|
7
|
+
* tamper-evident chain of every state-changing call. Append failures
|
|
8
|
+
* never break the request hot path; they are recorded on a best-
|
|
9
|
+
* effort error sink.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Canonical action discriminator emitted on every authenticated REST
|
|
15
|
+
* request per the Phase 14a spec (`§ Audit middleware`). Exposed as
|
|
16
|
+
* a constant so consumers (downstream filters, dashboards) can grep
|
|
17
|
+
* for it without restating the literal everywhere.
|
|
18
|
+
*
|
|
19
|
+
* @stable
|
|
20
|
+
*/
|
|
21
|
+
const HTTP_REQUEST_AUDIT_ACTION = "http:request";
|
|
22
|
+
function decisionForStatus(status) {
|
|
23
|
+
if (status >= 500) return "error";
|
|
24
|
+
if (status === 404) return "not-found";
|
|
25
|
+
if (status >= 400) return "denied";
|
|
26
|
+
return "success";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* @stable
|
|
30
|
+
*/
|
|
31
|
+
function createAuditMiddleware(options) {
|
|
32
|
+
const now = options.now ?? Date.now;
|
|
33
|
+
const onError = options.onError ?? (() => {});
|
|
34
|
+
const recordMetadata = options.recordMetadata ?? true;
|
|
35
|
+
return async (c, next) => {
|
|
36
|
+
const start = now();
|
|
37
|
+
await next();
|
|
38
|
+
const status = c.res?.status ?? 0;
|
|
39
|
+
const auth = c.get("state").auth;
|
|
40
|
+
if (auth.kind !== "token") return;
|
|
41
|
+
const token = auth.token;
|
|
42
|
+
const entry = {
|
|
43
|
+
ts: now(),
|
|
44
|
+
actor: {
|
|
45
|
+
kind: "token",
|
|
46
|
+
id: token.tokenId,
|
|
47
|
+
...token.label !== void 0 ? { label: token.label } : {}
|
|
48
|
+
},
|
|
49
|
+
action: HTTP_REQUEST_AUDIT_ACTION,
|
|
50
|
+
target: c.req.path,
|
|
51
|
+
decision: decisionForStatus(status),
|
|
52
|
+
...recordMetadata ? { metadata: {
|
|
53
|
+
method: c.req.method,
|
|
54
|
+
status,
|
|
55
|
+
durationMs: now() - start,
|
|
56
|
+
requestId: c.get("state").requestId,
|
|
57
|
+
...c.get("state").idempotencyKey !== void 0 ? { idempotencyKey: c.get("state").idempotencyKey } : {},
|
|
58
|
+
...c.get("state").idempotencyReplay === true ? { idempotencyReplayed: true } : {}
|
|
59
|
+
} } : {}
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
await appendAudit(options.auditDb, entry);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
onError(err, entry);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
export { HTTP_REQUEST_AUDIT_ACTION, createAuditMiddleware };
|
|
71
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","names":["entry: AuditEntryInput"],"sources":["../../src/middleware/audit.ts"],"sourcesContent":["/**\n * Audit middleware. Forwards every authenticated request through\n * `appendAudit` (`@graphorin/security/audit`) so the operator gets a\n * tamper-evident chain of every state-changing call. Append failures\n * never break the request hot path; they are recorded on a best-\n * effort error sink.\n *\n * @packageDocumentation\n */\n\nimport {\n type AuditDb,\n type AuditDecision,\n type AuditEntryInput,\n appendAudit,\n} from '@graphorin/security/audit';\nimport type { MiddlewareHandler } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * Canonical action discriminator emitted on every authenticated REST\n * request per the Phase 14a spec (`§ Audit middleware`). Exposed as\n * a constant so consumers (downstream filters, dashboards) can grep\n * for it without restating the literal everywhere.\n *\n * @stable\n */\nexport const HTTP_REQUEST_AUDIT_ACTION = 'http:request' as const;\n\n/**\n * Optional telemetry sink. The default ignores errors; production\n * deployments wire this into their structured logger.\n *\n * @stable\n */\nexport type AuditErrorSink = (err: unknown, entry: Omit<AuditEntryInput, 'ts'>) => void;\n\n/**\n * @stable\n */\nexport interface AuditMiddlewareOptions {\n readonly auditDb: AuditDb;\n /** Optional override for the time source. */\n readonly now?: () => number;\n /** Optional error sink. Defaults to swallow. */\n readonly onError?: AuditErrorSink;\n /**\n * When `true` (the default), include the path + method + status on\n * the audit entry's metadata. Disable for compliance-strict\n * deployments that already log the request envelope elsewhere.\n */\n readonly recordMetadata?: boolean;\n}\n\nfunction decisionForStatus(status: number): AuditDecision {\n if (status >= 500) return 'error';\n if (status === 404) return 'not-found';\n if (status >= 400) return 'denied';\n return 'success';\n}\n\n/**\n * @stable\n */\nexport function createAuditMiddleware(\n options: AuditMiddlewareOptions,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const now = options.now ?? Date.now;\n const onError = options.onError ?? (() => {});\n const recordMetadata = options.recordMetadata ?? true;\n\n return async (c, next) => {\n const start = now();\n await next();\n const status = c.res?.status ?? 0;\n const auth = c.get('state').auth;\n if (auth.kind !== 'token') {\n // Anonymous calls (health, etc.) are not audited.\n return;\n }\n const token = auth.token;\n const entry: AuditEntryInput = {\n ts: now(),\n actor: {\n kind: 'token',\n id: token.tokenId,\n ...(token.label !== undefined ? { label: token.label } : {}),\n },\n action: HTTP_REQUEST_AUDIT_ACTION,\n target: c.req.path,\n decision: decisionForStatus(status),\n ...(recordMetadata\n ? {\n metadata: {\n method: c.req.method,\n status,\n durationMs: now() - start,\n requestId: c.get('state').requestId,\n ...(c.get('state').idempotencyKey !== undefined\n ? { idempotencyKey: c.get('state').idempotencyKey }\n : {}),\n ...(c.get('state').idempotencyReplay === true ? { idempotencyReplayed: true } : {}),\n },\n }\n : {}),\n };\n try {\n await appendAudit(options.auditDb, entry);\n } catch (err) {\n onError(err, entry);\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,4BAA4B;AA2BzC,SAAS,kBAAkB,QAA+B;AACxD,KAAI,UAAU,IAAK,QAAO;AAC1B,KAAI,WAAW,IAAK,QAAO;AAC3B,KAAI,UAAU,IAAK,QAAO;AAC1B,QAAO;;;;;AAMT,SAAgB,sBACd,SACmD;CACnD,MAAM,MAAM,QAAQ,OAAO,KAAK;CAChC,MAAM,UAAU,QAAQ,kBAAkB;CAC1C,MAAM,iBAAiB,QAAQ,kBAAkB;AAEjD,QAAO,OAAO,GAAG,SAAS;EACxB,MAAM,QAAQ,KAAK;AACnB,QAAM,MAAM;EACZ,MAAM,SAAS,EAAE,KAAK,UAAU;EAChC,MAAM,OAAO,EAAE,IAAI,QAAQ,CAAC;AAC5B,MAAI,KAAK,SAAS,QAEhB;EAEF,MAAM,QAAQ,KAAK;EACnB,MAAMA,QAAyB;GAC7B,IAAI,KAAK;GACT,OAAO;IACL,MAAM;IACN,IAAI,MAAM;IACV,GAAI,MAAM,UAAU,SAAY,EAAE,OAAO,MAAM,OAAO,GAAG,EAAE;IAC5D;GACD,QAAQ;GACR,QAAQ,EAAE,IAAI;GACd,UAAU,kBAAkB,OAAO;GACnC,GAAI,iBACA,EACE,UAAU;IACR,QAAQ,EAAE,IAAI;IACd;IACA,YAAY,KAAK,GAAG;IACpB,WAAW,EAAE,IAAI,QAAQ,CAAC;IAC1B,GAAI,EAAE,IAAI,QAAQ,CAAC,mBAAmB,SAClC,EAAE,gBAAgB,EAAE,IAAI,QAAQ,CAAC,gBAAgB,GACjD,EAAE;IACN,GAAI,EAAE,IAAI,QAAQ,CAAC,sBAAsB,OAAO,EAAE,qBAAqB,MAAM,GAAG,EAAE;IACnF,EACF,GACD,EAAE;GACP;AACD,MAAI;AACF,SAAM,YAAY,QAAQ,SAAS,MAAM;WAClC,KAAK;AACZ,WAAQ,KAAK,MAAM"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ServerVariables } from "../internal/context.js";
|
|
2
|
+
import { MiddlewareHandler } from "hono";
|
|
3
|
+
import { TokenVerifier } from "@graphorin/security/auth";
|
|
4
|
+
|
|
5
|
+
//#region src/middleware/auth.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps
|
|
9
|
+
* `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the
|
|
10
|
+
* scope middleware, SSE handler and replay routes all treat the request as a
|
|
11
|
+
* fully-authorized principal. This is the documented trusted-loopback /
|
|
12
|
+
* single-operator mode — never mount it on a non-loopback deployment without
|
|
13
|
+
* understanding that every endpoint becomes open.
|
|
14
|
+
*
|
|
15
|
+
* @stable
|
|
16
|
+
*/
|
|
17
|
+
declare function createAnonymousAuthMiddleware(): MiddlewareHandler<{
|
|
18
|
+
Variables: ServerVariables;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Options accepted by {@link createAuthMiddleware}. Tests inject a
|
|
22
|
+
* stub verifier; production wiring uses the verifier built during the
|
|
23
|
+
* server's pre-bind step.
|
|
24
|
+
*
|
|
25
|
+
* @stable
|
|
26
|
+
*/
|
|
27
|
+
interface AuthMiddlewareOptions {
|
|
28
|
+
readonly verifier: TokenVerifier;
|
|
29
|
+
/**
|
|
30
|
+
* Whether to allow unauthenticated requests through. Used by
|
|
31
|
+
* `health` and (when explicitly opted-in) by the public read
|
|
32
|
+
* endpoints. When `false` (the default), missing / malformed /
|
|
33
|
+
* invalid tokens short-circuit the request with `401`.
|
|
34
|
+
*/
|
|
35
|
+
readonly allowAnonymous?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the bearer-token middleware. The middleware always sets
|
|
39
|
+
* `c.var.state.auth`, even on the unauthenticated branch, so
|
|
40
|
+
* downstream code can pattern-match the discriminated union without
|
|
41
|
+
* a separate "is anonymous?" check.
|
|
42
|
+
*
|
|
43
|
+
* @stable
|
|
44
|
+
*/
|
|
45
|
+
declare function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<{
|
|
46
|
+
Variables: ServerVariables;
|
|
47
|
+
}>;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { AuthMiddlewareOptions, createAnonymousAuthMiddleware, createAuthMiddleware };
|
|
50
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","names":[],"sources":["../../src/middleware/auth.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;;;;;iBAqCgB,6BAAA,CAAA,GAAiC;aACpC;;;;;;;;;UAkBI,qBAAA;qBACI;;;;;;;;;;;;;;;;;iBAwEL,oBAAA,UACL,wBACR;aAA+B"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { parseScope } from "@graphorin/security/auth";
|
|
2
|
+
|
|
3
|
+
//#region src/middleware/auth.ts
|
|
4
|
+
/**
|
|
5
|
+
* Bearer-token authentication middleware. Wraps the
|
|
6
|
+
* `@graphorin/security` token verifier so handlers see
|
|
7
|
+
* a stable {@link import('../internal/context.js').AuthState}
|
|
8
|
+
* structure on `c.var.state.auth`.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* IP-13: the scope set granted to every request when `auth.kind = 'none'`.
|
|
14
|
+
* `admin:*` matches every required scope (see `@graphorin/security/auth`
|
|
15
|
+
* `scopeMatches`), so the trusted-loopback operator is uniformly authorized
|
|
16
|
+
* without special-casing each route.
|
|
17
|
+
*/
|
|
18
|
+
const ANONYMOUS_GRANTED_SCOPES = Object.freeze([parseScope("admin:*")]);
|
|
19
|
+
/**
|
|
20
|
+
* Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps
|
|
21
|
+
* `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the
|
|
22
|
+
* scope middleware, SSE handler and replay routes all treat the request as a
|
|
23
|
+
* fully-authorized principal. This is the documented trusted-loopback /
|
|
24
|
+
* single-operator mode — never mount it on a non-loopback deployment without
|
|
25
|
+
* understanding that every endpoint becomes open.
|
|
26
|
+
*
|
|
27
|
+
* @stable
|
|
28
|
+
*/
|
|
29
|
+
function createAnonymousAuthMiddleware() {
|
|
30
|
+
return async (c, next) => {
|
|
31
|
+
c.set("state", {
|
|
32
|
+
...c.get("state"),
|
|
33
|
+
auth: {
|
|
34
|
+
kind: "anonymous",
|
|
35
|
+
grantedScopes: ANONYMOUS_GRANTED_SCOPES
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
await next();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const HEADER_NAME = "authorization";
|
|
42
|
+
const BEARER_PREFIX = "bearer ";
|
|
43
|
+
function extractBearer(c) {
|
|
44
|
+
const raw = c.req.header(HEADER_NAME);
|
|
45
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
46
|
+
const trimmed = raw.trim();
|
|
47
|
+
if (trimmed.length < 7) return void 0;
|
|
48
|
+
if (trimmed.slice(0, 7).toLowerCase() !== BEARER_PREFIX) return void 0;
|
|
49
|
+
return trimmed.slice(7).trim();
|
|
50
|
+
}
|
|
51
|
+
function reasonToStatus(reason) {
|
|
52
|
+
switch (reason) {
|
|
53
|
+
case "malformed": return {
|
|
54
|
+
status: 401,
|
|
55
|
+
body: {
|
|
56
|
+
error: "auth-invalid",
|
|
57
|
+
message: "Malformed bearer token."
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
case "unknown-token": return {
|
|
61
|
+
status: 401,
|
|
62
|
+
body: {
|
|
63
|
+
error: "auth-invalid",
|
|
64
|
+
message: "Unknown bearer token."
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
case "revoked": return {
|
|
68
|
+
status: 401,
|
|
69
|
+
body: {
|
|
70
|
+
error: "auth-revoked",
|
|
71
|
+
message: "Bearer token has been revoked."
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
case "expired": return {
|
|
75
|
+
status: 401,
|
|
76
|
+
body: {
|
|
77
|
+
error: "auth-expired",
|
|
78
|
+
message: "Bearer token has expired."
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
case "ip-locked-out": return {
|
|
82
|
+
status: 429,
|
|
83
|
+
body: {
|
|
84
|
+
error: "auth-locked-out",
|
|
85
|
+
message: "Too many failed authentications from this address.",
|
|
86
|
+
hint: "Wait for the lockout window to elapse before retrying."
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
case "token-locked-out": return {
|
|
90
|
+
status: 429,
|
|
91
|
+
body: {
|
|
92
|
+
error: "auth-locked-out",
|
|
93
|
+
message: "Bearer token is locked out after repeated failures.",
|
|
94
|
+
hint: "Operator must rotate the token."
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
default: return {
|
|
98
|
+
status: 401,
|
|
99
|
+
body: {
|
|
100
|
+
error: "auth-invalid",
|
|
101
|
+
message: `Authentication failed: ${reason}.`
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build the bearer-token middleware. The middleware always sets
|
|
108
|
+
* `c.var.state.auth`, even on the unauthenticated branch, so
|
|
109
|
+
* downstream code can pattern-match the discriminated union without
|
|
110
|
+
* a separate "is anonymous?" check.
|
|
111
|
+
*
|
|
112
|
+
* @stable
|
|
113
|
+
*/
|
|
114
|
+
function createAuthMiddleware(options) {
|
|
115
|
+
const allowAnonymous = options.allowAnonymous ?? false;
|
|
116
|
+
return async (c, next) => {
|
|
117
|
+
const token = extractBearer(c);
|
|
118
|
+
const ip = c.get("state").clientIp;
|
|
119
|
+
if (token === void 0) {
|
|
120
|
+
if (allowAnonymous) {
|
|
121
|
+
c.set("state", {
|
|
122
|
+
...c.get("state"),
|
|
123
|
+
auth: { kind: "unauthenticated" }
|
|
124
|
+
});
|
|
125
|
+
await next();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
return c.json({
|
|
129
|
+
error: "auth-required",
|
|
130
|
+
message: "Bearer token required.",
|
|
131
|
+
hint: "Set 'Authorization: Bearer <token>'."
|
|
132
|
+
}, 401);
|
|
133
|
+
}
|
|
134
|
+
const result = await options.verifier.verify(token, ip !== void 0 ? { ip } : {});
|
|
135
|
+
if (!result.ok) {
|
|
136
|
+
const mapped = reasonToStatus(result.reason);
|
|
137
|
+
const headers = {};
|
|
138
|
+
if (result.retryAfterMs !== void 0) headers["Retry-After"] = Math.ceil(result.retryAfterMs / 1e3).toString();
|
|
139
|
+
return c.json(mapped.body, mapped.status, headers);
|
|
140
|
+
}
|
|
141
|
+
const grantedScopes = [];
|
|
142
|
+
for (const scope of result.token.scopes) grantedScopes.push(scope);
|
|
143
|
+
c.set("state", {
|
|
144
|
+
...c.get("state"),
|
|
145
|
+
auth: {
|
|
146
|
+
kind: "token",
|
|
147
|
+
token: result.token,
|
|
148
|
+
grantedScopes: Object.freeze(grantedScopes)
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
c.set("token", {
|
|
152
|
+
id: result.token.tokenId,
|
|
153
|
+
label: result.token.label,
|
|
154
|
+
scopes: Object.freeze(grantedScopes.slice()),
|
|
155
|
+
env: result.token.env,
|
|
156
|
+
expiresAt: result.token.expiresAt
|
|
157
|
+
});
|
|
158
|
+
await next();
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
//#endregion
|
|
163
|
+
export { createAnonymousAuthMiddleware, createAuthMiddleware };
|
|
164
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","names":["ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope>","headers: Record<string, string>","grantedScopes: ParsedScope[]"],"sources":["../../src/middleware/auth.ts"],"sourcesContent":["/**\n * Bearer-token authentication middleware. Wraps the\n * `@graphorin/security` token verifier so handlers see\n * a stable {@link import('../internal/context.js').AuthState}\n * structure on `c.var.state.auth`.\n *\n * @packageDocumentation\n */\n\nimport {\n type ParsedScope,\n parseScope,\n type TokenVerifier,\n tryParseScope,\n} from '@graphorin/security/auth';\nimport type { Context, MiddlewareHandler, Next } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\n\n/**\n * IP-13: the scope set granted to every request when `auth.kind = 'none'`.\n * `admin:*` matches every required scope (see `@graphorin/security/auth`\n * `scopeMatches`), so the trusted-loopback operator is uniformly authorized\n * without special-casing each route.\n */\nconst ANONYMOUS_GRANTED_SCOPES: ReadonlyArray<ParsedScope> = Object.freeze([parseScope('admin:*')]);\n\n/**\n * Build the no-auth middleware mounted when `auth.kind = 'none'`. It stamps\n * `state.auth = { kind: 'anonymous', grantedScopes: [admin:*] }` so the\n * scope middleware, SSE handler and replay routes all treat the request as a\n * fully-authorized principal. This is the documented trusted-loopback /\n * single-operator mode — never mount it on a non-loopback deployment without\n * understanding that every endpoint becomes open.\n *\n * @stable\n */\nexport function createAnonymousAuthMiddleware(): MiddlewareHandler<{\n Variables: ServerVariables;\n}> {\n return async (c, next: Next) => {\n c.set('state', {\n ...c.get('state'),\n auth: { kind: 'anonymous' as const, grantedScopes: ANONYMOUS_GRANTED_SCOPES },\n });\n await next();\n };\n}\n\n/**\n * Options accepted by {@link createAuthMiddleware}. Tests inject a\n * stub verifier; production wiring uses the verifier built during the\n * server's pre-bind step.\n *\n * @stable\n */\nexport interface AuthMiddlewareOptions {\n readonly verifier: TokenVerifier;\n /**\n * Whether to allow unauthenticated requests through. Used by\n * `health` and (when explicitly opted-in) by the public read\n * endpoints. When `false` (the default), missing / malformed /\n * invalid tokens short-circuit the request with `401`.\n */\n readonly allowAnonymous?: boolean;\n}\n\nconst HEADER_NAME = 'authorization';\nconst BEARER_PREFIX = 'bearer ';\n\nfunction extractBearer(c: Context): string | undefined {\n const raw = c.req.header(HEADER_NAME);\n if (raw === undefined || raw === null) return undefined;\n const trimmed = raw.trim();\n if (trimmed.length < BEARER_PREFIX.length) return undefined;\n if (trimmed.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX) return undefined;\n return trimmed.slice(BEARER_PREFIX.length).trim();\n}\n\nfunction reasonToStatus(reason: string): {\n status: number;\n body: { error: string; message: string; hint?: string };\n} {\n switch (reason) {\n case 'malformed':\n return { status: 401, body: { error: 'auth-invalid', message: 'Malformed bearer token.' } };\n case 'unknown-token':\n return { status: 401, body: { error: 'auth-invalid', message: 'Unknown bearer token.' } };\n case 'revoked':\n return {\n status: 401,\n body: { error: 'auth-revoked', message: 'Bearer token has been revoked.' },\n };\n case 'expired':\n return { status: 401, body: { error: 'auth-expired', message: 'Bearer token has expired.' } };\n case 'ip-locked-out':\n return {\n status: 429,\n body: {\n error: 'auth-locked-out',\n message: 'Too many failed authentications from this address.',\n hint: 'Wait for the lockout window to elapse before retrying.',\n },\n };\n case 'token-locked-out':\n return {\n status: 429,\n body: {\n error: 'auth-locked-out',\n message: 'Bearer token is locked out after repeated failures.',\n hint: 'Operator must rotate the token.',\n },\n };\n default:\n return {\n status: 401,\n body: { error: 'auth-invalid', message: `Authentication failed: ${reason}.` },\n };\n }\n}\n\n/**\n * Build the bearer-token middleware. The middleware always sets\n * `c.var.state.auth`, even on the unauthenticated branch, so\n * downstream code can pattern-match the discriminated union without\n * a separate \"is anonymous?\" check.\n *\n * @stable\n */\nexport function createAuthMiddleware(\n options: AuthMiddlewareOptions,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n const allowAnonymous = options.allowAnonymous ?? false;\n return async (c, next: Next) => {\n const token = extractBearer(c);\n // IP-11: the request-state middleware is the ONLY IP authority —\n // it resolves the client IP per the configured trustProxy policy\n // (proxy headers when trusted, else the socket address). The old\n // fallback read attacker-controlled X-Real-IP unconditionally,\n // letting header rotation evade per-IP lockout and a spoofed\n // victim IP trigger a lockout against them.\n const ip = c.get('state').clientIp;\n if (token === undefined) {\n if (allowAnonymous) {\n c.set('state', { ...c.get('state'), auth: { kind: 'unauthenticated' as const } });\n await next();\n return;\n }\n return c.json(\n {\n error: 'auth-required',\n message: 'Bearer token required.',\n hint: \"Set 'Authorization: Bearer <token>'.\",\n },\n 401,\n );\n }\n const result = await options.verifier.verify(token, ip !== undefined ? { ip } : {});\n if (!result.ok) {\n const mapped = reasonToStatus(result.reason);\n const headers: Record<string, string> = {};\n if (result.retryAfterMs !== undefined) {\n headers['Retry-After'] = Math.ceil(result.retryAfterMs / 1000).toString();\n }\n return c.json(mapped.body, mapped.status as 401 | 429, headers);\n }\n const grantedScopes: ParsedScope[] = [];\n for (const scope of result.token.scopes) {\n grantedScopes.push(scope);\n }\n // Defensive: any string scopes coming from the store also parse here\n // even though the verifier itself parses them — see scope.ts.\n void tryParseScope;\n c.set('state', {\n ...c.get('state'),\n auth: {\n kind: 'token' as const,\n token: result.token,\n grantedScopes: Object.freeze(grantedScopes),\n },\n });\n // Mirror the verified token onto the documented `c.var.token`\n // slot so route handlers can read it without unwrapping the auth\n // discriminator. The shape is the one called out in the Phase\n // 14a spec: { id, label, scopes, env, expiresAt? }.\n c.set('token', {\n id: result.token.tokenId,\n label: result.token.label,\n scopes: Object.freeze(grantedScopes.slice()),\n env: result.token.env,\n expiresAt: result.token.expiresAt,\n });\n await next();\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ServerConfigSpec } from "../config.js";
|
|
2
|
+
import { ServerVariables } from "../internal/context.js";
|
|
3
|
+
import { MiddlewareHandler } from "hono";
|
|
4
|
+
|
|
5
|
+
//#region src/middleware/cors.d.ts
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @stable
|
|
9
|
+
*/
|
|
10
|
+
declare function createCorsMiddleware(config: ServerConfigSpec['server']['cors']): MiddlewareHandler<{
|
|
11
|
+
Variables: ServerVariables;
|
|
12
|
+
}>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { createCorsMiddleware };
|
|
15
|
+
//# sourceMappingURL=cors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cors.d.ts","names":[],"sources":["../../src/middleware/cors.ts"],"sourcesContent":[],"mappings":";;;;;;;;;iBAgBgB,oBAAA,SACN,qCACP;aAA+B"}
|