@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,86 @@
1
+ export { HIVE_HEADERS } from '../contracts/index.js';
2
+ /** Anything headers can be read from: fetch `Headers`, a Node `IncomingHttpHeaders` record, or a getter. */
3
+ export type HeaderSource = {
4
+ get(name: string): string | null | undefined;
5
+ } | Readonly<Record<string, string | readonly string[] | undefined>> | ((name: string) => string | null | undefined);
6
+ /** Read one header. A repeated header (array value) is treated as absent: it is ambiguous. */
7
+ export declare function readHeader(headers: HeaderSource, name: string): string | undefined;
8
+ /** Up to 128 printable characters that are safe in logs and headers. */
9
+ export declare const REQUEST_ID_PATTERN: RegExp;
10
+ export declare function isValidRequestId(value: unknown): value is string;
11
+ export declare function generateRequestId(): string;
12
+ /**
13
+ * The inbound request ID: `x-request-id`, else `x-hive-request-id`, else `x-correlation-id`.
14
+ * An invalid value (too long, unsafe characters) is ignored rather than propagated.
15
+ */
16
+ export declare function readRequestId(headers: HeaderSource): string | undefined;
17
+ /** The inbound request ID, or a new one. */
18
+ export declare function resolveRequestId(headers: HeaderSource, generate?: () => string): string;
19
+ export interface Traceparent {
20
+ readonly version: string;
21
+ readonly traceId: string;
22
+ /** The parent-id field: the span ID of the caller. */
23
+ readonly spanId: string;
24
+ readonly flags: string;
25
+ readonly sampled: boolean;
26
+ }
27
+ /** Parse a `traceparent` header per W3C Trace Context. Returns undefined when it is invalid. */
28
+ export declare function parseTraceparent(value: string | undefined | null): Traceparent | undefined;
29
+ export declare function isValidTraceparent(value: string | undefined | null): boolean;
30
+ export declare function generateTraceId(): string;
31
+ export declare function generateSpanId(): string;
32
+ export declare function formatTraceparent(trace: Pick<Traceparent, 'traceId' | 'spanId'> & {
33
+ readonly sampled?: boolean;
34
+ readonly flags?: string;
35
+ }): string;
36
+ /** A new root trace context. */
37
+ export declare function generateTraceparent(options?: {
38
+ readonly sampled?: boolean;
39
+ }): Traceparent;
40
+ /** A child span: same trace and flags, new span ID, always version 00 on output. */
41
+ export declare function childTraceparent(parent: Traceparent): Traceparent;
42
+ /** 1-255 visible ASCII characters. */
43
+ export declare const IDEMPOTENCY_KEY_PATTERN: RegExp;
44
+ /**
45
+ * The `Idempotency-Key` header, also accepting `x-idempotency-key`. Returns undefined when
46
+ * neither is present. Throws a 400 HiveError when the value is malformed or the two headers
47
+ * disagree, because silently picking one could apply a write twice.
48
+ */
49
+ export declare function readIdempotencyKey(headers: HeaderSource): string | undefined;
50
+ /** Like `readIdempotencyKey()`, but a missing key is a 428 error. */
51
+ export declare function requireIdempotencyKey(headers: HeaderSource): string;
52
+ export interface RequestContext {
53
+ readonly requestId: string;
54
+ /** This service's span for the request: a child of the inbound trace, or a new root. */
55
+ readonly trace: Traceparent;
56
+ /** The inbound `traceparent`, when one was valid. */
57
+ readonly parentTrace: Traceparent | undefined;
58
+ /** The inbound `tracestate`, propagated unchanged when a valid traceparent came with it. */
59
+ readonly tracestate: string | undefined;
60
+ readonly startedAt: number;
61
+ /** Set by auth adapters once a caller is verified. */
62
+ principal?: unknown;
63
+ /** Extra fields that loggers add to every line for this request. */
64
+ readonly bindings: Record<string, unknown>;
65
+ }
66
+ export interface CreateRequestContextOptions {
67
+ /**
68
+ * Accept the inbound request ID and trace context. Defaults to true; set false on an edge
69
+ * service that must not let the public internet choose its IDs.
70
+ */
71
+ readonly trustInbound?: boolean;
72
+ readonly generateRequestId?: () => string;
73
+ readonly now?: () => number;
74
+ }
75
+ export declare function createRequestContext(headers: HeaderSource, options?: CreateRequestContextOptions): RequestContext;
76
+ /** Run `fn` with `context` as the current request context. */
77
+ export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
78
+ /** The current request context, or undefined outside a request. */
79
+ export declare function getRequestContext(): RequestContext | undefined;
80
+ /** The current request ID, or undefined outside a request. */
81
+ export declare function currentRequestId(): string | undefined;
82
+ /**
83
+ * Headers to send to a peer service so it joins this request: `x-request-id`, `traceparent`
84
+ * (with this service's span as the parent) and `tracestate`. Empty outside a request.
85
+ */
86
+ export declare function outboundHeaders(context?: RequestContext | undefined): Record<string, string>;
@@ -0,0 +1,189 @@
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 requestId = (trust ? readRequestId(headers) : undefined) ?? (options.generateRequestId ?? generateRequestId)();
151
+ const parentTrace = trust ? parseTraceparent(readHeader(headers, HIVE_HEADERS.traceparent)) : undefined;
152
+ const rawState = parentTrace ? readHeader(headers, HIVE_HEADERS.tracestate)?.trim() : undefined;
153
+ // eslint-disable-next-line no-control-regex
154
+ const tracestate = rawState && rawState.length <= 512 && !/[\u0000-\u001f\u007f]/.test(rawState) ? rawState : undefined;
155
+ return {
156
+ requestId,
157
+ trace: parentTrace ? childTraceparent(parentTrace) : generateTraceparent(),
158
+ parentTrace,
159
+ tracestate,
160
+ startedAt: (options.now ?? Date.now)(),
161
+ bindings: {},
162
+ };
163
+ }
164
+ const storage = new AsyncLocalStorage();
165
+ /** Run `fn` with `context` as the current request context. */
166
+ export function runWithRequestContext(context, fn) {
167
+ return storage.run(context, fn);
168
+ }
169
+ /** The current request context, or undefined outside a request. */
170
+ export function getRequestContext() {
171
+ return storage.getStore();
172
+ }
173
+ /** The current request ID, or undefined outside a request. */
174
+ export function currentRequestId() {
175
+ return storage.getStore()?.requestId;
176
+ }
177
+ /**
178
+ * Headers to send to a peer service so it joins this request: `x-request-id`, `traceparent`
179
+ * (with this service's span as the parent) and `tracestate`. Empty outside a request.
180
+ */
181
+ export function outboundHeaders(context = getRequestContext()) {
182
+ if (!context)
183
+ return {};
184
+ return {
185
+ [HIVE_HEADERS.requestId]: context.requestId,
186
+ [HIVE_HEADERS.traceparent]: formatTraceparent(context.trace),
187
+ ...(context.tracestate ? { [HIVE_HEADERS.tracestate]: context.tracestate } : {}),
188
+ };
189
+ }
@@ -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
+ }