@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/durable.js
ADDED
|
@@ -0,0 +1,2232 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/filter.ts
|
|
3
|
+
function getPathValue(input, path) {
|
|
4
|
+
return path.split(".").reduce((value, part) => {
|
|
5
|
+
if (value && typeof value === "object" && part in value) {
|
|
6
|
+
return value[part];
|
|
7
|
+
}
|
|
8
|
+
return;
|
|
9
|
+
}, input);
|
|
10
|
+
}
|
|
11
|
+
function getFieldValues(input, path) {
|
|
12
|
+
const values = [];
|
|
13
|
+
const push = (value) => {
|
|
14
|
+
if (!values.some((item) => Object.is(item, value)))
|
|
15
|
+
values.push(value);
|
|
16
|
+
};
|
|
17
|
+
if (path.includes(".") && path in input)
|
|
18
|
+
push(input[path]);
|
|
19
|
+
const nestedValue = getPathValue(input, path);
|
|
20
|
+
if (nestedValue !== undefined || !path.includes("."))
|
|
21
|
+
push(nestedValue);
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
25
|
+
let body = "";
|
|
26
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
27
|
+
const char = pattern[index];
|
|
28
|
+
if (char === "*") {
|
|
29
|
+
if (pattern[index + 1] === "*") {
|
|
30
|
+
body += ".*";
|
|
31
|
+
index += 1;
|
|
32
|
+
} else {
|
|
33
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return new RegExp(`^${body}$`);
|
|
40
|
+
}
|
|
41
|
+
function matchString(value, matcher, options = {}) {
|
|
42
|
+
if (matcher === undefined)
|
|
43
|
+
return true;
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return false;
|
|
46
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
47
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
48
|
+
}
|
|
49
|
+
function matchRecord(input, matcher) {
|
|
50
|
+
if (!matcher)
|
|
51
|
+
return true;
|
|
52
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
53
|
+
const actualValues = getFieldValues(input, path);
|
|
54
|
+
return matchField(actualValues, expected, path);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function matchField(actualValues, expected, path) {
|
|
58
|
+
if (isNegativeMatcher(expected)) {
|
|
59
|
+
return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
|
|
60
|
+
}
|
|
61
|
+
return actualValues.some((actual) => matchPositiveField(actual, expected, path));
|
|
62
|
+
}
|
|
63
|
+
function matchPositiveField(actual, expected, path) {
|
|
64
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
65
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
66
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(actual)) {
|
|
70
|
+
return actual.some((item) => item === expected);
|
|
71
|
+
}
|
|
72
|
+
return actual === expected;
|
|
73
|
+
}
|
|
74
|
+
function stringCandidates(actual) {
|
|
75
|
+
if (actual === undefined)
|
|
76
|
+
return [];
|
|
77
|
+
if (Array.isArray(actual)) {
|
|
78
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
79
|
+
}
|
|
80
|
+
return [String(actual)];
|
|
81
|
+
}
|
|
82
|
+
function isPrimitiveFieldValue(value) {
|
|
83
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
84
|
+
}
|
|
85
|
+
function isNegativeMatcher(value) {
|
|
86
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
87
|
+
}
|
|
88
|
+
function eventMatchesFilter(event, filter) {
|
|
89
|
+
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
90
|
+
}
|
|
91
|
+
function channelMatchesEvent(channel, event) {
|
|
92
|
+
if (!channel.enabled)
|
|
93
|
+
return false;
|
|
94
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
95
|
+
return true;
|
|
96
|
+
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/storage.ts
|
|
100
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
101
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
102
|
+
import { existsSync } from "fs";
|
|
103
|
+
import { homedir } from "os";
|
|
104
|
+
import { join } from "path";
|
|
105
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
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;
|
|
110
|
+
function getEventsDataDir(override) {
|
|
111
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
112
|
+
}
|
|
113
|
+
function getActiveEventsDirEnv() {
|
|
114
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
115
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
116
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
117
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class JsonEventsStore {
|
|
122
|
+
dataDir;
|
|
123
|
+
runtime;
|
|
124
|
+
channelsPath;
|
|
125
|
+
eventsPath;
|
|
126
|
+
deliveriesPath;
|
|
127
|
+
constructor(dataDir = getEventsDataDir()) {
|
|
128
|
+
this.dataDir = dataDir;
|
|
129
|
+
this.runtime = localJsonRuntime(dataDir);
|
|
130
|
+
this.channelsPath = join(dataDir, "channels.json");
|
|
131
|
+
this.eventsPath = join(dataDir, "events.json");
|
|
132
|
+
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
133
|
+
}
|
|
134
|
+
async init() {
|
|
135
|
+
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
136
|
+
await chmod(this.dataDir, 448).catch(() => {
|
|
137
|
+
return;
|
|
138
|
+
});
|
|
139
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
140
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
141
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
142
|
+
}
|
|
143
|
+
async addChannel(channel) {
|
|
144
|
+
await this.init();
|
|
145
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
146
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
147
|
+
if (index >= 0) {
|
|
148
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
149
|
+
} else {
|
|
150
|
+
channels.push(channel);
|
|
151
|
+
}
|
|
152
|
+
await this.writeJson(this.channelsPath, channels);
|
|
153
|
+
return index >= 0 ? channels[index] : channel;
|
|
154
|
+
}
|
|
155
|
+
async listChannels() {
|
|
156
|
+
await this.init();
|
|
157
|
+
return this.readJson(this.channelsPath, []);
|
|
158
|
+
}
|
|
159
|
+
async getChannel(id) {
|
|
160
|
+
const channels = await this.listChannels();
|
|
161
|
+
return channels.find((channel) => channel.id === id);
|
|
162
|
+
}
|
|
163
|
+
async removeChannel(id) {
|
|
164
|
+
await this.init();
|
|
165
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
166
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
167
|
+
await this.writeJson(this.channelsPath, next);
|
|
168
|
+
return next.length !== channels.length;
|
|
169
|
+
}
|
|
170
|
+
async appendEvent(event) {
|
|
171
|
+
await this.init();
|
|
172
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
173
|
+
events.push(event);
|
|
174
|
+
await this.writeJson(this.eventsPath, events);
|
|
175
|
+
return event;
|
|
176
|
+
}
|
|
177
|
+
async appendEventOnce(event, options = {}) {
|
|
178
|
+
await this.init();
|
|
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
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async findEventByIdentity(identity) {
|
|
227
|
+
const events = await this.listEvents();
|
|
228
|
+
return findEventByIdentity(events, identity);
|
|
229
|
+
}
|
|
230
|
+
async appendDelivery(result) {
|
|
231
|
+
await this.init();
|
|
232
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
233
|
+
deliveries.push(result);
|
|
234
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
async listDeliveries() {
|
|
238
|
+
await this.init();
|
|
239
|
+
return this.readJson(this.deliveriesPath, []);
|
|
240
|
+
}
|
|
241
|
+
async exportData() {
|
|
242
|
+
return {
|
|
243
|
+
channels: await this.listChannels(),
|
|
244
|
+
events: await this.listEvents(),
|
|
245
|
+
deliveries: await this.listDeliveries()
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async ensureArrayFile(path) {
|
|
249
|
+
if (!existsSync(path)) {
|
|
250
|
+
await writeFile(path, `[]
|
|
251
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
252
|
+
}
|
|
253
|
+
await chmod(path, 384).catch(() => {
|
|
254
|
+
return;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async readJson(path, fallback) {
|
|
258
|
+
try {
|
|
259
|
+
const raw = await readFile(path, "utf-8");
|
|
260
|
+
if (!raw.trim())
|
|
261
|
+
return fallback;
|
|
262
|
+
return JSON.parse(raw);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error.code === "ENOENT")
|
|
265
|
+
return fallback;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async writeJson(path, value) {
|
|
270
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
271
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
272
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
273
|
+
await rename(tempPath, path);
|
|
274
|
+
await chmod(path, 384).catch(() => {
|
|
275
|
+
return;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
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
|
+
}
|
|
356
|
+
async function getEventsStatus(dataDir) {
|
|
357
|
+
const store = new JsonEventsStore(dataDir);
|
|
358
|
+
await store.init();
|
|
359
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
360
|
+
store.listChannels(),
|
|
361
|
+
store.listEvents(),
|
|
362
|
+
store.listDeliveries()
|
|
363
|
+
]);
|
|
364
|
+
const transports = channels.reduce((counts, channel) => {
|
|
365
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
366
|
+
return counts;
|
|
367
|
+
}, {});
|
|
368
|
+
return {
|
|
369
|
+
service: "events",
|
|
370
|
+
schemaVersion: "1.0",
|
|
371
|
+
dataDir: store.dataDir,
|
|
372
|
+
storage: store.runtime,
|
|
373
|
+
env: {
|
|
374
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
375
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
376
|
+
active: getActiveEventsDirEnv()
|
|
377
|
+
},
|
|
378
|
+
files: {
|
|
379
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
380
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
381
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
382
|
+
},
|
|
383
|
+
counts: {
|
|
384
|
+
channels: channels.length,
|
|
385
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
386
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
387
|
+
events: events.length,
|
|
388
|
+
deliveries: deliveries.length
|
|
389
|
+
},
|
|
390
|
+
transports,
|
|
391
|
+
safety: {
|
|
392
|
+
includesEventPayloads: false,
|
|
393
|
+
includesWebhookSecrets: false,
|
|
394
|
+
listOutputsRedactSecrets: true,
|
|
395
|
+
statusOutputIsMetadataOnly: true
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function statusFile(dataDir, fileName, records) {
|
|
400
|
+
const path = join(dataDir, fileName);
|
|
401
|
+
return { path, exists: existsSync(path), records };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/signing.ts
|
|
405
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
406
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
407
|
+
function buildSignatureBase(timestamp, body) {
|
|
408
|
+
return `${timestamp}.${body}`;
|
|
409
|
+
}
|
|
410
|
+
function signPayload(secret, timestamp, body) {
|
|
411
|
+
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
|
|
412
|
+
return `sha256=${digest}`;
|
|
413
|
+
}
|
|
414
|
+
function verifyPayloadSignature(secret, timestamp, body, signature) {
|
|
415
|
+
const expected = signPayload(secret, timestamp, body);
|
|
416
|
+
const actual = signature.trim();
|
|
417
|
+
const expectedBuffer = Buffer.from(expected);
|
|
418
|
+
const actualBuffer = Buffer.from(actual);
|
|
419
|
+
if (expectedBuffer.length !== actualBuffer.length)
|
|
420
|
+
return false;
|
|
421
|
+
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
422
|
+
}
|
|
423
|
+
function isTimestampWithinTolerance(timestamp, toleranceMs, now = Date.now()) {
|
|
424
|
+
const parsed = Date.parse(timestamp);
|
|
425
|
+
if (!Number.isFinite(parsed))
|
|
426
|
+
return false;
|
|
427
|
+
const reference = now instanceof Date ? now.getTime() : now;
|
|
428
|
+
return Math.abs(reference - parsed) <= toleranceMs;
|
|
429
|
+
}
|
|
430
|
+
function verifyWebhookSignature(secret, timestamp, body, signature, options = {}) {
|
|
431
|
+
const toleranceMs = options.toleranceMs ?? DEFAULT_SIGNATURE_TOLERANCE_MS;
|
|
432
|
+
if (!isTimestampWithinTolerance(timestamp, toleranceMs, options.now)) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
return verifyPayloadSignature(secret, timestamp, body, signature);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/transports.ts
|
|
439
|
+
import { randomUUID } from "crypto";
|
|
440
|
+
import { spawn } from "child_process";
|
|
441
|
+
function now() {
|
|
442
|
+
return new Date().toISOString();
|
|
443
|
+
}
|
|
444
|
+
function truncate(value, max = 4096) {
|
|
445
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
446
|
+
}
|
|
447
|
+
function buildWebhookRequest(event, channel, options = {}) {
|
|
448
|
+
if (!channel.webhook)
|
|
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
|
+
}
|
|
455
|
+
const body = JSON.stringify(event);
|
|
456
|
+
const timestamp = options.timestamp ?? new Date().toISOString();
|
|
457
|
+
const headers = {
|
|
458
|
+
"Content-Type": "application/json",
|
|
459
|
+
"User-Agent": "@hasna/events",
|
|
460
|
+
"X-Hasna-Event-Id": event.id,
|
|
461
|
+
"X-Hasna-Event-Type": event.type,
|
|
462
|
+
...channel.webhook.headers,
|
|
463
|
+
"X-Hasna-Timestamp": timestamp
|
|
464
|
+
};
|
|
465
|
+
const secret = options.secret ?? channel.webhook.secret;
|
|
466
|
+
if (secret) {
|
|
467
|
+
headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
|
|
468
|
+
}
|
|
469
|
+
return { body, headers };
|
|
470
|
+
}
|
|
471
|
+
async function dispatchWebhook(event, channel, options = {}) {
|
|
472
|
+
if (!channel.webhook)
|
|
473
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
474
|
+
const startedAt = now();
|
|
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 });
|
|
490
|
+
const controller = new AbortController;
|
|
491
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
492
|
+
try {
|
|
493
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
494
|
+
method: "POST",
|
|
495
|
+
headers,
|
|
496
|
+
body,
|
|
497
|
+
signal: controller.signal
|
|
498
|
+
});
|
|
499
|
+
const responseBody = truncate(await response.text());
|
|
500
|
+
return {
|
|
501
|
+
attempt: 1,
|
|
502
|
+
status: response.ok ? "success" : "failed",
|
|
503
|
+
startedAt,
|
|
504
|
+
completedAt: now(),
|
|
505
|
+
responseStatus: response.status,
|
|
506
|
+
responseBody,
|
|
507
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
508
|
+
};
|
|
509
|
+
} catch (error) {
|
|
510
|
+
return {
|
|
511
|
+
attempt: 1,
|
|
512
|
+
status: "failed",
|
|
513
|
+
startedAt,
|
|
514
|
+
completedAt: now(),
|
|
515
|
+
error: error instanceof Error ? error.message : String(error)
|
|
516
|
+
};
|
|
517
|
+
} finally {
|
|
518
|
+
clearTimeout(timeout);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function failedAttempt(startedAt, error) {
|
|
522
|
+
return {
|
|
523
|
+
attempt: 1,
|
|
524
|
+
status: "failed",
|
|
525
|
+
startedAt,
|
|
526
|
+
completedAt: now(),
|
|
527
|
+
error
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async function dispatchCommand(event, channel) {
|
|
531
|
+
if (!channel.command)
|
|
532
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
533
|
+
const startedAt = now();
|
|
534
|
+
const eventJson = JSON.stringify(event);
|
|
535
|
+
const env = {
|
|
536
|
+
...process.env,
|
|
537
|
+
...channel.command.env,
|
|
538
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
539
|
+
HASNA_EVENT_ID: event.id,
|
|
540
|
+
HASNA_EVENT_TYPE: event.type,
|
|
541
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
542
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
543
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
544
|
+
HASNA_EVENT_TIME: event.time,
|
|
545
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
546
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
547
|
+
HASNA_EVENT_JSON: eventJson
|
|
548
|
+
};
|
|
549
|
+
return new Promise((resolve) => {
|
|
550
|
+
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
551
|
+
cwd: channel.command.cwd,
|
|
552
|
+
env,
|
|
553
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
554
|
+
});
|
|
555
|
+
let stdout = "";
|
|
556
|
+
let stderr = "";
|
|
557
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
558
|
+
child.stdin.end(eventJson);
|
|
559
|
+
child.stdout.on("data", (chunk) => {
|
|
560
|
+
stdout += chunk.toString();
|
|
561
|
+
});
|
|
562
|
+
child.stderr.on("data", (chunk) => {
|
|
563
|
+
stderr += chunk.toString();
|
|
564
|
+
});
|
|
565
|
+
child.on("error", (error) => {
|
|
566
|
+
clearTimeout(timeout);
|
|
567
|
+
resolve({
|
|
568
|
+
attempt: 1,
|
|
569
|
+
status: "failed",
|
|
570
|
+
startedAt,
|
|
571
|
+
completedAt: now(),
|
|
572
|
+
stdout: truncate(stdout),
|
|
573
|
+
stderr: truncate(stderr),
|
|
574
|
+
error: error.message
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
child.on("close", (code, signal) => {
|
|
578
|
+
clearTimeout(timeout);
|
|
579
|
+
const success = code === 0;
|
|
580
|
+
resolve({
|
|
581
|
+
attempt: 1,
|
|
582
|
+
status: success ? "success" : "failed",
|
|
583
|
+
startedAt,
|
|
584
|
+
completedAt: now(),
|
|
585
|
+
stdout: truncate(stdout),
|
|
586
|
+
stderr: truncate(stderr),
|
|
587
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
async function dispatchChannel(event, channel, options = {}) {
|
|
593
|
+
if (channel.transport === "webhook")
|
|
594
|
+
return dispatchWebhook(event, channel, options);
|
|
595
|
+
if (channel.transport === "command")
|
|
596
|
+
return dispatchCommand(event, channel);
|
|
597
|
+
return {
|
|
598
|
+
attempt: 1,
|
|
599
|
+
status: "skipped",
|
|
600
|
+
startedAt: now(),
|
|
601
|
+
completedAt: now(),
|
|
602
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function createDeliveryResult(event, channel, attempts) {
|
|
606
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
607
|
+
return {
|
|
608
|
+
id: randomUUID(),
|
|
609
|
+
eventId: event.id,
|
|
610
|
+
channelId: channel.id,
|
|
611
|
+
transport: channel.transport,
|
|
612
|
+
status,
|
|
613
|
+
attempts,
|
|
614
|
+
createdAt: attempts[0]?.startedAt ?? now(),
|
|
615
|
+
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
616
|
+
};
|
|
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
|
+
|
|
1160
|
+
// src/index.ts
|
|
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
|
|
1204
|
+
function createEvent(input) {
|
|
1205
|
+
return {
|
|
1206
|
+
id: input.id ?? randomUUID2(),
|
|
1207
|
+
source: input.source,
|
|
1208
|
+
type: input.type,
|
|
1209
|
+
time: normalizeTime(input.time),
|
|
1210
|
+
subject: input.subject,
|
|
1211
|
+
severity: input.severity ?? "info",
|
|
1212
|
+
data: input.data ?? {},
|
|
1213
|
+
message: input.message,
|
|
1214
|
+
dedupeKey: input.dedupeKey,
|
|
1215
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
1216
|
+
metadata: input.metadata ?? {}
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
class EventsClient {
|
|
1221
|
+
store;
|
|
1222
|
+
redactors;
|
|
1223
|
+
transportOptions;
|
|
1224
|
+
catalog;
|
|
1225
|
+
validateCatalogTypes;
|
|
1226
|
+
constructor(options = {}) {
|
|
1227
|
+
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
1228
|
+
this.redactors = options.redactors ?? [];
|
|
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;
|
|
1236
|
+
}
|
|
1237
|
+
async addChannel(input) {
|
|
1238
|
+
const timestamp = new Date().toISOString();
|
|
1239
|
+
return this.store.addChannel({
|
|
1240
|
+
...input,
|
|
1241
|
+
createdAt: input.createdAt ?? timestamp,
|
|
1242
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
async listChannels() {
|
|
1246
|
+
return this.store.listChannels();
|
|
1247
|
+
}
|
|
1248
|
+
async removeChannel(id) {
|
|
1249
|
+
return this.store.removeChannel(id);
|
|
1250
|
+
}
|
|
1251
|
+
async emit(input, options = {}) {
|
|
1252
|
+
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
1253
|
+
if (options.validate ?? this.validateCatalogTypes) {
|
|
1254
|
+
this.catalog.assertEventValid(event);
|
|
1255
|
+
}
|
|
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 };
|
|
1262
|
+
}
|
|
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
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
async listDeliveries() {
|
|
1289
|
+
return this.store.listDeliveries();
|
|
1290
|
+
}
|
|
1291
|
+
async deliver(event) {
|
|
1292
|
+
const channels = await this.store.listChannels();
|
|
1293
|
+
const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
|
|
1294
|
+
const deliveries = [];
|
|
1295
|
+
for (const channel of selected) {
|
|
1296
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
1297
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
1298
|
+
await this.store.appendDelivery(result);
|
|
1299
|
+
deliveries.push(result);
|
|
1300
|
+
}
|
|
1301
|
+
return deliveries;
|
|
1302
|
+
}
|
|
1303
|
+
async matchChannel(id, input = {}) {
|
|
1304
|
+
const channel = await this.store.getChannel(id);
|
|
1305
|
+
if (!channel)
|
|
1306
|
+
throw new Error(`Channel not found: ${id}`);
|
|
1307
|
+
const event = createEvent({
|
|
1308
|
+
source: input.source ?? "hasna.events",
|
|
1309
|
+
type: input.type ?? "events.test",
|
|
1310
|
+
subject: input.subject ?? id,
|
|
1311
|
+
severity: input.severity ?? "info",
|
|
1312
|
+
data: input.data ?? { test: true },
|
|
1313
|
+
message: input.message ?? "Hasna events test delivery",
|
|
1314
|
+
dedupeKey: input.dedupeKey,
|
|
1315
|
+
schemaVersion: input.schemaVersion,
|
|
1316
|
+
metadata: input.metadata,
|
|
1317
|
+
time: input.time,
|
|
1318
|
+
id: input.id
|
|
1319
|
+
});
|
|
1320
|
+
const matched = channelMatchesEvent(channel, event);
|
|
1321
|
+
return {
|
|
1322
|
+
channelId: channel.id,
|
|
1323
|
+
matched,
|
|
1324
|
+
event,
|
|
1325
|
+
filters: channel.filters,
|
|
1326
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
async testChannel(id, input = {}, options = {}) {
|
|
1330
|
+
const channel = await this.store.getChannel(id);
|
|
1331
|
+
if (!channel)
|
|
1332
|
+
throw new Error(`Channel not found: ${id}`);
|
|
1333
|
+
const match = await this.matchChannel(id, input);
|
|
1334
|
+
const event = match.event;
|
|
1335
|
+
if (options.honorFilters && !match.matched) {
|
|
1336
|
+
const timestamp = new Date().toISOString();
|
|
1337
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
1338
|
+
attempt: 1,
|
|
1339
|
+
status: "skipped",
|
|
1340
|
+
startedAt: timestamp,
|
|
1341
|
+
completedAt: timestamp,
|
|
1342
|
+
error: match.reason
|
|
1343
|
+
}]);
|
|
1344
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
1345
|
+
await this.store.appendDelivery(result2);
|
|
1346
|
+
return result2;
|
|
1347
|
+
}
|
|
1348
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
1349
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
1350
|
+
await this.store.appendDelivery(result);
|
|
1351
|
+
return result;
|
|
1352
|
+
}
|
|
1353
|
+
async replay(options = {}) {
|
|
1354
|
+
const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
|
|
1355
|
+
if (options.dryRun)
|
|
1356
|
+
return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
|
|
1357
|
+
const deliveries = [];
|
|
1358
|
+
for (const event of page.events) {
|
|
1359
|
+
deliveries.push(...await this.deliver(event));
|
|
1360
|
+
}
|
|
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
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
async applyRedaction(event, channel) {
|
|
1387
|
+
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
1388
|
+
for (const redactor of this.redactors) {
|
|
1389
|
+
next = await redactor(next, channel);
|
|
1390
|
+
}
|
|
1391
|
+
return next;
|
|
1392
|
+
}
|
|
1393
|
+
async deliverWithRetry(event, channel) {
|
|
1394
|
+
const policy = normalizeRetryPolicy(channel.retry);
|
|
1395
|
+
const attempts = [];
|
|
1396
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
1397
|
+
const attempt = await dispatchChannel(event, channel, this.transportOptions);
|
|
1398
|
+
attempt.attempt = index + 1;
|
|
1399
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
1400
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
1401
|
+
}
|
|
1402
|
+
attempts.push(attempt);
|
|
1403
|
+
if (attempt.status !== "failed")
|
|
1404
|
+
break;
|
|
1405
|
+
if (attempt.nextBackoffMs)
|
|
1406
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
1407
|
+
}
|
|
1408
|
+
return createDeliveryResult(event, channel, attempts);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function sanitizeChannelForOutput(channel) {
|
|
1412
|
+
const copy = structuredClone(channel);
|
|
1413
|
+
if (copy.webhook?.secret)
|
|
1414
|
+
copy.webhook.secret = "[REDACTED]";
|
|
1415
|
+
if (copy.command?.env) {
|
|
1416
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
|
|
1417
|
+
}
|
|
1418
|
+
return copy;
|
|
1419
|
+
}
|
|
1420
|
+
function sanitizeChannelsForOutput(channels) {
|
|
1421
|
+
return channels.map(sanitizeChannelForOutput);
|
|
1422
|
+
}
|
|
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;
|
|
1436
|
+
}
|
|
1437
|
+
function normalizeTime(value) {
|
|
1438
|
+
if (!value)
|
|
1439
|
+
return new Date().toISOString();
|
|
1440
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
1441
|
+
}
|
|
1442
|
+
function normalizeRetryPolicy(policy) {
|
|
1443
|
+
return {
|
|
1444
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
1445
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
1446
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// src/durable.ts
|
|
1451
|
+
import { Database } from "bun:sqlite";
|
|
1452
|
+
import { createHash, randomUUID as randomUUID3 } from "crypto";
|
|
1453
|
+
import {
|
|
1454
|
+
chmodSync,
|
|
1455
|
+
closeSync,
|
|
1456
|
+
existsSync as existsSync2,
|
|
1457
|
+
fsyncSync,
|
|
1458
|
+
mkdirSync,
|
|
1459
|
+
openSync,
|
|
1460
|
+
readdirSync,
|
|
1461
|
+
readFileSync,
|
|
1462
|
+
unlinkSync
|
|
1463
|
+
} from "fs";
|
|
1464
|
+
import { join as join2 } from "path";
|
|
1465
|
+
var DURABLE_SCHEMA_VERSION = 1;
|
|
1466
|
+
var MAX_RETRY_ATTEMPTS = 1000;
|
|
1467
|
+
var MAX_RETRY_DELAY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
1468
|
+
var MAX_RETRY_MULTIPLIER = 100;
|
|
1469
|
+
var SCHEMA_V1_TABLE_SQL = {
|
|
1470
|
+
channels: `CREATE TABLE channels (
|
|
1471
|
+
id TEXT PRIMARY KEY,
|
|
1472
|
+
enabled INTEGER NOT NULL,
|
|
1473
|
+
config_json TEXT NOT NULL,
|
|
1474
|
+
created_at TEXT NOT NULL,
|
|
1475
|
+
updated_at TEXT NOT NULL
|
|
1476
|
+
)`,
|
|
1477
|
+
events: `CREATE TABLE events (
|
|
1478
|
+
id TEXT PRIMARY KEY,
|
|
1479
|
+
dedupe_key TEXT,
|
|
1480
|
+
source TEXT NOT NULL,
|
|
1481
|
+
type TEXT NOT NULL,
|
|
1482
|
+
time TEXT NOT NULL,
|
|
1483
|
+
envelope_json TEXT NOT NULL,
|
|
1484
|
+
created_at TEXT NOT NULL
|
|
1485
|
+
)`,
|
|
1486
|
+
outbox: `CREATE TABLE outbox (
|
|
1487
|
+
id TEXT PRIMARY KEY,
|
|
1488
|
+
event_id TEXT NOT NULL REFERENCES events(id),
|
|
1489
|
+
channel_id TEXT NOT NULL,
|
|
1490
|
+
event_json TEXT NOT NULL,
|
|
1491
|
+
channel_json TEXT NOT NULL,
|
|
1492
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'leased', 'delivered', 'dead')),
|
|
1493
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
1494
|
+
available_at INTEGER NOT NULL,
|
|
1495
|
+
lease_owner TEXT,
|
|
1496
|
+
lease_expires_at INTEGER,
|
|
1497
|
+
attempts_json TEXT NOT NULL DEFAULT '[]',
|
|
1498
|
+
created_at TEXT NOT NULL,
|
|
1499
|
+
updated_at TEXT NOT NULL,
|
|
1500
|
+
UNIQUE(event_id, channel_id)
|
|
1501
|
+
)`,
|
|
1502
|
+
deliveries: `CREATE TABLE deliveries (
|
|
1503
|
+
id TEXT PRIMARY KEY,
|
|
1504
|
+
event_id TEXT NOT NULL REFERENCES events(id),
|
|
1505
|
+
channel_id TEXT NOT NULL,
|
|
1506
|
+
result_json TEXT NOT NULL,
|
|
1507
|
+
created_at TEXT NOT NULL
|
|
1508
|
+
)`
|
|
1509
|
+
};
|
|
1510
|
+
var SCHEMA_V1_INDEX_SQL = {
|
|
1511
|
+
events_dedupe_key_unique: `CREATE UNIQUE INDEX events_dedupe_key_unique
|
|
1512
|
+
ON events(dedupe_key) WHERE dedupe_key IS NOT NULL`,
|
|
1513
|
+
events_source_type_idx: "CREATE INDEX events_source_type_idx ON events(source, type)",
|
|
1514
|
+
outbox_due_idx: "CREATE INDEX outbox_due_idx ON outbox(status, available_at, lease_expires_at)"
|
|
1515
|
+
};
|
|
1516
|
+
var SCHEMA_V1_COLUMNS = {
|
|
1517
|
+
channels: [
|
|
1518
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1519
|
+
{ name: "enabled", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
1520
|
+
{ name: "config_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1521
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1522
|
+
{ name: "updated_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1523
|
+
],
|
|
1524
|
+
events: [
|
|
1525
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1526
|
+
{ name: "dedupe_key", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
1527
|
+
{ name: "source", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1528
|
+
{ name: "type", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1529
|
+
{ name: "time", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1530
|
+
{ name: "envelope_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1531
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1532
|
+
],
|
|
1533
|
+
outbox: [
|
|
1534
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1535
|
+
{ name: "event_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1536
|
+
{ name: "channel_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1537
|
+
{ name: "event_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1538
|
+
{ name: "channel_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1539
|
+
{ name: "status", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1540
|
+
{ name: "attempt_count", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
|
|
1541
|
+
{ name: "available_at", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
|
|
1542
|
+
{ name: "lease_owner", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
|
|
1543
|
+
{ name: "lease_expires_at", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
|
|
1544
|
+
{ name: "attempts_json", type: "TEXT", notnull: 1, defaultValue: "'[]'", pk: 0 },
|
|
1545
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1546
|
+
{ name: "updated_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1547
|
+
],
|
|
1548
|
+
deliveries: [
|
|
1549
|
+
{ name: "id", type: "TEXT", notnull: 0, defaultValue: null, pk: 1 },
|
|
1550
|
+
{ name: "event_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1551
|
+
{ name: "channel_id", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1552
|
+
{ name: "result_json", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
|
|
1553
|
+
{ name: "created_at", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 }
|
|
1554
|
+
]
|
|
1555
|
+
};
|
|
1556
|
+
var EVENT_FOREIGN_KEY = {
|
|
1557
|
+
table: "events",
|
|
1558
|
+
from: "event_id",
|
|
1559
|
+
to: "id",
|
|
1560
|
+
onUpdate: "NO ACTION",
|
|
1561
|
+
onDelete: "NO ACTION",
|
|
1562
|
+
match: "NONE"
|
|
1563
|
+
};
|
|
1564
|
+
var SCHEMA_V1_FOREIGN_KEYS = {
|
|
1565
|
+
channels: [],
|
|
1566
|
+
events: [],
|
|
1567
|
+
outbox: [EVENT_FOREIGN_KEY],
|
|
1568
|
+
deliveries: [EVENT_FOREIGN_KEY]
|
|
1569
|
+
};
|
|
1570
|
+
var SCHEMA_V1_INDEXES = {
|
|
1571
|
+
channels: [
|
|
1572
|
+
{ name: "sqlite_autoindex_channels_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1573
|
+
],
|
|
1574
|
+
events: [
|
|
1575
|
+
{ name: "events_dedupe_key_unique", unique: 1, origin: "c", partial: 1, columns: ["dedupe_key"] },
|
|
1576
|
+
{ name: "events_source_type_idx", unique: 0, origin: "c", partial: 0, columns: ["source", "type"] },
|
|
1577
|
+
{ name: "sqlite_autoindex_events_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1578
|
+
],
|
|
1579
|
+
outbox: [
|
|
1580
|
+
{ name: "outbox_due_idx", unique: 0, origin: "c", partial: 0, columns: ["status", "available_at", "lease_expires_at"] },
|
|
1581
|
+
{ name: "sqlite_autoindex_outbox_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] },
|
|
1582
|
+
{ name: "sqlite_autoindex_outbox_2", unique: 1, origin: "u", partial: 0, columns: ["event_id", "channel_id"] }
|
|
1583
|
+
],
|
|
1584
|
+
deliveries: [
|
|
1585
|
+
{ name: "sqlite_autoindex_deliveries_1", unique: 1, origin: "pk", partial: 0, columns: ["id"] }
|
|
1586
|
+
]
|
|
1587
|
+
};
|
|
1588
|
+
function defaultWebhookSecretResolver(reference) {
|
|
1589
|
+
if (!reference.startsWith("env:"))
|
|
1590
|
+
throw new Error("Unsupported webhook secret reference scheme");
|
|
1591
|
+
const name = reference.slice("env:".length);
|
|
1592
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
1593
|
+
throw new Error("Invalid webhook secret environment reference");
|
|
1594
|
+
return process.env[name];
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
class DurableEventsBroker {
|
|
1598
|
+
dataDir;
|
|
1599
|
+
databasePath;
|
|
1600
|
+
db;
|
|
1601
|
+
now;
|
|
1602
|
+
transportOptions;
|
|
1603
|
+
constructor(options) {
|
|
1604
|
+
if (!options.dataDir)
|
|
1605
|
+
throw new Error("DurableEventsBroker requires dataDir");
|
|
1606
|
+
this.dataDir = options.dataDir;
|
|
1607
|
+
this.databasePath = join2(options.dataDir, options.databaseName ?? "events.sqlite");
|
|
1608
|
+
this.now = options.now ?? (() => new Date);
|
|
1609
|
+
this.transportOptions = {
|
|
1610
|
+
fetchImpl: options.fetchImpl,
|
|
1611
|
+
secretResolver: options.secretResolver ?? defaultWebhookSecretResolver,
|
|
1612
|
+
now: this.now
|
|
1613
|
+
};
|
|
1614
|
+
mkdirSync(this.dataDir, { recursive: true, mode: 448 });
|
|
1615
|
+
chmodSync(this.dataDir, 448);
|
|
1616
|
+
this.db = new Database(this.databasePath, { create: true, strict: true });
|
|
1617
|
+
try {
|
|
1618
|
+
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
1619
|
+
this.ensureSchema();
|
|
1620
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
1621
|
+
this.db.exec("PRAGMA synchronous = FULL;");
|
|
1622
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
1623
|
+
this.secureDatabaseFiles();
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
this.db.close();
|
|
1626
|
+
throw error;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
close() {
|
|
1630
|
+
this.db.close();
|
|
1631
|
+
}
|
|
1632
|
+
addChannel(input) {
|
|
1633
|
+
if (input.transport !== "webhook") {
|
|
1634
|
+
throw new Error("Durable SQLite channels support only webhook transport");
|
|
1635
|
+
}
|
|
1636
|
+
if (input.webhook?.secret !== undefined) {
|
|
1637
|
+
throw new Error("Durable SQLite channels reject inline webhook secrets; use webhook.secretRef");
|
|
1638
|
+
}
|
|
1639
|
+
if (input.transport === "webhook" && !input.webhook?.secretRef) {
|
|
1640
|
+
throw new Error("Durable SQLite webhook channels require webhook.secretRef");
|
|
1641
|
+
}
|
|
1642
|
+
if (input.webhook?.secretRef && !/^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(input.webhook.secretRef)) {
|
|
1643
|
+
throw new Error("Durable SQLite webhook secretRef must be a runtime reference");
|
|
1644
|
+
}
|
|
1645
|
+
if (input.webhook)
|
|
1646
|
+
validateDurableWebhookConfig(input.webhook);
|
|
1647
|
+
if (input.retry !== undefined)
|
|
1648
|
+
validateRetryPolicy(input.retry);
|
|
1649
|
+
const timestamp = this.now().toISOString();
|
|
1650
|
+
const existing = this.db.query("SELECT config_json FROM channels WHERE id = ?").get(input.id);
|
|
1651
|
+
const existingChannel = existing ? parseJson(existing.config_json) : undefined;
|
|
1652
|
+
const channel = {
|
|
1653
|
+
...input,
|
|
1654
|
+
createdAt: existingChannel?.createdAt ?? input.createdAt ?? timestamp,
|
|
1655
|
+
updatedAt: timestamp
|
|
1656
|
+
};
|
|
1657
|
+
this.immediate(() => {
|
|
1658
|
+
this.db.query(`
|
|
1659
|
+
INSERT INTO channels (id, enabled, config_json, created_at, updated_at)
|
|
1660
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1661
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1662
|
+
enabled = excluded.enabled,
|
|
1663
|
+
config_json = excluded.config_json,
|
|
1664
|
+
updated_at = excluded.updated_at
|
|
1665
|
+
`).run(channel.id, channel.enabled ? 1 : 0, JSON.stringify(channel), channel.createdAt, channel.updatedAt);
|
|
1666
|
+
});
|
|
1667
|
+
this.secureDatabaseFiles();
|
|
1668
|
+
return channel;
|
|
1669
|
+
}
|
|
1670
|
+
listChannels() {
|
|
1671
|
+
const rows = this.db.query("SELECT config_json FROM channels ORDER BY id").all();
|
|
1672
|
+
return rows.map((row) => parseJson(row.config_json));
|
|
1673
|
+
}
|
|
1674
|
+
enqueue(input, options = {}) {
|
|
1675
|
+
const event = redactSensitiveKeys(createEvent({ ...input, time: input.time ?? this.now() }));
|
|
1676
|
+
const result = this.immediate(() => {
|
|
1677
|
+
if (options.dedupe !== false) {
|
|
1678
|
+
const existing = this.findEvent(event.id, event.dedupeKey);
|
|
1679
|
+
if (existing) {
|
|
1680
|
+
const storedEvent = parseJson(existing.envelope_json);
|
|
1681
|
+
return {
|
|
1682
|
+
event: storedEvent,
|
|
1683
|
+
deduped: true,
|
|
1684
|
+
queued: this.queueMatchingChannels(storedEvent)
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
this.db.query(`
|
|
1689
|
+
INSERT INTO events (id, dedupe_key, source, type, time, envelope_json, created_at)
|
|
1690
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1691
|
+
`).run(event.id, event.dedupeKey ?? null, event.source, event.type, event.time, JSON.stringify(event), this.now().toISOString());
|
|
1692
|
+
const queued = this.queueMatchingChannels(event);
|
|
1693
|
+
return { event, deduped: false, queued };
|
|
1694
|
+
});
|
|
1695
|
+
this.secureDatabaseFiles();
|
|
1696
|
+
return result;
|
|
1697
|
+
}
|
|
1698
|
+
async drain(options = {}) {
|
|
1699
|
+
const workerId = options.workerId ?? randomUUID3();
|
|
1700
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1701
|
+
const leaseMs = normalizePositiveInteger(options.leaseMs, 60000, "leaseMs");
|
|
1702
|
+
const attemptedIds = new Set;
|
|
1703
|
+
const summary = {
|
|
1704
|
+
workerId,
|
|
1705
|
+
claimed: 0,
|
|
1706
|
+
delivered: 0,
|
|
1707
|
+
retried: 0,
|
|
1708
|
+
dead: 0,
|
|
1709
|
+
lost: 0,
|
|
1710
|
+
deliveries: []
|
|
1711
|
+
};
|
|
1712
|
+
while (summary.claimed < limit) {
|
|
1713
|
+
const [job] = this.claim({ workerId, limit: 1, leaseMs, excludeIds: [...attemptedIds] });
|
|
1714
|
+
if (!job)
|
|
1715
|
+
break;
|
|
1716
|
+
attemptedIds.add(job.id);
|
|
1717
|
+
summary.claimed += 1;
|
|
1718
|
+
let attempt;
|
|
1719
|
+
try {
|
|
1720
|
+
attempt = await dispatchChannel(job.event, job.channel, this.transportOptions);
|
|
1721
|
+
} catch {
|
|
1722
|
+
const timestamp = this.now().toISOString();
|
|
1723
|
+
attempt = {
|
|
1724
|
+
attempt: job.attempt,
|
|
1725
|
+
status: "failed",
|
|
1726
|
+
startedAt: timestamp,
|
|
1727
|
+
completedAt: timestamp,
|
|
1728
|
+
error: "Webhook delivery failed"
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
attempt.attempt = job.attempt;
|
|
1732
|
+
attempt = sanitizeDurableAttempt(attempt);
|
|
1733
|
+
const settled = this.settle(job, attempt);
|
|
1734
|
+
if (settled.status === "delivered")
|
|
1735
|
+
summary.delivered += 1;
|
|
1736
|
+
if (settled.status === "retry")
|
|
1737
|
+
summary.retried += 1;
|
|
1738
|
+
if (settled.status === "dead")
|
|
1739
|
+
summary.dead += 1;
|
|
1740
|
+
if (settled.status === "lost")
|
|
1741
|
+
summary.lost += 1;
|
|
1742
|
+
if (settled.delivery)
|
|
1743
|
+
summary.deliveries.push(settled.delivery);
|
|
1744
|
+
}
|
|
1745
|
+
this.secureDatabaseFiles();
|
|
1746
|
+
return summary;
|
|
1747
|
+
}
|
|
1748
|
+
importSpool(options = {}) {
|
|
1749
|
+
const inboxDir = join2(this.dataDir, "spool", "inbox");
|
|
1750
|
+
if (!existsSync2(inboxDir))
|
|
1751
|
+
return { scanned: 0, imported: 0, deduped: 0, queued: 0 };
|
|
1752
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1753
|
+
const names = readdirSync(inboxDir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, limit);
|
|
1754
|
+
const result = { scanned: names.length, imported: 0, deduped: 0, queued: 0 };
|
|
1755
|
+
for (const name of names) {
|
|
1756
|
+
const path = join2(inboxDir, name);
|
|
1757
|
+
let event;
|
|
1758
|
+
try {
|
|
1759
|
+
event = parseSpoolEnvelope(readFileSync(path, "utf8"));
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (isNodeError(error, "ENOENT"))
|
|
1762
|
+
continue;
|
|
1763
|
+
throw error;
|
|
1764
|
+
}
|
|
1765
|
+
if (spoolFileName(event) !== name)
|
|
1766
|
+
throw new Error("Durable event spool filename does not match its identity");
|
|
1767
|
+
const enqueued = this.enqueue(event);
|
|
1768
|
+
if (enqueued.deduped)
|
|
1769
|
+
result.deduped += 1;
|
|
1770
|
+
else
|
|
1771
|
+
result.imported += 1;
|
|
1772
|
+
result.queued += enqueued.queued;
|
|
1773
|
+
try {
|
|
1774
|
+
unlinkSync(path);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
if (!isNodeError(error, "ENOENT"))
|
|
1777
|
+
throw error;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (names.length > 0)
|
|
1781
|
+
syncDirectory(inboxDir);
|
|
1782
|
+
this.secureDatabaseFiles();
|
|
1783
|
+
return result;
|
|
1784
|
+
}
|
|
1785
|
+
retryDead(options = {}) {
|
|
1786
|
+
const limit = normalizePositiveInteger(options.limit, 100, "limit");
|
|
1787
|
+
return this.immediate(() => {
|
|
1788
|
+
const conditions = ["status = 'dead'"];
|
|
1789
|
+
const bindings = [];
|
|
1790
|
+
if (options.eventId) {
|
|
1791
|
+
conditions.push("event_id = ?");
|
|
1792
|
+
bindings.push(options.eventId);
|
|
1793
|
+
}
|
|
1794
|
+
if (options.channelId) {
|
|
1795
|
+
conditions.push("channel_id = ?");
|
|
1796
|
+
bindings.push(options.channelId);
|
|
1797
|
+
}
|
|
1798
|
+
const rows = this.db.query(`
|
|
1799
|
+
SELECT id FROM outbox
|
|
1800
|
+
WHERE ${conditions.join(" AND ")}
|
|
1801
|
+
ORDER BY updated_at, id
|
|
1802
|
+
LIMIT ?
|
|
1803
|
+
`).all(...bindings, limit);
|
|
1804
|
+
let requeued = 0;
|
|
1805
|
+
for (const row of rows) {
|
|
1806
|
+
const updated = this.db.query(`
|
|
1807
|
+
UPDATE outbox
|
|
1808
|
+
SET status = 'pending', attempt_count = 0, attempts_json = '[]',
|
|
1809
|
+
available_at = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
1810
|
+
updated_at = ?
|
|
1811
|
+
WHERE id = ? AND status = 'dead'
|
|
1812
|
+
`).run(this.now().getTime(), this.now().toISOString(), row.id);
|
|
1813
|
+
requeued += Number(updated.changes);
|
|
1814
|
+
}
|
|
1815
|
+
return { matched: rows.length, requeued };
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
status() {
|
|
1819
|
+
const channels = this.count("SELECT COUNT(*) AS count FROM channels");
|
|
1820
|
+
const enabledChannels = this.count("SELECT COUNT(*) AS count FROM channels WHERE enabled = 1");
|
|
1821
|
+
const events = this.count("SELECT COUNT(*) AS count FROM events");
|
|
1822
|
+
const statusRows = this.db.query("SELECT status, COUNT(*) AS count FROM outbox GROUP BY status").all();
|
|
1823
|
+
const statuses = Object.fromEntries(statusRows.map((row) => [row.status, Number(row.count)]));
|
|
1824
|
+
return {
|
|
1825
|
+
service: "events",
|
|
1826
|
+
storage: "local-sqlite",
|
|
1827
|
+
schemaVersion: DURABLE_SCHEMA_VERSION,
|
|
1828
|
+
databasePath: this.databasePath,
|
|
1829
|
+
counts: {
|
|
1830
|
+
channels,
|
|
1831
|
+
enabledChannels,
|
|
1832
|
+
events,
|
|
1833
|
+
pending: statuses.pending ?? 0,
|
|
1834
|
+
leased: statuses.leased ?? 0,
|
|
1835
|
+
delivered: statuses.delivered ?? 0,
|
|
1836
|
+
dead: statuses.dead ?? 0
|
|
1837
|
+
},
|
|
1838
|
+
safety: {
|
|
1839
|
+
statusOmitsEventPayloads: true,
|
|
1840
|
+
databasePersistsEventEnvelopes: true,
|
|
1841
|
+
includesResolvedSecrets: false,
|
|
1842
|
+
inlineWebhookSecretsAllowed: false
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
nextWakeAt() {
|
|
1847
|
+
const row = this.db.query(`
|
|
1848
|
+
SELECT MIN(
|
|
1849
|
+
CASE WHEN o.status = 'leased' THEN o.lease_expires_at ELSE o.available_at END
|
|
1850
|
+
) AS next_at
|
|
1851
|
+
FROM outbox o
|
|
1852
|
+
JOIN channels c ON c.id = o.channel_id AND c.enabled = 1
|
|
1853
|
+
WHERE o.status IN ('pending', 'leased')
|
|
1854
|
+
`).get();
|
|
1855
|
+
return row?.next_at === null || row?.next_at === undefined ? undefined : Number(row.next_at);
|
|
1856
|
+
}
|
|
1857
|
+
claim(options) {
|
|
1858
|
+
return this.immediate(() => {
|
|
1859
|
+
const nowMs = this.now().getTime();
|
|
1860
|
+
const excludeIds = options.excludeIds ?? [];
|
|
1861
|
+
const exclusion = excludeIds.length > 0 ? ` AND o.id NOT IN (${excludeIds.map(() => "?").join(", ")})` : "";
|
|
1862
|
+
const rows = this.db.query(`
|
|
1863
|
+
SELECT o.id, o.event_json, c.config_json AS channel_json,
|
|
1864
|
+
o.attempt_count, o.attempts_json
|
|
1865
|
+
FROM outbox o
|
|
1866
|
+
JOIN channels c ON c.id = o.channel_id AND c.enabled = 1
|
|
1867
|
+
WHERE ((o.status = 'pending' AND o.available_at <= ?)
|
|
1868
|
+
OR (o.status = 'leased' AND o.lease_expires_at <= ?))
|
|
1869
|
+
${exclusion}
|
|
1870
|
+
ORDER BY o.available_at, o.created_at, o.id
|
|
1871
|
+
LIMIT ?
|
|
1872
|
+
`).all(nowMs, nowMs, ...excludeIds, options.limit);
|
|
1873
|
+
const jobs = [];
|
|
1874
|
+
for (const row of rows) {
|
|
1875
|
+
const nextAttempt = Number(row.attempt_count) + 1;
|
|
1876
|
+
const channel = parseJson(row.channel_json);
|
|
1877
|
+
const transportTimeoutMs = channel.webhook?.timeoutMs ?? channel.command?.timeoutMs ?? 15000;
|
|
1878
|
+
const leaseMs = Math.max(options.leaseMs, transportTimeoutMs + 5000);
|
|
1879
|
+
const update = this.db.query(`
|
|
1880
|
+
UPDATE outbox
|
|
1881
|
+
SET status = 'leased', attempt_count = ?, lease_owner = ?,
|
|
1882
|
+
lease_expires_at = ?, updated_at = ?
|
|
1883
|
+
WHERE id = ?
|
|
1884
|
+
AND ((status = 'pending' AND available_at <= ?)
|
|
1885
|
+
OR (status = 'leased' AND lease_expires_at <= ?))
|
|
1886
|
+
`).run(nextAttempt, options.workerId, nowMs + leaseMs, this.now().toISOString(), row.id, nowMs, nowMs);
|
|
1887
|
+
if (Number(update.changes) !== 1)
|
|
1888
|
+
continue;
|
|
1889
|
+
jobs.push({
|
|
1890
|
+
id: row.id,
|
|
1891
|
+
event: parseJson(row.event_json),
|
|
1892
|
+
channel,
|
|
1893
|
+
attempt: nextAttempt,
|
|
1894
|
+
workerId: options.workerId
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
return jobs;
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
settle(job, attempt) {
|
|
1901
|
+
return this.immediate(() => {
|
|
1902
|
+
const row = this.db.query(`
|
|
1903
|
+
SELECT attempts_json FROM outbox
|
|
1904
|
+
WHERE id = ? AND status = 'leased' AND lease_owner = ?
|
|
1905
|
+
`).get(job.id, job.workerId);
|
|
1906
|
+
if (!row)
|
|
1907
|
+
return { status: "lost" };
|
|
1908
|
+
const attempts = parseJson(row.attempts_json);
|
|
1909
|
+
attempts.push(attempt);
|
|
1910
|
+
if (attempt.status === "success") {
|
|
1911
|
+
const delivery2 = createDeliveryResult(job.event, job.channel, attempts);
|
|
1912
|
+
this.completeOutbox(job, "delivered", attempts, delivery2);
|
|
1913
|
+
return { status: "delivered", delivery: delivery2 };
|
|
1914
|
+
}
|
|
1915
|
+
const retry = normalizeRetryPolicy2(job.channel.retry);
|
|
1916
|
+
if (job.attempt < retry.maxAttempts) {
|
|
1917
|
+
const backoffMs = retryBackoffMs(retry, job.attempt);
|
|
1918
|
+
attempt.nextBackoffMs = backoffMs;
|
|
1919
|
+
this.db.query(`
|
|
1920
|
+
UPDATE outbox
|
|
1921
|
+
SET status = 'pending', available_at = ?, attempts_json = ?,
|
|
1922
|
+
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
1923
|
+
WHERE id = ? AND lease_owner = ?
|
|
1924
|
+
`).run(this.now().getTime() + backoffMs, JSON.stringify(attempts), this.now().toISOString(), job.id, job.workerId);
|
|
1925
|
+
return { status: "retry" };
|
|
1926
|
+
}
|
|
1927
|
+
const delivery = createDeliveryResult(job.event, job.channel, attempts);
|
|
1928
|
+
this.completeOutbox(job, "dead", attempts, delivery);
|
|
1929
|
+
return { status: "dead", delivery };
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
completeOutbox(job, status, attempts, delivery) {
|
|
1933
|
+
const timestamp = this.now().toISOString();
|
|
1934
|
+
this.db.query(`
|
|
1935
|
+
UPDATE outbox
|
|
1936
|
+
SET status = ?, attempts_json = ?, lease_owner = NULL,
|
|
1937
|
+
lease_expires_at = NULL, updated_at = ?
|
|
1938
|
+
WHERE id = ? AND lease_owner = ?
|
|
1939
|
+
`).run(status, JSON.stringify(attempts), timestamp, job.id, job.workerId);
|
|
1940
|
+
this.db.query(`
|
|
1941
|
+
INSERT INTO deliveries (id, event_id, channel_id, result_json, created_at)
|
|
1942
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1943
|
+
`).run(delivery.id, job.event.id, job.channel.id, JSON.stringify(delivery), timestamp);
|
|
1944
|
+
}
|
|
1945
|
+
findEvent(id, dedupeKey) {
|
|
1946
|
+
if (dedupeKey === undefined) {
|
|
1947
|
+
return this.db.query("SELECT envelope_json FROM events WHERE id = ? LIMIT 1").get(id);
|
|
1948
|
+
}
|
|
1949
|
+
return this.db.query(`
|
|
1950
|
+
SELECT envelope_json FROM events
|
|
1951
|
+
WHERE id = ? OR dedupe_key = ?
|
|
1952
|
+
LIMIT 1
|
|
1953
|
+
`).get(id, dedupeKey);
|
|
1954
|
+
}
|
|
1955
|
+
queueMatchingChannels(event) {
|
|
1956
|
+
const channels = this.db.query("SELECT config_json FROM channels WHERE enabled = 1 ORDER BY id").all();
|
|
1957
|
+
let queued = 0;
|
|
1958
|
+
for (const row of channels) {
|
|
1959
|
+
const channel = parseJson(row.config_json);
|
|
1960
|
+
if (!channelMatchesEvent(channel, event))
|
|
1961
|
+
continue;
|
|
1962
|
+
const channelEvent = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
1963
|
+
const timestamp = this.now().toISOString();
|
|
1964
|
+
const inserted = this.db.query(`
|
|
1965
|
+
INSERT OR IGNORE INTO outbox (
|
|
1966
|
+
id, event_id, channel_id, event_json, channel_json, status,
|
|
1967
|
+
attempt_count, available_at, attempts_json, created_at, updated_at
|
|
1968
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, '[]', ?, ?)
|
|
1969
|
+
`).run(randomUUID3(), event.id, channel.id, JSON.stringify(channelEvent), JSON.stringify(channel), this.now().getTime(), timestamp, timestamp);
|
|
1970
|
+
queued += Number(inserted.changes);
|
|
1971
|
+
}
|
|
1972
|
+
return queued;
|
|
1973
|
+
}
|
|
1974
|
+
count(sql) {
|
|
1975
|
+
const row = this.db.query(sql).get();
|
|
1976
|
+
return Number(row?.count ?? 0);
|
|
1977
|
+
}
|
|
1978
|
+
immediate(operation) {
|
|
1979
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
1980
|
+
try {
|
|
1981
|
+
const result = operation();
|
|
1982
|
+
this.db.exec("COMMIT;");
|
|
1983
|
+
return result;
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
this.db.exec("ROLLBACK;");
|
|
1986
|
+
throw error;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
ensureSchema() {
|
|
1990
|
+
const version = this.readSchemaVersion();
|
|
1991
|
+
if (!Number.isInteger(version) || version < 0) {
|
|
1992
|
+
throw new Error("Durable SQLite schema version is invalid");
|
|
1993
|
+
}
|
|
1994
|
+
if (version > DURABLE_SCHEMA_VERSION) {
|
|
1995
|
+
throw new Error(`Durable SQLite schema version ${version} is newer than supported version ${DURABLE_SCHEMA_VERSION}`);
|
|
1996
|
+
}
|
|
1997
|
+
if (version === 0) {
|
|
1998
|
+
this.immediate(() => {
|
|
1999
|
+
if (this.readSchemaVersion() !== 0) {
|
|
2000
|
+
throw new Error("Durable SQLite schema version changed during initialization");
|
|
2001
|
+
}
|
|
2002
|
+
this.assertEmptyApplicationSchema();
|
|
2003
|
+
this.createSchemaV1();
|
|
2004
|
+
this.assertSchemaV1();
|
|
2005
|
+
this.db.exec(`PRAGMA user_version = ${DURABLE_SCHEMA_VERSION};`);
|
|
2006
|
+
if (this.readSchemaVersion() !== DURABLE_SCHEMA_VERSION) {
|
|
2007
|
+
throw new Error("Durable SQLite schema version could not be recorded");
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
this.assertSchemaV1();
|
|
2013
|
+
}
|
|
2014
|
+
createSchemaV1() {
|
|
2015
|
+
for (const sql of Object.values(SCHEMA_V1_TABLE_SQL))
|
|
2016
|
+
this.db.exec(`${sql};`);
|
|
2017
|
+
for (const sql of Object.values(SCHEMA_V1_INDEX_SQL))
|
|
2018
|
+
this.db.exec(`${sql};`);
|
|
2019
|
+
}
|
|
2020
|
+
assertSchemaV1() {
|
|
2021
|
+
const objects = this.applicationSchemaObjects();
|
|
2022
|
+
const expectedObjects = [
|
|
2023
|
+
...Object.entries(SCHEMA_V1_TABLE_SQL).map(([name, sql]) => ({ type: "table", name, table: name, sql })),
|
|
2024
|
+
...Object.entries(SCHEMA_V1_INDEX_SQL).map(([name, sql]) => ({
|
|
2025
|
+
type: "index",
|
|
2026
|
+
name,
|
|
2027
|
+
table: schemaIndexTable(name),
|
|
2028
|
+
sql
|
|
2029
|
+
}))
|
|
2030
|
+
].sort(compareSchemaObjects);
|
|
2031
|
+
assertSchemaShape("application objects", objects.map(({ type, name, table }) => ({ type, name, table })), expectedObjects.map(({ type, name, table }) => ({ type, name, table })));
|
|
2032
|
+
for (const table of Object.keys(SCHEMA_V1_TABLE_SQL)) {
|
|
2033
|
+
const columns = this.db.query(`PRAGMA table_info(${schemaIdentifier(table)})`).all().map((column) => ({
|
|
2034
|
+
name: column.name,
|
|
2035
|
+
type: column.type,
|
|
2036
|
+
notnull: Number(column.notnull),
|
|
2037
|
+
defaultValue: column.dflt_value,
|
|
2038
|
+
pk: Number(column.pk)
|
|
2039
|
+
}));
|
|
2040
|
+
assertSchemaShape(`${table} columns`, columns, SCHEMA_V1_COLUMNS[table]);
|
|
2041
|
+
const foreignKeys = this.db.query(`PRAGMA foreign_key_list(${schemaIdentifier(table)})`).all().map((foreignKey) => ({
|
|
2042
|
+
table: foreignKey.table,
|
|
2043
|
+
from: foreignKey.from,
|
|
2044
|
+
to: foreignKey.to,
|
|
2045
|
+
onUpdate: foreignKey.on_update,
|
|
2046
|
+
onDelete: foreignKey.on_delete,
|
|
2047
|
+
match: foreignKey.match
|
|
2048
|
+
})).sort((left, right) => `${left.from}:${left.table}`.localeCompare(`${right.from}:${right.table}`));
|
|
2049
|
+
assertSchemaShape(`${table} foreign keys`, foreignKeys, SCHEMA_V1_FOREIGN_KEYS[table]);
|
|
2050
|
+
const indexes = this.db.query(`PRAGMA index_list(${schemaIdentifier(table)})`).all().map((index) => ({
|
|
2051
|
+
name: index.name,
|
|
2052
|
+
unique: Number(index.unique),
|
|
2053
|
+
origin: index.origin,
|
|
2054
|
+
partial: Number(index.partial),
|
|
2055
|
+
columns: this.db.query(`PRAGMA index_info(${schemaIdentifier(index.name)})`).all().sort((left, right) => Number(left.seqno) - Number(right.seqno)).map((column) => column.name)
|
|
2056
|
+
})).sort((left, right) => left.name.localeCompare(right.name));
|
|
2057
|
+
const expectedIndexes = [...SCHEMA_V1_INDEXES[table]].sort((left, right) => left.name.localeCompare(right.name));
|
|
2058
|
+
assertSchemaShape(`${table} indexes`, indexes, expectedIndexes);
|
|
2059
|
+
}
|
|
2060
|
+
for (const expected of expectedObjects) {
|
|
2061
|
+
const actual = objects.find((object) => object.type === expected.type && object.name === expected.name);
|
|
2062
|
+
if (!actual?.sql || normalizeSchemaSql(actual.sql) !== normalizeSchemaSql(expected.sql)) {
|
|
2063
|
+
throw incompatibleSchema(`${expected.type} ${expected.name} SQL`);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
readSchemaVersion() {
|
|
2068
|
+
const row = this.db.query("PRAGMA user_version").get();
|
|
2069
|
+
return Number(row?.user_version);
|
|
2070
|
+
}
|
|
2071
|
+
applicationSchemaObjects() {
|
|
2072
|
+
return this.db.query(`
|
|
2073
|
+
SELECT type, name, tbl_name, sql
|
|
2074
|
+
FROM sqlite_master
|
|
2075
|
+
WHERE substr(name, 1, 7) <> 'sqlite_'
|
|
2076
|
+
ORDER BY type, name
|
|
2077
|
+
`).all().map((row) => ({
|
|
2078
|
+
type: row.type,
|
|
2079
|
+
name: row.name,
|
|
2080
|
+
table: row.tbl_name,
|
|
2081
|
+
sql: row.sql
|
|
2082
|
+
}));
|
|
2083
|
+
}
|
|
2084
|
+
assertEmptyApplicationSchema() {
|
|
2085
|
+
if (this.applicationSchemaObjects().length !== 0) {
|
|
2086
|
+
throw new Error("Durable SQLite schema version 0 requires an empty application schema");
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
secureDatabaseFiles() {
|
|
2090
|
+
for (const path of [this.databasePath, `${this.databasePath}-wal`, `${this.databasePath}-shm`]) {
|
|
2091
|
+
if (!existsSync2(path))
|
|
2092
|
+
continue;
|
|
2093
|
+
chmodSync(path, 384);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
function schemaIndexTable(name) {
|
|
2098
|
+
if (name === "events_dedupe_key_unique" || name === "events_source_type_idx")
|
|
2099
|
+
return "events";
|
|
2100
|
+
if (name === "outbox_due_idx")
|
|
2101
|
+
return "outbox";
|
|
2102
|
+
throw new Error(`Unknown durable schema index: ${name}`);
|
|
2103
|
+
}
|
|
2104
|
+
function schemaIdentifier(value) {
|
|
2105
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
2106
|
+
throw new Error("Invalid durable schema identifier");
|
|
2107
|
+
return value;
|
|
2108
|
+
}
|
|
2109
|
+
function compareSchemaObjects(left, right) {
|
|
2110
|
+
return `${left.type}:${left.name}`.localeCompare(`${right.type}:${right.name}`);
|
|
2111
|
+
}
|
|
2112
|
+
function normalizeSchemaSql(sql) {
|
|
2113
|
+
return sql.trim().replace(/;$/, "").replace(/\s+/g, " ").replace(/\s*([(),])\s*/g, "$1").toLowerCase();
|
|
2114
|
+
}
|
|
2115
|
+
function assertSchemaShape(label, actual, expected) {
|
|
2116
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
2117
|
+
throw incompatibleSchema(label);
|
|
2118
|
+
}
|
|
2119
|
+
function incompatibleSchema(detail) {
|
|
2120
|
+
return new Error(`Durable SQLite schema version 1 is incompatible: ${detail}`);
|
|
2121
|
+
}
|
|
2122
|
+
function normalizePositiveInteger(value, fallback, name) {
|
|
2123
|
+
const resolved = value ?? fallback;
|
|
2124
|
+
if (!Number.isInteger(resolved) || resolved < 1)
|
|
2125
|
+
throw new Error(`${name} must be a positive integer`);
|
|
2126
|
+
return resolved;
|
|
2127
|
+
}
|
|
2128
|
+
function normalizeRetryPolicy2(policy) {
|
|
2129
|
+
const normalized = {
|
|
2130
|
+
maxAttempts: policy?.maxAttempts ?? 1,
|
|
2131
|
+
backoffMs: policy?.backoffMs ?? 250,
|
|
2132
|
+
multiplier: policy?.multiplier ?? 2
|
|
2133
|
+
};
|
|
2134
|
+
validateRetryPolicy(normalized);
|
|
2135
|
+
return normalized;
|
|
2136
|
+
}
|
|
2137
|
+
function validateRetryPolicy(policy) {
|
|
2138
|
+
const maxAttempts = policy.maxAttempts ?? 1;
|
|
2139
|
+
const backoffMs = policy.backoffMs ?? 250;
|
|
2140
|
+
const multiplier = policy.multiplier ?? 2;
|
|
2141
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
2142
|
+
throw new Error(`retry.maxAttempts must be an integer from 1 to ${MAX_RETRY_ATTEMPTS}`);
|
|
2143
|
+
}
|
|
2144
|
+
if (!Number.isInteger(backoffMs) || backoffMs < 0 || backoffMs > MAX_RETRY_DELAY_MS) {
|
|
2145
|
+
throw new Error(`retry.backoffMs must be an integer from 0 to ${MAX_RETRY_DELAY_MS}`);
|
|
2146
|
+
}
|
|
2147
|
+
if (!Number.isFinite(multiplier) || multiplier < 1 || multiplier > MAX_RETRY_MULTIPLIER) {
|
|
2148
|
+
throw new Error(`retry.multiplier must be finite and from 1 to ${MAX_RETRY_MULTIPLIER}`);
|
|
2149
|
+
}
|
|
2150
|
+
if (maxAttempts > 1)
|
|
2151
|
+
retryBackoffMs({ maxAttempts, backoffMs, multiplier }, maxAttempts - 1);
|
|
2152
|
+
}
|
|
2153
|
+
function retryBackoffMs(policy, attempt) {
|
|
2154
|
+
const delay = Math.round(policy.backoffMs * policy.multiplier ** (attempt - 1));
|
|
2155
|
+
if (!Number.isSafeInteger(delay) || delay < 0 || delay > MAX_RETRY_DELAY_MS) {
|
|
2156
|
+
throw new Error(`retry policy must not produce a delay above ${MAX_RETRY_DELAY_MS}ms`);
|
|
2157
|
+
}
|
|
2158
|
+
return delay;
|
|
2159
|
+
}
|
|
2160
|
+
function parseJson(value) {
|
|
2161
|
+
return JSON.parse(value);
|
|
2162
|
+
}
|
|
2163
|
+
function validateDurableWebhookConfig(webhook) {
|
|
2164
|
+
let url;
|
|
2165
|
+
try {
|
|
2166
|
+
url = new URL(webhook.url);
|
|
2167
|
+
} catch {
|
|
2168
|
+
throw new Error("Durable webhook URL must be a valid URL");
|
|
2169
|
+
}
|
|
2170
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
2171
|
+
throw new Error("Durable webhook URL must use http or https");
|
|
2172
|
+
}
|
|
2173
|
+
if (url.username || url.password) {
|
|
2174
|
+
throw new Error("Durable webhook URL must not contain credentials");
|
|
2175
|
+
}
|
|
2176
|
+
for (const name of url.searchParams.keys()) {
|
|
2177
|
+
if (/authorization|cookie|api[-_]?key|token|secret|credential|signature/i.test(name)) {
|
|
2178
|
+
throw new Error("Durable webhook URL must not contain credential query parameters");
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
for (const name of Object.keys(webhook.headers ?? {})) {
|
|
2182
|
+
if (/^x-hasna-/i.test(name)) {
|
|
2183
|
+
throw new Error("Durable webhook X-Hasna headers are reserved for signed delivery metadata");
|
|
2184
|
+
}
|
|
2185
|
+
if (/authorization|cookie|api[-_]?key|token|secret|credential/i.test(name)) {
|
|
2186
|
+
throw new Error("Durable webhook credential headers are not persisted; use webhook.secretRef");
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
function sanitizeDurableAttempt(attempt) {
|
|
2191
|
+
const { responseBody: _responseBody, stdout: _stdout, stderr: _stderr, ...metadata } = attempt;
|
|
2192
|
+
if (metadata.status === "failed") {
|
|
2193
|
+
metadata.error = metadata.responseStatus === undefined ? "Webhook delivery failed" : `Webhook returned HTTP ${metadata.responseStatus}`;
|
|
2194
|
+
}
|
|
2195
|
+
return metadata;
|
|
2196
|
+
}
|
|
2197
|
+
function parseSpoolEnvelope(raw) {
|
|
2198
|
+
const value = parseJson(raw);
|
|
2199
|
+
if (!value || typeof value !== "object")
|
|
2200
|
+
throw new Error("Invalid durable event spool record");
|
|
2201
|
+
for (const field of ["id", "source", "type", "time", "schemaVersion"]) {
|
|
2202
|
+
if (typeof value[field] !== "string" || value[field].length === 0) {
|
|
2203
|
+
throw new Error("Invalid durable event spool record");
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
2207
|
+
throw new Error("Invalid durable event spool record");
|
|
2208
|
+
}
|
|
2209
|
+
if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) {
|
|
2210
|
+
throw new Error("Invalid durable event spool record");
|
|
2211
|
+
}
|
|
2212
|
+
return value;
|
|
2213
|
+
}
|
|
2214
|
+
function syncDirectory(path) {
|
|
2215
|
+
const descriptor = openSync(path, "r");
|
|
2216
|
+
try {
|
|
2217
|
+
fsyncSync(descriptor);
|
|
2218
|
+
} finally {
|
|
2219
|
+
closeSync(descriptor);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
function isNodeError(error, code) {
|
|
2223
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
2224
|
+
}
|
|
2225
|
+
function spoolFileName(event) {
|
|
2226
|
+
const identity = event.dedupeKey ?? event.id;
|
|
2227
|
+
return `${createHash("sha256").update(identity, "utf8").digest("hex")}.json`;
|
|
2228
|
+
}
|
|
2229
|
+
export {
|
|
2230
|
+
defaultWebhookSecretResolver,
|
|
2231
|
+
DurableEventsBroker
|
|
2232
|
+
};
|