@hivemagnus/hive-common 0.0.0-stage → 0.1.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 (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +11 -0
  4. package/README.md +382 -2
  5. package/dist/context/index.d.ts +86 -0
  6. package/dist/context/index.js +189 -0
  7. package/dist/contracts/index.d.ts +117 -0
  8. package/dist/contracts/index.js +228 -0
  9. package/dist/errors/index.d.ts +91 -0
  10. package/dist/errors/index.js +309 -0
  11. package/dist/events/index.d.ts +107 -0
  12. package/dist/events/index.js +232 -0
  13. package/dist/health/index.d.ts +85 -0
  14. package/dist/health/index.js +113 -0
  15. package/dist/hono/index.d.ts +64 -0
  16. package/dist/hono/index.js +105 -0
  17. package/dist/log/index.d.ts +39 -0
  18. package/dist/log/index.js +80 -0
  19. package/dist/log/redact.d.ts +23 -0
  20. package/dist/log/redact.js +111 -0
  21. package/dist/node-http/index.d.ts +43 -0
  22. package/dist/node-http/index.js +88 -0
  23. package/dist/oathweave/claims.d.ts +63 -0
  24. package/dist/oathweave/claims.js +91 -0
  25. package/dist/oathweave/conformance.d.ts +88 -0
  26. package/dist/oathweave/conformance.js +124 -0
  27. package/dist/oathweave/endpoint.d.ts +15 -0
  28. package/dist/oathweave/endpoint.js +35 -0
  29. package/dist/oathweave/errors.d.ts +19 -0
  30. package/dist/oathweave/errors.js +70 -0
  31. package/dist/oathweave/index.d.ts +10 -0
  32. package/dist/oathweave/index.js +10 -0
  33. package/dist/oathweave/links.d.ts +61 -0
  34. package/dist/oathweave/links.js +131 -0
  35. package/dist/oathweave/stream.d.ts +26 -0
  36. package/dist/oathweave/stream.js +46 -0
  37. package/dist/oathweave/verifier.d.ts +69 -0
  38. package/dist/oathweave/verifier.js +226 -0
  39. package/dist/registry/index.d.ts +138 -0
  40. package/dist/registry/index.js +175 -0
  41. package/package.json +126 -4
  42. package/test-vectors/README.md +27 -0
  43. package/test-vectors/oathweave/jwks.json +12 -0
  44. package/test-vectors/oathweave/vectors.json +174 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * HiveEvent feeds and the ChatterMost notifier. The HiveEvent schema and validator live in
3
+ * `/contracts` (isomorphic); this module re-exports them.
4
+ */
5
+ import { validateHiveEvent, } from '../contracts/index.js';
6
+ import { HiveError } from '../errors/index.js';
7
+ import { endpointProblem } from '../oathweave/endpoint.js';
8
+ import { redactText } from '../log/redact.js';
9
+ export { HIVE_EVENT_LIMITS, HIVE_EVENT_SEVERITIES, HIVE_EVENT_VERSION, isHiveEvent, validateHiveEvent, validateHiveEventPage, } from '../contracts/index.js';
10
+ // ---------------------------------------------------------------------------------------
11
+ // Feed: GET /v1/hive/events?after=<cursor>&limit=<n>
12
+ export const HIVE_EVENTS_PATH = '/v1/hive/events';
13
+ /** Read `after` and `limit` from a query. Throws a 400 HiveError on malformed values. */
14
+ export function parseFeedQuery(query, options = {}) {
15
+ const read = (name) => {
16
+ if (query instanceof URLSearchParams) {
17
+ const values = query.getAll(name);
18
+ if (values.length > 1)
19
+ throw new HiveError('invalid_query', `${name} must be given once`, { status: 400 });
20
+ return values[0];
21
+ }
22
+ const value = query[name];
23
+ if (Array.isArray(value)) {
24
+ if (value.length > 1)
25
+ throw new HiveError('invalid_query', `${name} must be given once`, { status: 400 });
26
+ return value[0];
27
+ }
28
+ return value;
29
+ };
30
+ const defaultLimit = options.defaultLimit ?? 50;
31
+ const maxLimit = options.maxLimit ?? 200;
32
+ const rawLimit = read('limit');
33
+ let limit = defaultLimit;
34
+ if (rawLimit !== undefined && rawLimit !== '') {
35
+ if (!/^\d{1,6}$/.test(rawLimit) || Number(rawLimit) < 1) {
36
+ throw new HiveError('invalid_query', `limit must be an integer from 1 to ${maxLimit}`, { status: 400 });
37
+ }
38
+ limit = Math.min(Number(rawLimit), maxLimit);
39
+ }
40
+ const after = read('after');
41
+ if (after !== undefined && after !== '' && !/^[A-Za-z0-9._~:-]{1,512}$/.test(after)) {
42
+ throw new HiveError('invalid_query', 'after must be a cursor from a previous page', { status: 400 });
43
+ }
44
+ return { after: after || undefined, limit };
45
+ }
46
+ /**
47
+ * Build a feed page. Pass up to `limit + 1` items read after the cursor, in feed order; the
48
+ * extra item only tells whether there is a next page and is not returned.
49
+ */
50
+ export function feedPage(items, limit, cursorOf) {
51
+ if (!Number.isInteger(limit) || limit < 1)
52
+ throw new RangeError('limit must be a positive integer');
53
+ const data = items.slice(0, limit);
54
+ const last = data.at(-1);
55
+ return { data, nextCursor: items.length > limit && last !== undefined ? cursorOf(last) : null };
56
+ }
57
+ /** Encode a cursor position as an opaque base64url string. Isomorphic. */
58
+ export function encodeCursor(position) {
59
+ const bytes = new TextEncoder().encode(JSON.stringify(position));
60
+ let binary = '';
61
+ bytes.forEach(byte => { binary += String.fromCharCode(byte); });
62
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
63
+ }
64
+ /** Decode a cursor from `encodeCursor()`. Throws a 400 HiveError when it was tampered with. */
65
+ export function decodeCursor(cursor) {
66
+ try {
67
+ if (!/^[A-Za-z0-9_-]+$/.test(cursor))
68
+ throw new Error('not base64url');
69
+ const binary = atob(cursor.replace(/-/g, '+').replace(/_/g, '/'));
70
+ const value = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, char => char.charCodeAt(0))));
71
+ if (!value || typeof value !== 'object' || Array.isArray(value)
72
+ || !Object.values(value).every(item => typeof item === 'string' || typeof item === 'number'))
73
+ throw new Error('not a position');
74
+ return value;
75
+ }
76
+ catch {
77
+ throw new HiveError('invalid_cursor', 'The cursor is not valid', { status: 400 });
78
+ }
79
+ }
80
+ /** An in-memory DedupeStore with TTL and a size bound. Per process only. */
81
+ export class MemoryDedupeStore {
82
+ #entries = new Map();
83
+ #max;
84
+ #now;
85
+ constructor(options = {}) {
86
+ this.#max = options.maxEntries ?? 10_000;
87
+ this.#now = options.now ?? Date.now;
88
+ }
89
+ claim(key, ttlMs) {
90
+ const now = this.#now();
91
+ const expiresAt = this.#entries.get(key);
92
+ if (expiresAt !== undefined && expiresAt > now)
93
+ return false;
94
+ this.#entries.delete(key);
95
+ if (this.#entries.size >= this.#max) {
96
+ for (const [entry, expiry] of this.#entries)
97
+ if (expiry <= now)
98
+ this.#entries.delete(entry);
99
+ while (this.#entries.size >= this.#max)
100
+ this.#entries.delete(this.#entries.keys().next().value);
101
+ }
102
+ this.#entries.set(key, now + ttlMs);
103
+ return true;
104
+ }
105
+ release(key) {
106
+ this.#entries.delete(key);
107
+ }
108
+ get size() {
109
+ return this.#entries.size;
110
+ }
111
+ }
112
+ const SEVERITY_ICON = {
113
+ info: ':information_source:',
114
+ warning: ':warning:',
115
+ critical: ':rotating_light:',
116
+ };
117
+ /** Neutralise Markdown and mentions in event text so a product cannot ping a channel or forge links. */
118
+ function escapeMarkdown(value) {
119
+ return value
120
+ .replace(/[\\`*_~[\]()#>|]/g, char => `\\${char}`)
121
+ .replace(/@(?=[A-Za-z0-9])/g, '@​')
122
+ .replace(/<!/g, '<​!');
123
+ }
124
+ /** The default message: severity icon, product and title, summary, and a link. */
125
+ export function formatChatterMostMessage(event, options = {}) {
126
+ const title = `${SEVERITY_ICON[event.severity]} **${escapeMarkdown(event.product)}: ${escapeMarkdown(event.title)}**`;
127
+ const summary = event.summary ? `\n${escapeMarkdown(event.summary)}` : '';
128
+ let link = '';
129
+ if (options.linkBaseUrl) {
130
+ const url = new URL(event.link.path, options.linkBaseUrl);
131
+ link = `\n[Open in ${escapeMarkdown(event.link.product)}](${url.href.replace(/[()]/g, encodeURIComponent)})`;
132
+ }
133
+ else
134
+ link = `\n${escapeMarkdown(event.link.product)}: \`${event.link.path.replace(/`/g, '')}\``;
135
+ return `${title}${summary}${link}`.slice(0, 16_000);
136
+ }
137
+ function retryAfterMs(response) {
138
+ const value = response.headers.get('retry-after');
139
+ if (!value)
140
+ return undefined;
141
+ if (/^\d+$/.test(value))
142
+ return Number(value) * 1000;
143
+ const date = Date.parse(value);
144
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
145
+ }
146
+ /**
147
+ * Posts HiveEvents to a Mattermost-compatible incoming webhook (ChatterMost). One-way
148
+ * announcements only. Each event ID is delivered at most once per dedupe window; failures
149
+ * release the claim so a later call can retry.
150
+ */
151
+ export class ChatterMostNotifier {
152
+ #options;
153
+ #fetch;
154
+ #dedupe;
155
+ #sleep;
156
+ #random;
157
+ constructor(options) {
158
+ const problem = endpointProblem(options.webhookUrl, { allowInsecureLoopback: options.allowInsecureLoopback ?? false });
159
+ // The URL is a secret, so the error describes the problem without echoing it.
160
+ if (problem)
161
+ throw new TypeError(`ChatterMost webhookUrl ${problem}`);
162
+ if (options.linkBaseUrl !== undefined && endpointProblem(options.linkBaseUrl, { allowInsecureLoopback: true })) {
163
+ throw new TypeError('ChatterMost linkBaseUrl must be an https origin');
164
+ }
165
+ this.#options = options;
166
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
167
+ this.#dedupe = options.dedupe ?? new MemoryDedupeStore();
168
+ this.#sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
169
+ this.#random = options.random ?? Math.random;
170
+ }
171
+ /** The webhook body for an event. */
172
+ payload(event) {
173
+ if (this.#options.format)
174
+ return this.#options.format(event);
175
+ return {
176
+ text: formatChatterMostMessage(event, this.#options.linkBaseUrl ? { linkBaseUrl: this.#options.linkBaseUrl } : {}),
177
+ ...(this.#options.username ? { username: this.#options.username } : {}),
178
+ ...(this.#options.iconUrl ? { icon_url: this.#options.iconUrl } : {}),
179
+ ...(this.#options.channel ? { channel: this.#options.channel } : {}),
180
+ props: { hive_event: { id: event.id, product: event.product, type: event.type, severity: event.severity } },
181
+ };
182
+ }
183
+ /** Validate and deliver one event. Never throws for delivery failures; it reports them. */
184
+ async notify(input) {
185
+ const validation = validateHiveEvent(input);
186
+ if (!validation.ok)
187
+ throw HiveError.validation('The event is not a valid HiveEvent', validation.issues);
188
+ const event = validation.value;
189
+ const key = `hive-event:${event.product}:${event.id}`;
190
+ if (!await this.#dedupe.claim(key, this.#options.dedupeTtlMs ?? 24 * 60 * 60_000))
191
+ return { status: 'duplicate', attempts: 0 };
192
+ const body = JSON.stringify(this.payload(event));
193
+ const retries = this.#options.retries ?? 3;
194
+ const base = this.#options.backoffMs ?? 500;
195
+ const max = this.#options.maxBackoffMs ?? 8_000;
196
+ let lastError = 'failed';
197
+ let lastStatus;
198
+ let attempts = 0;
199
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
200
+ attempts = attempt;
201
+ let wait;
202
+ try {
203
+ const response = await this.#fetch(this.#options.webhookUrl, {
204
+ method: 'POST',
205
+ headers: { 'content-type': 'application/json' },
206
+ body,
207
+ redirect: 'error',
208
+ signal: AbortSignal.timeout(this.#options.timeoutMs ?? 5_000),
209
+ });
210
+ void response.body?.cancel().catch(() => undefined);
211
+ if (response.ok)
212
+ return { status: 'sent', attempts: attempt };
213
+ lastStatus = response.status;
214
+ lastError = `HTTP ${response.status}`;
215
+ // Other 4xx mean the request itself is wrong; repeating it cannot help.
216
+ if (response.status !== 408 && response.status !== 429 && response.status < 500)
217
+ break;
218
+ wait = retryAfterMs(response);
219
+ }
220
+ catch (error) {
221
+ lastStatus = undefined;
222
+ lastError = error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'network error';
223
+ }
224
+ if (attempt <= retries) {
225
+ const backoff = Math.min(max, base * 2 ** (attempt - 1));
226
+ await this.#sleep(Math.min(max, wait ?? Math.round(backoff * (0.5 + this.#random() / 2))));
227
+ }
228
+ }
229
+ await this.#dedupe.release(key);
230
+ return { status: 'failed', attempts, error: redactText(lastError), ...(lastStatus !== undefined ? { httpStatus: lastStatus } : {}) };
231
+ }
232
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `/health/live` and `/health/ready` responses with named, time-limited dependency checks.
3
+ * Framework-free: adapters in `/hono` and `/node-http` serve these.
4
+ */
5
+ export declare const HEALTH_PATHS: {
6
+ readonly live: "/health/live";
7
+ readonly ready: "/health/ready";
8
+ };
9
+ /** Paths products used before the suite standard. Serve them only while clients still call them. */
10
+ export declare const LEGACY_HEALTH_ALIASES: Readonly<Record<HealthKind, readonly string[]>>;
11
+ export type HealthKind = 'live' | 'ready';
12
+ export type CheckStatus = 'up' | 'down';
13
+ /** What a check function may return. Throwing, `false` or `{ ok: false }` all mean down. */
14
+ export type CheckOutcome = void | boolean | {
15
+ readonly ok?: boolean;
16
+ readonly details?: Readonly<Record<string, unknown>>;
17
+ };
18
+ export interface HealthCheck {
19
+ /** Shown in the response, for example `database` or `oathweave`. */
20
+ readonly name: string;
21
+ /** Receives a signal that aborts at the timeout; pass it to fetch and database calls. */
22
+ readonly check: (signal: AbortSignal) => CheckOutcome | Promise<CheckOutcome>;
23
+ /** Milliseconds before the check counts as down. Defaults to the readiness default. */
24
+ readonly timeoutMs?: number;
25
+ /** A non-critical check that fails makes the service `degraded`, still 200. Defaults to true. */
26
+ readonly critical?: boolean;
27
+ }
28
+ export interface CheckReport {
29
+ readonly status: CheckStatus;
30
+ readonly critical: boolean;
31
+ readonly latencyMs: number;
32
+ /** `timeout` or `failed`, or the error message when `exposeErrors` is on. */
33
+ readonly error?: string;
34
+ readonly details?: Readonly<Record<string, unknown>>;
35
+ }
36
+ export interface LiveBody {
37
+ readonly status: 'live';
38
+ readonly service?: string;
39
+ readonly version?: string;
40
+ readonly time: string;
41
+ }
42
+ export interface ReadyBody {
43
+ readonly status: 'ready' | 'degraded' | 'not_ready';
44
+ readonly service?: string;
45
+ readonly version?: string;
46
+ readonly time: string;
47
+ readonly checks: Readonly<Record<string, CheckReport>>;
48
+ }
49
+ export interface HealthResponse<B> {
50
+ readonly status: 200 | 503;
51
+ readonly body: B;
52
+ readonly headers: Readonly<Record<string, string>>;
53
+ }
54
+ export interface HealthOptions {
55
+ readonly service?: string;
56
+ readonly version?: string;
57
+ readonly checks?: readonly HealthCheck[];
58
+ /** Default per-check timeout. 2000 ms. */
59
+ readonly timeoutMs?: number;
60
+ /**
61
+ * Include error messages from failing checks. Off by default: readiness is often reachable
62
+ * without authentication, and error messages can name internal hosts.
63
+ */
64
+ readonly exposeErrors?: boolean;
65
+ /** Also serve the legacy paths in `LEGACY_HEALTH_ALIASES`, or a custom alias map. */
66
+ readonly aliases?: boolean | Partial<Readonly<Record<HealthKind, readonly string[]>>>;
67
+ readonly now?: () => number;
68
+ }
69
+ /** The liveness response: 200 whenever the process can answer. It never runs checks. */
70
+ export declare function liveness(options?: HealthOptions): HealthResponse<LiveBody>;
71
+ /** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
72
+ export declare function readiness(options?: HealthOptions): Promise<HealthResponse<ReadyBody>>;
73
+ /** Which health endpoint a path names, including legacy aliases when enabled. */
74
+ export declare function resolveHealthPath(pathname: string, aliases?: HealthOptions['aliases']): HealthKind | undefined;
75
+ /** All paths served for each kind, standard first. */
76
+ export declare function healthPaths(aliases?: HealthOptions['aliases']): Readonly<Record<HealthKind, readonly string[]>>;
77
+ export interface Health {
78
+ live(): HealthResponse<LiveBody>;
79
+ ready(): Promise<HealthResponse<ReadyBody>>;
80
+ /** The response for a health path, or undefined when the path is not a health path. */
81
+ handle(pathname: string): Promise<HealthResponse<LiveBody | ReadyBody>> | undefined;
82
+ readonly paths: Readonly<Record<HealthKind, readonly string[]>>;
83
+ }
84
+ /** Bind options once and serve both endpoints. */
85
+ export declare function createHealth(options?: HealthOptions): Health;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `/health/live` and `/health/ready` responses with named, time-limited dependency checks.
3
+ * Framework-free: adapters in `/hono` and `/node-http` serve these.
4
+ */
5
+ export const HEALTH_PATHS = { live: '/health/live', ready: '/health/ready' };
6
+ /** Paths products used before the suite standard. Serve them only while clients still call them. */
7
+ export const LEGACY_HEALTH_ALIASES = {
8
+ live: ['/health', '/healthz', '/livez', '/live', '/api/health'],
9
+ ready: ['/ready', '/readyz'],
10
+ };
11
+ const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' };
12
+ function identity(options) {
13
+ return { ...(options.service ? { service: options.service } : {}), ...(options.version ? { version: options.version } : {}) };
14
+ }
15
+ /** The liveness response: 200 whenever the process can answer. It never runs checks. */
16
+ export function liveness(options = {}) {
17
+ const now = options.now ?? Date.now;
18
+ return { status: 200, body: { status: 'live', ...identity(options), time: new Date(now()).toISOString() }, headers: HEADERS };
19
+ }
20
+ class CheckTimeout extends Error {
21
+ }
22
+ async function runCheck(check, defaultTimeout, exposeErrors, now) {
23
+ const critical = check.critical ?? true;
24
+ const timeoutMs = check.timeoutMs ?? defaultTimeout;
25
+ const controller = new AbortController();
26
+ const started = now();
27
+ let timer;
28
+ const timeout = new Promise((_, reject) => {
29
+ timer = setTimeout(() => {
30
+ controller.abort(new CheckTimeout('timeout'));
31
+ reject(new CheckTimeout('timeout'));
32
+ }, timeoutMs);
33
+ });
34
+ try {
35
+ const outcome = await Promise.race([Promise.resolve().then(() => check.check(controller.signal)), timeout]);
36
+ const latencyMs = Math.max(0, now() - started);
37
+ const ok = outcome === undefined || outcome === true || (typeof outcome === 'object' && outcome !== null && outcome.ok !== false);
38
+ const details = typeof outcome === 'object' && outcome !== null ? outcome.details : undefined;
39
+ return { status: ok ? 'up' : 'down', critical, latencyMs, ...(ok ? {} : { error: 'failed' }), ...(details ? { details } : {}) };
40
+ }
41
+ catch (error) {
42
+ const latencyMs = Math.max(0, now() - started);
43
+ const reason = error instanceof CheckTimeout ? 'timeout' : exposeErrors && error instanceof Error && error.message ? error.message.slice(0, 300) : 'failed';
44
+ return { status: 'down', critical, latencyMs, error: reason };
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ /** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
51
+ export async function readiness(options = {}) {
52
+ const now = options.now ?? Date.now;
53
+ const checks = options.checks ?? [];
54
+ const names = new Set();
55
+ for (const check of checks) {
56
+ if (!check.name || names.has(check.name))
57
+ throw new Error(`Health check names must be unique and non-empty: ${check.name}`);
58
+ names.add(check.name);
59
+ }
60
+ const reports = await Promise.all(checks.map(check => runCheck(check, options.timeoutMs ?? 2000, options.exposeErrors ?? false, now)));
61
+ const byName = {};
62
+ checks.forEach((check, index) => { byName[check.name] = reports[index]; });
63
+ const criticalDown = reports.some(report => report.status === 'down' && report.critical);
64
+ const anyDown = reports.some(report => report.status === 'down');
65
+ const status = criticalDown ? 'not_ready' : anyDown ? 'degraded' : 'ready';
66
+ return {
67
+ status: criticalDown ? 503 : 200,
68
+ body: { status, ...identity(options), time: new Date(now()).toISOString(), checks: byName },
69
+ headers: HEADERS,
70
+ };
71
+ }
72
+ /** Which health endpoint a path names, including legacy aliases when enabled. */
73
+ export function resolveHealthPath(pathname, aliases = false) {
74
+ const path = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
75
+ if (path === HEALTH_PATHS.live)
76
+ return 'live';
77
+ if (path === HEALTH_PATHS.ready)
78
+ return 'ready';
79
+ const map = aliasMap(aliases);
80
+ if (map.live.includes(path))
81
+ return 'live';
82
+ if (map.ready.includes(path))
83
+ return 'ready';
84
+ return undefined;
85
+ }
86
+ /** All paths served for each kind, standard first. */
87
+ export function healthPaths(aliases = false) {
88
+ const map = aliasMap(aliases);
89
+ return { live: [HEALTH_PATHS.live, ...map.live], ready: [HEALTH_PATHS.ready, ...map.ready] };
90
+ }
91
+ function aliasMap(aliases) {
92
+ if (aliases === true)
93
+ return { live: LEGACY_HEALTH_ALIASES.live, ready: LEGACY_HEALTH_ALIASES.ready };
94
+ if (!aliases)
95
+ return { live: [], ready: [] };
96
+ return { live: aliases.live ?? [], ready: aliases.ready ?? [] };
97
+ }
98
+ /** Bind options once and serve both endpoints. */
99
+ export function createHealth(options = {}) {
100
+ return {
101
+ live: () => liveness(options),
102
+ ready: () => readiness(options),
103
+ handle(pathname) {
104
+ const kind = resolveHealthPath(pathname, options.aliases);
105
+ if (kind === 'live')
106
+ return Promise.resolve(liveness(options));
107
+ if (kind === 'ready')
108
+ return readiness(options);
109
+ return undefined;
110
+ },
111
+ paths: healthPaths(options.aliases),
112
+ };
113
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Hono adapters: request context, Oathweave auth, the error envelope and health routes.
3
+ * `hono` is an optional peer dependency (>= 4.13.18).
4
+ *
5
+ * const app = new Hono<HiveEnv>();
6
+ * app.use(requestContext());
7
+ * registerHealthRoutes(app, { checks });
8
+ * app.use('/v1/*', oathweaveAuth(verifier));
9
+ * app.onError(errorHandler({ logger }));
10
+ * app.notFound(notFoundHandler());
11
+ */
12
+ import type { Context, Env, ErrorHandler, Hono, MiddlewareHandler, NotFoundHandler } from 'hono';
13
+ import { type CreateRequestContextOptions, type RequestContext } from '../context/index.js';
14
+ import { type HealthOptions } from '../health/index.js';
15
+ import type { Logger } from '../log/index.js';
16
+ import type { AcceptPrincipal, OathweavePrincipal } from '../oathweave/claims.js';
17
+ import { type PermissionOptions } from '../oathweave/links.js';
18
+ import type { OathweaveVerifier } from '../oathweave/verifier.js';
19
+ /** Context variables set by these middlewares. Use `new Hono<HiveEnv>()` or extend it. */
20
+ export interface HiveVariables {
21
+ hiveContext: RequestContext;
22
+ hivePrincipal: OathweavePrincipal;
23
+ }
24
+ export interface HiveEnv extends Env {
25
+ Variables: HiveVariables;
26
+ }
27
+ /** The request context set by `requestContext()`, or undefined. */
28
+ export declare function getHiveContext(c: Context): RequestContext | undefined;
29
+ /** The verified principal set by `oathweaveAuth()`, or undefined. */
30
+ export declare function getPrincipal(c: Context): OathweavePrincipal | undefined;
31
+ /** The verified principal, or a 401 when the route was not authenticated. */
32
+ export declare function requirePrincipal(c: Context): OathweavePrincipal;
33
+ export interface RequestContextMiddlewareOptions extends CreateRequestContextOptions {
34
+ /** Echo the request ID as `x-request-id` on the response. Defaults to true. */
35
+ readonly responseHeader?: boolean;
36
+ }
37
+ /** Create the request context, run the rest of the request inside it and echo `x-request-id`. */
38
+ export declare function requestContext(options?: RequestContextMiddlewareOptions): MiddlewareHandler;
39
+ export interface OathweaveAuthOptions {
40
+ /** Which principals this route accepts. Defaults to the verifier's default. */
41
+ readonly accept?: AcceptPrincipal;
42
+ /** When false, a request without credentials passes with no principal; a bad token still fails. */
43
+ readonly required?: boolean;
44
+ /** Further checks after verification, such as an identity-link lookup. Throw to refuse. */
45
+ readonly authorize?: (principal: OathweavePrincipal, c: Context) => void | Promise<void>;
46
+ }
47
+ /** Verify `Authorization: Bearer` with Oathweave and set `hivePrincipal`. Failures throw OathweaveError. */
48
+ export declare function oathweaveAuth(verifier: OathweaveVerifier, options?: OathweaveAuthOptions): MiddlewareHandler;
49
+ /** Require every listed permission (403 otherwise). Must follow `oathweaveAuth()`. */
50
+ export declare function requirePermission(permissions: readonly string[], options?: PermissionOptions): MiddlewareHandler;
51
+ /** Require at least one listed role (403 otherwise). Must follow `oathweaveAuth()`. */
52
+ export declare function requireRole(roles: readonly string[]): MiddlewareHandler;
53
+ /** Require every listed scope (403 otherwise). Must follow `oathweaveAuth()`. */
54
+ export declare function requireScope(scopes: readonly string[]): MiddlewareHandler;
55
+ export interface ErrorHandlerOptions {
56
+ /** Unexpected errors (not HiveError) are logged here at error level. */
57
+ readonly logger?: Pick<Logger, 'error'>;
58
+ }
59
+ /** `app.onError(errorHandler())`: HiveError → envelope; everything else → generic 500, logged. */
60
+ export declare function errorHandler(options?: ErrorHandlerOptions): ErrorHandler;
61
+ /** `app.notFound(notFoundHandler())`: a 404 envelope. */
62
+ export declare function notFoundHandler(): NotFoundHandler;
63
+ /** Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). */
64
+ export declare function registerHealthRoutes<E extends Env>(app: Hono<E>, options?: HealthOptions): void;
@@ -0,0 +1,105 @@
1
+ import { HTTPException } from 'hono/http-exception';
2
+ import { createRequestContext, runWithRequestContext, } from '../context/index.js';
3
+ import { defaultCodeForStatus, defaultMessageForStatus, HiveError, toErrorResponse } from '../errors/index.js';
4
+ import { createHealth } from '../health/index.js';
5
+ import { OathweaveError } from '../oathweave/errors.js';
6
+ import { hasAnyRole, hasScopes, requirePermissions } from '../oathweave/links.js';
7
+ /** The request context set by `requestContext()`, or undefined. */
8
+ export function getHiveContext(c) {
9
+ return c.get('hiveContext');
10
+ }
11
+ /** The verified principal set by `oathweaveAuth()`, or undefined. */
12
+ export function getPrincipal(c) {
13
+ return c.get('hivePrincipal');
14
+ }
15
+ /** The verified principal, or a 401 when the route was not authenticated. */
16
+ export function requirePrincipal(c) {
17
+ const principal = getPrincipal(c);
18
+ if (!principal)
19
+ throw new OathweaveError('missing_token');
20
+ return principal;
21
+ }
22
+ /** Create the request context, run the rest of the request inside it and echo `x-request-id`. */
23
+ export function requestContext(options = {}) {
24
+ return async (c, next) => {
25
+ const context = createRequestContext(name => c.req.header(name), options);
26
+ c.set('hiveContext', context);
27
+ await runWithRequestContext(context, next);
28
+ if (options.responseHeader ?? true)
29
+ c.header('x-request-id', context.requestId);
30
+ };
31
+ }
32
+ /** Verify `Authorization: Bearer` with Oathweave and set `hivePrincipal`. Failures throw OathweaveError. */
33
+ export function oathweaveAuth(verifier, options = {}) {
34
+ return async (c, next) => {
35
+ const header = c.req.header('authorization');
36
+ if (!header && options.required === false) {
37
+ await next();
38
+ return;
39
+ }
40
+ const principal = await verifier.verifyAuthorization(header, options.accept ? { accept: options.accept } : {});
41
+ await options.authorize?.(principal, c);
42
+ c.set('hivePrincipal', principal);
43
+ const context = getHiveContext(c);
44
+ if (context)
45
+ context.principal = principal;
46
+ await next();
47
+ };
48
+ }
49
+ /** Require every listed permission (403 otherwise). Must follow `oathweaveAuth()`. */
50
+ export function requirePermission(permissions, options = {}) {
51
+ return async (c, next) => {
52
+ requirePermissions(requirePrincipal(c), permissions, options);
53
+ await next();
54
+ };
55
+ }
56
+ /** Require at least one listed role (403 otherwise). Must follow `oathweaveAuth()`. */
57
+ export function requireRole(roles) {
58
+ return async (c, next) => {
59
+ if (!hasAnyRole(requirePrincipal(c), roles))
60
+ throw new OathweaveError('insufficient_permission', { details: { roles: [...roles] } });
61
+ await next();
62
+ };
63
+ }
64
+ /** Require every listed scope (403 otherwise). Must follow `oathweaveAuth()`. */
65
+ export function requireScope(scopes) {
66
+ return async (c, next) => {
67
+ if (!hasScopes(requirePrincipal(c), scopes))
68
+ throw new OathweaveError('insufficient_permission', { details: { scopes: [...scopes] } });
69
+ await next();
70
+ };
71
+ }
72
+ function respond(c, error, options) {
73
+ const requestId = getHiveContext(c)?.requestId;
74
+ let value = error;
75
+ // Hono's own HTTPException (body limits, bad JSON, etc.) keeps its status with a standard code.
76
+ if (!HiveError.is(error) && error instanceof HTTPException && error.status >= 400 && error.status <= 599) {
77
+ const message = error.status >= 500 || !error.message ? defaultMessageForStatus(error.status) : error.message;
78
+ value = new HiveError(defaultCodeForStatus(error.status), message, { status: error.status, cause: error });
79
+ }
80
+ const response = toErrorResponse(value, {
81
+ requestId,
82
+ onUnexpected: unexpected => options.logger?.error('unhandled error', { err: unexpected, path: c.req.path, method: c.req.method }),
83
+ });
84
+ return new Response(JSON.stringify(response.body), { status: response.status, headers: response.headers });
85
+ }
86
+ /** `app.onError(errorHandler())`: HiveError → envelope; everything else → generic 500, logged. */
87
+ export function errorHandler(options = {}) {
88
+ return (error, c) => respond(c, error, options);
89
+ }
90
+ /** `app.notFound(notFoundHandler())`: a 404 envelope. */
91
+ export function notFoundHandler() {
92
+ return c => respond(c, HiveError.notFound(), {});
93
+ }
94
+ /** Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). */
95
+ export function registerHealthRoutes(app, options = {}) {
96
+ const health = createHealth(options);
97
+ const serve = (kind) => async (c) => {
98
+ const result = kind === 'live' ? health.live() : await health.ready();
99
+ return new Response(c.req.method === 'HEAD' ? null : JSON.stringify(result.body), { status: result.status, headers: result.headers });
100
+ };
101
+ for (const path of health.paths.live)
102
+ app.get(path, serve('live'));
103
+ for (const path of health.paths.ready)
104
+ app.get(path, serve('ready'));
105
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A small structured JSON logger. One line per entry, secrets redacted, and the current
3
+ * request ID and trace ID added automatically from the request context.
4
+ */
5
+ import { type RequestContext } from '../context/index.js';
6
+ export { redact, redactHeaders, redactText, isSensitiveKey, REDACTED, type RedactOptions } from './redact.js';
7
+ export declare const LOG_LEVELS: readonly ["trace", "debug", "info", "warn", "error", "fatal"];
8
+ export type LogLevel = (typeof LOG_LEVELS)[number];
9
+ export type LogFields = Readonly<Record<string, unknown>>;
10
+ export interface Logger {
11
+ trace(message: string, fields?: LogFields): void;
12
+ debug(message: string, fields?: LogFields): void;
13
+ info(message: string, fields?: LogFields): void;
14
+ warn(message: string, fields?: LogFields): void;
15
+ error(message: string, fields?: LogFields): void;
16
+ fatal(message: string, fields?: LogFields): void;
17
+ /** A logger that adds `bindings` to every line. */
18
+ child(bindings: LogFields): Logger;
19
+ isLevelEnabled(level: LogLevel): boolean;
20
+ readonly level: LogLevel | 'silent';
21
+ }
22
+ export interface LoggerOptions {
23
+ /** Minimum level written. Defaults to `LOG_LEVEL` when valid, else `info`. */
24
+ readonly level?: LogLevel | 'silent';
25
+ /** Fields on every line, such as `{ service: 'hivemem' }`. */
26
+ readonly bindings?: LogFields;
27
+ /** Where lines go. Defaults to `process.stdout.write` with a trailing newline. */
28
+ readonly write?: (line: string) => void;
29
+ /** Redact secrets. On by default; turn off only for trusted, already-clean fields. */
30
+ readonly redact?: boolean;
31
+ /** Extra keys to redact. */
32
+ readonly redactKeys?: readonly string[];
33
+ /** Include `err.stack` for Error fields. Off by default. */
34
+ readonly stacks?: boolean;
35
+ readonly now?: () => number;
36
+ /** Where the request context comes from. Defaults to the AsyncLocalStorage context. */
37
+ readonly context?: () => RequestContext | undefined;
38
+ }
39
+ export declare function createLogger(options?: LoggerOptions): Logger;