@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,309 @@
1
+ export { isErrorEnvelope } from '../contracts/index.js';
2
+ /** Default code and message for each status this package produces or parses. */
3
+ const STATUS_DEFAULTS = {
4
+ 400: ['bad_request', 'The request is not valid'],
5
+ 401: ['unauthorized', 'Authentication is required'],
6
+ 403: ['forbidden', 'This action is not allowed'],
7
+ 404: ['not_found', 'The resource was not found'],
8
+ 405: ['method_not_allowed', 'The method is not allowed'],
9
+ 406: ['not_acceptable', 'The requested representation is not available'],
10
+ 408: ['request_timeout', 'The request took too long'],
11
+ 409: ['conflict', 'The request conflicts with the current state'],
12
+ 410: ['gone', 'The resource is no longer available'],
13
+ 412: ['precondition_failed', 'A precondition failed'],
14
+ 413: ['too_large', 'The request is too large'],
15
+ 415: ['unsupported_media_type', 'The media type is not supported'],
16
+ 422: ['invalid_request', 'The request could not be processed'],
17
+ 428: ['precondition_required', 'A precondition is required'],
18
+ 429: ['rate_limited', 'Too many requests'],
19
+ 500: ['internal', 'An internal error occurred'],
20
+ 501: ['not_implemented', 'This is not implemented'],
21
+ 502: ['bad_gateway', 'An upstream service failed'],
22
+ 503: ['unavailable', 'The service is unavailable'],
23
+ 504: ['gateway_timeout', 'An upstream service timed out'],
24
+ };
25
+ /** The default code for an HTTP status, for example `not_found` for 404. */
26
+ export function defaultCodeForStatus(status) {
27
+ return STATUS_DEFAULTS[status]?.[0] ?? (status >= 500 ? 'internal' : 'bad_request');
28
+ }
29
+ /** The default message for an HTTP status. */
30
+ export function defaultMessageForStatus(status) {
31
+ return STATUS_DEFAULTS[status]?.[1] ?? (status >= 500 ? 'An internal error occurred' : 'The request failed');
32
+ }
33
+ const HIVE_ERROR_BRAND = Symbol.for('@hivemagnus/hive-common/HiveError');
34
+ /** An error that knows its HTTP status, stable code and safe message. */
35
+ export class HiveError extends Error {
36
+ name = 'HiveError';
37
+ code;
38
+ status;
39
+ details;
40
+ requestId;
41
+ headers;
42
+ /** Set by `parseHiveError()`: the legacy body shape it recognised. */
43
+ shape;
44
+ constructor(code, message, options = {}) {
45
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
46
+ const status = options.status ?? 500;
47
+ if (!Number.isInteger(status) || status < 400 || status > 599)
48
+ throw new RangeError('HiveError status must be 400-599');
49
+ this.code = code;
50
+ this.status = status;
51
+ this.details = options.details;
52
+ this.requestId = options.requestId;
53
+ this.headers = options.headers;
54
+ this.shape = options.shape;
55
+ Object.defineProperty(this, HIVE_ERROR_BRAND, { value: true });
56
+ }
57
+ /** True when `error` is a HiveError with this code (case-insensitive), across package copies. */
58
+ static is(error, code) {
59
+ if (!isHiveError(error))
60
+ return false;
61
+ return code === undefined || error.code.toLowerCase() === code.toLowerCase();
62
+ }
63
+ toJSON() {
64
+ return errorEnvelope(this.code, this.message, {
65
+ ...(this.requestId ? { requestId: this.requestId } : {}),
66
+ ...(this.details ? { details: this.details } : {}),
67
+ });
68
+ }
69
+ static badRequest(message = defaultMessageForStatus(400), details) {
70
+ return new HiveError('bad_request', message, { status: 400, ...(details ? { details } : {}) });
71
+ }
72
+ static validation(message, issues) {
73
+ return new HiveError('validation_failed', message, { status: 422, details: { issues } });
74
+ }
75
+ static unauthorized(message = defaultMessageForStatus(401)) {
76
+ return new HiveError('unauthorized', message, { status: 401, headers: { 'www-authenticate': 'Bearer' } });
77
+ }
78
+ static forbidden(message = defaultMessageForStatus(403)) {
79
+ return new HiveError('forbidden', message, { status: 403 });
80
+ }
81
+ static notFound(message = defaultMessageForStatus(404)) {
82
+ return new HiveError('not_found', message, { status: 404 });
83
+ }
84
+ static conflict(message = defaultMessageForStatus(409), details) {
85
+ return new HiveError('conflict', message, { status: 409, ...(details ? { details } : {}) });
86
+ }
87
+ static rateLimited(retryAfterSeconds) {
88
+ return new HiveError('rate_limited', defaultMessageForStatus(429), {
89
+ status: 429,
90
+ ...(retryAfterSeconds !== undefined ? { headers: { 'retry-after': String(Math.max(0, Math.ceil(retryAfterSeconds))) } } : {}),
91
+ });
92
+ }
93
+ static unavailable(message = defaultMessageForStatus(503)) {
94
+ return new HiveError('unavailable', message, { status: 503 });
95
+ }
96
+ static internal(message = defaultMessageForStatus(500), cause) {
97
+ return new HiveError('internal', message, { status: 500, ...(cause !== undefined ? { cause } : {}) });
98
+ }
99
+ }
100
+ /** True for any HiveError, including one created by another copy of this package. */
101
+ export function isHiveError(value) {
102
+ return value instanceof HiveError
103
+ || (value instanceof Error && value[HIVE_ERROR_BRAND] === true);
104
+ }
105
+ /** Build `{ error: { code, message, requestId?, details? } }`. */
106
+ export function errorEnvelope(code, message, extra = {}) {
107
+ const error = { code, message };
108
+ if (extra.requestId)
109
+ error.requestId = extra.requestId;
110
+ if (extra.details && Object.keys(extra.details).length)
111
+ error.details = extra.details;
112
+ return { error };
113
+ }
114
+ /**
115
+ * Turn any thrown value into a status, envelope body and headers. A HiveError keeps its code,
116
+ * message and details; anything else becomes a generic `internal` 500, so internal messages
117
+ * and stack traces never reach a client.
118
+ */
119
+ export function toErrorResponse(error, options = {}) {
120
+ let hive;
121
+ if (isHiveError(error))
122
+ hive = error;
123
+ else {
124
+ options.onUnexpected?.(error);
125
+ hive = HiveError.internal();
126
+ }
127
+ const requestId = options.requestId ?? hive.requestId;
128
+ return {
129
+ status: hive.status,
130
+ body: errorEnvelope(hive.code, hive.message, { requestId, details: hive.details }),
131
+ headers: {
132
+ ...lowercaseKeys(hive.headers),
133
+ 'content-type': 'application/json; charset=utf-8',
134
+ 'cache-control': 'no-store',
135
+ ...(requestId ? { 'x-request-id': requestId } : {}),
136
+ },
137
+ };
138
+ }
139
+ function lowercaseKeys(headers) {
140
+ const result = {};
141
+ for (const [key, value] of Object.entries(headers ?? {}))
142
+ result[key.toLowerCase()] = value;
143
+ return result;
144
+ }
145
+ // ---------------------------------------------------------------------------------------
146
+ // Parsing
147
+ const MAX_MESSAGE = 1000;
148
+ const MAX_CODE = 128;
149
+ function isRecord(value) {
150
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
151
+ }
152
+ /** Collapse whitespace, drop control characters and bound the length of remote text. */
153
+ function cleanText(value, max = MAX_MESSAGE) {
154
+ // eslint-disable-next-line no-control-regex
155
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
156
+ return cleaned.length > max ? `${cleaned.slice(0, max - 1)}…` : cleaned;
157
+ }
158
+ /** A remote code is kept as sent (products compare against their own codes), but bounded and safe. */
159
+ function cleanCode(value) {
160
+ const cleaned = value.trim().replace(/[^A-Za-z0-9_.:-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, MAX_CODE);
161
+ return cleaned || undefined;
162
+ }
163
+ /** A single token with no spaces is a code (`UNAUTHORIZED`, `invalid_grant`); prose is a message. */
164
+ function looksLikeCode(value) {
165
+ return /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(value.trim());
166
+ }
167
+ function nonEmptyString(value) {
168
+ return typeof value === 'string' && value.trim().length > 0;
169
+ }
170
+ function validStatus(value) {
171
+ return typeof value === 'number' && Number.isInteger(value) && value >= 400 && value <= 599;
172
+ }
173
+ function stringField(record, ...keys) {
174
+ for (const key of keys)
175
+ if (nonEmptyString(record[key]))
176
+ return record[key];
177
+ return undefined;
178
+ }
179
+ function fastApiIssues(detail) {
180
+ const issues = detail.slice(0, 50).map(item => {
181
+ if (!isRecord(item))
182
+ return { path: '$', message: typeof item === 'string' ? cleanText(item, 300) : 'Invalid value' };
183
+ const loc = Array.isArray(item['loc']) ? item['loc'].filter(part => typeof part === 'string' || typeof part === 'number') : [];
184
+ const path = loc.length ? loc.join('.') : '$';
185
+ const message = nonEmptyString(item['msg']) ? cleanText(item['msg'], 300) : 'Invalid value';
186
+ return { path: cleanText(path, 200), message, ...(nonEmptyString(item['type']) ? { type: cleanText(item['type'], 100) } : {}) };
187
+ });
188
+ const first = issues[0];
189
+ const message = first ? `${first.path}: ${first.message}${issues.length > 1 ? ` (and ${issues.length - 1} more)` : ''}` : 'The request is not valid';
190
+ return { message: cleanText(message), issues };
191
+ }
192
+ function parseObject(body) {
193
+ const error = body['error'];
194
+ // {error:{code,message,...}} and Magnus {success:false,error:{...}}
195
+ if (isRecord(error)) {
196
+ const details = isRecord(error['details']) ? error['details'] : undefined;
197
+ return {
198
+ shape: body['success'] === false ? 'magnus' : 'envelope',
199
+ code: stringField(error, 'code', 'type'),
200
+ message: stringField(error, 'message', 'detail'),
201
+ requestId: stringField(error, 'requestId', 'request_id') ?? stringField(body, 'requestId', 'request_id'),
202
+ details: details ?? (error['details'] !== undefined ? { value: error['details'] } : undefined),
203
+ };
204
+ }
205
+ if (nonEmptyString(error)) {
206
+ // OAuth 2.0: {error, error_description?, error_uri?}
207
+ if (nonEmptyString(body['error_description'])) {
208
+ return { shape: 'oauth', code: error, message: body['error_description'] };
209
+ }
210
+ if (looksLikeCode(error)) {
211
+ // Oathweave {error:CODE, message}; Embermend {error:CODE}; bare OAuth {error:"invalid_grant"}
212
+ if (nonEmptyString(body['message']))
213
+ return { shape: 'oathweave', code: error, message: body['message'], requestId: stringField(body, 'requestId') };
214
+ return { shape: 'code', code: error };
215
+ }
216
+ // Emberpath, HiveSkills, Cindertrace: {error:"human text"}
217
+ return { shape: 'string', message: error, requestId: stringField(body, 'requestId', 'request_id') };
218
+ }
219
+ // HiveMem / FastAPI
220
+ const detail = body['detail'];
221
+ if (nonEmptyString(detail))
222
+ return { shape: 'fastapi', message: detail };
223
+ if (Array.isArray(detail)) {
224
+ const { message, issues } = fastApiIssues(detail);
225
+ return { shape: 'fastapi-validation', code: 'validation_failed', message, details: { issues } };
226
+ }
227
+ if (isRecord(detail)) {
228
+ return { shape: 'fastapi', code: stringField(detail, 'code', 'error'), message: stringField(detail, 'message', 'msg', 'detail') };
229
+ }
230
+ // Mattermost: {id, message, detailed_error, request_id, status_code}
231
+ if (nonEmptyString(body['id']) && nonEmptyString(body['message']) && ('status_code' in body || 'request_id' in body)) {
232
+ return {
233
+ shape: 'mattermost',
234
+ code: body['id'],
235
+ message: body['message'],
236
+ requestId: stringField(body, 'request_id'),
237
+ status: validStatus(body['status_code']) ? body['status_code'] : undefined,
238
+ };
239
+ }
240
+ if (nonEmptyString(body['message'])) {
241
+ return { shape: 'message', code: stringField(body, 'code'), message: body['message'], requestId: stringField(body, 'requestId', 'request_id') };
242
+ }
243
+ return { shape: 'unknown', code: stringField(body, 'code') };
244
+ }
245
+ function looksLikeHtml(value) {
246
+ return /^\s*<(?:!doctype|html|head|body|\?xml)\b/i.test(value);
247
+ }
248
+ function parseBody(body) {
249
+ if (body === undefined || body === null)
250
+ return { shape: 'empty' };
251
+ if (typeof body === 'string' || body instanceof Uint8Array || body instanceof ArrayBuffer) {
252
+ const textBody = typeof body === 'string' ? body : new TextDecoder().decode(body);
253
+ if (!textBody.trim())
254
+ return { shape: 'empty' };
255
+ try {
256
+ const json = JSON.parse(textBody);
257
+ if (isRecord(json))
258
+ return parseObject(json);
259
+ if (typeof json === 'string' && json.trim())
260
+ return { shape: 'text', message: json };
261
+ return { shape: 'unknown' };
262
+ }
263
+ catch {
264
+ // HTML error pages from proxies carry nothing useful and may carry internals.
265
+ return looksLikeHtml(textBody) ? { shape: 'text' } : { shape: 'text', message: textBody };
266
+ }
267
+ }
268
+ if (isRecord(body))
269
+ return parseObject(body);
270
+ return { shape: 'unknown' };
271
+ }
272
+ /**
273
+ * Normalise any suite error response into a HiveError. `body` may be the raw text, bytes or
274
+ * already-parsed JSON. Recognises every legacy shape in the suite (see `HiveErrorShape`); the
275
+ * remote code is kept as sent and the recognised shape is on `error.shape`.
276
+ *
277
+ * `status` is the HTTP status. When it is not 400-599, a Mattermost `status_code` is used,
278
+ * otherwise 502: a non-error status with an error body means the upstream misbehaved.
279
+ */
280
+ export function parseHiveError(status, body, options = {}) {
281
+ const parsed = parseBody(body);
282
+ const effectiveStatus = validStatus(status) ? status : parsed.status ?? 502;
283
+ const code = (parsed.code !== undefined ? cleanCode(parsed.code) : undefined) ?? defaultCodeForStatus(effectiveStatus);
284
+ const message = parsed.message !== undefined && cleanText(parsed.message) ? cleanText(parsed.message) : defaultMessageForStatus(effectiveStatus);
285
+ const requestId = parsed.requestId !== undefined ? cleanText(parsed.requestId, 128) : options.requestId;
286
+ return new HiveError(code, message, {
287
+ status: effectiveStatus,
288
+ shape: parsed.shape,
289
+ ...(parsed.details ? { details: parsed.details } : {}),
290
+ ...(requestId ? { requestId } : {}),
291
+ ...(options.headers ? { headers: options.headers } : {}),
292
+ });
293
+ }
294
+ /** Read a fetch `Response` and normalise it with `parseHiveError()`. Never throws on a bad body. */
295
+ export async function parseHiveErrorResponse(response) {
296
+ let body = '';
297
+ try {
298
+ body = await response.text();
299
+ }
300
+ catch {
301
+ body = '';
302
+ }
303
+ const requestId = response.headers.get('x-request-id') ?? undefined;
304
+ const retryAfter = response.headers.get('retry-after');
305
+ return parseHiveError(response.status, body, {
306
+ ...(requestId ? { requestId } : {}),
307
+ ...(retryAfter ? { headers: { 'retry-after': retryAfter } } : {}),
308
+ });
309
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * HiveEvent feeds and the ChatterMost notifier. The HiveEvent schema and validator live in
3
+ * `/contracts` (isomorphic); this module re-exports them.
4
+ */
5
+ import { type HiveEvent, type Page } from '../contracts/index.js';
6
+ export { HIVE_EVENT_LIMITS, HIVE_EVENT_SEVERITIES, HIVE_EVENT_VERSION, isHiveEvent, validateHiveEvent, validateHiveEventPage, type HiveEvent, type HiveEventSeverity, type Page, } from '../contracts/index.js';
7
+ export declare const HIVE_EVENTS_PATH = "/v1/hive/events";
8
+ export interface FeedQuery {
9
+ /** Opaque cursor from a previous page's `nextCursor`; undefined for the first page. */
10
+ readonly after: string | undefined;
11
+ readonly limit: number;
12
+ }
13
+ export interface FeedQueryOptions {
14
+ readonly defaultLimit?: number;
15
+ readonly maxLimit?: number;
16
+ }
17
+ /** Read `after` and `limit` from a query. Throws a 400 HiveError on malformed values. */
18
+ export declare function parseFeedQuery(query: URLSearchParams | Readonly<Record<string, string | readonly string[] | undefined>>, options?: FeedQueryOptions): FeedQuery;
19
+ /**
20
+ * Build a feed page. Pass up to `limit + 1` items read after the cursor, in feed order; the
21
+ * extra item only tells whether there is a next page and is not returned.
22
+ */
23
+ export declare function feedPage<T>(items: readonly T[], limit: number, cursorOf: (item: T) => string): Page<T>;
24
+ /** Encode a cursor position as an opaque base64url string. Isomorphic. */
25
+ export declare function encodeCursor(position: Readonly<Record<string, string | number>>): string;
26
+ /** Decode a cursor from `encodeCursor()`. Throws a 400 HiveError when it was tampered with. */
27
+ export declare function decodeCursor(cursor: string): Record<string, string | number>;
28
+ /** Remembers which event IDs were delivered. Implement it over Redis or SQL to share across replicas. */
29
+ export interface DedupeStore {
30
+ /** Atomically claim `key`. Resolve true if it was not claimed (and is now), false if it already was. */
31
+ claim(key: string, ttlMs: number): boolean | Promise<boolean>;
32
+ /** Release a claim after a failed delivery so the event can be sent again. */
33
+ release(key: string): void | Promise<void>;
34
+ }
35
+ /** An in-memory DedupeStore with TTL and a size bound. Per process only. */
36
+ export declare class MemoryDedupeStore implements DedupeStore {
37
+ #private;
38
+ constructor(options?: {
39
+ readonly maxEntries?: number;
40
+ readonly now?: () => number;
41
+ });
42
+ claim(key: string, ttlMs: number): boolean;
43
+ release(key: string): void;
44
+ get size(): number;
45
+ }
46
+ export interface MattermostWebhookPayload {
47
+ readonly text: string;
48
+ readonly username?: string;
49
+ readonly icon_url?: string;
50
+ readonly channel?: string;
51
+ readonly props?: Readonly<Record<string, unknown>>;
52
+ }
53
+ export interface ChatterMostNotifierOptions {
54
+ /** The incoming-webhook URL. It is a secret: it is never logged or included in errors. */
55
+ readonly webhookUrl: string;
56
+ /** Absolute console origin used to turn `link.path` into a clickable URL, e.g. `https://hive.example.com`. */
57
+ readonly linkBaseUrl?: string;
58
+ readonly username?: string;
59
+ readonly iconUrl?: string;
60
+ readonly channel?: string;
61
+ readonly fetch?: typeof fetch;
62
+ /** Per-attempt timeout. 5000 ms. */
63
+ readonly timeoutMs?: number;
64
+ /** Retries after the first attempt. 3. */
65
+ readonly retries?: number;
66
+ /** First backoff delay; doubles per retry with jitter. 500 ms. */
67
+ readonly backoffMs?: number;
68
+ readonly maxBackoffMs?: number;
69
+ readonly dedupe?: DedupeStore;
70
+ /** How long a delivered event ID is remembered. 24 hours. */
71
+ readonly dedupeTtlMs?: number;
72
+ /** Replace the default message formatting. */
73
+ readonly format?: (event: HiveEvent) => MattermostWebhookPayload;
74
+ /** Allow `http:` webhook URLs on loopback, for tests. */
75
+ readonly allowInsecureLoopback?: boolean;
76
+ readonly sleep?: (ms: number) => Promise<void>;
77
+ readonly random?: () => number;
78
+ }
79
+ export type NotifyResult = {
80
+ readonly status: 'sent';
81
+ readonly attempts: number;
82
+ } | {
83
+ readonly status: 'duplicate';
84
+ readonly attempts: 0;
85
+ } | {
86
+ readonly status: 'failed';
87
+ readonly attempts: number;
88
+ readonly error: string;
89
+ readonly httpStatus?: number;
90
+ };
91
+ /** The default message: severity icon, product and title, summary, and a link. */
92
+ export declare function formatChatterMostMessage(event: HiveEvent, options?: {
93
+ readonly linkBaseUrl?: string;
94
+ }): string;
95
+ /**
96
+ * Posts HiveEvents to a Mattermost-compatible incoming webhook (ChatterMost). One-way
97
+ * announcements only. Each event ID is delivered at most once per dedupe window; failures
98
+ * release the claim so a later call can retry.
99
+ */
100
+ export declare class ChatterMostNotifier {
101
+ #private;
102
+ constructor(options: ChatterMostNotifierOptions);
103
+ /** The webhook body for an event. */
104
+ payload(event: HiveEvent): MattermostWebhookPayload;
105
+ /** Validate and deliver one event. Never throws for delivery failures; it reports them. */
106
+ notify(input: HiveEvent): Promise<NotifyResult>;
107
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * HiveEvent feeds and the ChatterMost notifier. The HiveEvent schema and validator live in
3
+ * `/contracts` (isomorphic); this module re-exports them.
4
+ */
5
+ import { validateHiveEvent, } from '../contracts/index.js';
6
+ import { HiveError } from '../errors/index.js';
7
+ import { endpointProblem } from '../oathweave/endpoint.js';
8
+ import { redactText } from '../log/redact.js';
9
+ export { HIVE_EVENT_LIMITS, HIVE_EVENT_SEVERITIES, HIVE_EVENT_VERSION, isHiveEvent, validateHiveEvent, validateHiveEventPage, } from '../contracts/index.js';
10
+ // ---------------------------------------------------------------------------------------
11
+ // Feed: GET /v1/hive/events?after=<cursor>&limit=<n>
12
+ export const HIVE_EVENTS_PATH = '/v1/hive/events';
13
+ /** Read `after` and `limit` from a query. Throws a 400 HiveError on malformed values. */
14
+ export function parseFeedQuery(query, options = {}) {
15
+ const read = (name) => {
16
+ if (query instanceof URLSearchParams) {
17
+ const values = query.getAll(name);
18
+ if (values.length > 1)
19
+ throw new HiveError('invalid_query', `${name} must be given once`, { status: 400 });
20
+ return values[0];
21
+ }
22
+ const value = query[name];
23
+ if (Array.isArray(value)) {
24
+ if (value.length > 1)
25
+ throw new HiveError('invalid_query', `${name} must be given once`, { status: 400 });
26
+ return value[0];
27
+ }
28
+ return value;
29
+ };
30
+ const defaultLimit = options.defaultLimit ?? 50;
31
+ const maxLimit = options.maxLimit ?? 200;
32
+ const rawLimit = read('limit');
33
+ let limit = defaultLimit;
34
+ if (rawLimit !== undefined && rawLimit !== '') {
35
+ if (!/^\d{1,6}$/.test(rawLimit) || Number(rawLimit) < 1) {
36
+ throw new HiveError('invalid_query', `limit must be an integer from 1 to ${maxLimit}`, { status: 400 });
37
+ }
38
+ limit = Math.min(Number(rawLimit), maxLimit);
39
+ }
40
+ const after = read('after');
41
+ if (after !== undefined && after !== '' && !/^[A-Za-z0-9._~:-]{1,512}$/.test(after)) {
42
+ throw new HiveError('invalid_query', 'after must be a cursor from a previous page', { status: 400 });
43
+ }
44
+ return { after: after || undefined, limit };
45
+ }
46
+ /**
47
+ * Build a feed page. Pass up to `limit + 1` items read after the cursor, in feed order; the
48
+ * extra item only tells whether there is a next page and is not returned.
49
+ */
50
+ export function feedPage(items, limit, cursorOf) {
51
+ if (!Number.isInteger(limit) || limit < 1)
52
+ throw new RangeError('limit must be a positive integer');
53
+ const data = items.slice(0, limit);
54
+ const last = data.at(-1);
55
+ return { data, nextCursor: items.length > limit && last !== undefined ? cursorOf(last) : null };
56
+ }
57
+ /** Encode a cursor position as an opaque base64url string. Isomorphic. */
58
+ export function encodeCursor(position) {
59
+ const bytes = new TextEncoder().encode(JSON.stringify(position));
60
+ let binary = '';
61
+ bytes.forEach(byte => { binary += String.fromCharCode(byte); });
62
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
63
+ }
64
+ /** Decode a cursor from `encodeCursor()`. Throws a 400 HiveError when it was tampered with. */
65
+ export function decodeCursor(cursor) {
66
+ try {
67
+ if (!/^[A-Za-z0-9_-]+$/.test(cursor))
68
+ throw new Error('not base64url');
69
+ const binary = atob(cursor.replace(/-/g, '+').replace(/_/g, '/'));
70
+ const value = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, char => char.charCodeAt(0))));
71
+ if (!value || typeof value !== 'object' || Array.isArray(value)
72
+ || !Object.values(value).every(item => typeof item === 'string' || typeof item === 'number'))
73
+ throw new Error('not a position');
74
+ return value;
75
+ }
76
+ catch {
77
+ throw new HiveError('invalid_cursor', 'The cursor is not valid', { status: 400 });
78
+ }
79
+ }
80
+ /** An in-memory DedupeStore with TTL and a size bound. Per process only. */
81
+ export class MemoryDedupeStore {
82
+ #entries = new Map();
83
+ #max;
84
+ #now;
85
+ constructor(options = {}) {
86
+ this.#max = options.maxEntries ?? 10_000;
87
+ this.#now = options.now ?? Date.now;
88
+ }
89
+ claim(key, ttlMs) {
90
+ const now = this.#now();
91
+ const expiresAt = this.#entries.get(key);
92
+ if (expiresAt !== undefined && expiresAt > now)
93
+ return false;
94
+ this.#entries.delete(key);
95
+ if (this.#entries.size >= this.#max) {
96
+ for (const [entry, expiry] of this.#entries)
97
+ if (expiry <= now)
98
+ this.#entries.delete(entry);
99
+ while (this.#entries.size >= this.#max)
100
+ this.#entries.delete(this.#entries.keys().next().value);
101
+ }
102
+ this.#entries.set(key, now + ttlMs);
103
+ return true;
104
+ }
105
+ release(key) {
106
+ this.#entries.delete(key);
107
+ }
108
+ get size() {
109
+ return this.#entries.size;
110
+ }
111
+ }
112
+ const SEVERITY_ICON = {
113
+ info: ':information_source:',
114
+ warning: ':warning:',
115
+ critical: ':rotating_light:',
116
+ };
117
+ /** Neutralise Markdown and mentions in event text so a product cannot ping a channel or forge links. */
118
+ function escapeMarkdown(value) {
119
+ return value
120
+ .replace(/[\\`*_~[\]()#>|]/g, char => `\\${char}`)
121
+ .replace(/@(?=[A-Za-z0-9])/g, '@​')
122
+ .replace(/<!/g, '<​!');
123
+ }
124
+ /** The default message: severity icon, product and title, summary, and a link. */
125
+ export function formatChatterMostMessage(event, options = {}) {
126
+ const title = `${SEVERITY_ICON[event.severity]} **${escapeMarkdown(event.product)}: ${escapeMarkdown(event.title)}**`;
127
+ const summary = event.summary ? `\n${escapeMarkdown(event.summary)}` : '';
128
+ let link = '';
129
+ if (options.linkBaseUrl) {
130
+ const url = new URL(event.link.path, options.linkBaseUrl);
131
+ link = `\n[Open in ${escapeMarkdown(event.link.product)}](${url.href.replace(/[()]/g, encodeURIComponent)})`;
132
+ }
133
+ else
134
+ link = `\n${escapeMarkdown(event.link.product)}: \`${event.link.path.replace(/`/g, '')}\``;
135
+ return `${title}${summary}${link}`.slice(0, 16_000);
136
+ }
137
+ function retryAfterMs(response) {
138
+ const value = response.headers.get('retry-after');
139
+ if (!value)
140
+ return undefined;
141
+ if (/^\d+$/.test(value))
142
+ return Number(value) * 1000;
143
+ const date = Date.parse(value);
144
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
145
+ }
146
+ /**
147
+ * Posts HiveEvents to a Mattermost-compatible incoming webhook (ChatterMost). One-way
148
+ * announcements only. Each event ID is delivered at most once per dedupe window; failures
149
+ * release the claim so a later call can retry.
150
+ */
151
+ export class ChatterMostNotifier {
152
+ #options;
153
+ #fetch;
154
+ #dedupe;
155
+ #sleep;
156
+ #random;
157
+ constructor(options) {
158
+ const problem = endpointProblem(options.webhookUrl, { allowInsecureLoopback: options.allowInsecureLoopback ?? false });
159
+ // The URL is a secret, so the error describes the problem without echoing it.
160
+ if (problem)
161
+ throw new TypeError(`ChatterMost webhookUrl ${problem}`);
162
+ if (options.linkBaseUrl !== undefined && endpointProblem(options.linkBaseUrl, { allowInsecureLoopback: true })) {
163
+ throw new TypeError('ChatterMost linkBaseUrl must be an https origin');
164
+ }
165
+ this.#options = options;
166
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
167
+ this.#dedupe = options.dedupe ?? new MemoryDedupeStore();
168
+ this.#sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
169
+ this.#random = options.random ?? Math.random;
170
+ }
171
+ /** The webhook body for an event. */
172
+ payload(event) {
173
+ if (this.#options.format)
174
+ return this.#options.format(event);
175
+ return {
176
+ text: formatChatterMostMessage(event, this.#options.linkBaseUrl ? { linkBaseUrl: this.#options.linkBaseUrl } : {}),
177
+ ...(this.#options.username ? { username: this.#options.username } : {}),
178
+ ...(this.#options.iconUrl ? { icon_url: this.#options.iconUrl } : {}),
179
+ ...(this.#options.channel ? { channel: this.#options.channel } : {}),
180
+ props: { hive_event: { id: event.id, product: event.product, type: event.type, severity: event.severity } },
181
+ };
182
+ }
183
+ /** Validate and deliver one event. Never throws for delivery failures; it reports them. */
184
+ async notify(input) {
185
+ const validation = validateHiveEvent(input);
186
+ if (!validation.ok)
187
+ throw HiveError.validation('The event is not a valid HiveEvent', validation.issues);
188
+ const event = validation.value;
189
+ const key = `hive-event:${event.product}:${event.id}`;
190
+ if (!await this.#dedupe.claim(key, this.#options.dedupeTtlMs ?? 24 * 60 * 60_000))
191
+ return { status: 'duplicate', attempts: 0 };
192
+ const body = JSON.stringify(this.payload(event));
193
+ const retries = this.#options.retries ?? 3;
194
+ const base = this.#options.backoffMs ?? 500;
195
+ const max = this.#options.maxBackoffMs ?? 8_000;
196
+ let lastError = 'failed';
197
+ let lastStatus;
198
+ let attempts = 0;
199
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
200
+ attempts = attempt;
201
+ let wait;
202
+ try {
203
+ const response = await this.#fetch(this.#options.webhookUrl, {
204
+ method: 'POST',
205
+ headers: { 'content-type': 'application/json' },
206
+ body,
207
+ redirect: 'error',
208
+ signal: AbortSignal.timeout(this.#options.timeoutMs ?? 5_000),
209
+ });
210
+ void response.body?.cancel().catch(() => undefined);
211
+ if (response.ok)
212
+ return { status: 'sent', attempts: attempt };
213
+ lastStatus = response.status;
214
+ lastError = `HTTP ${response.status}`;
215
+ // Other 4xx mean the request itself is wrong; repeating it cannot help.
216
+ if (response.status !== 408 && response.status !== 429 && response.status < 500)
217
+ break;
218
+ wait = retryAfterMs(response);
219
+ }
220
+ catch (error) {
221
+ lastStatus = undefined;
222
+ lastError = error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'network error';
223
+ }
224
+ if (attempt <= retries) {
225
+ const backoff = Math.min(max, base * 2 ** (attempt - 1));
226
+ await this.#sleep(Math.min(max, wait ?? Math.round(backoff * (0.5 + this.#random() / 2))));
227
+ }
228
+ }
229
+ await this.#dedupe.release(key);
230
+ return { status: 'failed', attempts, error: redactText(lastError), ...(lastStatus !== undefined ? { httpStatus: lastStatus } : {}) };
231
+ }
232
+ }