@hasna/events 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +198 -13
- package/README.md +274 -26
- package/dist/app-event.js +382 -0
- package/dist/catalog.js +191 -0
- package/dist/cli/index.js +1680 -107
- package/dist/commander.js +882 -92
- package/dist/durable-spool.js +184 -0
- package/dist/durable-worker.js +378 -0
- package/dist/durable.js +2232 -0
- package/dist/index.js +868 -71
- package/dist/storage.js +140 -4
- package/dist/transports.js +36 -7
- package/fixtures/hasna.app_event.v1.json +108 -0
- package/hasna.contract.json +70 -0
- package/package.json +46 -12
- package/schemas/hasna.app_event.v1.json +186 -0
- package/types/app-event.d.ts +130 -0
- package/types/catalog.d.ts +136 -0
- package/{dist → types}/commander.d.ts +14 -0
- package/types/durable-spool.d.ts +30 -0
- package/types/durable-worker.d.ts +27 -0
- package/types/durable.d.ts +112 -0
- package/{dist → types}/index.d.ts +23 -8
- package/types/redaction.d.ts +4 -0
- package/{dist → types}/storage.d.ts +16 -3
- package/{dist → types}/transports.d.ts +8 -1
- package/{dist → types}/types.d.ts +64 -2
- /package/{dist → types}/cli/index.d.ts +0 -0
- /package/{dist → types}/filter-options.d.ts +0 -0
- /package/{dist → types}/filter.d.ts +0 -0
- /package/{dist → types}/signing.d.ts +0 -0
package/dist/index.js
CHANGED
|
@@ -98,11 +98,15 @@ function channelMatchesEvent(channel, event) {
|
|
|
98
98
|
|
|
99
99
|
// src/storage.ts
|
|
100
100
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
101
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
101
102
|
import { existsSync } from "fs";
|
|
102
103
|
import { homedir } from "os";
|
|
103
104
|
import { join } from "path";
|
|
104
105
|
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
105
106
|
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
107
|
+
var LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:";
|
|
108
|
+
var DEFAULT_EVENT_PAGE_LIMIT = 100;
|
|
109
|
+
var MAX_EVENT_PAGE_LIMIT = 1000;
|
|
106
110
|
function getEventsDataDir(override) {
|
|
107
111
|
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
108
112
|
}
|
|
@@ -116,11 +120,13 @@ function getActiveEventsDirEnv() {
|
|
|
116
120
|
|
|
117
121
|
class JsonEventsStore {
|
|
118
122
|
dataDir;
|
|
123
|
+
runtime;
|
|
119
124
|
channelsPath;
|
|
120
125
|
eventsPath;
|
|
121
126
|
deliveriesPath;
|
|
122
127
|
constructor(dataDir = getEventsDataDir()) {
|
|
123
128
|
this.dataDir = dataDir;
|
|
129
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
124
130
|
this.channelsPath = join(dataDir, "channels.json");
|
|
125
131
|
this.eventsPath = join(dataDir, "events.json");
|
|
126
132
|
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
@@ -168,13 +174,58 @@ class JsonEventsStore {
|
|
|
168
174
|
await this.writeJson(this.eventsPath, events);
|
|
169
175
|
return event;
|
|
170
176
|
}
|
|
171
|
-
async
|
|
177
|
+
async appendEventOnce(event, options = {}) {
|
|
172
178
|
await this.init();
|
|
173
|
-
|
|
179
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
180
|
+
const dedupe = options.dedupe !== false;
|
|
181
|
+
if (dedupe) {
|
|
182
|
+
const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
|
|
183
|
+
if (existing) {
|
|
184
|
+
return {
|
|
185
|
+
event: existing,
|
|
186
|
+
stored: false,
|
|
187
|
+
deduped: true,
|
|
188
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
events.push(event);
|
|
193
|
+
await this.writeJson(this.eventsPath, events);
|
|
194
|
+
return {
|
|
195
|
+
event,
|
|
196
|
+
stored: true,
|
|
197
|
+
deduped: false,
|
|
198
|
+
identity: { id: event.id, dedupeKey: event.dedupeKey }
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async listEvents(options = {}) {
|
|
202
|
+
await this.init();
|
|
203
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
204
|
+
return queryEvents(events, options);
|
|
205
|
+
}
|
|
206
|
+
async listEventsPage(options = {}) {
|
|
207
|
+
await this.init();
|
|
208
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
209
|
+
const queried = queryEvents(events, {
|
|
210
|
+
eventId: options.eventId,
|
|
211
|
+
source: options.source,
|
|
212
|
+
type: options.type
|
|
213
|
+
});
|
|
214
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
215
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
216
|
+
const pageEvents = queried.slice(offset, offset + limit);
|
|
217
|
+
const nextOffset = offset + pageEvents.length;
|
|
218
|
+
const hasMore = nextOffset < queried.length;
|
|
219
|
+
return {
|
|
220
|
+
events: pageEvents,
|
|
221
|
+
cursor: options.cursor,
|
|
222
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
223
|
+
hasMore
|
|
224
|
+
};
|
|
174
225
|
}
|
|
175
226
|
async findEventByIdentity(identity) {
|
|
176
227
|
const events = await this.listEvents();
|
|
177
|
-
return events
|
|
228
|
+
return findEventByIdentity(events, identity);
|
|
178
229
|
}
|
|
179
230
|
async appendDelivery(result) {
|
|
180
231
|
await this.init();
|
|
@@ -225,6 +276,83 @@ class JsonEventsStore {
|
|
|
225
276
|
});
|
|
226
277
|
}
|
|
227
278
|
}
|
|
279
|
+
function localJsonRuntime(dataDir = getEventsDataDir()) {
|
|
280
|
+
return {
|
|
281
|
+
mode: "local-files",
|
|
282
|
+
name: "json-events-store",
|
|
283
|
+
remote: false,
|
|
284
|
+
localFiles: true,
|
|
285
|
+
localSqlite: false,
|
|
286
|
+
postgres: false,
|
|
287
|
+
s3: false,
|
|
288
|
+
aws: false,
|
|
289
|
+
durable: true,
|
|
290
|
+
idempotency: "best-effort-local",
|
|
291
|
+
replayCursors: true,
|
|
292
|
+
description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
296
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
297
|
+
throw new Error(`Invalid event cursor offset: ${offset}`);
|
|
298
|
+
const payload = {
|
|
299
|
+
offset,
|
|
300
|
+
eventId: options.eventId,
|
|
301
|
+
source: options.source,
|
|
302
|
+
type: options.type
|
|
303
|
+
};
|
|
304
|
+
return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
|
|
305
|
+
}
|
|
306
|
+
function decodeLocalJsonEventCursor(cursor, options = {}) {
|
|
307
|
+
if (!cursor)
|
|
308
|
+
return 0;
|
|
309
|
+
if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
|
|
310
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
311
|
+
const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
|
|
312
|
+
let payload;
|
|
313
|
+
try {
|
|
314
|
+
payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
|
|
315
|
+
} catch {
|
|
316
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
317
|
+
}
|
|
318
|
+
const offset = payload.offset;
|
|
319
|
+
if (!Number.isInteger(offset) || offset < 0)
|
|
320
|
+
throw new Error(`Invalid local JSON event cursor: ${cursor}`);
|
|
321
|
+
assertCursorFilter("eventId", payload.eventId, options.eventId);
|
|
322
|
+
assertCursorFilter("source", payload.source, options.source);
|
|
323
|
+
assertCursorFilter("type", payload.type, options.type);
|
|
324
|
+
return offset;
|
|
325
|
+
}
|
|
326
|
+
function normalizeEventPageLimit(limit) {
|
|
327
|
+
if (limit === undefined)
|
|
328
|
+
return DEFAULT_EVENT_PAGE_LIMIT;
|
|
329
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
330
|
+
throw new Error(`Event page limit must be a positive integer, got ${limit}`);
|
|
331
|
+
return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
|
|
332
|
+
}
|
|
333
|
+
function queryEvents(events, options) {
|
|
334
|
+
let rows = events;
|
|
335
|
+
if (options.eventId)
|
|
336
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
337
|
+
if (options.source)
|
|
338
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
339
|
+
if (options.type)
|
|
340
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
341
|
+
if (options.cursor) {
|
|
342
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
343
|
+
rows = rows.slice(offset);
|
|
344
|
+
}
|
|
345
|
+
if (options.limit !== undefined)
|
|
346
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
347
|
+
return rows;
|
|
348
|
+
}
|
|
349
|
+
function assertCursorFilter(name, cursorValue, optionValue) {
|
|
350
|
+
if (cursorValue !== optionValue)
|
|
351
|
+
throw new Error(`Local JSON event cursor ${name} filter mismatch`);
|
|
352
|
+
}
|
|
353
|
+
function findEventByIdentity(events, identity) {
|
|
354
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
355
|
+
}
|
|
228
356
|
async function getEventsStatus(dataDir) {
|
|
229
357
|
const store = new JsonEventsStore(dataDir);
|
|
230
358
|
await store.init();
|
|
@@ -241,6 +369,7 @@ async function getEventsStatus(dataDir) {
|
|
|
241
369
|
service: "events",
|
|
242
370
|
schemaVersion: "1.0",
|
|
243
371
|
dataDir: store.dataDir,
|
|
372
|
+
storage: store.runtime,
|
|
244
373
|
env: {
|
|
245
374
|
primary: HASNA_EVENTS_DIR_ENV,
|
|
246
375
|
fallback: HASNA_EVENTS_HOME_ENV,
|
|
@@ -315,21 +444,27 @@ function now() {
|
|
|
315
444
|
function truncate(value, max = 4096) {
|
|
316
445
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
317
446
|
}
|
|
318
|
-
function buildWebhookRequest(event, channel) {
|
|
447
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
319
448
|
if (!channel.webhook)
|
|
320
449
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
450
|
+
for (const name of Object.keys(channel.webhook.headers ?? {})) {
|
|
451
|
+
if (/^x-hasna-/i.test(name)) {
|
|
452
|
+
throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
321
455
|
const body = JSON.stringify(event);
|
|
322
|
-
const timestamp =
|
|
456
|
+
const timestamp = options.timestamp ?? new Date().toISOString();
|
|
323
457
|
const headers = {
|
|
324
458
|
"Content-Type": "application/json",
|
|
325
459
|
"User-Agent": "@hasna/events",
|
|
326
460
|
"X-Hasna-Event-Id": event.id,
|
|
327
461
|
"X-Hasna-Event-Type": event.type,
|
|
328
|
-
|
|
329
|
-
|
|
462
|
+
...channel.webhook.headers,
|
|
463
|
+
"X-Hasna-Timestamp": timestamp
|
|
330
464
|
};
|
|
331
|
-
|
|
332
|
-
|
|
465
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
466
|
+
if (secret) {
|
|
467
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
|
|
333
468
|
}
|
|
334
469
|
return { body, headers };
|
|
335
470
|
}
|
|
@@ -337,7 +472,21 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
337
472
|
if (!channel.webhook)
|
|
338
473
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
339
474
|
const startedAt = now();
|
|
340
|
-
|
|
475
|
+
let secret = channel.webhook.secret;
|
|
476
|
+
if (channel.webhook.secretRef) {
|
|
477
|
+
if (!options.secretResolver) {
|
|
478
|
+
return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
secret = await options.secretResolver(channel.webhook.secretRef);
|
|
482
|
+
} catch {
|
|
483
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
484
|
+
}
|
|
485
|
+
if (!secret)
|
|
486
|
+
return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
|
|
487
|
+
}
|
|
488
|
+
const timestamp = (options.now?.() ?? new Date).toISOString();
|
|
489
|
+
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
|
|
341
490
|
const controller = new AbortController;
|
|
342
491
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
343
492
|
try {
|
|
@@ -369,6 +518,15 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
369
518
|
clearTimeout(timeout);
|
|
370
519
|
}
|
|
371
520
|
}
|
|
521
|
+
function failedAttempt(startedAt, error) {
|
|
522
|
+
return {
|
|
523
|
+
attempt: 1,
|
|
524
|
+
status: "failed",
|
|
525
|
+
startedAt,
|
|
526
|
+
completedAt: now(),
|
|
527
|
+
error
|
|
528
|
+
};
|
|
529
|
+
}
|
|
372
530
|
async function dispatchCommand(event, channel) {
|
|
373
531
|
if (!channel.command)
|
|
374
532
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
@@ -457,8 +615,592 @@ function createDeliveryResult(event, channel, attempts) {
|
|
|
457
615
|
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
458
616
|
};
|
|
459
617
|
}
|
|
618
|
+
|
|
619
|
+
// src/catalog.ts
|
|
620
|
+
class EventValidationError extends Error {
|
|
621
|
+
eventType;
|
|
622
|
+
issues;
|
|
623
|
+
constructor(eventType, issues) {
|
|
624
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
625
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
626
|
+
this.name = "EventValidationError";
|
|
627
|
+
this.eventType = eventType;
|
|
628
|
+
this.issues = issues;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
class EventTypeCatalog {
|
|
633
|
+
definitions = new Map;
|
|
634
|
+
register(definition) {
|
|
635
|
+
this.definitions.set(definition.type, definition);
|
|
636
|
+
return this;
|
|
637
|
+
}
|
|
638
|
+
unregister(type) {
|
|
639
|
+
return this.definitions.delete(type);
|
|
640
|
+
}
|
|
641
|
+
has(type) {
|
|
642
|
+
return this.definitions.has(type);
|
|
643
|
+
}
|
|
644
|
+
get(type) {
|
|
645
|
+
return this.definitions.get(type);
|
|
646
|
+
}
|
|
647
|
+
list() {
|
|
648
|
+
return [...this.definitions.values()];
|
|
649
|
+
}
|
|
650
|
+
validateEvent(event) {
|
|
651
|
+
const definition = this.definitions.get(event.type);
|
|
652
|
+
if (!definition)
|
|
653
|
+
return { ok: true };
|
|
654
|
+
return definition.validate(event.data, event);
|
|
655
|
+
}
|
|
656
|
+
assertEventValid(event) {
|
|
657
|
+
const result = this.validateEvent(event);
|
|
658
|
+
if (!result.ok) {
|
|
659
|
+
throw new EventValidationError(event.type, result.issues);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
var defaultEventTypeCatalog = new EventTypeCatalog;
|
|
664
|
+
var DISTRIBUTION_EVENT_TYPES = {
|
|
665
|
+
releasePublished: "release.published",
|
|
666
|
+
rolloutStarted: "release.rollout.started",
|
|
667
|
+
rolloutCompleted: "release.rollout.completed",
|
|
668
|
+
rolloutFailed: "release.rollout.failed",
|
|
669
|
+
appInstalled: "app.installed",
|
|
670
|
+
announcementSent: "announcement.sent",
|
|
671
|
+
feedbackCreated: "feedback.created",
|
|
672
|
+
feedbackTriaged: "feedback.triaged"
|
|
673
|
+
};
|
|
674
|
+
var DISTRIBUTION_EVENT_CONTRACT_SCHEMAS = {
|
|
675
|
+
"release.published": "hasna.release.v1",
|
|
676
|
+
"release.rollout.started": "hasna.rollout_record.v1",
|
|
677
|
+
"release.rollout.completed": "hasna.rollout_record.v1",
|
|
678
|
+
"release.rollout.failed": "hasna.rollout_record.v1",
|
|
679
|
+
"app.installed": "hasna.rollout_record.v1",
|
|
680
|
+
"announcement.sent": "hasna.announcement.v1",
|
|
681
|
+
"feedback.created": "hasna.feedback.v1",
|
|
682
|
+
"feedback.triaged": "hasna.feedback.v1"
|
|
683
|
+
};
|
|
684
|
+
var PUBLISH_PATHS = ["skill", "ci", "backfilled"];
|
|
685
|
+
var ROLLOUT_ACTIONS = ["install", "update", "rollback", "freeze-blocked"];
|
|
686
|
+
function requireString(data, key, issues) {
|
|
687
|
+
const value = data[key];
|
|
688
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
689
|
+
issues.push({ path: key, message: "must be a non-empty string" });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function optionalString(data, key, issues) {
|
|
693
|
+
const value = data[key];
|
|
694
|
+
if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) {
|
|
695
|
+
issues.push({ path: key, message: "must be a non-empty string when present" });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function optionalEnum(data, key, allowed, issues) {
|
|
699
|
+
const value = data[key];
|
|
700
|
+
if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) {
|
|
701
|
+
issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function optionalStringArray(data, key, issues) {
|
|
705
|
+
const value = data[key];
|
|
706
|
+
if (value === undefined)
|
|
707
|
+
return;
|
|
708
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
|
|
709
|
+
issues.push({ path: key, message: "must be an array of non-empty strings when present" });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function toResult(issues) {
|
|
713
|
+
return issues.length === 0 ? { ok: true } : { ok: false, issues };
|
|
714
|
+
}
|
|
715
|
+
var validateReleasePublishedData = (data) => {
|
|
716
|
+
const issues = [];
|
|
717
|
+
requireString(data, "appId", issues);
|
|
718
|
+
requireString(data, "package", issues);
|
|
719
|
+
requireString(data, "version", issues);
|
|
720
|
+
optionalString(data, "gitSha", issues);
|
|
721
|
+
optionalString(data, "publishedAt", issues);
|
|
722
|
+
optionalEnum(data, "publishPath", PUBLISH_PATHS, issues);
|
|
723
|
+
return toResult(issues);
|
|
724
|
+
};
|
|
725
|
+
var validateRolloutData = (data, event) => {
|
|
726
|
+
const issues = [];
|
|
727
|
+
requireString(data, "appId", issues);
|
|
728
|
+
requireString(data, "package", issues);
|
|
729
|
+
requireString(data, "version", issues);
|
|
730
|
+
requireString(data, "machine", issues);
|
|
731
|
+
optionalEnum(data, "action", ROLLOUT_ACTIONS, issues);
|
|
732
|
+
if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") {
|
|
733
|
+
requireString(data, "result", issues);
|
|
734
|
+
}
|
|
735
|
+
return toResult(issues);
|
|
736
|
+
};
|
|
737
|
+
var validateAppInstalledData = (data) => {
|
|
738
|
+
const issues = [];
|
|
739
|
+
requireString(data, "appId", issues);
|
|
740
|
+
requireString(data, "package", issues);
|
|
741
|
+
requireString(data, "version", issues);
|
|
742
|
+
requireString(data, "machine", issues);
|
|
743
|
+
return toResult(issues);
|
|
744
|
+
};
|
|
745
|
+
var validateAnnouncementSentData = (data) => {
|
|
746
|
+
const issues = [];
|
|
747
|
+
requireString(data, "campaignId", issues);
|
|
748
|
+
optionalString(data, "appId", issues);
|
|
749
|
+
optionalString(data, "audienceId", issues);
|
|
750
|
+
optionalString(data, "releaseId", issues);
|
|
751
|
+
optionalStringArray(data, "channels", issues);
|
|
752
|
+
return toResult(issues);
|
|
753
|
+
};
|
|
754
|
+
var validateFeedbackCreatedData = (data) => {
|
|
755
|
+
const issues = [];
|
|
756
|
+
requireString(data, "feedbackId", issues);
|
|
757
|
+
optionalString(data, "appId", issues);
|
|
758
|
+
optionalString(data, "source", issues);
|
|
759
|
+
optionalString(data, "summary", issues);
|
|
760
|
+
return toResult(issues);
|
|
761
|
+
};
|
|
762
|
+
var validateFeedbackTriagedData = (data) => {
|
|
763
|
+
const issues = [];
|
|
764
|
+
requireString(data, "feedbackId", issues);
|
|
765
|
+
requireString(data, "disposition", issues);
|
|
766
|
+
optionalString(data, "appId", issues);
|
|
767
|
+
optionalString(data, "triagedBy", issues);
|
|
768
|
+
return toResult(issues);
|
|
769
|
+
};
|
|
770
|
+
function createDistributionEventDefinitions() {
|
|
771
|
+
const bind = (type, validate, description) => ({
|
|
772
|
+
type,
|
|
773
|
+
contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type],
|
|
774
|
+
description,
|
|
775
|
+
validate
|
|
776
|
+
});
|
|
777
|
+
return [
|
|
778
|
+
bind("release.published", validateReleasePublishedData, "A package version was published"),
|
|
779
|
+
bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"),
|
|
780
|
+
bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"),
|
|
781
|
+
bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"),
|
|
782
|
+
bind("app.installed", validateAppInstalledData, "An app was installed on a machine"),
|
|
783
|
+
bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"),
|
|
784
|
+
bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"),
|
|
785
|
+
bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged")
|
|
786
|
+
];
|
|
787
|
+
}
|
|
788
|
+
function registerDistributionEventTypes(catalog = defaultEventTypeCatalog) {
|
|
789
|
+
for (const definition of createDistributionEventDefinitions()) {
|
|
790
|
+
catalog.register(definition);
|
|
791
|
+
}
|
|
792
|
+
return catalog;
|
|
793
|
+
}
|
|
794
|
+
// src/app-event.ts
|
|
795
|
+
var APP_EVENT_V1_SCHEMA_VERSION = "hasna.app_event.v1";
|
|
796
|
+
var APP_EVENT_V1_METADATA_KEY = "app_event";
|
|
797
|
+
var APP_EVENT_V1_MAX_SUMMARY_LENGTH = 512;
|
|
798
|
+
var APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
|
|
799
|
+
var APP_EVENT_V1_MAX_REFS = 32;
|
|
800
|
+
var APP_EVENT_V1_MAX_TARGETS = 16;
|
|
801
|
+
|
|
802
|
+
class AppEventValidationError extends Error {
|
|
803
|
+
issues;
|
|
804
|
+
constructor(issues) {
|
|
805
|
+
super(`Invalid ${APP_EVENT_V1_SCHEMA_VERSION}: ${issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
|
|
806
|
+
this.name = "AppEventValidationError";
|
|
807
|
+
this.issues = issues;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
class AppEventReplaySafetyError extends Error {
|
|
812
|
+
eventId;
|
|
813
|
+
constructor(eventId) {
|
|
814
|
+
super(`App event ${eventId} is not marked replay-safe`);
|
|
815
|
+
this.name = "AppEventReplaySafetyError";
|
|
816
|
+
this.eventId = eventId;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
var SEVERITIES = ["debug", "info", "notice", "warning", "error", "critical"];
|
|
820
|
+
var ACTOR_KINDS = ["agent", "human", "service", "model", "workflow", "system"];
|
|
821
|
+
var SENSITIVITIES = ["public", "internal", "confidential", "restricted"];
|
|
822
|
+
var REDACTION_STATES = ["none", "partial", "full"];
|
|
823
|
+
var DELIVERY_INTENTS = ["notification", "state_sync", "audit", "command"];
|
|
824
|
+
var DELIVERY_MODES = ["at_most_once", "at_least_once"];
|
|
825
|
+
function validateAppEventV1(value) {
|
|
826
|
+
const issues = [];
|
|
827
|
+
if (!isRecord(value))
|
|
828
|
+
return { ok: false, issues: [{ path: "<root>", message: "must be an object" }] };
|
|
829
|
+
rejectUnknownKeys(value, [
|
|
830
|
+
"event_id",
|
|
831
|
+
"event_type",
|
|
832
|
+
"schema_version",
|
|
833
|
+
"source",
|
|
834
|
+
"occurred_at",
|
|
835
|
+
"severity",
|
|
836
|
+
"idempotency",
|
|
837
|
+
"correlation",
|
|
838
|
+
"subject",
|
|
839
|
+
"actor",
|
|
840
|
+
"project_mappings",
|
|
841
|
+
"summary",
|
|
842
|
+
"data",
|
|
843
|
+
"resource_refs",
|
|
844
|
+
"evidence_refs",
|
|
845
|
+
"sensitivity",
|
|
846
|
+
"redaction",
|
|
847
|
+
"delivery"
|
|
848
|
+
], "", issues);
|
|
849
|
+
requireString2(value, "event_id", "event_id", issues, 200);
|
|
850
|
+
requireString2(value, "event_type", "event_type", issues, 200);
|
|
851
|
+
if (value.schema_version !== APP_EVENT_V1_SCHEMA_VERSION) {
|
|
852
|
+
issues.push({ path: "schema_version", message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}` });
|
|
853
|
+
}
|
|
854
|
+
requireTimestamp(value, "occurred_at", issues);
|
|
855
|
+
requireEnum(value, "severity", SEVERITIES, "severity", issues);
|
|
856
|
+
requireString2(value, "summary", "summary", issues, APP_EVENT_V1_MAX_SUMMARY_LENGTH);
|
|
857
|
+
const source = requireRecord(value, "source", issues);
|
|
858
|
+
if (source) {
|
|
859
|
+
rejectUnknownKeys(source, ["app", "version", "machine"], "source", issues);
|
|
860
|
+
requireString2(source, "app", "source.app", issues, 200);
|
|
861
|
+
requireString2(source, "version", "source.version", issues, 100);
|
|
862
|
+
requireString2(source, "machine", "source.machine", issues, 200);
|
|
863
|
+
}
|
|
864
|
+
const idempotency = requireRecord(value, "idempotency", issues);
|
|
865
|
+
if (idempotency) {
|
|
866
|
+
rejectUnknownKeys(idempotency, ["dedupe_key", "replay_safe", "replay_of_event_id"], "idempotency", issues);
|
|
867
|
+
requireString2(idempotency, "dedupe_key", "idempotency.dedupe_key", issues, 512);
|
|
868
|
+
requireBoolean(idempotency, "replay_safe", "idempotency.replay_safe", issues);
|
|
869
|
+
optionalString2(idempotency, "replay_of_event_id", "idempotency.replay_of_event_id", issues, 200);
|
|
870
|
+
if (idempotency.replay_of_event_id === value.event_id) {
|
|
871
|
+
issues.push({ path: "idempotency.replay_of_event_id", message: "must not reference the event itself" });
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const correlation = requireRecord(value, "correlation", issues);
|
|
875
|
+
if (correlation) {
|
|
876
|
+
rejectUnknownKeys(correlation, ["correlation_id", "causation_id", "trace_id"], "correlation", issues);
|
|
877
|
+
requireString2(correlation, "correlation_id", "correlation.correlation_id", issues, 200);
|
|
878
|
+
optionalString2(correlation, "causation_id", "correlation.causation_id", issues, 200);
|
|
879
|
+
optionalString2(correlation, "trace_id", "correlation.trace_id", issues, 200);
|
|
880
|
+
}
|
|
881
|
+
validateSubject(value, issues);
|
|
882
|
+
validateActor(value, issues);
|
|
883
|
+
validateProjectMappings(value, issues);
|
|
884
|
+
validateData(value.data, issues);
|
|
885
|
+
validateResourceRefs(value.resource_refs, issues);
|
|
886
|
+
validateEvidenceRefs(value.evidence_refs, issues);
|
|
887
|
+
validateSensitivity(value, issues);
|
|
888
|
+
validateRedaction(value, issues);
|
|
889
|
+
validateDelivery(value, issues);
|
|
890
|
+
return issues.length === 0 ? { ok: true } : { ok: false, issues };
|
|
891
|
+
}
|
|
892
|
+
function assertAppEventV1(value) {
|
|
893
|
+
const result = validateAppEventV1(value);
|
|
894
|
+
if (!result.ok)
|
|
895
|
+
throw new AppEventValidationError(result.issues);
|
|
896
|
+
}
|
|
897
|
+
function assertAppEventV1ReplaySafe(event) {
|
|
898
|
+
assertAppEventV1(event);
|
|
899
|
+
if (!event.idempotency.replay_safe)
|
|
900
|
+
throw new AppEventReplaySafetyError(event.event_id);
|
|
901
|
+
}
|
|
902
|
+
function appEventV1ReplayIdentity(event) {
|
|
903
|
+
assertAppEventV1ReplaySafe(event);
|
|
904
|
+
return { eventId: event.event_id, dedupeKey: event.idempotency.dedupe_key };
|
|
905
|
+
}
|
|
906
|
+
function appEventV1ToEventInput(event) {
|
|
907
|
+
assertAppEventV1(event);
|
|
908
|
+
const metadata = {
|
|
909
|
+
profile: APP_EVENT_V1_SCHEMA_VERSION,
|
|
910
|
+
source_version: event.source.version,
|
|
911
|
+
source_machine: event.source.machine,
|
|
912
|
+
replay_safe: event.idempotency.replay_safe,
|
|
913
|
+
replay_of_event_id: event.idempotency.replay_of_event_id,
|
|
914
|
+
correlation: structuredClone(event.correlation),
|
|
915
|
+
subject: structuredClone(event.subject),
|
|
916
|
+
actor: structuredClone(event.actor),
|
|
917
|
+
project_mappings: structuredClone(event.project_mappings),
|
|
918
|
+
resource_refs: structuredClone(event.resource_refs),
|
|
919
|
+
evidence_refs: structuredClone(event.evidence_refs),
|
|
920
|
+
sensitivity: structuredClone(event.sensitivity),
|
|
921
|
+
redaction: structuredClone(event.redaction),
|
|
922
|
+
delivery: structuredClone(event.delivery)
|
|
923
|
+
};
|
|
924
|
+
return {
|
|
925
|
+
id: event.event_id,
|
|
926
|
+
source: event.source.app,
|
|
927
|
+
type: event.event_type,
|
|
928
|
+
time: event.occurred_at,
|
|
929
|
+
subject: event.subject.uri ?? `${event.subject.kind}:${event.subject.id}`,
|
|
930
|
+
severity: event.severity,
|
|
931
|
+
data: structuredClone(event.data),
|
|
932
|
+
message: event.summary,
|
|
933
|
+
dedupeKey: event.idempotency.dedupe_key,
|
|
934
|
+
schemaVersion: APP_EVENT_V1_SCHEMA_VERSION,
|
|
935
|
+
metadata: { [APP_EVENT_V1_METADATA_KEY]: metadata }
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function appEventV1FromEventEnvelope(envelope) {
|
|
939
|
+
const metadata = envelope.metadata[APP_EVENT_V1_METADATA_KEY];
|
|
940
|
+
if (!isRecord(metadata) || metadata.profile !== APP_EVENT_V1_SCHEMA_VERSION) {
|
|
941
|
+
throw new AppEventValidationError([{
|
|
942
|
+
path: `metadata.${APP_EVENT_V1_METADATA_KEY}.profile`,
|
|
943
|
+
message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}`
|
|
944
|
+
}]);
|
|
945
|
+
}
|
|
946
|
+
const event = {
|
|
947
|
+
event_id: envelope.id,
|
|
948
|
+
event_type: envelope.type,
|
|
949
|
+
schema_version: envelope.schemaVersion,
|
|
950
|
+
source: {
|
|
951
|
+
app: envelope.source,
|
|
952
|
+
version: metadata.source_version,
|
|
953
|
+
machine: metadata.source_machine
|
|
954
|
+
},
|
|
955
|
+
occurred_at: envelope.time,
|
|
956
|
+
severity: envelope.severity,
|
|
957
|
+
idempotency: {
|
|
958
|
+
dedupe_key: envelope.dedupeKey,
|
|
959
|
+
replay_safe: metadata.replay_safe,
|
|
960
|
+
replay_of_event_id: metadata.replay_of_event_id
|
|
961
|
+
},
|
|
962
|
+
correlation: metadata.correlation,
|
|
963
|
+
subject: metadata.subject,
|
|
964
|
+
actor: metadata.actor,
|
|
965
|
+
project_mappings: metadata.project_mappings,
|
|
966
|
+
summary: envelope.message,
|
|
967
|
+
data: structuredClone(envelope.data),
|
|
968
|
+
resource_refs: metadata.resource_refs,
|
|
969
|
+
evidence_refs: metadata.evidence_refs,
|
|
970
|
+
sensitivity: metadata.sensitivity,
|
|
971
|
+
redaction: metadata.redaction,
|
|
972
|
+
delivery: metadata.delivery
|
|
973
|
+
};
|
|
974
|
+
assertAppEventV1(event);
|
|
975
|
+
return structuredClone(event);
|
|
976
|
+
}
|
|
977
|
+
function validateSubject(value, issues) {
|
|
978
|
+
const subject = requireRecord(value, "subject", issues);
|
|
979
|
+
if (!subject)
|
|
980
|
+
return;
|
|
981
|
+
rejectUnknownKeys(subject, ["kind", "id", "uri"], "subject", issues);
|
|
982
|
+
requireString2(subject, "kind", "subject.kind", issues, 100);
|
|
983
|
+
requireString2(subject, "id", "subject.id", issues, 200);
|
|
984
|
+
optionalString2(subject, "uri", "subject.uri", issues, 2048);
|
|
985
|
+
}
|
|
986
|
+
function validateActor(value, issues) {
|
|
987
|
+
const actor = requireRecord(value, "actor", issues);
|
|
988
|
+
if (!actor)
|
|
989
|
+
return;
|
|
990
|
+
rejectUnknownKeys(actor, ["kind", "id", "name"], "actor", issues);
|
|
991
|
+
requireEnum(actor, "kind", ACTOR_KINDS, "actor.kind", issues);
|
|
992
|
+
requireString2(actor, "id", "actor.id", issues, 200);
|
|
993
|
+
optionalString2(actor, "name", "actor.name", issues, 200);
|
|
994
|
+
}
|
|
995
|
+
function validateProjectMappings(value, issues) {
|
|
996
|
+
const project = requireRecord(value, "project_mappings", issues);
|
|
997
|
+
if (!project)
|
|
998
|
+
return;
|
|
999
|
+
rejectUnknownKeys(project, ["canonical_id", "slug", "repository", "workspace", "external_ids"], "project_mappings", issues);
|
|
1000
|
+
requireString2(project, "canonical_id", "project_mappings.canonical_id", issues, 200);
|
|
1001
|
+
optionalString2(project, "slug", "project_mappings.slug", issues, 200);
|
|
1002
|
+
optionalString2(project, "repository", "project_mappings.repository", issues, 2048);
|
|
1003
|
+
optionalString2(project, "workspace", "project_mappings.workspace", issues, 2048);
|
|
1004
|
+
const externalIds = requireRecord(project, "external_ids", issues, "project_mappings.external_ids");
|
|
1005
|
+
if (externalIds) {
|
|
1006
|
+
if (Object.keys(externalIds).length > APP_EVENT_V1_MAX_TARGETS) {
|
|
1007
|
+
issues.push({ path: "project_mappings.external_ids", message: `must have at most ${APP_EVENT_V1_MAX_TARGETS} entries` });
|
|
1008
|
+
}
|
|
1009
|
+
for (const [key, entry] of Object.entries(externalIds)) {
|
|
1010
|
+
if (!key.trim() || typeof entry !== "string" || !entry.trim()) {
|
|
1011
|
+
issues.push({ path: `project_mappings.external_ids.${key}`, message: "keys and values must be non-empty strings" });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function validateData(value, issues) {
|
|
1017
|
+
if (!isRecord(value)) {
|
|
1018
|
+
issues.push({ path: "data", message: "must be an object" });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
try {
|
|
1022
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1023
|
+
if (bytes > APP_EVENT_V1_MAX_DATA_BYTES) {
|
|
1024
|
+
issues.push({ path: "data", message: `must serialize to at most ${APP_EVENT_V1_MAX_DATA_BYTES} UTF-8 bytes` });
|
|
1025
|
+
}
|
|
1026
|
+
} catch {
|
|
1027
|
+
issues.push({ path: "data", message: "must be JSON serializable" });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function validateResourceRefs(value, issues) {
|
|
1031
|
+
validateRefArray(value, "resource_refs", issues, (ref, path) => {
|
|
1032
|
+
rejectUnknownKeys(ref, ["kind", "id", "uri", "source_package", "external_id"], path, issues);
|
|
1033
|
+
requireString2(ref, "kind", `${path}.kind`, issues, 100);
|
|
1034
|
+
requireString2(ref, "id", `${path}.id`, issues, 200);
|
|
1035
|
+
optionalString2(ref, "uri", `${path}.uri`, issues, 2048);
|
|
1036
|
+
optionalString2(ref, "source_package", `${path}.source_package`, issues, 200);
|
|
1037
|
+
optionalString2(ref, "external_id", `${path}.external_id`, issues, 200);
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
function validateEvidenceRefs(value, issues) {
|
|
1041
|
+
validateRefArray(value, "evidence_refs", issues, (ref, path) => {
|
|
1042
|
+
rejectUnknownKeys(ref, ["kind", "id", "uri", "sha256", "redaction"], path, issues);
|
|
1043
|
+
requireString2(ref, "kind", `${path}.kind`, issues, 100);
|
|
1044
|
+
requireString2(ref, "id", `${path}.id`, issues, 200);
|
|
1045
|
+
requireString2(ref, "uri", `${path}.uri`, issues, 2048);
|
|
1046
|
+
optionalString2(ref, "sha256", `${path}.sha256`, issues, 64);
|
|
1047
|
+
if (typeof ref.sha256 === "string" && !/^[a-f0-9]{64}$/i.test(ref.sha256)) {
|
|
1048
|
+
issues.push({ path: `${path}.sha256`, message: "must be a 64-character hexadecimal digest" });
|
|
1049
|
+
}
|
|
1050
|
+
requireEnum(ref, "redaction", REDACTION_STATES, `${path}.redaction`, issues);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
function validateSensitivity(value, issues) {
|
|
1054
|
+
const sensitivity = requireRecord(value, "sensitivity", issues);
|
|
1055
|
+
if (!sensitivity)
|
|
1056
|
+
return;
|
|
1057
|
+
rejectUnknownKeys(sensitivity, ["classification", "contains_personal_data"], "sensitivity", issues);
|
|
1058
|
+
requireEnum(sensitivity, "classification", SENSITIVITIES, "sensitivity.classification", issues);
|
|
1059
|
+
requireBoolean(sensitivity, "contains_personal_data", "sensitivity.contains_personal_data", issues);
|
|
1060
|
+
}
|
|
1061
|
+
function validateRedaction(value, issues) {
|
|
1062
|
+
const redaction = requireRecord(value, "redaction", issues);
|
|
1063
|
+
if (!redaction)
|
|
1064
|
+
return;
|
|
1065
|
+
rejectUnknownKeys(redaction, ["state", "fields", "safe_for_logs"], "redaction", issues);
|
|
1066
|
+
requireEnum(redaction, "state", REDACTION_STATES, "redaction.state", issues);
|
|
1067
|
+
validateStringArray(redaction.fields, "redaction.fields", APP_EVENT_V1_MAX_REFS, issues, true);
|
|
1068
|
+
requireBoolean(redaction, "safe_for_logs", "redaction.safe_for_logs", issues);
|
|
1069
|
+
if (redaction.state === "none" && Array.isArray(redaction.fields) && redaction.fields.length > 0) {
|
|
1070
|
+
issues.push({ path: "redaction.fields", message: "must be empty when redaction.state is none" });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
function validateDelivery(value, issues) {
|
|
1074
|
+
const delivery = requireRecord(value, "delivery", issues);
|
|
1075
|
+
if (!delivery)
|
|
1076
|
+
return;
|
|
1077
|
+
rejectUnknownKeys(delivery, ["intent", "mode", "targets", "agent_conversation_injection"], "delivery", issues);
|
|
1078
|
+
requireEnum(delivery, "intent", DELIVERY_INTENTS, "delivery.intent", issues);
|
|
1079
|
+
requireEnum(delivery, "mode", DELIVERY_MODES, "delivery.mode", issues);
|
|
1080
|
+
validateStringArray(delivery.targets, "delivery.targets", APP_EVENT_V1_MAX_TARGETS, issues, false);
|
|
1081
|
+
if (delivery.agent_conversation_injection !== false) {
|
|
1082
|
+
issues.push({ path: "delivery.agent_conversation_injection", message: "must be false" });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
function validateRefArray(value, path, issues, validate) {
|
|
1086
|
+
if (!Array.isArray(value)) {
|
|
1087
|
+
issues.push({ path, message: "must be an array" });
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (value.length > APP_EVENT_V1_MAX_REFS) {
|
|
1091
|
+
issues.push({ path, message: `must contain at most ${APP_EVENT_V1_MAX_REFS} entries` });
|
|
1092
|
+
}
|
|
1093
|
+
value.forEach((entry, index) => {
|
|
1094
|
+
if (!isRecord(entry))
|
|
1095
|
+
issues.push({ path: `${path}.${index}`, message: "must be an object" });
|
|
1096
|
+
else
|
|
1097
|
+
validate(entry, `${path}.${index}`);
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
function validateStringArray(value, path, maxItems, issues, allowEmpty) {
|
|
1101
|
+
if (!Array.isArray(value)) {
|
|
1102
|
+
issues.push({ path, message: "must be an array" });
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (!allowEmpty && value.length === 0)
|
|
1106
|
+
issues.push({ path, message: "must contain at least one entry" });
|
|
1107
|
+
if (value.length > maxItems)
|
|
1108
|
+
issues.push({ path, message: `must contain at most ${maxItems} entries` });
|
|
1109
|
+
value.forEach((entry, index) => {
|
|
1110
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
1111
|
+
issues.push({ path: `${path}.${index}`, message: "must be a non-empty string" });
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
function isRecord(value) {
|
|
1116
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1117
|
+
}
|
|
1118
|
+
function rejectUnknownKeys(value, allowed, path, issues) {
|
|
1119
|
+
for (const key of Object.keys(value)) {
|
|
1120
|
+
if (!allowed.includes(key))
|
|
1121
|
+
issues.push({ path: path ? `${path}.${key}` : key, message: "is not allowed" });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function requireRecord(value, key, issues, path = key) {
|
|
1125
|
+
const entry = value[key];
|
|
1126
|
+
if (!isRecord(entry)) {
|
|
1127
|
+
issues.push({ path, message: "must be an object" });
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
return entry;
|
|
1131
|
+
}
|
|
1132
|
+
function requireString2(value, key, path, issues, maxLength) {
|
|
1133
|
+
const entry = value[key];
|
|
1134
|
+
if (typeof entry !== "string" || !entry.trim())
|
|
1135
|
+
issues.push({ path, message: "must be a non-empty string" });
|
|
1136
|
+
else if (entry.length > maxLength)
|
|
1137
|
+
issues.push({ path, message: `must have at most ${maxLength} characters` });
|
|
1138
|
+
}
|
|
1139
|
+
function optionalString2(value, key, path, issues, maxLength) {
|
|
1140
|
+
if (value[key] === undefined)
|
|
1141
|
+
return;
|
|
1142
|
+
requireString2(value, key, path, issues, maxLength);
|
|
1143
|
+
}
|
|
1144
|
+
function requireBoolean(value, key, path, issues) {
|
|
1145
|
+
if (typeof value[key] !== "boolean")
|
|
1146
|
+
issues.push({ path, message: "must be a boolean" });
|
|
1147
|
+
}
|
|
1148
|
+
function requireEnum(value, key, allowed, path, issues) {
|
|
1149
|
+
if (typeof value[key] !== "string" || !allowed.includes(value[key])) {
|
|
1150
|
+
issues.push({ path, message: `must be one of: ${allowed.join(", ")}` });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function requireTimestamp(value, key, issues) {
|
|
1154
|
+
const entry = value[key];
|
|
1155
|
+
if (typeof entry !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(entry) || Number.isNaN(Date.parse(entry))) {
|
|
1156
|
+
issues.push({ path: key, message: "must be an RFC 3339 date-time" });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
460
1160
|
// src/index.ts
|
|
461
1161
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1162
|
+
|
|
1163
|
+
// src/redaction.ts
|
|
1164
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
1165
|
+
if (paths.length === 0)
|
|
1166
|
+
return event;
|
|
1167
|
+
const copy = structuredClone(event);
|
|
1168
|
+
for (const path of paths) {
|
|
1169
|
+
setPath(copy, path, replacement);
|
|
1170
|
+
}
|
|
1171
|
+
return copy;
|
|
1172
|
+
}
|
|
1173
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
1174
|
+
return redactValue(event, replacement);
|
|
1175
|
+
}
|
|
1176
|
+
function shouldRedactKey(key) {
|
|
1177
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
1178
|
+
}
|
|
1179
|
+
function redactValue(value, replacement) {
|
|
1180
|
+
if (Array.isArray(value))
|
|
1181
|
+
return value.map((item) => redactValue(item, replacement));
|
|
1182
|
+
if (!value || typeof value !== "object")
|
|
1183
|
+
return value;
|
|
1184
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
1185
|
+
key,
|
|
1186
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
1187
|
+
]));
|
|
1188
|
+
}
|
|
1189
|
+
function setPath(input, path, replacement) {
|
|
1190
|
+
const parts = path.split(".");
|
|
1191
|
+
let cursor = input;
|
|
1192
|
+
for (const part of parts.slice(0, -1)) {
|
|
1193
|
+
const next = cursor[part];
|
|
1194
|
+
if (!next || typeof next !== "object")
|
|
1195
|
+
return;
|
|
1196
|
+
cursor = next;
|
|
1197
|
+
}
|
|
1198
|
+
const last = parts.at(-1);
|
|
1199
|
+
if (last && last in cursor)
|
|
1200
|
+
cursor[last] = replacement;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// src/index.ts
|
|
462
1204
|
function createEvent(input) {
|
|
463
1205
|
return {
|
|
464
1206
|
id: input.id ?? randomUUID2(),
|
|
@@ -479,10 +1221,18 @@ class EventsClient {
|
|
|
479
1221
|
store;
|
|
480
1222
|
redactors;
|
|
481
1223
|
transportOptions;
|
|
1224
|
+
catalog;
|
|
1225
|
+
validateCatalogTypes;
|
|
482
1226
|
constructor(options = {}) {
|
|
483
1227
|
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
484
1228
|
this.redactors = options.redactors ?? [];
|
|
485
|
-
this.transportOptions = {
|
|
1229
|
+
this.transportOptions = {
|
|
1230
|
+
fetchImpl: options.fetchImpl,
|
|
1231
|
+
secretResolver: options.secretResolver,
|
|
1232
|
+
now: options.now
|
|
1233
|
+
};
|
|
1234
|
+
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
1235
|
+
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
486
1236
|
}
|
|
487
1237
|
async addChannel(input) {
|
|
488
1238
|
const timestamp = new Date().toISOString();
|
|
@@ -500,18 +1250,40 @@ class EventsClient {
|
|
|
500
1250
|
}
|
|
501
1251
|
async emit(input, options = {}) {
|
|
502
1252
|
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
503
|
-
if (options.
|
|
504
|
-
|
|
505
|
-
if (existing) {
|
|
506
|
-
return { event: existing, deliveries: [], deduped: true };
|
|
507
|
-
}
|
|
1253
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
1254
|
+
this.catalog.assertEventValid(event);
|
|
508
1255
|
}
|
|
509
|
-
await this.
|
|
510
|
-
|
|
511
|
-
|
|
1256
|
+
const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
|
|
1257
|
+
if (append.deduped) {
|
|
1258
|
+
return { event: append.event, deliveries: [], deduped: true };
|
|
1259
|
+
}
|
|
1260
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
|
|
1261
|
+
return { event: append.event, deliveries, deduped: false };
|
|
512
1262
|
}
|
|
513
|
-
async listEvents() {
|
|
514
|
-
|
|
1263
|
+
async listEvents(options = {}) {
|
|
1264
|
+
if (Object.keys(options).length === 0)
|
|
1265
|
+
return this.store.listEvents();
|
|
1266
|
+
return queryClientEvents(await this.store.listEvents(), options);
|
|
1267
|
+
}
|
|
1268
|
+
async listEventsPage(options = {}) {
|
|
1269
|
+
if (this.store.listEventsPage)
|
|
1270
|
+
return this.store.listEventsPage(options);
|
|
1271
|
+
const events = queryClientEvents(await this.store.listEvents(), {
|
|
1272
|
+
eventId: options.eventId,
|
|
1273
|
+
source: options.source,
|
|
1274
|
+
type: options.type
|
|
1275
|
+
});
|
|
1276
|
+
const offset = decodeLocalJsonEventCursor(options.cursor, options);
|
|
1277
|
+
const limit = normalizeEventPageLimit(options.limit);
|
|
1278
|
+
const pageEvents = events.slice(offset, offset + limit);
|
|
1279
|
+
const nextOffset = offset + pageEvents.length;
|
|
1280
|
+
const hasMore = nextOffset < events.length;
|
|
1281
|
+
return {
|
|
1282
|
+
events: pageEvents,
|
|
1283
|
+
cursor: options.cursor,
|
|
1284
|
+
nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
|
|
1285
|
+
hasMore
|
|
1286
|
+
};
|
|
515
1287
|
}
|
|
516
1288
|
async listDeliveries() {
|
|
517
1289
|
return this.store.listDeliveries();
|
|
@@ -579,22 +1351,37 @@ class EventsClient {
|
|
|
579
1351
|
return result;
|
|
580
1352
|
}
|
|
581
1353
|
async replay(options = {}) {
|
|
582
|
-
const
|
|
583
|
-
if (options.eventId && event.id !== options.eventId)
|
|
584
|
-
return false;
|
|
585
|
-
if (options.source && event.source !== options.source)
|
|
586
|
-
return false;
|
|
587
|
-
if (options.type && event.type !== options.type)
|
|
588
|
-
return false;
|
|
589
|
-
return true;
|
|
590
|
-
});
|
|
1354
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
591
1355
|
if (options.dryRun)
|
|
592
|
-
return { events, deliveries: [] };
|
|
1356
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
593
1357
|
const deliveries = [];
|
|
594
|
-
for (const event of events) {
|
|
1358
|
+
for (const event of page.events) {
|
|
595
1359
|
deliveries.push(...await this.deliver(event));
|
|
596
1360
|
}
|
|
597
|
-
return { events, deliveries };
|
|
1361
|
+
return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
1362
|
+
}
|
|
1363
|
+
async appendEvent(event, options) {
|
|
1364
|
+
if (this.store.appendEventOnce) {
|
|
1365
|
+
return this.store.appendEventOnce(event, { dedupe: options.dedupe });
|
|
1366
|
+
}
|
|
1367
|
+
if (options.dedupe) {
|
|
1368
|
+
const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
|
|
1369
|
+
if (existing) {
|
|
1370
|
+
return {
|
|
1371
|
+
event: existing,
|
|
1372
|
+
stored: false,
|
|
1373
|
+
deduped: true,
|
|
1374
|
+
identity: { id: existing.id, dedupeKey: existing.dedupeKey }
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
const stored = await this.store.appendEvent(event);
|
|
1379
|
+
return {
|
|
1380
|
+
event: stored,
|
|
1381
|
+
stored: true,
|
|
1382
|
+
deduped: false,
|
|
1383
|
+
identity: { id: stored.id, dedupeKey: stored.dedupeKey }
|
|
1384
|
+
};
|
|
598
1385
|
}
|
|
599
1386
|
async applyRedaction(event, channel) {
|
|
600
1387
|
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
@@ -621,15 +1408,6 @@ class EventsClient {
|
|
|
621
1408
|
return createDeliveryResult(event, channel, attempts);
|
|
622
1409
|
}
|
|
623
1410
|
}
|
|
624
|
-
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
625
|
-
if (paths.length === 0)
|
|
626
|
-
return event;
|
|
627
|
-
const copy = structuredClone(event);
|
|
628
|
-
for (const path of paths) {
|
|
629
|
-
setPath(copy, path, replacement);
|
|
630
|
-
}
|
|
631
|
-
return copy;
|
|
632
|
-
}
|
|
633
1411
|
function sanitizeChannelForOutput(channel) {
|
|
634
1412
|
const copy = structuredClone(channel);
|
|
635
1413
|
if (copy.webhook?.secret)
|
|
@@ -642,34 +1420,19 @@ function sanitizeChannelForOutput(channel) {
|
|
|
642
1420
|
function sanitizeChannelsForOutput(channels) {
|
|
643
1421
|
return channels.map(sanitizeChannelForOutput);
|
|
644
1422
|
}
|
|
645
|
-
function
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
659
|
-
]));
|
|
660
|
-
}
|
|
661
|
-
function setPath(input, path, replacement) {
|
|
662
|
-
const parts = path.split(".");
|
|
663
|
-
let cursor = input;
|
|
664
|
-
for (const part of parts.slice(0, -1)) {
|
|
665
|
-
const next = cursor[part];
|
|
666
|
-
if (!next || typeof next !== "object")
|
|
667
|
-
return;
|
|
668
|
-
cursor = next;
|
|
669
|
-
}
|
|
670
|
-
const last = parts.at(-1);
|
|
671
|
-
if (last && last in cursor)
|
|
672
|
-
cursor[last] = replacement;
|
|
1423
|
+
function queryClientEvents(events, options) {
|
|
1424
|
+
let rows = events;
|
|
1425
|
+
if (options.eventId)
|
|
1426
|
+
rows = rows.filter((event) => event.id === options.eventId);
|
|
1427
|
+
if (options.source)
|
|
1428
|
+
rows = rows.filter((event) => event.source === options.source);
|
|
1429
|
+
if (options.type)
|
|
1430
|
+
rows = rows.filter((event) => event.type === options.type);
|
|
1431
|
+
if (options.cursor)
|
|
1432
|
+
rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
|
|
1433
|
+
if (options.limit !== undefined)
|
|
1434
|
+
rows = rows.slice(0, normalizeEventPageLimit(options.limit));
|
|
1435
|
+
return rows;
|
|
673
1436
|
}
|
|
674
1437
|
function normalizeTime(value) {
|
|
675
1438
|
if (!value)
|
|
@@ -686,28 +1449,62 @@ function normalizeRetryPolicy(policy) {
|
|
|
686
1449
|
export {
|
|
687
1450
|
verifyWebhookSignature,
|
|
688
1451
|
verifyPayloadSignature,
|
|
1452
|
+
validateRolloutData,
|
|
1453
|
+
validateReleasePublishedData,
|
|
1454
|
+
validateFeedbackTriagedData,
|
|
1455
|
+
validateFeedbackCreatedData,
|
|
1456
|
+
validateAppInstalledData,
|
|
1457
|
+
validateAppEventV1,
|
|
1458
|
+
validateAnnouncementSentData,
|
|
689
1459
|
signPayload,
|
|
690
1460
|
sanitizeChannelsForOutput,
|
|
691
1461
|
sanitizeChannelForOutput,
|
|
1462
|
+
registerDistributionEventTypes,
|
|
692
1463
|
redactSensitiveKeys,
|
|
693
1464
|
redactPaths,
|
|
1465
|
+
normalizeEventPageLimit,
|
|
694
1466
|
matchString,
|
|
1467
|
+
localJsonRuntime,
|
|
695
1468
|
isTimestampWithinTolerance,
|
|
696
1469
|
getEventsStatus,
|
|
697
1470
|
getEventsDataDir,
|
|
698
1471
|
getActiveEventsDirEnv,
|
|
699
1472
|
eventMatchesFilter,
|
|
1473
|
+
encodeLocalJsonEventCursor,
|
|
700
1474
|
dispatchWebhook,
|
|
701
1475
|
dispatchCommand,
|
|
702
1476
|
dispatchChannel,
|
|
1477
|
+
defaultEventTypeCatalog,
|
|
1478
|
+
decodeLocalJsonEventCursor,
|
|
703
1479
|
createEvent,
|
|
1480
|
+
createDistributionEventDefinitions,
|
|
704
1481
|
createDeliveryResult,
|
|
705
1482
|
channelMatchesEvent,
|
|
706
1483
|
buildWebhookRequest,
|
|
707
1484
|
buildSignatureBase,
|
|
1485
|
+
assertAppEventV1ReplaySafe,
|
|
1486
|
+
assertAppEventV1,
|
|
1487
|
+
appEventV1ToEventInput,
|
|
1488
|
+
appEventV1ReplayIdentity,
|
|
1489
|
+
appEventV1FromEventEnvelope,
|
|
1490
|
+
MAX_EVENT_PAGE_LIMIT,
|
|
1491
|
+
LOCAL_JSON_EVENT_CURSOR_PREFIX,
|
|
708
1492
|
JsonEventsStore,
|
|
709
1493
|
HASNA_EVENTS_HOME_ENV,
|
|
710
1494
|
HASNA_EVENTS_DIR_ENV,
|
|
711
1495
|
EventsClient,
|
|
712
|
-
|
|
1496
|
+
EventValidationError,
|
|
1497
|
+
EventTypeCatalog,
|
|
1498
|
+
DISTRIBUTION_EVENT_TYPES,
|
|
1499
|
+
DISTRIBUTION_EVENT_CONTRACT_SCHEMAS,
|
|
1500
|
+
DEFAULT_SIGNATURE_TOLERANCE_MS,
|
|
1501
|
+
DEFAULT_EVENT_PAGE_LIMIT,
|
|
1502
|
+
AppEventValidationError,
|
|
1503
|
+
AppEventReplaySafetyError,
|
|
1504
|
+
APP_EVENT_V1_SCHEMA_VERSION,
|
|
1505
|
+
APP_EVENT_V1_METADATA_KEY,
|
|
1506
|
+
APP_EVENT_V1_MAX_TARGETS,
|
|
1507
|
+
APP_EVENT_V1_MAX_SUMMARY_LENGTH,
|
|
1508
|
+
APP_EVENT_V1_MAX_REFS,
|
|
1509
|
+
APP_EVENT_V1_MAX_DATA_BYTES
|
|
713
1510
|
};
|