@keemakr/event-sdk 0.2.0-dev-20261012160211
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/README.md +41 -0
- package/dist/apps/channels.d.ts +400 -0
- package/dist/apps/channels.js +180 -0
- package/dist/apps/define.d.ts +29 -0
- package/dist/apps/define.js +41 -0
- package/dist/apps/index.d.ts +16 -0
- package/dist/apps/index.js +27 -0
- package/dist/apps/smartlead.d.ts +52 -0
- package/dist/apps/smartlead.js +45 -0
- package/dist/index.d.ts +342 -0
- package/dist/index.js +576 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
/**
|
|
5
|
+
* Fetch a contract-only build artifact with a short-lived tenant access token.
|
|
6
|
+
* The exact release pin is checked before the artifact reaches build tooling.
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchEventContractArtifact(input) {
|
|
9
|
+
const request = input.fetch ?? fetch;
|
|
10
|
+
const response = await request(`${input.coreUrl.replace(/\/$/, '')}/api/events/contracts/${encodeURIComponent(input.key)}/${input.version}`, { headers: { authorization: `Bearer ${input.accessToken}` } });
|
|
11
|
+
if (!response.ok)
|
|
12
|
+
throw new Error(`Event contract fetch failed with HTTP ${response.status}`);
|
|
13
|
+
const artifact = (await response.json());
|
|
14
|
+
if (artifact.owner !== input.owner ||
|
|
15
|
+
artifact.event_key !== input.key ||
|
|
16
|
+
artifact.version !== input.version ||
|
|
17
|
+
artifact.fingerprint !== input.fingerprint ||
|
|
18
|
+
!artifact.payload_schema ||
|
|
19
|
+
typeof artifact.payload_schema !== 'object') {
|
|
20
|
+
throw new Error('Event contract artifact does not match the pinned contract identity');
|
|
21
|
+
}
|
|
22
|
+
return artifact;
|
|
23
|
+
}
|
|
24
|
+
const NAMESPACE = /^[a-z][a-z0-9-]*$/;
|
|
25
|
+
const EVENT_KEY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/;
|
|
26
|
+
export function canonicalEventJson(value) {
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return `[${value.map(canonicalEventJson).join(',')}]`;
|
|
29
|
+
if (value !== null && typeof value === 'object') {
|
|
30
|
+
const entries = Object.entries(value)
|
|
31
|
+
.filter(([, item]) => item !== undefined)
|
|
32
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
33
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalEventJson(item)}`).join(',')}}`;
|
|
34
|
+
}
|
|
35
|
+
return JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
export function eventPrimarySubject(payload, fields) {
|
|
38
|
+
if (fields.length === 0 || payload === null || typeof payload !== 'object')
|
|
39
|
+
return null;
|
|
40
|
+
const record = payload;
|
|
41
|
+
const subject = {};
|
|
42
|
+
for (const field of fields) {
|
|
43
|
+
subject[field] = record[field] ?? null;
|
|
44
|
+
}
|
|
45
|
+
return canonicalEventJson(subject);
|
|
46
|
+
}
|
|
47
|
+
function cloneJson(value) {
|
|
48
|
+
return JSON.parse(JSON.stringify(value));
|
|
49
|
+
}
|
|
50
|
+
function deepFreeze(value) {
|
|
51
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
52
|
+
Object.freeze(value);
|
|
53
|
+
for (const child of Object.values(value))
|
|
54
|
+
deepFreeze(child);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function schemaValidator(schema) {
|
|
59
|
+
try {
|
|
60
|
+
return z.fromJSONSchema(schema);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error(`payload schema is not enforceable: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function defineEvent(input) {
|
|
67
|
+
if (!NAMESPACE.test(input.owner))
|
|
68
|
+
throw new Error('event owner must be a Pack namespace');
|
|
69
|
+
if (!EVENT_KEY.test(input.key))
|
|
70
|
+
throw new Error('event key must be a namespaced dotted key');
|
|
71
|
+
const namespace = input.key.split('.')[0];
|
|
72
|
+
if (namespace !== input.owner) {
|
|
73
|
+
throw new Error(`event key namespace '${namespace}' does not match owner '${input.owner}'`);
|
|
74
|
+
}
|
|
75
|
+
if (namespace === 'core' && input.owner !== 'core') {
|
|
76
|
+
throw new Error("the 'core' Event namespace is reserved");
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isInteger(input.version) || input.version <= 0) {
|
|
79
|
+
throw new Error('event version must be a positive integer');
|
|
80
|
+
}
|
|
81
|
+
if (!input.description.trim())
|
|
82
|
+
throw new Error('event description is required');
|
|
83
|
+
if (!input.payloadSchema || typeof input.payloadSchema !== 'object') {
|
|
84
|
+
throw new Error('event payload schema must be an object');
|
|
85
|
+
}
|
|
86
|
+
const primary = input.subjects?.primary ?? [];
|
|
87
|
+
if (new Set(primary).size !== primary.length || primary.some((field) => !field.trim())) {
|
|
88
|
+
throw new Error('event primary subject fields must be unique non-empty names');
|
|
89
|
+
}
|
|
90
|
+
const payloadSchema = deepFreeze(cloneJson(input.payloadSchema));
|
|
91
|
+
const subjects = primary.length
|
|
92
|
+
? deepFreeze({ primary: [...primary] })
|
|
93
|
+
: undefined;
|
|
94
|
+
const sensitivity = input.sensitivity ?? 'none';
|
|
95
|
+
const identity = {
|
|
96
|
+
owner: input.owner,
|
|
97
|
+
key: input.key,
|
|
98
|
+
version: input.version,
|
|
99
|
+
description: input.description,
|
|
100
|
+
payloadSchema,
|
|
101
|
+
sensitivity,
|
|
102
|
+
...(subjects ? { subjects } : {}),
|
|
103
|
+
};
|
|
104
|
+
const fingerprint = eventContractFingerprint(identity);
|
|
105
|
+
const validator = schemaValidator(payloadSchema);
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
...identity,
|
|
108
|
+
fingerprint,
|
|
109
|
+
validate(value) {
|
|
110
|
+
const parsed = validator.safeParse(value);
|
|
111
|
+
if (parsed.success)
|
|
112
|
+
return parsed.data;
|
|
113
|
+
const issues = parsed.error.issues.map((issue) => issue.path.length ? `${issue.path.join('.')}: ${issue.message}` : issue.message);
|
|
114
|
+
throw new Error(`event payload is invalid: ${issues.join('; ')}`);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export function eventContractFingerprint(identity) {
|
|
119
|
+
const normalized = {
|
|
120
|
+
...identity,
|
|
121
|
+
...(identity.subjects?.primary.length
|
|
122
|
+
? { subjects: identity.subjects }
|
|
123
|
+
: { subjects: undefined }),
|
|
124
|
+
};
|
|
125
|
+
return `sha256:${createHash('sha256').update(canonicalEventJson(normalized)).digest('hex')}`;
|
|
126
|
+
}
|
|
127
|
+
export const CoreAppEventProducerSchema = z.object({
|
|
128
|
+
kind: z.literal('core_app'),
|
|
129
|
+
app: z.enum(['email', 'whatsapp', 'widget']),
|
|
130
|
+
integrationId: z.string().min(1),
|
|
131
|
+
});
|
|
132
|
+
/**
|
|
133
|
+
* The exclusive channel lane. It is intentionally not an EventDelivery variant:
|
|
134
|
+
* generic Event handlers cannot accept it by widening their existing contract.
|
|
135
|
+
*/
|
|
136
|
+
export const ChannelEventDeliverySchema = z.object({
|
|
137
|
+
deliveryId: z.string().min(1),
|
|
138
|
+
eventId: z.string().min(1),
|
|
139
|
+
tenantId: z.string().min(1),
|
|
140
|
+
installId: z.string().min(1),
|
|
141
|
+
bindingId: z.string().min(1),
|
|
142
|
+
bindingRevisionId: z.string().min(1),
|
|
143
|
+
conversationId: z.string().min(1),
|
|
144
|
+
audience: z.url(),
|
|
145
|
+
ownerEpoch: z.number().int().nonnegative(),
|
|
146
|
+
target: z.discriminatedUnion('kind', [
|
|
147
|
+
z.object({ kind: z.literal('agent'), key: z.string().min(1) }),
|
|
148
|
+
z.object({ kind: z.literal('team'), key: z.string().min(1) }),
|
|
149
|
+
]),
|
|
150
|
+
producer: CoreAppEventProducerSchema,
|
|
151
|
+
externalPrincipal: z.object({ kind: z.literal('external'), id: z.string().min(1) }),
|
|
152
|
+
payload: z.record(z.string(), z.unknown()),
|
|
153
|
+
});
|
|
154
|
+
const jwks = new Map();
|
|
155
|
+
export async function verifyEventDeliveryGrant(token, options) {
|
|
156
|
+
// Core publishes its keys at a fixed path, so naming core is enough.
|
|
157
|
+
const coreUrl = options.coreUrl ?? process.env.KEE_CORE_URL;
|
|
158
|
+
const url = options.jwksUrl ??
|
|
159
|
+
process.env.KEE_CORE_JWKS_URL ??
|
|
160
|
+
(coreUrl ? `${coreUrl.replace(/\/$/, '')}/.well-known/jwks.json` : undefined);
|
|
161
|
+
if (!url)
|
|
162
|
+
return null;
|
|
163
|
+
let keys = jwks.get(url);
|
|
164
|
+
if (!keys) {
|
|
165
|
+
keys = createRemoteJWKSet(new URL(url));
|
|
166
|
+
jwks.set(url, keys);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
170
|
+
issuer: 'keemakr',
|
|
171
|
+
audience: options.audience,
|
|
172
|
+
});
|
|
173
|
+
const delivery = payload.event_delivery;
|
|
174
|
+
if (!delivery || typeof payload.tenant_id !== 'string')
|
|
175
|
+
return null;
|
|
176
|
+
const contract = {
|
|
177
|
+
owner: String(delivery.event_key ?? '').split('.')[0] ?? '',
|
|
178
|
+
key: String(delivery.event_key ?? ''),
|
|
179
|
+
version: Number(delivery.event_version),
|
|
180
|
+
fingerprint: String(delivery.contract_fingerprint ?? ''),
|
|
181
|
+
};
|
|
182
|
+
const grant = {
|
|
183
|
+
tenantId: payload.tenant_id,
|
|
184
|
+
installId: String(delivery.consumer_install_id ?? ''),
|
|
185
|
+
packVersion: Number(delivery.pack_version),
|
|
186
|
+
deploymentRevision: String(delivery.deployment_revision ?? ''),
|
|
187
|
+
audience: Array.isArray(payload.aud)
|
|
188
|
+
? String(payload.aud[0] ?? '')
|
|
189
|
+
: String(payload.aud ?? ''),
|
|
190
|
+
handlerId: String(delivery.handler_id ?? ''),
|
|
191
|
+
contract,
|
|
192
|
+
bindingId: String(delivery.binding_id ?? ''),
|
|
193
|
+
deliveryId: String(delivery.delivery_id ?? ''),
|
|
194
|
+
attemptNumber: Number(delivery.attempt_number),
|
|
195
|
+
eventId: String(delivery.event_id ?? ''),
|
|
196
|
+
payloadDigest: String(delivery.payload_digest ?? ''),
|
|
197
|
+
scopes: Array.isArray(payload.scopes)
|
|
198
|
+
? Object.freeze(payload.scopes.filter((s) => typeof s === 'string'))
|
|
199
|
+
: Object.freeze([]),
|
|
200
|
+
traceId: typeof payload.trace_id === 'string' ? payload.trace_id : null,
|
|
201
|
+
authority: delivery.authority && typeof delivery.authority === 'object'
|
|
202
|
+
? deepFreeze(cloneJson(delivery.authority))
|
|
203
|
+
: {},
|
|
204
|
+
};
|
|
205
|
+
const invalid = !grant.installId ||
|
|
206
|
+
!Number.isInteger(grant.packVersion) ||
|
|
207
|
+
grant.packVersion <= 0 ||
|
|
208
|
+
!grant.deploymentRevision ||
|
|
209
|
+
!grant.audience ||
|
|
210
|
+
!grant.handlerId ||
|
|
211
|
+
!grant.bindingId ||
|
|
212
|
+
!grant.deliveryId ||
|
|
213
|
+
!Number.isInteger(grant.attemptNumber) ||
|
|
214
|
+
grant.attemptNumber <= 0 ||
|
|
215
|
+
!grant.eventId ||
|
|
216
|
+
!grant.payloadDigest ||
|
|
217
|
+
!contract.owner ||
|
|
218
|
+
!contract.key ||
|
|
219
|
+
!Number.isInteger(contract.version) ||
|
|
220
|
+
!contract.fingerprint;
|
|
221
|
+
return invalid ? null : grant;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function eventEffectIdempotencyKey(context, operation, discriminator) {
|
|
228
|
+
const suffix = discriminator ? `:${discriminator}` : '';
|
|
229
|
+
return `${context.deliveryId}:${operation}${suffix}`;
|
|
230
|
+
}
|
|
231
|
+
class EventDispatchError extends Error {
|
|
232
|
+
code;
|
|
233
|
+
status;
|
|
234
|
+
retryable;
|
|
235
|
+
detail;
|
|
236
|
+
constructor(code, status, retryable, message, detail) {
|
|
237
|
+
super(message);
|
|
238
|
+
this.code = code;
|
|
239
|
+
this.status = status;
|
|
240
|
+
this.retryable = retryable;
|
|
241
|
+
this.detail = detail;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** A handler failure whose retry policy is explicit and portable across runtimes. */
|
|
245
|
+
export class EventHandlerError extends Error {
|
|
246
|
+
code;
|
|
247
|
+
retryable;
|
|
248
|
+
detail;
|
|
249
|
+
constructor(input) {
|
|
250
|
+
if (!/^[a-z][a-z0-9_]*$/.test(input.code)) {
|
|
251
|
+
throw new Error('Event handler failure code must be a lowercase snake-case identifier');
|
|
252
|
+
}
|
|
253
|
+
super(input.message);
|
|
254
|
+
this.name = 'EventHandlerError';
|
|
255
|
+
this.code = input.code;
|
|
256
|
+
this.retryable = input.retryable;
|
|
257
|
+
this.detail = input.detail ? deepFreeze(cloneJson(input.detail)) : undefined;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function terminalEventError(code, message, detail) {
|
|
261
|
+
return new EventHandlerError({ code, message, retryable: false, detail });
|
|
262
|
+
}
|
|
263
|
+
export function retryableEventError(code, message, detail) {
|
|
264
|
+
return new EventHandlerError({ code, message, retryable: true, detail });
|
|
265
|
+
}
|
|
266
|
+
export function handleEvent(input) {
|
|
267
|
+
if (!/^[a-z][a-z0-9-]*$/.test(input.id)) {
|
|
268
|
+
throw new Error('event handler id must be a lowercase slug');
|
|
269
|
+
}
|
|
270
|
+
return Object.freeze({
|
|
271
|
+
id: input.id,
|
|
272
|
+
event: input.event,
|
|
273
|
+
consumes: Object.freeze({
|
|
274
|
+
owner: input.event.owner,
|
|
275
|
+
key: input.event.key,
|
|
276
|
+
version: input.event.version,
|
|
277
|
+
fingerprint: input.event.fingerprint,
|
|
278
|
+
}),
|
|
279
|
+
authority: deepFreeze({ ...(input.authority ?? {}) }),
|
|
280
|
+
run: input.run,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** Build the Event-only manifest fragment. Pack build tooling writes this output to entry.json. */
|
|
284
|
+
export function eventManifest(input) {
|
|
285
|
+
const events = input.events ?? [];
|
|
286
|
+
const handlers = input.handlers ?? [];
|
|
287
|
+
return deepFreeze({
|
|
288
|
+
events: events.map((event) => ({
|
|
289
|
+
owner: event.owner,
|
|
290
|
+
key: event.key,
|
|
291
|
+
version: event.version,
|
|
292
|
+
description: event.description,
|
|
293
|
+
payload_schema: cloneJson(event.payloadSchema),
|
|
294
|
+
primary_subject_fields: [...(event.subjects?.primary ?? [])],
|
|
295
|
+
sensitivity: event.sensitivity,
|
|
296
|
+
fingerprint: event.fingerprint,
|
|
297
|
+
})),
|
|
298
|
+
eventHandlers: handlers.map((handler) => ({
|
|
299
|
+
id: handler.id,
|
|
300
|
+
consumes: { ...handler.consumes },
|
|
301
|
+
authority: {
|
|
302
|
+
...(handler.authority.agents ? { agents: [...handler.authority.agents] } : {}),
|
|
303
|
+
...(handler.authority.teams ? { teams: [...handler.authority.teams] } : {}),
|
|
304
|
+
...(handler.authority.coreOperations
|
|
305
|
+
? { core_operations: [...handler.authority.coreOperations] }
|
|
306
|
+
: {}),
|
|
307
|
+
...(handler.authority.publishes
|
|
308
|
+
? {
|
|
309
|
+
publishes: handler.authority.publishes.map((event) => ({
|
|
310
|
+
key: event.key,
|
|
311
|
+
version: event.version,
|
|
312
|
+
fingerprint: event.fingerprint,
|
|
313
|
+
})),
|
|
314
|
+
}
|
|
315
|
+
: {}),
|
|
316
|
+
},
|
|
317
|
+
})),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
export async function dispatchEventDelivery(delivery, handlers, context) {
|
|
321
|
+
const matches = handlers.filter((handler) => handler.id === delivery.handlerId);
|
|
322
|
+
if (matches.length !== 1) {
|
|
323
|
+
throw new EventDispatchError('handler_not_found', 404, false, matches.length === 0
|
|
324
|
+
? `unknown Event handler '${delivery.handlerId}'`
|
|
325
|
+
: `Event handler '${delivery.handlerId}' is registered more than once`);
|
|
326
|
+
}
|
|
327
|
+
const handler = matches[0];
|
|
328
|
+
const expected = handler.consumes;
|
|
329
|
+
const actual = delivery.event;
|
|
330
|
+
if (actual.owner !== expected.owner ||
|
|
331
|
+
actual.key !== expected.key ||
|
|
332
|
+
actual.version !== expected.version ||
|
|
333
|
+
actual.fingerprint !== expected.fingerprint) {
|
|
334
|
+
throw new EventDispatchError('contract_mismatch', 409, false, `delivery contract or fingerprint does not match handler '${handler.id}'`);
|
|
335
|
+
}
|
|
336
|
+
let payload;
|
|
337
|
+
try {
|
|
338
|
+
payload = handler.event.validate(actual.payload);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
throw new EventDispatchError('payload_invalid', 422, false, error instanceof Error ? error.message : String(error));
|
|
342
|
+
}
|
|
343
|
+
const event = { ...actual, payload };
|
|
344
|
+
try {
|
|
345
|
+
return await handler.run(event, {
|
|
346
|
+
deliveryId: delivery.deliveryId,
|
|
347
|
+
attemptNumber: delivery.attemptNumber,
|
|
348
|
+
tenantId: actual.tenantId,
|
|
349
|
+
correlationId: actual.correlationId,
|
|
350
|
+
causation: actual.causation,
|
|
351
|
+
publication: context?.publication ?? null,
|
|
352
|
+
authority: context?.authority ?? {},
|
|
353
|
+
grant: context?.grant ?? null,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (error instanceof EventHandlerError) {
|
|
358
|
+
throw new EventDispatchError(error.code, error.retryable ? 503 : 422, error.retryable, error.message, error.detail ? { ...error.detail } : undefined);
|
|
359
|
+
}
|
|
360
|
+
throw new EventDispatchError('handler_failed', 500, true, error instanceof Error ? error.message : String(error));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function jsonResponse(status, body) {
|
|
364
|
+
return new Response(JSON.stringify(body), {
|
|
365
|
+
status,
|
|
366
|
+
headers: { 'content-type': 'application/json' },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The Pack's `/events/v1/deliver` route. Verifies core's delivery grant for
|
|
371
|
+
* this runtime, checks that it names exactly this delivery, and runs the
|
|
372
|
+
* handler. The answer is the HTTP status: any 2xx means delivered (the body is
|
|
373
|
+
* the handler's result, or empty with 204); a failure is a non-2xx with
|
|
374
|
+
* `{ code, message, retryable }`, and `retryable` tells core whether to try again.
|
|
375
|
+
* Core may post one delivery more than once (see `EventHandlerContext`).
|
|
376
|
+
*/
|
|
377
|
+
export async function handleEventDeliveryRequest(request, handlers, options) {
|
|
378
|
+
const authorization = request.headers.get('authorization') ?? '';
|
|
379
|
+
const token = authorization.match(/^Bearer\s+([^\s]+)$/i)?.[1];
|
|
380
|
+
if (!token) {
|
|
381
|
+
return jsonResponse(401, {
|
|
382
|
+
code: 'grant_missing',
|
|
383
|
+
message: 'a Core Event delivery grant is required',
|
|
384
|
+
retryable: true,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const grant = await (options.verifyGrant ?? verifyEventDeliveryGrant)(token, {
|
|
388
|
+
audience: options.audience,
|
|
389
|
+
coreUrl: options.coreUrl,
|
|
390
|
+
});
|
|
391
|
+
if (!grant) {
|
|
392
|
+
return jsonResponse(401, {
|
|
393
|
+
code: 'grant_rejected',
|
|
394
|
+
message: 'the Event delivery grant did not verify',
|
|
395
|
+
retryable: true,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
let delivery;
|
|
399
|
+
try {
|
|
400
|
+
delivery = (await request.json());
|
|
401
|
+
if (!delivery?.deliveryId || !delivery?.event || !delivery?.consumer)
|
|
402
|
+
throw new Error();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return jsonResponse(400, {
|
|
406
|
+
code: 'bad_request',
|
|
407
|
+
message: 'the Event delivery envelope is invalid',
|
|
408
|
+
retryable: false,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const contract = grant.contract;
|
|
412
|
+
const identityMatches = grant.tenantId === delivery.event.tenantId &&
|
|
413
|
+
grant.installId === delivery.consumer.installId &&
|
|
414
|
+
grant.packVersion === delivery.consumer.packVersion &&
|
|
415
|
+
grant.deploymentRevision === delivery.consumer.deploymentRevision &&
|
|
416
|
+
grant.audience === options.audience &&
|
|
417
|
+
grant.handlerId === delivery.handlerId &&
|
|
418
|
+
contract.owner === delivery.event.owner &&
|
|
419
|
+
contract.key === delivery.event.key &&
|
|
420
|
+
contract.version === delivery.event.version &&
|
|
421
|
+
contract.fingerprint === delivery.event.fingerprint &&
|
|
422
|
+
grant.eventId === delivery.event.id &&
|
|
423
|
+
grant.payloadDigest === delivery.event.payloadDigest &&
|
|
424
|
+
grant.payloadDigest ===
|
|
425
|
+
`sha256:${createHash('sha256').update(canonicalEventJson(delivery.event.payload)).digest('hex')}` &&
|
|
426
|
+
grant.bindingId === delivery.bindingId &&
|
|
427
|
+
grant.deliveryId === delivery.deliveryId &&
|
|
428
|
+
grant.attemptNumber === delivery.attemptNumber;
|
|
429
|
+
if (!identityMatches) {
|
|
430
|
+
return jsonResponse(403, {
|
|
431
|
+
code: 'grant_mismatch',
|
|
432
|
+
message: 'the grant does not bind this Event delivery',
|
|
433
|
+
retryable: false,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const result = await dispatchEventDelivery(delivery, handlers, {
|
|
438
|
+
authority: grant.authority,
|
|
439
|
+
grant: { token, scopes: grant.scopes, traceId: grant.traceId },
|
|
440
|
+
publication: options.coreUrl
|
|
441
|
+
? {
|
|
442
|
+
coreUrl: options.coreUrl,
|
|
443
|
+
token,
|
|
444
|
+
deliveryId: delivery.deliveryId,
|
|
445
|
+
eventId: delivery.event.id,
|
|
446
|
+
tenantId: delivery.event.tenantId,
|
|
447
|
+
correlationId: delivery.event.correlationId,
|
|
448
|
+
causation: delivery.event.causation,
|
|
449
|
+
}
|
|
450
|
+
: null,
|
|
451
|
+
});
|
|
452
|
+
// A 2xx is the whole verdict: delivered. The handler's result is the body.
|
|
453
|
+
return result === undefined || result === null
|
|
454
|
+
? new Response(null, { status: 204 })
|
|
455
|
+
: jsonResponse(200, result);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
459
|
+
const dispatchError = error instanceof EventDispatchError
|
|
460
|
+
? error
|
|
461
|
+
: new EventDispatchError('handler_failed', 500, true, message);
|
|
462
|
+
return jsonResponse(dispatchError.status, {
|
|
463
|
+
code: dispatchError.code,
|
|
464
|
+
message,
|
|
465
|
+
retryable: dispatchError.retryable,
|
|
466
|
+
...(dispatchError.detail ? { detail: dispatchError.detail } : {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
export async function publishEvent(context, contract, payload, options, dependencies = {}) {
|
|
471
|
+
if (!options.idempotencyKey?.trim() || options.idempotencyKey.length > 512) {
|
|
472
|
+
throw terminalEventError('publication_key_invalid', 'Publication key must contain 1–512 characters');
|
|
473
|
+
}
|
|
474
|
+
let validated;
|
|
475
|
+
try {
|
|
476
|
+
validated = contract.validate(payload);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
throw terminalEventError('payload_invalid', 'Publication payload does not match its contract');
|
|
480
|
+
}
|
|
481
|
+
const response = await (dependencies.fetch ?? fetch)(`${context.coreUrl.replace(/\/$/, '')}/api/events/publish`, {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: {
|
|
484
|
+
authorization: `Bearer ${context.token}`,
|
|
485
|
+
'content-type': 'application/json',
|
|
486
|
+
'idempotency-key': options.idempotencyKey,
|
|
487
|
+
},
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
contract: {
|
|
490
|
+
owner: contract.owner,
|
|
491
|
+
key: contract.key,
|
|
492
|
+
version: contract.version,
|
|
493
|
+
fingerprint: contract.fingerprint,
|
|
494
|
+
},
|
|
495
|
+
payload: validated,
|
|
496
|
+
causation: { parentDeliveryId: context.deliveryId },
|
|
497
|
+
}),
|
|
498
|
+
});
|
|
499
|
+
return publicationAnswer(response);
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Core's refusals that no retry can change. Everything else Core refuses is
|
|
503
|
+
* handed back retryable with its code, so a handler can still tell a lease
|
|
504
|
+
* that expired mid-run (try again: the next attempt has a fresh lease) from a
|
|
505
|
+
* refusal it must not repeat.
|
|
506
|
+
*/
|
|
507
|
+
const PERMANENT_PUBLICATION_ERRORS = new Set([
|
|
508
|
+
'publication_invalid',
|
|
509
|
+
'publication_key_invalid',
|
|
510
|
+
'parent_delivery_mismatch',
|
|
511
|
+
'parent_required',
|
|
512
|
+
'delivery_grant_required',
|
|
513
|
+
'root_publication_not_allowed',
|
|
514
|
+
'event_owner_mismatch',
|
|
515
|
+
'event_not_declared',
|
|
516
|
+
'publication_key_conflict',
|
|
517
|
+
'chain_depth_exceeded',
|
|
518
|
+
'body_too_large',
|
|
519
|
+
'payload_invalid',
|
|
520
|
+
'pack_not_installed',
|
|
521
|
+
]);
|
|
522
|
+
async function publicationAnswer(response) {
|
|
523
|
+
const body = (await response.json().catch(() => ({})));
|
|
524
|
+
if (!response.ok || !body.eventId) {
|
|
525
|
+
const code = typeof body.error === 'string' ? body.error : `http_${response.status}`;
|
|
526
|
+
const message = `Core rejected Event publication: ${code}`;
|
|
527
|
+
if ([400, 403, 409, 413, 422].includes(response.status) &&
|
|
528
|
+
PERMANENT_PUBLICATION_ERRORS.has(code)) {
|
|
529
|
+
throw terminalEventError(code, message);
|
|
530
|
+
}
|
|
531
|
+
throw retryableEventError(code, message, { httpStatus: response.status });
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
status: body.status ?? 'accepted',
|
|
535
|
+
eventId: body.eventId,
|
|
536
|
+
deliveryIds: body.deliveryIds ?? [],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Publishes a root event: one no incoming delivery caused, from a scheduled or
|
|
541
|
+
* standalone run of the Pack. The grant must be a run grant carrying
|
|
542
|
+
* `events:publish`, which the tenant puts there by allowing it on the install;
|
|
543
|
+
* a delivery grant is refused here (`delivery_grant_required` is the other
|
|
544
|
+
* door's answer to a run grant). The same key from the same install returns
|
|
545
|
+
* the same root and never a second.
|
|
546
|
+
*/
|
|
547
|
+
export async function publishRootEvent(context, contract, payload, options, dependencies = {}) {
|
|
548
|
+
if (!options.idempotencyKey?.trim() || options.idempotencyKey.length > 512) {
|
|
549
|
+
throw terminalEventError('publication_key_invalid', 'Publication key must contain 1–512 characters');
|
|
550
|
+
}
|
|
551
|
+
let validated;
|
|
552
|
+
try {
|
|
553
|
+
validated = contract.validate(payload);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
throw terminalEventError('payload_invalid', 'Publication payload does not match its contract');
|
|
557
|
+
}
|
|
558
|
+
const response = await (dependencies.fetch ?? fetch)(`${context.coreUrl.replace(/\/$/, '')}/api/events/publish`, {
|
|
559
|
+
method: 'POST',
|
|
560
|
+
headers: {
|
|
561
|
+
authorization: `Bearer ${context.token}`,
|
|
562
|
+
'content-type': 'application/json',
|
|
563
|
+
'idempotency-key': options.idempotencyKey,
|
|
564
|
+
},
|
|
565
|
+
body: JSON.stringify({
|
|
566
|
+
contract: {
|
|
567
|
+
owner: contract.owner,
|
|
568
|
+
key: contract.key,
|
|
569
|
+
version: contract.version,
|
|
570
|
+
fingerprint: contract.fingerprint,
|
|
571
|
+
},
|
|
572
|
+
payload: validated,
|
|
573
|
+
}),
|
|
574
|
+
});
|
|
575
|
+
return publicationAnswer(response);
|
|
576
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keemakr/event-sdk",
|
|
3
|
+
"version": "0.2.0-dev-20261012160211",
|
|
4
|
+
"description": "Typed Event declarations, publication, and delivery dispatch for Keemakr Packs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./apps/smartlead": {
|
|
16
|
+
"types": "./dist/apps/smartlead.d.ts",
|
|
17
|
+
"import": "./dist/apps/smartlead.js",
|
|
18
|
+
"default": "./dist/apps/smartlead.js"
|
|
19
|
+
},
|
|
20
|
+
"./apps": {
|
|
21
|
+
"types": "./dist/apps/index.d.ts",
|
|
22
|
+
"import": "./dist/apps/index.js",
|
|
23
|
+
"default": "./dist/apps/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/fsztpartners/keemakr.git",
|
|
36
|
+
"directory": "packages/event-sdk"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"prepack": "npm run build",
|
|
42
|
+
"contracts:lock": "npm run build && node scripts/contracts-lock.mjs",
|
|
43
|
+
"contracts:check": "npm run build && node scripts/contracts-lock.mjs --check"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"zod": "^4.4.3"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"jose": "^6.2.3"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.19.43",
|
|
53
|
+
"typescript": "^5",
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=20.9"
|
|
58
|
+
}
|
|
59
|
+
}
|