@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
|
@@ -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
|
+
};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/catalog.ts
|
|
3
|
+
class EventValidationError extends Error {
|
|
4
|
+
eventType;
|
|
5
|
+
issues;
|
|
6
|
+
constructor(eventType, issues) {
|
|
7
|
+
const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
|
|
8
|
+
super(`Event validation failed for type "${eventType}": ${detail}`);
|
|
9
|
+
this.name = "EventValidationError";
|
|
10
|
+
this.eventType = eventType;
|
|
11
|
+
this.issues = issues;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class EventTypeCatalog {
|
|
16
|
+
definitions = new Map;
|
|
17
|
+
register(definition) {
|
|
18
|
+
this.definitions.set(definition.type, definition);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
unregister(type) {
|
|
22
|
+
return this.definitions.delete(type);
|
|
23
|
+
}
|
|
24
|
+
has(type) {
|
|
25
|
+
return this.definitions.has(type);
|
|
26
|
+
}
|
|
27
|
+
get(type) {
|
|
28
|
+
return this.definitions.get(type);
|
|
29
|
+
}
|
|
30
|
+
list() {
|
|
31
|
+
return [...this.definitions.values()];
|
|
32
|
+
}
|
|
33
|
+
validateEvent(event) {
|
|
34
|
+
const definition = this.definitions.get(event.type);
|
|
35
|
+
if (!definition)
|
|
36
|
+
return { ok: true };
|
|
37
|
+
return definition.validate(event.data, event);
|
|
38
|
+
}
|
|
39
|
+
assertEventValid(event) {
|
|
40
|
+
const result = this.validateEvent(event);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
throw new EventValidationError(event.type, result.issues);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
var defaultEventTypeCatalog = new EventTypeCatalog;
|
|
47
|
+
var DISTRIBUTION_EVENT_TYPES = {
|
|
48
|
+
releasePublished: "release.published",
|
|
49
|
+
rolloutStarted: "release.rollout.started",
|
|
50
|
+
rolloutCompleted: "release.rollout.completed",
|
|
51
|
+
rolloutFailed: "release.rollout.failed",
|
|
52
|
+
appInstalled: "app.installed",
|
|
53
|
+
announcementSent: "announcement.sent",
|
|
54
|
+
feedbackCreated: "feedback.created",
|
|
55
|
+
feedbackTriaged: "feedback.triaged"
|
|
56
|
+
};
|
|
57
|
+
var DISTRIBUTION_EVENT_CONTRACT_SCHEMAS = {
|
|
58
|
+
"release.published": "hasna.release.v1",
|
|
59
|
+
"release.rollout.started": "hasna.rollout_record.v1",
|
|
60
|
+
"release.rollout.completed": "hasna.rollout_record.v1",
|
|
61
|
+
"release.rollout.failed": "hasna.rollout_record.v1",
|
|
62
|
+
"app.installed": "hasna.rollout_record.v1",
|
|
63
|
+
"announcement.sent": "hasna.announcement.v1",
|
|
64
|
+
"feedback.created": "hasna.feedback.v1",
|
|
65
|
+
"feedback.triaged": "hasna.feedback.v1"
|
|
66
|
+
};
|
|
67
|
+
var PUBLISH_PATHS = ["skill", "ci", "backfilled"];
|
|
68
|
+
var ROLLOUT_ACTIONS = ["install", "update", "rollback", "freeze-blocked"];
|
|
69
|
+
function requireString(data, key, issues) {
|
|
70
|
+
const value = data[key];
|
|
71
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
72
|
+
issues.push({ path: key, message: "must be a non-empty string" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function optionalString(data, key, issues) {
|
|
76
|
+
const value = data[key];
|
|
77
|
+
if (value !== undefined && (typeof value !== "string" || value.trim().length === 0)) {
|
|
78
|
+
issues.push({ path: key, message: "must be a non-empty string when present" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function optionalEnum(data, key, allowed, issues) {
|
|
82
|
+
const value = data[key];
|
|
83
|
+
if (value !== undefined && (typeof value !== "string" || !allowed.includes(value))) {
|
|
84
|
+
issues.push({ path: key, message: `must be one of: ${allowed.join(", ")}` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function optionalStringArray(data, key, issues) {
|
|
88
|
+
const value = data[key];
|
|
89
|
+
if (value === undefined)
|
|
90
|
+
return;
|
|
91
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
|
|
92
|
+
issues.push({ path: key, message: "must be an array of non-empty strings when present" });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function toResult(issues) {
|
|
96
|
+
return issues.length === 0 ? { ok: true } : { ok: false, issues };
|
|
97
|
+
}
|
|
98
|
+
var validateReleasePublishedData = (data) => {
|
|
99
|
+
const issues = [];
|
|
100
|
+
requireString(data, "appId", issues);
|
|
101
|
+
requireString(data, "package", issues);
|
|
102
|
+
requireString(data, "version", issues);
|
|
103
|
+
optionalString(data, "gitSha", issues);
|
|
104
|
+
optionalString(data, "publishedAt", issues);
|
|
105
|
+
optionalEnum(data, "publishPath", PUBLISH_PATHS, issues);
|
|
106
|
+
return toResult(issues);
|
|
107
|
+
};
|
|
108
|
+
var validateRolloutData = (data, event) => {
|
|
109
|
+
const issues = [];
|
|
110
|
+
requireString(data, "appId", issues);
|
|
111
|
+
requireString(data, "package", issues);
|
|
112
|
+
requireString(data, "version", issues);
|
|
113
|
+
requireString(data, "machine", issues);
|
|
114
|
+
optionalEnum(data, "action", ROLLOUT_ACTIONS, issues);
|
|
115
|
+
if (event.type === "release.rollout.completed" || event.type === "release.rollout.failed") {
|
|
116
|
+
requireString(data, "result", issues);
|
|
117
|
+
}
|
|
118
|
+
return toResult(issues);
|
|
119
|
+
};
|
|
120
|
+
var validateAppInstalledData = (data) => {
|
|
121
|
+
const issues = [];
|
|
122
|
+
requireString(data, "appId", issues);
|
|
123
|
+
requireString(data, "package", issues);
|
|
124
|
+
requireString(data, "version", issues);
|
|
125
|
+
requireString(data, "machine", issues);
|
|
126
|
+
return toResult(issues);
|
|
127
|
+
};
|
|
128
|
+
var validateAnnouncementSentData = (data) => {
|
|
129
|
+
const issues = [];
|
|
130
|
+
requireString(data, "campaignId", issues);
|
|
131
|
+
optionalString(data, "appId", issues);
|
|
132
|
+
optionalString(data, "audienceId", issues);
|
|
133
|
+
optionalString(data, "releaseId", issues);
|
|
134
|
+
optionalStringArray(data, "channels", issues);
|
|
135
|
+
return toResult(issues);
|
|
136
|
+
};
|
|
137
|
+
var validateFeedbackCreatedData = (data) => {
|
|
138
|
+
const issues = [];
|
|
139
|
+
requireString(data, "feedbackId", issues);
|
|
140
|
+
optionalString(data, "appId", issues);
|
|
141
|
+
optionalString(data, "source", issues);
|
|
142
|
+
optionalString(data, "summary", issues);
|
|
143
|
+
return toResult(issues);
|
|
144
|
+
};
|
|
145
|
+
var validateFeedbackTriagedData = (data) => {
|
|
146
|
+
const issues = [];
|
|
147
|
+
requireString(data, "feedbackId", issues);
|
|
148
|
+
requireString(data, "disposition", issues);
|
|
149
|
+
optionalString(data, "appId", issues);
|
|
150
|
+
optionalString(data, "triagedBy", issues);
|
|
151
|
+
return toResult(issues);
|
|
152
|
+
};
|
|
153
|
+
function createDistributionEventDefinitions() {
|
|
154
|
+
const bind = (type, validate, description) => ({
|
|
155
|
+
type,
|
|
156
|
+
contractSchemaId: DISTRIBUTION_EVENT_CONTRACT_SCHEMAS[type],
|
|
157
|
+
description,
|
|
158
|
+
validate
|
|
159
|
+
});
|
|
160
|
+
return [
|
|
161
|
+
bind("release.published", validateReleasePublishedData, "A package version was published"),
|
|
162
|
+
bind("release.rollout.started", validateRolloutData, "A rollout of a release to a machine started"),
|
|
163
|
+
bind("release.rollout.completed", validateRolloutData, "A rollout of a release to a machine completed"),
|
|
164
|
+
bind("release.rollout.failed", validateRolloutData, "A rollout of a release to a machine failed"),
|
|
165
|
+
bind("app.installed", validateAppInstalledData, "An app was installed on a machine"),
|
|
166
|
+
bind("announcement.sent", validateAnnouncementSentData, "An announcement campaign was sent"),
|
|
167
|
+
bind("feedback.created", validateFeedbackCreatedData, "User or agent feedback was captured"),
|
|
168
|
+
bind("feedback.triaged", validateFeedbackTriagedData, "Captured feedback was triaged")
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
function registerDistributionEventTypes(catalog = defaultEventTypeCatalog) {
|
|
172
|
+
for (const definition of createDistributionEventDefinitions()) {
|
|
173
|
+
catalog.register(definition);
|
|
174
|
+
}
|
|
175
|
+
return catalog;
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
validateRolloutData,
|
|
179
|
+
validateReleasePublishedData,
|
|
180
|
+
validateFeedbackTriagedData,
|
|
181
|
+
validateFeedbackCreatedData,
|
|
182
|
+
validateAppInstalledData,
|
|
183
|
+
validateAnnouncementSentData,
|
|
184
|
+
registerDistributionEventTypes,
|
|
185
|
+
defaultEventTypeCatalog,
|
|
186
|
+
createDistributionEventDefinitions,
|
|
187
|
+
EventValidationError,
|
|
188
|
+
EventTypeCatalog,
|
|
189
|
+
DISTRIBUTION_EVENT_TYPES,
|
|
190
|
+
DISTRIBUTION_EVENT_CONTRACT_SCHEMAS
|
|
191
|
+
};
|