@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +11 -0
  4. package/README.md +439 -2
  5. package/dist/context/index.d.ts +98 -0
  6. package/dist/context/index.js +191 -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 +101 -0
  14. package/dist/health/index.js +126 -0
  15. package/dist/hono/index.d.ts +67 -0
  16. package/dist/hono/index.js +108 -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 +46 -0
  22. package/dist/node-http/index.js +91 -0
  23. package/dist/oathweave/claims.d.ts +79 -0
  24. package/dist/oathweave/claims.js +99 -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 +63 -0
  34. package/dist/oathweave/links.js +138 -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 +83 -0
  38. package/dist/oathweave/verifier.js +237 -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,191 @@
1
+ /**
2
+ * Request context: request IDs, W3C trace context, idempotency keys and an
3
+ * AsyncLocalStorage-scoped context that loggers and outbound clients read.
4
+ */
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { HIVE_HEADERS } from '../contracts/index.js';
7
+ import { HiveError } from '../errors/index.js';
8
+ export { HIVE_HEADERS } from '../contracts/index.js';
9
+ /** Read one header. A repeated header (array value) is treated as absent: it is ambiguous. */
10
+ export function readHeader(headers, name) {
11
+ const lower = name.toLowerCase();
12
+ let value;
13
+ if (typeof headers === 'function')
14
+ value = headers(lower);
15
+ else if (typeof headers.get === 'function')
16
+ value = headers.get(lower);
17
+ else {
18
+ const record = headers;
19
+ value = record[lower];
20
+ if (value === undefined) {
21
+ const key = Object.keys(record).find(candidate => candidate.toLowerCase() === lower);
22
+ value = key === undefined ? undefined : record[key];
23
+ }
24
+ }
25
+ if (Array.isArray(value))
26
+ return value.length === 1 && typeof value[0] === 'string' ? value[0] : undefined;
27
+ return typeof value === 'string' ? value : undefined;
28
+ }
29
+ // ---------------------------------------------------------------------------------------
30
+ // Request IDs
31
+ /** Up to 128 printable characters that are safe in logs and headers. */
32
+ export const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:@/+=-]{1,128}$/;
33
+ export function isValidRequestId(value) {
34
+ return typeof value === 'string' && REQUEST_ID_PATTERN.test(value);
35
+ }
36
+ export function generateRequestId() {
37
+ return globalThis.crypto.randomUUID();
38
+ }
39
+ /**
40
+ * The inbound request ID: `x-request-id`, else `x-hive-request-id`, else `x-correlation-id`.
41
+ * An invalid value (too long, unsafe characters) is ignored rather than propagated.
42
+ */
43
+ export function readRequestId(headers) {
44
+ for (const name of [HIVE_HEADERS.requestId, ...HIVE_HEADERS.requestIdAliases]) {
45
+ const value = readHeader(headers, name)?.trim();
46
+ if (isValidRequestId(value))
47
+ return value;
48
+ }
49
+ return undefined;
50
+ }
51
+ /** The inbound request ID, or a new one. */
52
+ export function resolveRequestId(headers, generate = generateRequestId) {
53
+ return readRequestId(headers) ?? generate();
54
+ }
55
+ const HEX = /^[0-9a-f]+$/;
56
+ const isZero = (value) => /^0+$/.test(value);
57
+ /** Parse a `traceparent` header per W3C Trace Context. Returns undefined when it is invalid. */
58
+ export function parseTraceparent(value) {
59
+ if (typeof value !== 'string')
60
+ return undefined;
61
+ const header = value.trim();
62
+ if (header.length < 55)
63
+ return undefined;
64
+ const version = header.slice(0, 2);
65
+ if (!HEX.test(version) || version === 'ff')
66
+ return undefined;
67
+ // Version 00 is exactly 55 characters; later versions may append fields after a dash.
68
+ if (version === '00' ? header.length !== 55 : header.length > 55 && header[55] !== '-')
69
+ return undefined;
70
+ const [, traceId, spanId, flags] = header.slice(0, 55).split('-');
71
+ if (header[2] !== '-' || header[35] !== '-' || header[52] !== '-')
72
+ return undefined;
73
+ if (!traceId || !spanId || !flags)
74
+ return undefined;
75
+ if (traceId.length !== 32 || !HEX.test(traceId) || isZero(traceId))
76
+ return undefined;
77
+ if (spanId.length !== 16 || !HEX.test(spanId) || isZero(spanId))
78
+ return undefined;
79
+ if (flags.length !== 2 || !HEX.test(flags))
80
+ return undefined;
81
+ return { version, traceId, spanId, flags, sampled: (Number.parseInt(flags, 16) & 1) === 1 };
82
+ }
83
+ export function isValidTraceparent(value) {
84
+ return parseTraceparent(value) !== undefined;
85
+ }
86
+ function randomHex(bytes) {
87
+ const buffer = new Uint8Array(bytes);
88
+ // A zero ID is invalid; regenerate in the vanishingly rare case it occurs.
89
+ do
90
+ globalThis.crypto.getRandomValues(buffer);
91
+ while (buffer.every(byte => byte === 0));
92
+ return Array.from(buffer, byte => byte.toString(16).padStart(2, '0')).join('');
93
+ }
94
+ export function generateTraceId() {
95
+ return randomHex(16);
96
+ }
97
+ export function generateSpanId() {
98
+ return randomHex(8);
99
+ }
100
+ export function formatTraceparent(trace) {
101
+ const flags = trace.flags ?? (trace.sampled === false ? '00' : '01');
102
+ const header = `00-${trace.traceId}-${trace.spanId}-${flags}`;
103
+ if (!parseTraceparent(header))
104
+ throw new TypeError('Invalid trace context');
105
+ return header;
106
+ }
107
+ /** A new root trace context. */
108
+ export function generateTraceparent(options = {}) {
109
+ const sampled = options.sampled ?? true;
110
+ return { version: '00', traceId: generateTraceId(), spanId: generateSpanId(), flags: sampled ? '01' : '00', sampled };
111
+ }
112
+ /** A child span: same trace and flags, new span ID, always version 00 on output. */
113
+ export function childTraceparent(parent) {
114
+ return { version: '00', traceId: parent.traceId, spanId: generateSpanId(), flags: parent.flags, sampled: parent.sampled };
115
+ }
116
+ // ---------------------------------------------------------------------------------------
117
+ // Idempotency keys
118
+ /** 1-255 visible ASCII characters. */
119
+ export const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{1,255}$/;
120
+ /**
121
+ * The `Idempotency-Key` header, also accepting `x-idempotency-key`. Returns undefined when
122
+ * neither is present. Throws a 400 HiveError when the value is malformed or the two headers
123
+ * disagree, because silently picking one could apply a write twice.
124
+ */
125
+ export function readIdempotencyKey(headers) {
126
+ const values = [HIVE_HEADERS.idempotencyKey, ...HIVE_HEADERS.idempotencyKeyAliases]
127
+ .map(name => readHeader(headers, name))
128
+ .filter((value) => value !== undefined)
129
+ .map(value => value.trim());
130
+ if (!values.length)
131
+ return undefined;
132
+ const [first] = values;
133
+ if (values.some(value => value !== first)) {
134
+ throw new HiveError('invalid_idempotency_key', 'Idempotency-Key and X-Idempotency-Key disagree', { status: 400 });
135
+ }
136
+ if (!IDEMPOTENCY_KEY_PATTERN.test(first)) {
137
+ throw new HiveError('invalid_idempotency_key', 'Idempotency-Key must be 1-255 visible ASCII characters', { status: 400 });
138
+ }
139
+ return first;
140
+ }
141
+ /** Like `readIdempotencyKey()`, but a missing key is a 428 error. */
142
+ export function requireIdempotencyKey(headers) {
143
+ const key = readIdempotencyKey(headers);
144
+ if (key === undefined)
145
+ throw new HiveError('idempotency_key_required', 'This request requires an Idempotency-Key header', { status: 428 });
146
+ return key;
147
+ }
148
+ export function createRequestContext(headers, options = {}) {
149
+ const trust = options.trustInbound ?? true;
150
+ const trustRequestId = options.trustInboundRequestId ?? trust;
151
+ const trustTrace = options.trustInboundTrace ?? trust;
152
+ const requestId = (trustRequestId ? readRequestId(headers) : undefined) ?? (options.generateRequestId ?? generateRequestId)();
153
+ const parentTrace = trustTrace ? parseTraceparent(readHeader(headers, HIVE_HEADERS.traceparent)) : undefined;
154
+ const rawState = parentTrace ? readHeader(headers, HIVE_HEADERS.tracestate)?.trim() : undefined;
155
+ // eslint-disable-next-line no-control-regex
156
+ const tracestate = rawState && rawState.length <= 512 && !/[\u0000-\u001f\u007f]/.test(rawState) ? rawState : undefined;
157
+ return {
158
+ requestId,
159
+ trace: parentTrace ? childTraceparent(parentTrace) : generateTraceparent(),
160
+ parentTrace,
161
+ tracestate,
162
+ startedAt: (options.now ?? Date.now)(),
163
+ bindings: {},
164
+ };
165
+ }
166
+ const storage = new AsyncLocalStorage();
167
+ /** Run `fn` with `context` as the current request context. */
168
+ export function runWithRequestContext(context, fn) {
169
+ return storage.run(context, fn);
170
+ }
171
+ /** The current request context, or undefined outside a request. */
172
+ export function getRequestContext() {
173
+ return storage.getStore();
174
+ }
175
+ /** The current request ID, or undefined outside a request. */
176
+ export function currentRequestId() {
177
+ return storage.getStore()?.requestId;
178
+ }
179
+ /**
180
+ * Headers to send to a peer service so it joins this request: `x-request-id`, `traceparent`
181
+ * (with this service's span as the parent) and `tracestate`. Empty outside a request.
182
+ */
183
+ export function outboundHeaders(context = getRequestContext()) {
184
+ if (!context)
185
+ return {};
186
+ return {
187
+ [HIVE_HEADERS.requestId]: context.requestId,
188
+ [HIVE_HEADERS.traceparent]: formatTraceparent(context.trace),
189
+ ...(context.tracestate ? { [HIVE_HEADERS.tracestate]: context.tracestate } : {}),
190
+ };
191
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Isomorphic suite contracts: types and small, dependency-free validators.
3
+ *
4
+ * Nothing in this module may import a Node built-in, a framework or a runtime dependency,
5
+ * because the browser UI vendors it. Validators return a result instead of throwing so
6
+ * they can be used on untrusted input without try/catch.
7
+ */
8
+ export type ValidationResult<T> = {
9
+ readonly ok: true;
10
+ readonly value: T;
11
+ } | {
12
+ readonly ok: false;
13
+ readonly issues: readonly string[];
14
+ };
15
+ /** Header names used across the suite. Lowercase, as HTTP/2 and fetch normalise them. */
16
+ export declare const HIVE_HEADERS: {
17
+ readonly requestId: "x-request-id";
18
+ /** Accepted on inbound requests for compatibility; never sent. */
19
+ readonly requestIdAliases: readonly ["x-hive-request-id", "x-correlation-id"];
20
+ readonly traceparent: "traceparent";
21
+ readonly tracestate: "tracestate";
22
+ readonly idempotencyKey: "idempotency-key";
23
+ /** Accepted on inbound requests for compatibility; never sent. */
24
+ readonly idempotencyKeyAliases: readonly ["x-idempotency-key"];
25
+ };
26
+ /** The body of every suite error response: `{ error: { code, message, requestId?, details? } }`. */
27
+ export interface ErrorBody {
28
+ /** Stable, machine-readable code such as `not_found` or `invalid_token`. */
29
+ readonly code: string;
30
+ /** Human-readable message. Never contains secrets or internal stack traces. */
31
+ readonly message: string;
32
+ readonly requestId?: string;
33
+ readonly details?: Readonly<Record<string, unknown>>;
34
+ }
35
+ export interface ErrorEnvelope {
36
+ readonly error: ErrorBody;
37
+ }
38
+ export declare function isErrorEnvelope(value: unknown): value is ErrorEnvelope;
39
+ /** A cursor-paginated list: `nextCursor` is null on the last page. */
40
+ export interface Page<T> {
41
+ readonly data: readonly T[];
42
+ readonly nextCursor: string | null;
43
+ }
44
+ export declare function isPage(value: unknown): value is Page<unknown>;
45
+ /** A suite identifier segment: node IDs, profile names and actor IDs. */
46
+ export declare const IDENTIFIER_PATTERN: RegExp;
47
+ /** `<nodeId>~<profile>`, both identifier segments. */
48
+ export declare const AGENT_REF_PATTERN: RegExp;
49
+ /** A product ID as the Hive Registry stores it. */
50
+ export declare const PRODUCT_ID_PATTERN: RegExp;
51
+ export interface AgentIdentity {
52
+ /** `<nodeId>~<profile>` */
53
+ readonly agentRef: string;
54
+ readonly actorId: string;
55
+ }
56
+ export declare function isIdentifier(value: unknown): value is string;
57
+ export declare function isAgentRef(value: unknown): value is string;
58
+ export declare function isProductId(value: unknown): value is string;
59
+ export declare function parseAgentRef(value: string): {
60
+ readonly nodeId: string;
61
+ readonly profile: string;
62
+ } | undefined;
63
+ export declare function formatAgentRef(nodeId: string, profile: string): string;
64
+ export declare function validateAgentIdentity(value: unknown): ValidationResult<AgentIdentity>;
65
+ export declare const HIVE_EVENT_VERSION = 1;
66
+ export declare const HIVE_EVENT_SEVERITIES: readonly ["info", "warning", "critical"];
67
+ export type HiveEventSeverity = (typeof HIVE_EVENT_SEVERITIES)[number];
68
+ /** HiveEvent v1: one notable thing that happened in a product, linking back to it. */
69
+ export interface HiveEvent {
70
+ /** Globally unique and stable; consumers deduplicate on it. */
71
+ readonly id: string;
72
+ /** Product ID of the emitter, as the Hive Registry names it. */
73
+ readonly product: string;
74
+ /** Dotted, lowercase event type, for example `agent.task.failed`. */
75
+ readonly type: string;
76
+ readonly severity: HiveEventSeverity;
77
+ readonly title: string;
78
+ readonly summary: string;
79
+ /** RFC 3339 timestamp in UTC (`Z`). */
80
+ readonly occurredAt: string;
81
+ readonly subject?: {
82
+ readonly actorId?: string;
83
+ readonly agentRef?: string;
84
+ readonly nodeId?: string;
85
+ };
86
+ /** Where a person can see the event: a same-origin console path of a product. */
87
+ readonly link: {
88
+ readonly product: string;
89
+ readonly path: string;
90
+ };
91
+ /** The emitter's own reference to the record behind the event. */
92
+ readonly sourceRef?: string;
93
+ }
94
+ export declare const HIVE_EVENT_LIMITS: {
95
+ readonly id: 128;
96
+ readonly type: 100;
97
+ readonly title: 200;
98
+ readonly summary: 2000;
99
+ readonly path: 1024;
100
+ readonly sourceRef: 256;
101
+ };
102
+ /** True for an RFC 3339 UTC timestamp that names a real instant. */
103
+ export declare function isRfc3339Utc(value: unknown): value is string;
104
+ /** A same-origin path: starts with one `/`, no scheme, no backslash, no control characters. */
105
+ export declare function isSameOriginPath(value: unknown): value is string;
106
+ export interface HiveEventValidationOptions {
107
+ /** Reject unknown fields instead of dropping them. Producers should validate strictly. */
108
+ readonly strict?: boolean;
109
+ }
110
+ /**
111
+ * Validate a HiveEvent v1. The returned value is a fresh object holding only known fields,
112
+ * so a consumer never passes along data it did not validate.
113
+ */
114
+ export declare function validateHiveEvent(value: unknown, options?: HiveEventValidationOptions): ValidationResult<HiveEvent>;
115
+ export declare function isHiveEvent(value: unknown): value is HiveEvent;
116
+ /** Validate a feed page of HiveEvents. Any invalid event fails the whole page. */
117
+ export declare function validateHiveEventPage(value: unknown, options?: HiveEventValidationOptions): ValidationResult<Page<HiveEvent>>;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Isomorphic suite contracts: types and small, dependency-free validators.
3
+ *
4
+ * Nothing in this module may import a Node built-in, a framework or a runtime dependency,
5
+ * because the browser UI vendors it. Validators return a result instead of throwing so
6
+ * they can be used on untrusted input without try/catch.
7
+ */
8
+ function isRecord(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
10
+ }
11
+ // ---------------------------------------------------------------------------------------
12
+ // Headers
13
+ /** Header names used across the suite. Lowercase, as HTTP/2 and fetch normalise them. */
14
+ export const HIVE_HEADERS = {
15
+ requestId: 'x-request-id',
16
+ /** Accepted on inbound requests for compatibility; never sent. */
17
+ requestIdAliases: ['x-hive-request-id', 'x-correlation-id'],
18
+ traceparent: 'traceparent',
19
+ tracestate: 'tracestate',
20
+ idempotencyKey: 'idempotency-key',
21
+ /** Accepted on inbound requests for compatibility; never sent. */
22
+ idempotencyKeyAliases: ['x-idempotency-key'],
23
+ };
24
+ export function isErrorEnvelope(value) {
25
+ if (!isRecord(value) || !isRecord(value['error']))
26
+ return false;
27
+ const error = value['error'];
28
+ return typeof error['code'] === 'string' && error['code'].length > 0
29
+ && typeof error['message'] === 'string'
30
+ && (error['requestId'] === undefined || typeof error['requestId'] === 'string')
31
+ && (error['details'] === undefined || isRecord(error['details']));
32
+ }
33
+ export function isPage(value) {
34
+ return isRecord(value) && Array.isArray(value['data'])
35
+ && (value['nextCursor'] === null || (typeof value['nextCursor'] === 'string' && value['nextCursor'].length > 0));
36
+ }
37
+ // ---------------------------------------------------------------------------------------
38
+ // Identifiers
39
+ /** A suite identifier segment: node IDs, profile names and actor IDs. */
40
+ export const IDENTIFIER_PATTERN = /^[a-z0-9_-]{2,32}$/;
41
+ /** `<nodeId>~<profile>`, both identifier segments. */
42
+ export const AGENT_REF_PATTERN = /^([a-z0-9_-]{2,32})~([a-z0-9_-]{2,32})$/;
43
+ /** A product ID as the Hive Registry stores it. */
44
+ export const PRODUCT_ID_PATTERN = /^[a-z][a-z0-9-]{0,47}$/;
45
+ export function isIdentifier(value) {
46
+ return typeof value === 'string' && IDENTIFIER_PATTERN.test(value);
47
+ }
48
+ export function isAgentRef(value) {
49
+ return typeof value === 'string' && AGENT_REF_PATTERN.test(value);
50
+ }
51
+ export function isProductId(value) {
52
+ return typeof value === 'string' && PRODUCT_ID_PATTERN.test(value);
53
+ }
54
+ export function parseAgentRef(value) {
55
+ const match = AGENT_REF_PATTERN.exec(value);
56
+ return match ? { nodeId: match[1], profile: match[2] } : undefined;
57
+ }
58
+ export function formatAgentRef(nodeId, profile) {
59
+ if (!isIdentifier(nodeId) || !isIdentifier(profile))
60
+ throw new TypeError('nodeId and profile must match [a-z0-9_-]{2,32}');
61
+ return `${nodeId}~${profile}`;
62
+ }
63
+ export function validateAgentIdentity(value) {
64
+ if (!isRecord(value))
65
+ return { ok: false, issues: ['$: expected an object'] };
66
+ const issues = [];
67
+ if (!isAgentRef(value['agentRef']))
68
+ issues.push('agentRef: expected <nodeId>~<profile> matching [a-z0-9_-]{2,32}');
69
+ if (!isIdentifier(value['actorId']))
70
+ issues.push('actorId: expected [a-z0-9_-]{2,32}');
71
+ return issues.length ? { ok: false, issues } : { ok: true, value: { agentRef: value['agentRef'], actorId: value['actorId'] } };
72
+ }
73
+ // ---------------------------------------------------------------------------------------
74
+ // HiveEvent v1
75
+ export const HIVE_EVENT_VERSION = 1;
76
+ export const HIVE_EVENT_SEVERITIES = ['info', 'warning', 'critical'];
77
+ export const HIVE_EVENT_LIMITS = {
78
+ id: 128,
79
+ type: 100,
80
+ title: 200,
81
+ summary: 2000,
82
+ path: 1024,
83
+ sourceRef: 256,
84
+ };
85
+ const EVENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
86
+ const EVENT_TYPE_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
87
+ const RFC3339_UTC = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/;
88
+ // Control characters other than tab and newline never belong in display text.
89
+ // eslint-disable-next-line no-control-regex
90
+ const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
91
+ /** True for an RFC 3339 UTC timestamp that names a real instant. */
92
+ export function isRfc3339Utc(value) {
93
+ if (typeof value !== 'string')
94
+ return false;
95
+ const match = RFC3339_UTC.exec(value);
96
+ if (!match)
97
+ return false;
98
+ const [, year, month, day, hour, minute, second] = match.map(Number);
99
+ const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
100
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
101
+ && date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second;
102
+ }
103
+ /** A same-origin path: starts with one `/`, no scheme, no backslash, no control characters. */
104
+ export function isSameOriginPath(value) {
105
+ return typeof value === 'string' && value.length <= HIVE_EVENT_LIMITS.path && /^\/(?![/\\])/.test(value)
106
+ && !value.includes('\\') && !CONTROL_CHARACTERS.test(value) && !/\s/.test(value);
107
+ }
108
+ function text(value, field, min, max, issues) {
109
+ if (typeof value !== 'string') {
110
+ issues.push(`${field}: expected a string`);
111
+ return false;
112
+ }
113
+ if (value.length < min || value.length > max) {
114
+ issues.push(`${field}: expected ${min}-${max} characters`);
115
+ return false;
116
+ }
117
+ if (CONTROL_CHARACTERS.test(value)) {
118
+ issues.push(`${field}: must not contain control characters`);
119
+ return false;
120
+ }
121
+ return true;
122
+ }
123
+ const EVENT_FIELDS = new Set(['id', 'product', 'type', 'severity', 'title', 'summary', 'occurredAt', 'subject', 'link', 'sourceRef']);
124
+ const SUBJECT_FIELDS = new Set(['actorId', 'agentRef', 'nodeId']);
125
+ const LINK_FIELDS = new Set(['product', 'path']);
126
+ function unknownFields(value, known, prefix, issues) {
127
+ for (const key of Object.keys(value))
128
+ if (!known.has(key))
129
+ issues.push(`${prefix}${key}: unknown field`);
130
+ }
131
+ /**
132
+ * Validate a HiveEvent v1. The returned value is a fresh object holding only known fields,
133
+ * so a consumer never passes along data it did not validate.
134
+ */
135
+ export function validateHiveEvent(value, options = {}) {
136
+ if (!isRecord(value))
137
+ return { ok: false, issues: ['$: expected an object'] };
138
+ const issues = [];
139
+ if (options.strict)
140
+ unknownFields(value, EVENT_FIELDS, '', issues);
141
+ const { id, product, type, severity, title, summary, occurredAt, subject, link, sourceRef } = value;
142
+ if (text(id, 'id', 1, HIVE_EVENT_LIMITS.id, issues) && !EVENT_ID_PATTERN.test(id))
143
+ issues.push('id: expected [A-Za-z0-9._:-]');
144
+ if (!isProductId(product))
145
+ issues.push('product: expected a product ID');
146
+ if (text(type, 'type', 1, HIVE_EVENT_LIMITS.type, issues) && !EVENT_TYPE_PATTERN.test(type))
147
+ issues.push('type: expected a dotted lowercase type');
148
+ if (!HIVE_EVENT_SEVERITIES.includes(severity))
149
+ issues.push('severity: expected info, warning or critical');
150
+ text(title, 'title', 1, HIVE_EVENT_LIMITS.title, issues);
151
+ text(summary, 'summary', 0, HIVE_EVENT_LIMITS.summary, issues);
152
+ if (!isRfc3339Utc(occurredAt))
153
+ issues.push('occurredAt: expected an RFC 3339 UTC timestamp');
154
+ let cleanSubject;
155
+ if (subject !== undefined) {
156
+ if (!isRecord(subject))
157
+ issues.push('subject: expected an object');
158
+ else {
159
+ if (options.strict)
160
+ unknownFields(subject, SUBJECT_FIELDS, 'subject.', issues);
161
+ cleanSubject = {};
162
+ if (subject['actorId'] !== undefined) {
163
+ if (isIdentifier(subject['actorId']))
164
+ cleanSubject.actorId = subject['actorId'];
165
+ else
166
+ issues.push('subject.actorId: expected [a-z0-9_-]{2,32}');
167
+ }
168
+ if (subject['agentRef'] !== undefined) {
169
+ if (isAgentRef(subject['agentRef']))
170
+ cleanSubject.agentRef = subject['agentRef'];
171
+ else
172
+ issues.push('subject.agentRef: expected <nodeId>~<profile>');
173
+ }
174
+ if (subject['nodeId'] !== undefined) {
175
+ if (isIdentifier(subject['nodeId']))
176
+ cleanSubject.nodeId = subject['nodeId'];
177
+ else
178
+ issues.push('subject.nodeId: expected [a-z0-9_-]{2,32}');
179
+ }
180
+ }
181
+ }
182
+ if (!isRecord(link))
183
+ issues.push('link: expected an object');
184
+ else {
185
+ if (options.strict)
186
+ unknownFields(link, LINK_FIELDS, 'link.', issues);
187
+ if (!isProductId(link['product']))
188
+ issues.push('link.product: expected a product ID');
189
+ if (!isSameOriginPath(link['path']))
190
+ issues.push('link.path: expected a same-origin path starting with /');
191
+ }
192
+ if (sourceRef !== undefined)
193
+ text(sourceRef, 'sourceRef', 1, HIVE_EVENT_LIMITS.sourceRef, issues);
194
+ if (issues.length)
195
+ return { ok: false, issues };
196
+ const linkRecord = link;
197
+ const event = {
198
+ id: id,
199
+ product: product,
200
+ type: type,
201
+ severity: severity,
202
+ title: title,
203
+ summary: summary,
204
+ occurredAt: occurredAt,
205
+ ...(cleanSubject ? { subject: cleanSubject } : {}),
206
+ link: { product: linkRecord['product'], path: linkRecord['path'] },
207
+ ...(sourceRef !== undefined ? { sourceRef: sourceRef } : {}),
208
+ };
209
+ return { ok: true, value: event };
210
+ }
211
+ export function isHiveEvent(value) {
212
+ return validateHiveEvent(value).ok;
213
+ }
214
+ /** Validate a feed page of HiveEvents. Any invalid event fails the whole page. */
215
+ export function validateHiveEventPage(value, options = {}) {
216
+ if (!isPage(value))
217
+ return { ok: false, issues: ['$: expected { data: [], nextCursor: string | null }'] };
218
+ const data = [];
219
+ const issues = [];
220
+ value.data.forEach((item, index) => {
221
+ const result = validateHiveEvent(item, options);
222
+ if (result.ok)
223
+ data.push(result.value);
224
+ else
225
+ issues.push(...result.issues.map(issue => `data[${index}].${issue}`));
226
+ });
227
+ return issues.length ? { ok: false, issues } : { ok: true, value: { data, nextCursor: value.nextCursor } };
228
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The suite error envelope, `HiveError`, and `parseHiveError()`, which turns any error
3
+ * response a suite product has ever sent into one `HiveError`.
4
+ *
5
+ * Isomorphic: no Node built-ins, so the browser UI can use it.
6
+ */
7
+ import type { ErrorEnvelope } from '../contracts/index.js';
8
+ export type { ErrorBody, ErrorEnvelope } from '../contracts/index.js';
9
+ export { isErrorEnvelope } from '../contracts/index.js';
10
+ /** The default code for an HTTP status, for example `not_found` for 404. */
11
+ export declare function defaultCodeForStatus(status: number): string;
12
+ /** The default message for an HTTP status. */
13
+ export declare function defaultMessageForStatus(status: number): string;
14
+ /** The legacy body shape a parsed error came from. */
15
+ export type HiveErrorShape = 'envelope' | 'magnus' | 'oathweave' | 'oauth' | 'code' | 'string' | 'fastapi' | 'fastapi-validation' | 'mattermost' | 'message' | 'text' | 'empty' | 'unknown';
16
+ export interface HiveErrorOptions {
17
+ readonly status?: number;
18
+ readonly details?: Readonly<Record<string, unknown>>;
19
+ readonly requestId?: string;
20
+ /** Extra response headers, such as `WWW-Authenticate` or `Retry-After`. */
21
+ readonly headers?: Readonly<Record<string, string>>;
22
+ readonly cause?: unknown;
23
+ readonly shape?: HiveErrorShape;
24
+ }
25
+ /** An error that knows its HTTP status, stable code and safe message. */
26
+ export declare class HiveError extends Error {
27
+ readonly name: string;
28
+ readonly code: string;
29
+ readonly status: number;
30
+ readonly details: Readonly<Record<string, unknown>> | undefined;
31
+ readonly requestId: string | undefined;
32
+ readonly headers: Readonly<Record<string, string>> | undefined;
33
+ /** Set by `parseHiveError()`: the legacy body shape it recognised. */
34
+ readonly shape: HiveErrorShape | undefined;
35
+ constructor(code: string, message: string, options?: HiveErrorOptions);
36
+ /** True when `error` is a HiveError with this code (case-insensitive), across package copies. */
37
+ static is(error: unknown, code?: string): error is HiveError;
38
+ toJSON(): ErrorEnvelope;
39
+ static badRequest(message?: string, details?: Record<string, unknown>): HiveError;
40
+ static validation(message: string, issues: readonly unknown[]): HiveError;
41
+ static unauthorized(message?: string): HiveError;
42
+ static forbidden(message?: string): HiveError;
43
+ static notFound(message?: string): HiveError;
44
+ static conflict(message?: string, details?: Record<string, unknown>): HiveError;
45
+ static rateLimited(retryAfterSeconds?: number): HiveError;
46
+ static unavailable(message?: string): HiveError;
47
+ static internal(message?: string, cause?: unknown): HiveError;
48
+ }
49
+ /** True for any HiveError, including one created by another copy of this package. */
50
+ export declare function isHiveError(value: unknown): value is HiveError;
51
+ /** Build `{ error: { code, message, requestId?, details? } }`. */
52
+ export declare function errorEnvelope(code: string, message: string, extra?: {
53
+ readonly requestId?: string | undefined;
54
+ readonly details?: Readonly<Record<string, unknown>> | undefined;
55
+ }): ErrorEnvelope;
56
+ export interface ErrorResponse {
57
+ readonly status: number;
58
+ readonly body: ErrorEnvelope;
59
+ /** Lowercase header names, including `content-type` and `cache-control`. */
60
+ readonly headers: Readonly<Record<string, string>>;
61
+ }
62
+ export interface ToErrorResponseOptions {
63
+ readonly requestId?: string | undefined;
64
+ /**
65
+ * Called for anything that is not a HiveError, before it is replaced by a generic 500.
66
+ * Use it to log the original error; its message is never sent to the client.
67
+ */
68
+ readonly onUnexpected?: (error: unknown) => void;
69
+ }
70
+ /**
71
+ * Turn any thrown value into a status, envelope body and headers. A HiveError keeps its code,
72
+ * message and details; anything else becomes a generic `internal` 500, so internal messages
73
+ * and stack traces never reach a client.
74
+ */
75
+ export declare function toErrorResponse(error: unknown, options?: ToErrorResponseOptions): ErrorResponse;
76
+ export interface ParseHiveErrorOptions {
77
+ /** Used when the body carries no request ID, typically the response's `x-request-id`. */
78
+ readonly requestId?: string | undefined;
79
+ readonly headers?: Readonly<Record<string, string>>;
80
+ }
81
+ /**
82
+ * Normalise any suite error response into a HiveError. `body` may be the raw text, bytes or
83
+ * already-parsed JSON. Recognises every legacy shape in the suite (see `HiveErrorShape`); the
84
+ * remote code is kept as sent and the recognised shape is on `error.shape`.
85
+ *
86
+ * `status` is the HTTP status. When it is not 400-599, a Mattermost `status_code` is used,
87
+ * otherwise 502: a non-error status with an error body means the upstream misbehaved.
88
+ */
89
+ export declare function parseHiveError(status: number, body: unknown, options?: ParseHiveErrorOptions): HiveError;
90
+ /** Read a fetch `Response` and normalise it with `parseHiveError()`. Never throws on a bad body. */
91
+ export declare function parseHiveErrorResponse(response: Response): Promise<HiveError>;