@hivemagnus/hive-common 0.0.0-stage → 0.2.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 +65 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +439 -2
- package/dist/context/index.d.ts +98 -0
- package/dist/context/index.js +191 -0
- package/dist/contracts/index.d.ts +117 -0
- package/dist/contracts/index.js +228 -0
- package/dist/errors/index.d.ts +91 -0
- package/dist/errors/index.js +309 -0
- package/dist/events/index.d.ts +107 -0
- package/dist/events/index.js +232 -0
- package/dist/health/index.d.ts +101 -0
- package/dist/health/index.js +126 -0
- package/dist/hono/index.d.ts +67 -0
- package/dist/hono/index.js +108 -0
- package/dist/log/index.d.ts +39 -0
- package/dist/log/index.js +80 -0
- package/dist/log/redact.d.ts +23 -0
- package/dist/log/redact.js +111 -0
- package/dist/node-http/index.d.ts +46 -0
- package/dist/node-http/index.js +91 -0
- package/dist/oathweave/claims.d.ts +79 -0
- package/dist/oathweave/claims.js +99 -0
- package/dist/oathweave/conformance.d.ts +88 -0
- package/dist/oathweave/conformance.js +124 -0
- package/dist/oathweave/endpoint.d.ts +15 -0
- package/dist/oathweave/endpoint.js +35 -0
- package/dist/oathweave/errors.d.ts +19 -0
- package/dist/oathweave/errors.js +70 -0
- package/dist/oathweave/index.d.ts +10 -0
- package/dist/oathweave/index.js +10 -0
- package/dist/oathweave/links.d.ts +63 -0
- package/dist/oathweave/links.js +138 -0
- package/dist/oathweave/stream.d.ts +26 -0
- package/dist/oathweave/stream.js +46 -0
- package/dist/oathweave/verifier.d.ts +83 -0
- package/dist/oathweave/verifier.js +237 -0
- package/dist/registry/index.d.ts +138 -0
- package/dist/registry/index.js +175 -0
- package/package.json +126 -4
- package/test-vectors/README.md +27 -0
- package/test-vectors/oathweave/jwks.json +12 -0
- package/test-vectors/oathweave/vectors.json +174 -0
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
/**
|
|
55
|
+
* A product's own liveness body, in place of the standard `LiveBody`: an object served as is,
|
|
56
|
+
* or a function called on every liveness request with the standard body.
|
|
57
|
+
*/
|
|
58
|
+
export type LiveBodyOverride<L extends object> = L | ((standard: LiveBody) => L);
|
|
59
|
+
/** `L` is the liveness body type: `LiveBody` unless `liveBody` is set. */
|
|
60
|
+
export interface HealthOptions<L extends object = LiveBody> {
|
|
61
|
+
readonly service?: string;
|
|
62
|
+
readonly version?: string;
|
|
63
|
+
readonly checks?: readonly HealthCheck[];
|
|
64
|
+
/** Default per-check timeout. 2000 ms. */
|
|
65
|
+
readonly timeoutMs?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Include error messages from failing checks. Off by default: readiness is often reachable
|
|
68
|
+
* without authentication, and error messages can name internal hosts.
|
|
69
|
+
*/
|
|
70
|
+
readonly exposeErrors?: boolean;
|
|
71
|
+
/** Also serve the legacy paths in `LEGACY_HEALTH_ALIASES`, or a custom alias map. */
|
|
72
|
+
readonly aliases?: boolean | Partial<Readonly<Record<HealthKind, readonly string[]>>>;
|
|
73
|
+
readonly now?: () => number;
|
|
74
|
+
/**
|
|
75
|
+
* Serve this body from `/health/live` instead of the standard one, for a product that keeps
|
|
76
|
+
* a published liveness body such as `{ status: 'ok' }`. The status (200) and headers are
|
|
77
|
+
* unchanged, and liveness still never runs checks. Readiness is not affected.
|
|
78
|
+
*/
|
|
79
|
+
readonly liveBody?: LiveBodyOverride<L>;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The liveness response: 200 whenever the process can answer. It never runs checks. The body
|
|
83
|
+
* is the standard `LiveBody`, or the product's own when `liveBody` is set.
|
|
84
|
+
*/
|
|
85
|
+
export declare function liveness<L extends object = LiveBody>(options?: HealthOptions<L>): HealthResponse<L>;
|
|
86
|
+
/** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
|
|
87
|
+
export declare function readiness(options?: HealthOptions<object>): Promise<HealthResponse<ReadyBody>>;
|
|
88
|
+
/** Which health endpoint a path names, including legacy aliases when enabled. */
|
|
89
|
+
export declare function resolveHealthPath(pathname: string, aliases?: HealthOptions['aliases']): HealthKind | undefined;
|
|
90
|
+
/** All paths served for each kind, standard first. */
|
|
91
|
+
export declare function healthPaths(aliases?: HealthOptions['aliases']): Readonly<Record<HealthKind, readonly string[]>>;
|
|
92
|
+
/** `L` is the liveness body type: `LiveBody` unless `liveBody` was set. */
|
|
93
|
+
export interface Health<L extends object = LiveBody> {
|
|
94
|
+
live(): HealthResponse<L>;
|
|
95
|
+
ready(): Promise<HealthResponse<ReadyBody>>;
|
|
96
|
+
/** The response for a health path, or undefined when the path is not a health path. */
|
|
97
|
+
handle(pathname: string): Promise<HealthResponse<L | ReadyBody>> | undefined;
|
|
98
|
+
readonly paths: Readonly<Record<HealthKind, readonly string[]>>;
|
|
99
|
+
}
|
|
100
|
+
/** Bind options once and serve both endpoints. */
|
|
101
|
+
export declare function createHealth<L extends object = LiveBody>(options?: HealthOptions<L>): Health<L>;
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
function assertLiveBody(value) {
|
|
16
|
+
if (value !== undefined && typeof value !== 'function' && (typeof value !== 'object' || value === null || Array.isArray(value))) {
|
|
17
|
+
throw new TypeError('liveBody must be an object or a function');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The liveness response: 200 whenever the process can answer. It never runs checks. The body
|
|
22
|
+
* is the standard `LiveBody`, or the product's own when `liveBody` is set.
|
|
23
|
+
*/
|
|
24
|
+
export function liveness(options = {}) {
|
|
25
|
+
assertLiveBody(options.liveBody);
|
|
26
|
+
const now = options.now ?? Date.now;
|
|
27
|
+
const standard = { status: 'live', ...identity(options), time: new Date(now()).toISOString() };
|
|
28
|
+
const custom = options.liveBody;
|
|
29
|
+
const body = custom === undefined ? standard : typeof custom === 'function' ? custom(standard) : { ...custom };
|
|
30
|
+
return { status: 200, body: body, headers: HEADERS };
|
|
31
|
+
}
|
|
32
|
+
class CheckTimeout extends Error {
|
|
33
|
+
}
|
|
34
|
+
async function runCheck(check, defaultTimeout, exposeErrors, now) {
|
|
35
|
+
const critical = check.critical ?? true;
|
|
36
|
+
const timeoutMs = check.timeoutMs ?? defaultTimeout;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const started = now();
|
|
39
|
+
let timer;
|
|
40
|
+
const timeout = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
controller.abort(new CheckTimeout('timeout'));
|
|
43
|
+
reject(new CheckTimeout('timeout'));
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
const outcome = await Promise.race([Promise.resolve().then(() => check.check(controller.signal)), timeout]);
|
|
48
|
+
const latencyMs = Math.max(0, now() - started);
|
|
49
|
+
const ok = outcome === undefined || outcome === true || (typeof outcome === 'object' && outcome !== null && outcome.ok !== false);
|
|
50
|
+
const details = typeof outcome === 'object' && outcome !== null ? outcome.details : undefined;
|
|
51
|
+
return { status: ok ? 'up' : 'down', critical, latencyMs, ...(ok ? {} : { error: 'failed' }), ...(details ? { details } : {}) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const latencyMs = Math.max(0, now() - started);
|
|
55
|
+
const reason = error instanceof CheckTimeout ? 'timeout' : exposeErrors && error instanceof Error && error.message ? error.message.slice(0, 300) : 'failed';
|
|
56
|
+
return { status: 'down', critical, latencyMs, error: reason };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Run every check concurrently and build the readiness response: 503 if a critical check is down. */
|
|
63
|
+
export async function readiness(options = {}) {
|
|
64
|
+
const now = options.now ?? Date.now;
|
|
65
|
+
const checks = options.checks ?? [];
|
|
66
|
+
const names = new Set();
|
|
67
|
+
for (const check of checks) {
|
|
68
|
+
if (!check.name || names.has(check.name))
|
|
69
|
+
throw new Error(`Health check names must be unique and non-empty: ${check.name}`);
|
|
70
|
+
names.add(check.name);
|
|
71
|
+
}
|
|
72
|
+
const reports = await Promise.all(checks.map(check => runCheck(check, options.timeoutMs ?? 2000, options.exposeErrors ?? false, now)));
|
|
73
|
+
const byName = {};
|
|
74
|
+
checks.forEach((check, index) => { byName[check.name] = reports[index]; });
|
|
75
|
+
const criticalDown = reports.some(report => report.status === 'down' && report.critical);
|
|
76
|
+
const anyDown = reports.some(report => report.status === 'down');
|
|
77
|
+
const status = criticalDown ? 'not_ready' : anyDown ? 'degraded' : 'ready';
|
|
78
|
+
return {
|
|
79
|
+
status: criticalDown ? 503 : 200,
|
|
80
|
+
body: { status, ...identity(options), time: new Date(now()).toISOString(), checks: byName },
|
|
81
|
+
headers: HEADERS,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Which health endpoint a path names, including legacy aliases when enabled. */
|
|
85
|
+
export function resolveHealthPath(pathname, aliases = false) {
|
|
86
|
+
const path = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
|
|
87
|
+
if (path === HEALTH_PATHS.live)
|
|
88
|
+
return 'live';
|
|
89
|
+
if (path === HEALTH_PATHS.ready)
|
|
90
|
+
return 'ready';
|
|
91
|
+
const map = aliasMap(aliases);
|
|
92
|
+
if (map.live.includes(path))
|
|
93
|
+
return 'live';
|
|
94
|
+
if (map.ready.includes(path))
|
|
95
|
+
return 'ready';
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/** All paths served for each kind, standard first. */
|
|
99
|
+
export function healthPaths(aliases = false) {
|
|
100
|
+
const map = aliasMap(aliases);
|
|
101
|
+
return { live: [HEALTH_PATHS.live, ...map.live], ready: [HEALTH_PATHS.ready, ...map.ready] };
|
|
102
|
+
}
|
|
103
|
+
function aliasMap(aliases) {
|
|
104
|
+
if (aliases === true)
|
|
105
|
+
return { live: LEGACY_HEALTH_ALIASES.live, ready: LEGACY_HEALTH_ALIASES.ready };
|
|
106
|
+
if (!aliases)
|
|
107
|
+
return { live: [], ready: [] };
|
|
108
|
+
return { live: aliases.live ?? [], ready: aliases.ready ?? [] };
|
|
109
|
+
}
|
|
110
|
+
/** Bind options once and serve both endpoints. */
|
|
111
|
+
export function createHealth(options = {}) {
|
|
112
|
+
assertLiveBody(options.liveBody);
|
|
113
|
+
return {
|
|
114
|
+
live: () => liveness(options),
|
|
115
|
+
ready: () => readiness(options),
|
|
116
|
+
handle(pathname) {
|
|
117
|
+
const kind = resolveHealthPath(pathname, options.aliases);
|
|
118
|
+
if (kind === 'live')
|
|
119
|
+
return Promise.resolve(liveness(options));
|
|
120
|
+
if (kind === 'ready')
|
|
121
|
+
return readiness(options);
|
|
122
|
+
return undefined;
|
|
123
|
+
},
|
|
124
|
+
paths: healthPaths(options.aliases),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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, type LiveBody } 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
|
+
/**
|
|
64
|
+
* Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
|
|
65
|
+
* `liveBody` to keep a product's published liveness body.
|
|
66
|
+
*/
|
|
67
|
+
export declare function registerHealthRoutes<E extends Env, L extends object = LiveBody>(app: Hono<E>, options?: HealthOptions<L>): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
/**
|
|
95
|
+
* Serve `/health/live`, `/health/ready` and any aliases on `app` (GET and HEAD). Set
|
|
96
|
+
* `liveBody` to keep a product's published liveness body.
|
|
97
|
+
*/
|
|
98
|
+
export function registerHealthRoutes(app, options = {}) {
|
|
99
|
+
const health = createHealth(options);
|
|
100
|
+
const serve = (kind) => async (c) => {
|
|
101
|
+
const result = kind === 'live' ? health.live() : await health.ready();
|
|
102
|
+
return new Response(c.req.method === 'HEAD' ? null : JSON.stringify(result.body), { status: result.status, headers: result.headers });
|
|
103
|
+
};
|
|
104
|
+
for (const path of health.paths.live)
|
|
105
|
+
app.get(path, serve('live'));
|
|
106
|
+
for (const path of health.paths.ready)
|
|
107
|
+
app.get(path, serve('ready'));
|
|
108
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { getRequestContext } from '../context/index.js';
|
|
6
|
+
import { redact } from './redact.js';
|
|
7
|
+
export { redact, redactHeaders, redactText, isSensitiveKey, REDACTED } from './redact.js';
|
|
8
|
+
export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
|
9
|
+
const SEVERITY = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60, silent: 100 };
|
|
10
|
+
function defaultLevel() {
|
|
11
|
+
const value = typeof process !== 'undefined' ? process.env['LOG_LEVEL']?.toLowerCase() : undefined;
|
|
12
|
+
return LOG_LEVELS.includes(value) ? value : 'info';
|
|
13
|
+
}
|
|
14
|
+
function serializeErrors(fields, stacks) {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
17
|
+
if (value instanceof Error) {
|
|
18
|
+
const code = value.code;
|
|
19
|
+
const status = value.status;
|
|
20
|
+
result[key] = {
|
|
21
|
+
name: value.name,
|
|
22
|
+
message: value.message,
|
|
23
|
+
...(typeof code === 'string' || typeof code === 'number' ? { code } : {}),
|
|
24
|
+
...(typeof status === 'number' ? { status } : {}),
|
|
25
|
+
...(stacks && value.stack ? { stack: value.stack } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
result[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
function safeStringify(value) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return JSON.stringify({ level: 'error', msg: 'log entry could not be serialised' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function createLogger(options = {}) {
|
|
42
|
+
const level = options.level ?? defaultLevel();
|
|
43
|
+
const threshold = SEVERITY[level];
|
|
44
|
+
const write = options.write ?? ((line) => { process.stdout.write(`${line}\n`); });
|
|
45
|
+
const shouldRedact = options.redact ?? true;
|
|
46
|
+
const now = options.now ?? Date.now;
|
|
47
|
+
const context = options.context ?? getRequestContext;
|
|
48
|
+
const bindings = options.bindings ?? {};
|
|
49
|
+
const emit = (entryLevel, message, fields) => {
|
|
50
|
+
if (SEVERITY[entryLevel] < threshold)
|
|
51
|
+
return;
|
|
52
|
+
const current = context();
|
|
53
|
+
const merged = {
|
|
54
|
+
...bindings,
|
|
55
|
+
...(current ? current.bindings : {}),
|
|
56
|
+
...serializeErrors({ ...fields }, options.stacks ?? false),
|
|
57
|
+
};
|
|
58
|
+
// Reserved fields win over bindings so a caller cannot forge them.
|
|
59
|
+
const entry = {
|
|
60
|
+
...(shouldRedact ? redact(merged, { keys: options.redactKeys ?? [] }) : merged),
|
|
61
|
+
time: new Date(now()).toISOString(),
|
|
62
|
+
level: entryLevel,
|
|
63
|
+
msg: shouldRedact ? redact(message) : message,
|
|
64
|
+
...(current ? { requestId: current.requestId, traceId: current.trace.traceId } : {}),
|
|
65
|
+
};
|
|
66
|
+
write(safeStringify(entry));
|
|
67
|
+
};
|
|
68
|
+
const logger = {
|
|
69
|
+
level,
|
|
70
|
+
trace: (message, fields) => emit('trace', message, fields),
|
|
71
|
+
debug: (message, fields) => emit('debug', message, fields),
|
|
72
|
+
info: (message, fields) => emit('info', message, fields),
|
|
73
|
+
warn: (message, fields) => emit('warn', message, fields),
|
|
74
|
+
error: (message, fields) => emit('error', message, fields),
|
|
75
|
+
fatal: (message, fields) => emit('fatal', message, fields),
|
|
76
|
+
child: childBindings => createLogger({ ...options, level, bindings: { ...bindings, ...childBindings } }),
|
|
77
|
+
isLevelEnabled: candidate => SEVERITY[candidate] >= threshold,
|
|
78
|
+
};
|
|
79
|
+
return logger;
|
|
80
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for logs and diagnostics. Follows the suite pattern (secret-looking keys
|
|
3
|
+
* are masked, strings are scrubbed of credentials) and adds headers, JWTs, URL credentials,
|
|
4
|
+
* provider token formats and cycle-safe traversal.
|
|
5
|
+
*/
|
|
6
|
+
export declare const REDACTED = "[REDACTED]";
|
|
7
|
+
/** True for a key whose value should never be logged. */
|
|
8
|
+
export declare function isSensitiveKey(key: string): boolean;
|
|
9
|
+
/** Scrub credentials out of free text. */
|
|
10
|
+
export declare function redactText(value: string): string;
|
|
11
|
+
export interface RedactOptions {
|
|
12
|
+
/** Extra keys to mask, compared case-insensitively and exactly. */
|
|
13
|
+
readonly keys?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A deep copy of `value` with secrets masked. Sensitive keys holding strings, objects or
|
|
17
|
+
* arrays are replaced by `[REDACTED]`; numbers and booleans under such keys (token counts,
|
|
18
|
+
* `hasPassword` flags) are kept. Strings everywhere are scrubbed with `redactText()`.
|
|
19
|
+
* Errors become `{ name, message, code? }`; cycles become `[Circular]`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function redact<T>(value: T, options?: RedactOptions): T;
|
|
22
|
+
/** Mask sensitive request or response headers, keeping the others. */
|
|
23
|
+
export declare function redactHeaders(headers: Readonly<Record<string, string | readonly string[] | undefined>> | Headers): Record<string, unknown>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for logs and diagnostics. Follows the suite pattern (secret-looking keys
|
|
3
|
+
* are masked, strings are scrubbed of credentials) and adds headers, JWTs, URL credentials,
|
|
4
|
+
* provider token formats and cycle-safe traversal.
|
|
5
|
+
*/
|
|
6
|
+
export const REDACTED = '[REDACTED]';
|
|
7
|
+
const MAX_DEPTH = 10;
|
|
8
|
+
/** Key fragments, compared after lowercasing and removing `-`, `_` and `.`. */
|
|
9
|
+
const SENSITIVE_KEY_PARTS = [
|
|
10
|
+
'authorization', 'cookie', 'password', 'passwd', 'passphrase', 'secret', 'token',
|
|
11
|
+
'apikey', 'privatekey', 'accesskey', 'signingkey', 'credential', 'webhookurl', 'sessionid',
|
|
12
|
+
];
|
|
13
|
+
/** Keys that end in "key" but are not secret. */
|
|
14
|
+
const NON_SECRET_KEYS = new Set(['publickey', 'idempotencykey', 'cachekey', 'sortkey', 'partitionkey', 'primarykey', 'foreignkey']);
|
|
15
|
+
/** True for a key whose value should never be logged. */
|
|
16
|
+
export function isSensitiveKey(key) {
|
|
17
|
+
const normalized = key.toLowerCase().replace(/[-_.\s]/g, '');
|
|
18
|
+
if (NON_SECRET_KEYS.has(normalized))
|
|
19
|
+
return false;
|
|
20
|
+
if (SENSITIVE_KEY_PARTS.some(part => normalized.includes(part)))
|
|
21
|
+
return true;
|
|
22
|
+
// key, apiKey, signing_key, x-api-key, keyMaterial...; not "keys", "monkey" is accepted collateral.
|
|
23
|
+
return normalized === 'key' || normalized.endsWith('key') || normalized.startsWith('keymaterial');
|
|
24
|
+
}
|
|
25
|
+
const TEXT_RULES = [
|
|
26
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
|
|
27
|
+
// Authorization schemes.
|
|
28
|
+
[/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{4,}/gi, `$1 ${REDACTED}`],
|
|
29
|
+
// JWT / JWS compact serialisations (header.payload.signature, signature may be empty).
|
|
30
|
+
[/\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, REDACTED],
|
|
31
|
+
// Provider key formats.
|
|
32
|
+
[/\bsk-[A-Za-z0-9_-]{12,}/g, REDACTED],
|
|
33
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, REDACTED],
|
|
34
|
+
[/\bnpm_[A-Za-z0-9]{20,}\b/g, REDACTED],
|
|
35
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
|
|
36
|
+
[/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, REDACTED],
|
|
37
|
+
// Credentials in URLs.
|
|
38
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, `$1${REDACTED}@`],
|
|
39
|
+
// Secret-looking query parameters and key=value / key: value pairs.
|
|
40
|
+
[/([?&](?:access_token|refresh_token|id_token|token|code|client_secret|api_key|apikey|key|password|sig|signature)=)[^&#\s]+/gi, `$1${REDACTED}`],
|
|
41
|
+
[/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|client[_-]?secret|password|passwd|private[_-]?key)["']?\s*[:=]\s*["']?)[^"',\s}&]+/gi, `$1${REDACTED}`],
|
|
42
|
+
];
|
|
43
|
+
/** Scrub credentials out of free text. */
|
|
44
|
+
export function redactText(value) {
|
|
45
|
+
let text = value;
|
|
46
|
+
for (const [pattern, replacement] of TEXT_RULES)
|
|
47
|
+
text = text.replace(pattern, replacement);
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A deep copy of `value` with secrets masked. Sensitive keys holding strings, objects or
|
|
52
|
+
* arrays are replaced by `[REDACTED]`; numbers and booleans under such keys (token counts,
|
|
53
|
+
* `hasPassword` flags) are kept. Strings everywhere are scrubbed with `redactText()`.
|
|
54
|
+
* Errors become `{ name, message, code? }`; cycles become `[Circular]`.
|
|
55
|
+
*/
|
|
56
|
+
export function redact(value, options = {}) {
|
|
57
|
+
const extra = new Set((options.keys ?? []).map(key => key.toLowerCase()));
|
|
58
|
+
const seen = new WeakSet();
|
|
59
|
+
const visit = (item, depth) => {
|
|
60
|
+
if (typeof item === 'string')
|
|
61
|
+
return redactText(item);
|
|
62
|
+
if (typeof item === 'bigint')
|
|
63
|
+
return item.toString();
|
|
64
|
+
if (typeof item !== 'object' || item === null)
|
|
65
|
+
return item;
|
|
66
|
+
if (seen.has(item))
|
|
67
|
+
return '[Circular]';
|
|
68
|
+
if (depth >= MAX_DEPTH)
|
|
69
|
+
return '[Truncated]';
|
|
70
|
+
seen.add(item);
|
|
71
|
+
try {
|
|
72
|
+
if (item instanceof Date)
|
|
73
|
+
return Number.isNaN(item.getTime()) ? null : item.toISOString();
|
|
74
|
+
if (item instanceof Error) {
|
|
75
|
+
const code = item.code;
|
|
76
|
+
return { name: item.name, message: redactText(item.message), ...(typeof code === 'string' || typeof code === 'number' ? { code } : {}) };
|
|
77
|
+
}
|
|
78
|
+
if (typeof item.forEach === 'function' && typeof item.entries === 'function'
|
|
79
|
+
&& typeof item.get === 'function' && !(item instanceof Map)) {
|
|
80
|
+
// fetch Headers and similar.
|
|
81
|
+
const record = {};
|
|
82
|
+
item.forEach((headerValue, key) => { record[key] = headerValue; });
|
|
83
|
+
return visit(record, depth);
|
|
84
|
+
}
|
|
85
|
+
if (item instanceof Map)
|
|
86
|
+
return visit(Object.fromEntries(item), depth);
|
|
87
|
+
if (item instanceof Set)
|
|
88
|
+
return visit([...item], depth);
|
|
89
|
+
if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer)
|
|
90
|
+
return `[Binary ${item.byteLength} bytes]`;
|
|
91
|
+
if (Array.isArray(item))
|
|
92
|
+
return item.map(element => visit(element, depth + 1));
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const [key, child] of Object.entries(item)) {
|
|
95
|
+
const sensitive = isSensitiveKey(key) || extra.has(key.toLowerCase());
|
|
96
|
+
result[key] = sensitive && (typeof child === 'string' || (typeof child === 'object' && child !== null))
|
|
97
|
+
? REDACTED
|
|
98
|
+
: visit(child, depth + 1);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
seen.delete(item);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return visit(value, 0);
|
|
107
|
+
}
|
|
108
|
+
/** Mask sensitive request or response headers, keeping the others. */
|
|
109
|
+
export function redactHeaders(headers) {
|
|
110
|
+
return redact(headers);
|
|
111
|
+
}
|