@hasna/events 0.1.14 → 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.
@@ -0,0 +1,382 @@
1
+ // @bun
2
+ // src/app-event.ts
3
+ var APP_EVENT_V1_SCHEMA_VERSION = "hasna.app_event.v1";
4
+ var APP_EVENT_V1_METADATA_KEY = "app_event";
5
+ var APP_EVENT_V1_MAX_SUMMARY_LENGTH = 512;
6
+ var APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
7
+ var APP_EVENT_V1_MAX_REFS = 32;
8
+ var APP_EVENT_V1_MAX_TARGETS = 16;
9
+
10
+ class AppEventValidationError extends Error {
11
+ issues;
12
+ constructor(issues) {
13
+ super(`Invalid ${APP_EVENT_V1_SCHEMA_VERSION}: ${issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`);
14
+ this.name = "AppEventValidationError";
15
+ this.issues = issues;
16
+ }
17
+ }
18
+
19
+ class AppEventReplaySafetyError extends Error {
20
+ eventId;
21
+ constructor(eventId) {
22
+ super(`App event ${eventId} is not marked replay-safe`);
23
+ this.name = "AppEventReplaySafetyError";
24
+ this.eventId = eventId;
25
+ }
26
+ }
27
+ var SEVERITIES = ["debug", "info", "notice", "warning", "error", "critical"];
28
+ var ACTOR_KINDS = ["agent", "human", "service", "model", "workflow", "system"];
29
+ var SENSITIVITIES = ["public", "internal", "confidential", "restricted"];
30
+ var REDACTION_STATES = ["none", "partial", "full"];
31
+ var DELIVERY_INTENTS = ["notification", "state_sync", "audit", "command"];
32
+ var DELIVERY_MODES = ["at_most_once", "at_least_once"];
33
+ function validateAppEventV1(value) {
34
+ const issues = [];
35
+ if (!isRecord(value))
36
+ return { ok: false, issues: [{ path: "<root>", message: "must be an object" }] };
37
+ rejectUnknownKeys(value, [
38
+ "event_id",
39
+ "event_type",
40
+ "schema_version",
41
+ "source",
42
+ "occurred_at",
43
+ "severity",
44
+ "idempotency",
45
+ "correlation",
46
+ "subject",
47
+ "actor",
48
+ "project_mappings",
49
+ "summary",
50
+ "data",
51
+ "resource_refs",
52
+ "evidence_refs",
53
+ "sensitivity",
54
+ "redaction",
55
+ "delivery"
56
+ ], "", issues);
57
+ requireString(value, "event_id", "event_id", issues, 200);
58
+ requireString(value, "event_type", "event_type", issues, 200);
59
+ if (value.schema_version !== APP_EVENT_V1_SCHEMA_VERSION) {
60
+ issues.push({ path: "schema_version", message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}` });
61
+ }
62
+ requireTimestamp(value, "occurred_at", issues);
63
+ requireEnum(value, "severity", SEVERITIES, "severity", issues);
64
+ requireString(value, "summary", "summary", issues, APP_EVENT_V1_MAX_SUMMARY_LENGTH);
65
+ const source = requireRecord(value, "source", issues);
66
+ if (source) {
67
+ rejectUnknownKeys(source, ["app", "version", "machine"], "source", issues);
68
+ requireString(source, "app", "source.app", issues, 200);
69
+ requireString(source, "version", "source.version", issues, 100);
70
+ requireString(source, "machine", "source.machine", issues, 200);
71
+ }
72
+ const idempotency = requireRecord(value, "idempotency", issues);
73
+ if (idempotency) {
74
+ rejectUnknownKeys(idempotency, ["dedupe_key", "replay_safe", "replay_of_event_id"], "idempotency", issues);
75
+ requireString(idempotency, "dedupe_key", "idempotency.dedupe_key", issues, 512);
76
+ requireBoolean(idempotency, "replay_safe", "idempotency.replay_safe", issues);
77
+ optionalString(idempotency, "replay_of_event_id", "idempotency.replay_of_event_id", issues, 200);
78
+ if (idempotency.replay_of_event_id === value.event_id) {
79
+ issues.push({ path: "idempotency.replay_of_event_id", message: "must not reference the event itself" });
80
+ }
81
+ }
82
+ const correlation = requireRecord(value, "correlation", issues);
83
+ if (correlation) {
84
+ rejectUnknownKeys(correlation, ["correlation_id", "causation_id", "trace_id"], "correlation", issues);
85
+ requireString(correlation, "correlation_id", "correlation.correlation_id", issues, 200);
86
+ optionalString(correlation, "causation_id", "correlation.causation_id", issues, 200);
87
+ optionalString(correlation, "trace_id", "correlation.trace_id", issues, 200);
88
+ }
89
+ validateSubject(value, issues);
90
+ validateActor(value, issues);
91
+ validateProjectMappings(value, issues);
92
+ validateData(value.data, issues);
93
+ validateResourceRefs(value.resource_refs, issues);
94
+ validateEvidenceRefs(value.evidence_refs, issues);
95
+ validateSensitivity(value, issues);
96
+ validateRedaction(value, issues);
97
+ validateDelivery(value, issues);
98
+ return issues.length === 0 ? { ok: true } : { ok: false, issues };
99
+ }
100
+ function assertAppEventV1(value) {
101
+ const result = validateAppEventV1(value);
102
+ if (!result.ok)
103
+ throw new AppEventValidationError(result.issues);
104
+ }
105
+ function assertAppEventV1ReplaySafe(event) {
106
+ assertAppEventV1(event);
107
+ if (!event.idempotency.replay_safe)
108
+ throw new AppEventReplaySafetyError(event.event_id);
109
+ }
110
+ function appEventV1ReplayIdentity(event) {
111
+ assertAppEventV1ReplaySafe(event);
112
+ return { eventId: event.event_id, dedupeKey: event.idempotency.dedupe_key };
113
+ }
114
+ function appEventV1ToEventInput(event) {
115
+ assertAppEventV1(event);
116
+ const metadata = {
117
+ profile: APP_EVENT_V1_SCHEMA_VERSION,
118
+ source_version: event.source.version,
119
+ source_machine: event.source.machine,
120
+ replay_safe: event.idempotency.replay_safe,
121
+ replay_of_event_id: event.idempotency.replay_of_event_id,
122
+ correlation: structuredClone(event.correlation),
123
+ subject: structuredClone(event.subject),
124
+ actor: structuredClone(event.actor),
125
+ project_mappings: structuredClone(event.project_mappings),
126
+ resource_refs: structuredClone(event.resource_refs),
127
+ evidence_refs: structuredClone(event.evidence_refs),
128
+ sensitivity: structuredClone(event.sensitivity),
129
+ redaction: structuredClone(event.redaction),
130
+ delivery: structuredClone(event.delivery)
131
+ };
132
+ return {
133
+ id: event.event_id,
134
+ source: event.source.app,
135
+ type: event.event_type,
136
+ time: event.occurred_at,
137
+ subject: event.subject.uri ?? `${event.subject.kind}:${event.subject.id}`,
138
+ severity: event.severity,
139
+ data: structuredClone(event.data),
140
+ message: event.summary,
141
+ dedupeKey: event.idempotency.dedupe_key,
142
+ schemaVersion: APP_EVENT_V1_SCHEMA_VERSION,
143
+ metadata: { [APP_EVENT_V1_METADATA_KEY]: metadata }
144
+ };
145
+ }
146
+ function appEventV1FromEventEnvelope(envelope) {
147
+ const metadata = envelope.metadata[APP_EVENT_V1_METADATA_KEY];
148
+ if (!isRecord(metadata) || metadata.profile !== APP_EVENT_V1_SCHEMA_VERSION) {
149
+ throw new AppEventValidationError([{
150
+ path: `metadata.${APP_EVENT_V1_METADATA_KEY}.profile`,
151
+ message: `must equal ${APP_EVENT_V1_SCHEMA_VERSION}`
152
+ }]);
153
+ }
154
+ const event = {
155
+ event_id: envelope.id,
156
+ event_type: envelope.type,
157
+ schema_version: envelope.schemaVersion,
158
+ source: {
159
+ app: envelope.source,
160
+ version: metadata.source_version,
161
+ machine: metadata.source_machine
162
+ },
163
+ occurred_at: envelope.time,
164
+ severity: envelope.severity,
165
+ idempotency: {
166
+ dedupe_key: envelope.dedupeKey,
167
+ replay_safe: metadata.replay_safe,
168
+ replay_of_event_id: metadata.replay_of_event_id
169
+ },
170
+ correlation: metadata.correlation,
171
+ subject: metadata.subject,
172
+ actor: metadata.actor,
173
+ project_mappings: metadata.project_mappings,
174
+ summary: envelope.message,
175
+ data: structuredClone(envelope.data),
176
+ resource_refs: metadata.resource_refs,
177
+ evidence_refs: metadata.evidence_refs,
178
+ sensitivity: metadata.sensitivity,
179
+ redaction: metadata.redaction,
180
+ delivery: metadata.delivery
181
+ };
182
+ assertAppEventV1(event);
183
+ return structuredClone(event);
184
+ }
185
+ function validateSubject(value, issues) {
186
+ const subject = requireRecord(value, "subject", issues);
187
+ if (!subject)
188
+ return;
189
+ rejectUnknownKeys(subject, ["kind", "id", "uri"], "subject", issues);
190
+ requireString(subject, "kind", "subject.kind", issues, 100);
191
+ requireString(subject, "id", "subject.id", issues, 200);
192
+ optionalString(subject, "uri", "subject.uri", issues, 2048);
193
+ }
194
+ function validateActor(value, issues) {
195
+ const actor = requireRecord(value, "actor", issues);
196
+ if (!actor)
197
+ return;
198
+ rejectUnknownKeys(actor, ["kind", "id", "name"], "actor", issues);
199
+ requireEnum(actor, "kind", ACTOR_KINDS, "actor.kind", issues);
200
+ requireString(actor, "id", "actor.id", issues, 200);
201
+ optionalString(actor, "name", "actor.name", issues, 200);
202
+ }
203
+ function validateProjectMappings(value, issues) {
204
+ const project = requireRecord(value, "project_mappings", issues);
205
+ if (!project)
206
+ return;
207
+ rejectUnknownKeys(project, ["canonical_id", "slug", "repository", "workspace", "external_ids"], "project_mappings", issues);
208
+ requireString(project, "canonical_id", "project_mappings.canonical_id", issues, 200);
209
+ optionalString(project, "slug", "project_mappings.slug", issues, 200);
210
+ optionalString(project, "repository", "project_mappings.repository", issues, 2048);
211
+ optionalString(project, "workspace", "project_mappings.workspace", issues, 2048);
212
+ const externalIds = requireRecord(project, "external_ids", issues, "project_mappings.external_ids");
213
+ if (externalIds) {
214
+ if (Object.keys(externalIds).length > APP_EVENT_V1_MAX_TARGETS) {
215
+ issues.push({ path: "project_mappings.external_ids", message: `must have at most ${APP_EVENT_V1_MAX_TARGETS} entries` });
216
+ }
217
+ for (const [key, entry] of Object.entries(externalIds)) {
218
+ if (!key.trim() || typeof entry !== "string" || !entry.trim()) {
219
+ issues.push({ path: `project_mappings.external_ids.${key}`, message: "keys and values must be non-empty strings" });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ function validateData(value, issues) {
225
+ if (!isRecord(value)) {
226
+ issues.push({ path: "data", message: "must be an object" });
227
+ return;
228
+ }
229
+ try {
230
+ const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
231
+ if (bytes > APP_EVENT_V1_MAX_DATA_BYTES) {
232
+ issues.push({ path: "data", message: `must serialize to at most ${APP_EVENT_V1_MAX_DATA_BYTES} UTF-8 bytes` });
233
+ }
234
+ } catch {
235
+ issues.push({ path: "data", message: "must be JSON serializable" });
236
+ }
237
+ }
238
+ function validateResourceRefs(value, issues) {
239
+ validateRefArray(value, "resource_refs", issues, (ref, path) => {
240
+ rejectUnknownKeys(ref, ["kind", "id", "uri", "source_package", "external_id"], path, issues);
241
+ requireString(ref, "kind", `${path}.kind`, issues, 100);
242
+ requireString(ref, "id", `${path}.id`, issues, 200);
243
+ optionalString(ref, "uri", `${path}.uri`, issues, 2048);
244
+ optionalString(ref, "source_package", `${path}.source_package`, issues, 200);
245
+ optionalString(ref, "external_id", `${path}.external_id`, issues, 200);
246
+ });
247
+ }
248
+ function validateEvidenceRefs(value, issues) {
249
+ validateRefArray(value, "evidence_refs", issues, (ref, path) => {
250
+ rejectUnknownKeys(ref, ["kind", "id", "uri", "sha256", "redaction"], path, issues);
251
+ requireString(ref, "kind", `${path}.kind`, issues, 100);
252
+ requireString(ref, "id", `${path}.id`, issues, 200);
253
+ requireString(ref, "uri", `${path}.uri`, issues, 2048);
254
+ optionalString(ref, "sha256", `${path}.sha256`, issues, 64);
255
+ if (typeof ref.sha256 === "string" && !/^[a-f0-9]{64}$/i.test(ref.sha256)) {
256
+ issues.push({ path: `${path}.sha256`, message: "must be a 64-character hexadecimal digest" });
257
+ }
258
+ requireEnum(ref, "redaction", REDACTION_STATES, `${path}.redaction`, issues);
259
+ });
260
+ }
261
+ function validateSensitivity(value, issues) {
262
+ const sensitivity = requireRecord(value, "sensitivity", issues);
263
+ if (!sensitivity)
264
+ return;
265
+ rejectUnknownKeys(sensitivity, ["classification", "contains_personal_data"], "sensitivity", issues);
266
+ requireEnum(sensitivity, "classification", SENSITIVITIES, "sensitivity.classification", issues);
267
+ requireBoolean(sensitivity, "contains_personal_data", "sensitivity.contains_personal_data", issues);
268
+ }
269
+ function validateRedaction(value, issues) {
270
+ const redaction = requireRecord(value, "redaction", issues);
271
+ if (!redaction)
272
+ return;
273
+ rejectUnknownKeys(redaction, ["state", "fields", "safe_for_logs"], "redaction", issues);
274
+ requireEnum(redaction, "state", REDACTION_STATES, "redaction.state", issues);
275
+ validateStringArray(redaction.fields, "redaction.fields", APP_EVENT_V1_MAX_REFS, issues, true);
276
+ requireBoolean(redaction, "safe_for_logs", "redaction.safe_for_logs", issues);
277
+ if (redaction.state === "none" && Array.isArray(redaction.fields) && redaction.fields.length > 0) {
278
+ issues.push({ path: "redaction.fields", message: "must be empty when redaction.state is none" });
279
+ }
280
+ }
281
+ function validateDelivery(value, issues) {
282
+ const delivery = requireRecord(value, "delivery", issues);
283
+ if (!delivery)
284
+ return;
285
+ rejectUnknownKeys(delivery, ["intent", "mode", "targets", "agent_conversation_injection"], "delivery", issues);
286
+ requireEnum(delivery, "intent", DELIVERY_INTENTS, "delivery.intent", issues);
287
+ requireEnum(delivery, "mode", DELIVERY_MODES, "delivery.mode", issues);
288
+ validateStringArray(delivery.targets, "delivery.targets", APP_EVENT_V1_MAX_TARGETS, issues, false);
289
+ if (delivery.agent_conversation_injection !== false) {
290
+ issues.push({ path: "delivery.agent_conversation_injection", message: "must be false" });
291
+ }
292
+ }
293
+ function validateRefArray(value, path, issues, validate) {
294
+ if (!Array.isArray(value)) {
295
+ issues.push({ path, message: "must be an array" });
296
+ return;
297
+ }
298
+ if (value.length > APP_EVENT_V1_MAX_REFS) {
299
+ issues.push({ path, message: `must contain at most ${APP_EVENT_V1_MAX_REFS} entries` });
300
+ }
301
+ value.forEach((entry, index) => {
302
+ if (!isRecord(entry))
303
+ issues.push({ path: `${path}.${index}`, message: "must be an object" });
304
+ else
305
+ validate(entry, `${path}.${index}`);
306
+ });
307
+ }
308
+ function validateStringArray(value, path, maxItems, issues, allowEmpty) {
309
+ if (!Array.isArray(value)) {
310
+ issues.push({ path, message: "must be an array" });
311
+ return;
312
+ }
313
+ if (!allowEmpty && value.length === 0)
314
+ issues.push({ path, message: "must contain at least one entry" });
315
+ if (value.length > maxItems)
316
+ issues.push({ path, message: `must contain at most ${maxItems} entries` });
317
+ value.forEach((entry, index) => {
318
+ if (typeof entry !== "string" || !entry.trim()) {
319
+ issues.push({ path: `${path}.${index}`, message: "must be a non-empty string" });
320
+ }
321
+ });
322
+ }
323
+ function isRecord(value) {
324
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
325
+ }
326
+ function rejectUnknownKeys(value, allowed, path, issues) {
327
+ for (const key of Object.keys(value)) {
328
+ if (!allowed.includes(key))
329
+ issues.push({ path: path ? `${path}.${key}` : key, message: "is not allowed" });
330
+ }
331
+ }
332
+ function requireRecord(value, key, issues, path = key) {
333
+ const entry = value[key];
334
+ if (!isRecord(entry)) {
335
+ issues.push({ path, message: "must be an object" });
336
+ return;
337
+ }
338
+ return entry;
339
+ }
340
+ function requireString(value, key, path, issues, maxLength) {
341
+ const entry = value[key];
342
+ if (typeof entry !== "string" || !entry.trim())
343
+ issues.push({ path, message: "must be a non-empty string" });
344
+ else if (entry.length > maxLength)
345
+ issues.push({ path, message: `must have at most ${maxLength} characters` });
346
+ }
347
+ function optionalString(value, key, path, issues, maxLength) {
348
+ if (value[key] === undefined)
349
+ return;
350
+ requireString(value, key, path, issues, maxLength);
351
+ }
352
+ function requireBoolean(value, key, path, issues) {
353
+ if (typeof value[key] !== "boolean")
354
+ issues.push({ path, message: "must be a boolean" });
355
+ }
356
+ function requireEnum(value, key, allowed, path, issues) {
357
+ if (typeof value[key] !== "string" || !allowed.includes(value[key])) {
358
+ issues.push({ path, message: `must be one of: ${allowed.join(", ")}` });
359
+ }
360
+ }
361
+ function requireTimestamp(value, key, issues) {
362
+ const entry = value[key];
363
+ 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))) {
364
+ issues.push({ path: key, message: "must be an RFC 3339 date-time" });
365
+ }
366
+ }
367
+ export {
368
+ validateAppEventV1,
369
+ assertAppEventV1ReplaySafe,
370
+ assertAppEventV1,
371
+ appEventV1ToEventInput,
372
+ appEventV1ReplayIdentity,
373
+ appEventV1FromEventEnvelope,
374
+ AppEventValidationError,
375
+ AppEventReplaySafetyError,
376
+ APP_EVENT_V1_SCHEMA_VERSION,
377
+ APP_EVENT_V1_METADATA_KEY,
378
+ APP_EVENT_V1_MAX_TARGETS,
379
+ APP_EVENT_V1_MAX_SUMMARY_LENGTH,
380
+ APP_EVENT_V1_MAX_REFS,
381
+ APP_EVENT_V1_MAX_DATA_BYTES
382
+ };