@keemakr/event-sdk 0.2.0-poc-20260911235414
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/index.d.ts +287 -0
- package/dist/index.js +478 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @keemakr/event-sdk
|
|
2
|
+
|
|
3
|
+
Declare, publish, and consume typed Keemakr Events without accepting provider traffic directly in a
|
|
4
|
+
Pack Runtime.
|
|
5
|
+
|
|
6
|
+
An Event key is owned by its Pack namespace and one positive integer contract version. Core remains
|
|
7
|
+
the authoritative validator and delivery ledger. Delivery is at least once, so direct external
|
|
8
|
+
effects must use the stable delivery identity for idempotency.
|
|
9
|
+
|
|
10
|
+
Consumer builds call `fetchEventContractArtifact` with a short-lived, tenant-scoped native access
|
|
11
|
+
token in CI. Core applies Pack visibility, and the SDK rejects any artifact whose owner, key,
|
|
12
|
+
version, or fingerprint differs from the build pin. Do not persist the access token in generated
|
|
13
|
+
artifacts.
|
|
14
|
+
|
|
15
|
+
## Runtime delivery endpoint
|
|
16
|
+
|
|
17
|
+
A consuming Pack mounts one SDK-owned HTTP adapter at `/events/v1/deliver`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { handleEventDeliveryRequest } from '@keemakr/event-sdk';
|
|
21
|
+
import { startReplenishment, retryIsolationProbe } from './consumers';
|
|
22
|
+
|
|
23
|
+
export function POST(request: Request) {
|
|
24
|
+
return handleEventDeliveryRequest(request, [startReplenishment, retryIsolationProbe], {
|
|
25
|
+
audience: requireEnv('EVENT_RUNTIME_ORIGIN'),
|
|
26
|
+
coreUrl: requireEnv('KEEMAKR_CORE_URL'),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The release manifest must set `runtime.event_url` to the full public endpoint, for example
|
|
32
|
+
`https://inventory-consumer-v1.example/events/v1/deliver`. The URL is an immutable release pin and
|
|
33
|
+
must be unique to that Pack release. A later release uses a different URL. The general
|
|
34
|
+
`runtime.url` remains mutable and does not retarget accepted Event deliveries.
|
|
35
|
+
|
|
36
|
+
`EVENT_RUNTIME_ORIGIN` is the URL origin used as the signed grant audience.
|
|
37
|
+
`KEE_CORE_JWKS_URL` locates Core's JWKS for grant verification. `KEEMAKR_CORE_URL` is the Core API
|
|
38
|
+
base used when a handler publishes an allowed child Event. The SDK compares tenant installation,
|
|
39
|
+
Pack version, and deployment revision values from the signed grant with the delivery envelope. A
|
|
40
|
+
release endpoint can therefore serve several tenant installations without tenant-specific runtime
|
|
41
|
+
configuration.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export type JsonSchema = Record<string, unknown>;
|
|
3
|
+
export type EventSensitivity = 'none' | 'personal' | 'phi';
|
|
4
|
+
export interface EventDefinitionInput {
|
|
5
|
+
owner: string;
|
|
6
|
+
key: string;
|
|
7
|
+
version: number;
|
|
8
|
+
description: string;
|
|
9
|
+
payloadSchema: JsonSchema;
|
|
10
|
+
sensitivity?: EventSensitivity;
|
|
11
|
+
subjects?: {
|
|
12
|
+
primary: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface EventContract<P = unknown> {
|
|
16
|
+
readonly owner: string;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly version: number;
|
|
19
|
+
readonly description: string;
|
|
20
|
+
readonly payloadSchema: Readonly<JsonSchema>;
|
|
21
|
+
readonly sensitivity: EventSensitivity;
|
|
22
|
+
readonly subjects?: {
|
|
23
|
+
readonly primary: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
readonly fingerprint: string;
|
|
26
|
+
validate(value: unknown): P;
|
|
27
|
+
}
|
|
28
|
+
export interface EventContractArtifact {
|
|
29
|
+
owner: string;
|
|
30
|
+
event_key: string;
|
|
31
|
+
version: number;
|
|
32
|
+
description: string;
|
|
33
|
+
payload_schema: JsonSchema;
|
|
34
|
+
primary_subject_fields: string[];
|
|
35
|
+
sensitivity: EventSensitivity;
|
|
36
|
+
fingerprint: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fetch a contract-only build artifact with a short-lived tenant access token.
|
|
40
|
+
* The exact release pin is checked before the artifact reaches build tooling.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fetchEventContractArtifact(input: {
|
|
43
|
+
coreUrl: string;
|
|
44
|
+
accessToken: string;
|
|
45
|
+
owner: string;
|
|
46
|
+
key: string;
|
|
47
|
+
version: number;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
fetch?: typeof fetch;
|
|
50
|
+
}): Promise<EventContractArtifact>;
|
|
51
|
+
export declare function canonicalEventJson(value: unknown): string;
|
|
52
|
+
export declare function eventPrimarySubject(payload: unknown, fields: readonly string[]): string | null;
|
|
53
|
+
export declare function defineEvent<P = Record<string, unknown>>(input: EventDefinitionInput): EventContract<P>;
|
|
54
|
+
export declare function eventContractFingerprint(identity: {
|
|
55
|
+
owner: string;
|
|
56
|
+
key: string;
|
|
57
|
+
version: number;
|
|
58
|
+
description: string;
|
|
59
|
+
payloadSchema: JsonSchema;
|
|
60
|
+
sensitivity: EventSensitivity;
|
|
61
|
+
subjects?: {
|
|
62
|
+
primary: readonly string[];
|
|
63
|
+
};
|
|
64
|
+
}): string;
|
|
65
|
+
export interface EventEnvelope<P = unknown> {
|
|
66
|
+
id: string;
|
|
67
|
+
owner: string;
|
|
68
|
+
key: string;
|
|
69
|
+
version: number;
|
|
70
|
+
fingerprint: string;
|
|
71
|
+
payloadDigest: string;
|
|
72
|
+
tenantId: string;
|
|
73
|
+
correlationId: string;
|
|
74
|
+
causation: {
|
|
75
|
+
rootEventId: string;
|
|
76
|
+
parentEventId: string | null;
|
|
77
|
+
};
|
|
78
|
+
payload: P;
|
|
79
|
+
}
|
|
80
|
+
export interface EventDelivery<P = unknown> {
|
|
81
|
+
deliveryId: string;
|
|
82
|
+
attemptNumber: number;
|
|
83
|
+
bindingId?: string;
|
|
84
|
+
consumer?: {
|
|
85
|
+
installId: string;
|
|
86
|
+
packVersion: number;
|
|
87
|
+
deploymentRevision: string;
|
|
88
|
+
};
|
|
89
|
+
handlerId: string;
|
|
90
|
+
event: EventEnvelope<P>;
|
|
91
|
+
}
|
|
92
|
+
export declare const CoreAppEventProducerSchema: z.ZodObject<{
|
|
93
|
+
kind: z.ZodLiteral<"core_app">;
|
|
94
|
+
app: z.ZodEnum<{
|
|
95
|
+
email: "email";
|
|
96
|
+
whatsapp: "whatsapp";
|
|
97
|
+
widget: "widget";
|
|
98
|
+
}>;
|
|
99
|
+
integrationId: z.ZodString;
|
|
100
|
+
}, z.core.$strip>;
|
|
101
|
+
/**
|
|
102
|
+
* The exclusive channel lane. It is intentionally not an EventDelivery variant:
|
|
103
|
+
* generic Event handlers cannot accept it by widening their existing contract.
|
|
104
|
+
*/
|
|
105
|
+
export declare const ChannelEventDeliverySchema: z.ZodObject<{
|
|
106
|
+
deliveryId: z.ZodString;
|
|
107
|
+
eventId: z.ZodString;
|
|
108
|
+
tenantId: z.ZodString;
|
|
109
|
+
installId: z.ZodString;
|
|
110
|
+
bindingId: z.ZodString;
|
|
111
|
+
bindingRevisionId: z.ZodString;
|
|
112
|
+
conversationId: z.ZodString;
|
|
113
|
+
audience: z.ZodURL;
|
|
114
|
+
ownerEpoch: z.ZodNumber;
|
|
115
|
+
target: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
116
|
+
kind: z.ZodLiteral<"agent">;
|
|
117
|
+
key: z.ZodString;
|
|
118
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
119
|
+
kind: z.ZodLiteral<"team">;
|
|
120
|
+
key: z.ZodString;
|
|
121
|
+
}, z.core.$strip>], "kind">;
|
|
122
|
+
producer: z.ZodObject<{
|
|
123
|
+
kind: z.ZodLiteral<"core_app">;
|
|
124
|
+
app: z.ZodEnum<{
|
|
125
|
+
email: "email";
|
|
126
|
+
whatsapp: "whatsapp";
|
|
127
|
+
widget: "widget";
|
|
128
|
+
}>;
|
|
129
|
+
integrationId: z.ZodString;
|
|
130
|
+
}, z.core.$strip>;
|
|
131
|
+
externalPrincipal: z.ZodObject<{
|
|
132
|
+
kind: z.ZodLiteral<"external">;
|
|
133
|
+
id: z.ZodString;
|
|
134
|
+
}, z.core.$strip>;
|
|
135
|
+
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
136
|
+
}, z.core.$strip>;
|
|
137
|
+
export type CoreAppEventProducer = z.infer<typeof CoreAppEventProducerSchema>;
|
|
138
|
+
export type ChannelEventDelivery<P = Record<string, unknown>> = Omit<z.infer<typeof ChannelEventDeliverySchema>, 'payload'> & {
|
|
139
|
+
payload: P;
|
|
140
|
+
};
|
|
141
|
+
export interface EventDeliveryGrant {
|
|
142
|
+
tenantId: string;
|
|
143
|
+
installId: string;
|
|
144
|
+
packVersion: number;
|
|
145
|
+
deploymentRevision: string;
|
|
146
|
+
audience: string;
|
|
147
|
+
handlerId: string;
|
|
148
|
+
contract: {
|
|
149
|
+
owner: string;
|
|
150
|
+
key: string;
|
|
151
|
+
version: number;
|
|
152
|
+
fingerprint: string;
|
|
153
|
+
};
|
|
154
|
+
bindingId: string;
|
|
155
|
+
deliveryId: string;
|
|
156
|
+
attemptNumber: number;
|
|
157
|
+
eventId: string;
|
|
158
|
+
payloadDigest: string;
|
|
159
|
+
authority: Readonly<{
|
|
160
|
+
workflows?: readonly string[];
|
|
161
|
+
agents?: readonly string[];
|
|
162
|
+
teams?: readonly string[];
|
|
163
|
+
core_operations?: readonly string[];
|
|
164
|
+
publishes?: readonly {
|
|
165
|
+
key: string;
|
|
166
|
+
version: number;
|
|
167
|
+
fingerprint: string;
|
|
168
|
+
}[];
|
|
169
|
+
}>;
|
|
170
|
+
}
|
|
171
|
+
export declare function verifyEventDeliveryGrant(token: string, options: {
|
|
172
|
+
audience: string;
|
|
173
|
+
jwksUrl?: string;
|
|
174
|
+
}): Promise<EventDeliveryGrant | null>;
|
|
175
|
+
export interface EventHandlerContext {
|
|
176
|
+
deliveryId: string;
|
|
177
|
+
tenantId: string;
|
|
178
|
+
correlationId: string;
|
|
179
|
+
causation: EventEnvelope['causation'];
|
|
180
|
+
publication: EventPublishContext | null;
|
|
181
|
+
authority: EventDeliveryGrant['authority'];
|
|
182
|
+
}
|
|
183
|
+
export declare function eventEffectIdempotencyKey(context: Pick<EventHandlerContext, 'deliveryId'>, operation: string, discriminator?: string): string;
|
|
184
|
+
/** A handler failure whose retry policy is explicit and portable across runtimes. */
|
|
185
|
+
export declare class EventHandlerError extends Error {
|
|
186
|
+
readonly code: string;
|
|
187
|
+
readonly retryable: boolean;
|
|
188
|
+
readonly detail?: Readonly<Record<string, unknown>>;
|
|
189
|
+
constructor(input: {
|
|
190
|
+
code: string;
|
|
191
|
+
message: string;
|
|
192
|
+
retryable: boolean;
|
|
193
|
+
detail?: Record<string, unknown>;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
export declare function terminalEventError(code: string, message: string, detail?: Record<string, unknown>): EventHandlerError;
|
|
197
|
+
export declare function retryableEventError(code: string, message: string, detail?: Record<string, unknown>): EventHandlerError;
|
|
198
|
+
export interface EventHandler<P = any, R = unknown> {
|
|
199
|
+
readonly id: string;
|
|
200
|
+
readonly event: EventContract<P>;
|
|
201
|
+
readonly consumes: Readonly<{
|
|
202
|
+
owner: string;
|
|
203
|
+
key: string;
|
|
204
|
+
version: number;
|
|
205
|
+
fingerprint: string;
|
|
206
|
+
}>;
|
|
207
|
+
readonly run: (event: EventEnvelope<P>, context: EventHandlerContext) => Promise<R>;
|
|
208
|
+
readonly authority: Readonly<EventHandlerAuthority>;
|
|
209
|
+
}
|
|
210
|
+
export interface EventHandlerAuthority {
|
|
211
|
+
workflows?: readonly string[];
|
|
212
|
+
agents?: readonly string[];
|
|
213
|
+
teams?: readonly string[];
|
|
214
|
+
coreOperations?: readonly string[];
|
|
215
|
+
publishes?: readonly EventContract[];
|
|
216
|
+
}
|
|
217
|
+
export declare function handleEvent<P = unknown, R = unknown>(input: {
|
|
218
|
+
id: string;
|
|
219
|
+
event: EventContract<P>;
|
|
220
|
+
authority?: EventHandlerAuthority;
|
|
221
|
+
run: EventHandler<P, R>['run'];
|
|
222
|
+
}): EventHandler<P, R>;
|
|
223
|
+
/** Build the Event-only manifest fragment. Pack build tooling writes this output to entry.json. */
|
|
224
|
+
export declare function eventManifest(input: {
|
|
225
|
+
events?: readonly EventContract[];
|
|
226
|
+
handlers?: readonly EventHandler<any, any>[];
|
|
227
|
+
}): {
|
|
228
|
+
events: {
|
|
229
|
+
owner: string;
|
|
230
|
+
key: string;
|
|
231
|
+
version: number;
|
|
232
|
+
description: string;
|
|
233
|
+
payload_schema: Readonly<JsonSchema>;
|
|
234
|
+
primary_subject_fields: string[];
|
|
235
|
+
sensitivity: EventSensitivity;
|
|
236
|
+
fingerprint: string;
|
|
237
|
+
}[];
|
|
238
|
+
eventHandlers: {
|
|
239
|
+
id: string;
|
|
240
|
+
consumes: {
|
|
241
|
+
owner: string;
|
|
242
|
+
key: string;
|
|
243
|
+
version: number;
|
|
244
|
+
fingerprint: string;
|
|
245
|
+
};
|
|
246
|
+
authority: {
|
|
247
|
+
publishes?: {
|
|
248
|
+
key: string;
|
|
249
|
+
version: number;
|
|
250
|
+
fingerprint: string;
|
|
251
|
+
}[] | undefined;
|
|
252
|
+
core_operations?: string[] | undefined;
|
|
253
|
+
teams?: string[] | undefined;
|
|
254
|
+
agents?: string[] | undefined;
|
|
255
|
+
workflows?: string[] | undefined;
|
|
256
|
+
};
|
|
257
|
+
}[];
|
|
258
|
+
};
|
|
259
|
+
export declare function dispatchEventDelivery(delivery: EventDelivery, handlers: readonly EventHandler<any, any>[], context?: {
|
|
260
|
+
publication?: EventPublishContext | null;
|
|
261
|
+
authority?: EventDeliveryGrant['authority'];
|
|
262
|
+
}): Promise<unknown>;
|
|
263
|
+
export declare function handleEventDeliveryRequest(request: Request, handlers: readonly EventHandler<any, any>[], options: {
|
|
264
|
+
audience: string;
|
|
265
|
+
coreUrl?: string;
|
|
266
|
+
verifyGrant?: (token: string, options: {
|
|
267
|
+
audience: string;
|
|
268
|
+
}) => Promise<EventDeliveryGrant | null>;
|
|
269
|
+
}): Promise<Response>;
|
|
270
|
+
export interface EventPublishContext {
|
|
271
|
+
coreUrl: string;
|
|
272
|
+
token: string;
|
|
273
|
+
deliveryId: string;
|
|
274
|
+
eventId: string;
|
|
275
|
+
tenantId: string;
|
|
276
|
+
correlationId: string;
|
|
277
|
+
causation: EventEnvelope['causation'];
|
|
278
|
+
}
|
|
279
|
+
export declare function publishEvent<P>(context: EventPublishContext, contract: EventContract<P>, payload: P, options: {
|
|
280
|
+
idempotencyKey: string;
|
|
281
|
+
}, dependencies?: {
|
|
282
|
+
fetch?: typeof fetch;
|
|
283
|
+
}): Promise<{
|
|
284
|
+
status: string;
|
|
285
|
+
eventId: string;
|
|
286
|
+
deliveryIds: string[];
|
|
287
|
+
}>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
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
|
+
const url = options.jwksUrl ?? process.env.KEE_CORE_JWKS_URL;
|
|
157
|
+
if (!url)
|
|
158
|
+
return null;
|
|
159
|
+
let keys = jwks.get(url);
|
|
160
|
+
if (!keys) {
|
|
161
|
+
keys = createRemoteJWKSet(new URL(url));
|
|
162
|
+
jwks.set(url, keys);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const { payload } = await jwtVerify(token, keys, {
|
|
166
|
+
issuer: 'keemakr',
|
|
167
|
+
audience: options.audience,
|
|
168
|
+
});
|
|
169
|
+
const delivery = payload.event_delivery;
|
|
170
|
+
if (!delivery || typeof payload.tenant_id !== 'string')
|
|
171
|
+
return null;
|
|
172
|
+
const contract = {
|
|
173
|
+
owner: String(delivery.event_key ?? '').split('.')[0] ?? '',
|
|
174
|
+
key: String(delivery.event_key ?? ''),
|
|
175
|
+
version: Number(delivery.event_version),
|
|
176
|
+
fingerprint: String(delivery.contract_fingerprint ?? ''),
|
|
177
|
+
};
|
|
178
|
+
const grant = {
|
|
179
|
+
tenantId: payload.tenant_id,
|
|
180
|
+
installId: String(delivery.consumer_install_id ?? ''),
|
|
181
|
+
packVersion: Number(delivery.pack_version),
|
|
182
|
+
deploymentRevision: String(delivery.deployment_revision ?? ''),
|
|
183
|
+
audience: Array.isArray(payload.aud)
|
|
184
|
+
? String(payload.aud[0] ?? '')
|
|
185
|
+
: String(payload.aud ?? ''),
|
|
186
|
+
handlerId: String(delivery.handler_id ?? ''),
|
|
187
|
+
contract,
|
|
188
|
+
bindingId: String(delivery.binding_id ?? ''),
|
|
189
|
+
deliveryId: String(delivery.delivery_id ?? ''),
|
|
190
|
+
attemptNumber: Number(delivery.attempt_number),
|
|
191
|
+
eventId: String(delivery.event_id ?? ''),
|
|
192
|
+
payloadDigest: String(delivery.payload_digest ?? ''),
|
|
193
|
+
authority: delivery.authority && typeof delivery.authority === 'object'
|
|
194
|
+
? deepFreeze(cloneJson(delivery.authority))
|
|
195
|
+
: {},
|
|
196
|
+
};
|
|
197
|
+
const invalid = !grant.installId ||
|
|
198
|
+
!Number.isInteger(grant.packVersion) ||
|
|
199
|
+
grant.packVersion <= 0 ||
|
|
200
|
+
!grant.deploymentRevision ||
|
|
201
|
+
!grant.audience ||
|
|
202
|
+
!grant.handlerId ||
|
|
203
|
+
!grant.bindingId ||
|
|
204
|
+
!grant.deliveryId ||
|
|
205
|
+
!Number.isInteger(grant.attemptNumber) ||
|
|
206
|
+
grant.attemptNumber <= 0 ||
|
|
207
|
+
!grant.eventId ||
|
|
208
|
+
!grant.payloadDigest ||
|
|
209
|
+
!contract.owner ||
|
|
210
|
+
!contract.key ||
|
|
211
|
+
!Number.isInteger(contract.version) ||
|
|
212
|
+
!contract.fingerprint;
|
|
213
|
+
return invalid ? null : grant;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function eventEffectIdempotencyKey(context, operation, discriminator) {
|
|
220
|
+
const suffix = discriminator ? `:${discriminator}` : '';
|
|
221
|
+
return `${context.deliveryId}:${operation}${suffix}`;
|
|
222
|
+
}
|
|
223
|
+
class EventDispatchError extends Error {
|
|
224
|
+
code;
|
|
225
|
+
status;
|
|
226
|
+
retryable;
|
|
227
|
+
detail;
|
|
228
|
+
constructor(code, status, retryable, message, detail) {
|
|
229
|
+
super(message);
|
|
230
|
+
this.code = code;
|
|
231
|
+
this.status = status;
|
|
232
|
+
this.retryable = retryable;
|
|
233
|
+
this.detail = detail;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** A handler failure whose retry policy is explicit and portable across runtimes. */
|
|
237
|
+
export class EventHandlerError extends Error {
|
|
238
|
+
code;
|
|
239
|
+
retryable;
|
|
240
|
+
detail;
|
|
241
|
+
constructor(input) {
|
|
242
|
+
if (!/^[a-z][a-z0-9_]*$/.test(input.code)) {
|
|
243
|
+
throw new Error('Event handler failure code must be a lowercase snake-case identifier');
|
|
244
|
+
}
|
|
245
|
+
super(input.message);
|
|
246
|
+
this.name = 'EventHandlerError';
|
|
247
|
+
this.code = input.code;
|
|
248
|
+
this.retryable = input.retryable;
|
|
249
|
+
this.detail = input.detail ? deepFreeze(cloneJson(input.detail)) : undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export function terminalEventError(code, message, detail) {
|
|
253
|
+
return new EventHandlerError({ code, message, retryable: false, detail });
|
|
254
|
+
}
|
|
255
|
+
export function retryableEventError(code, message, detail) {
|
|
256
|
+
return new EventHandlerError({ code, message, retryable: true, detail });
|
|
257
|
+
}
|
|
258
|
+
export function handleEvent(input) {
|
|
259
|
+
if (!/^[a-z][a-z0-9-]*$/.test(input.id)) {
|
|
260
|
+
throw new Error('event handler id must be a lowercase slug');
|
|
261
|
+
}
|
|
262
|
+
return Object.freeze({
|
|
263
|
+
id: input.id,
|
|
264
|
+
event: input.event,
|
|
265
|
+
consumes: Object.freeze({
|
|
266
|
+
owner: input.event.owner,
|
|
267
|
+
key: input.event.key,
|
|
268
|
+
version: input.event.version,
|
|
269
|
+
fingerprint: input.event.fingerprint,
|
|
270
|
+
}),
|
|
271
|
+
authority: deepFreeze({ ...(input.authority ?? {}) }),
|
|
272
|
+
run: input.run,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
/** Build the Event-only manifest fragment. Pack build tooling writes this output to entry.json. */
|
|
276
|
+
export function eventManifest(input) {
|
|
277
|
+
const events = input.events ?? [];
|
|
278
|
+
const handlers = input.handlers ?? [];
|
|
279
|
+
return deepFreeze({
|
|
280
|
+
events: events.map((event) => ({
|
|
281
|
+
owner: event.owner,
|
|
282
|
+
key: event.key,
|
|
283
|
+
version: event.version,
|
|
284
|
+
description: event.description,
|
|
285
|
+
payload_schema: cloneJson(event.payloadSchema),
|
|
286
|
+
primary_subject_fields: [...(event.subjects?.primary ?? [])],
|
|
287
|
+
sensitivity: event.sensitivity,
|
|
288
|
+
fingerprint: event.fingerprint,
|
|
289
|
+
})),
|
|
290
|
+
eventHandlers: handlers.map((handler) => ({
|
|
291
|
+
id: handler.id,
|
|
292
|
+
consumes: { ...handler.consumes },
|
|
293
|
+
authority: {
|
|
294
|
+
...(handler.authority.workflows ? { workflows: [...handler.authority.workflows] } : {}),
|
|
295
|
+
...(handler.authority.agents ? { agents: [...handler.authority.agents] } : {}),
|
|
296
|
+
...(handler.authority.teams ? { teams: [...handler.authority.teams] } : {}),
|
|
297
|
+
...(handler.authority.coreOperations
|
|
298
|
+
? { core_operations: [...handler.authority.coreOperations] }
|
|
299
|
+
: {}),
|
|
300
|
+
...(handler.authority.publishes
|
|
301
|
+
? {
|
|
302
|
+
publishes: handler.authority.publishes.map((event) => ({
|
|
303
|
+
key: event.key,
|
|
304
|
+
version: event.version,
|
|
305
|
+
fingerprint: event.fingerprint,
|
|
306
|
+
})),
|
|
307
|
+
}
|
|
308
|
+
: {}),
|
|
309
|
+
},
|
|
310
|
+
})),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
export async function dispatchEventDelivery(delivery, handlers, context) {
|
|
314
|
+
const matches = handlers.filter((handler) => handler.id === delivery.handlerId);
|
|
315
|
+
if (matches.length !== 1) {
|
|
316
|
+
throw new EventDispatchError('handler_not_found', 404, false, matches.length === 0
|
|
317
|
+
? `unknown Event handler '${delivery.handlerId}'`
|
|
318
|
+
: `Event handler '${delivery.handlerId}' is registered more than once`);
|
|
319
|
+
}
|
|
320
|
+
const handler = matches[0];
|
|
321
|
+
const expected = handler.consumes;
|
|
322
|
+
const actual = delivery.event;
|
|
323
|
+
if (actual.owner !== expected.owner ||
|
|
324
|
+
actual.key !== expected.key ||
|
|
325
|
+
actual.version !== expected.version ||
|
|
326
|
+
actual.fingerprint !== expected.fingerprint) {
|
|
327
|
+
throw new EventDispatchError('contract_mismatch', 409, false, `delivery contract or fingerprint does not match handler '${handler.id}'`);
|
|
328
|
+
}
|
|
329
|
+
let payload;
|
|
330
|
+
try {
|
|
331
|
+
payload = handler.event.validate(actual.payload);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
throw new EventDispatchError('payload_invalid', 422, false, error instanceof Error ? error.message : String(error));
|
|
335
|
+
}
|
|
336
|
+
const event = { ...actual, payload };
|
|
337
|
+
try {
|
|
338
|
+
return await handler.run(event, {
|
|
339
|
+
deliveryId: delivery.deliveryId,
|
|
340
|
+
tenantId: actual.tenantId,
|
|
341
|
+
correlationId: actual.correlationId,
|
|
342
|
+
causation: actual.causation,
|
|
343
|
+
publication: context?.publication ?? null,
|
|
344
|
+
authority: context?.authority ?? {},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error instanceof EventHandlerError) {
|
|
349
|
+
throw new EventDispatchError(error.code, error.retryable ? 503 : 422, error.retryable, error.message, error.detail ? { ...error.detail } : undefined);
|
|
350
|
+
}
|
|
351
|
+
throw new EventDispatchError('handler_failed', 500, true, error instanceof Error ? error.message : String(error));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function jsonResponse(status, body) {
|
|
355
|
+
return new Response(JSON.stringify(body), {
|
|
356
|
+
status,
|
|
357
|
+
headers: { 'content-type': 'application/json' },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
export async function handleEventDeliveryRequest(request, handlers, options) {
|
|
361
|
+
const authorization = request.headers.get('authorization') ?? '';
|
|
362
|
+
const token = authorization.match(/^Bearer\s+([^\s]+)$/i)?.[1];
|
|
363
|
+
if (!token) {
|
|
364
|
+
return jsonResponse(401, {
|
|
365
|
+
ok: false,
|
|
366
|
+
failure: { code: 'grant_missing', message: 'a Core Event delivery grant is required' },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const grant = await (options.verifyGrant ?? verifyEventDeliveryGrant)(token, {
|
|
370
|
+
audience: options.audience,
|
|
371
|
+
});
|
|
372
|
+
if (!grant) {
|
|
373
|
+
return jsonResponse(401, {
|
|
374
|
+
ok: false,
|
|
375
|
+
failure: { code: 'grant_rejected', message: 'the Event delivery grant did not verify' },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
let delivery;
|
|
379
|
+
try {
|
|
380
|
+
delivery = (await request.json());
|
|
381
|
+
if (!delivery?.deliveryId || !delivery?.event || !delivery?.consumer)
|
|
382
|
+
throw new Error();
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return jsonResponse(400, {
|
|
386
|
+
ok: false,
|
|
387
|
+
failure: { code: 'bad_request', message: 'the Event delivery envelope is invalid' },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const contract = grant.contract;
|
|
391
|
+
const identityMatches = grant.tenantId === delivery.event.tenantId &&
|
|
392
|
+
grant.installId === delivery.consumer.installId &&
|
|
393
|
+
grant.packVersion === delivery.consumer.packVersion &&
|
|
394
|
+
grant.deploymentRevision === delivery.consumer.deploymentRevision &&
|
|
395
|
+
grant.audience === options.audience &&
|
|
396
|
+
grant.handlerId === delivery.handlerId &&
|
|
397
|
+
contract.owner === delivery.event.owner &&
|
|
398
|
+
contract.key === delivery.event.key &&
|
|
399
|
+
contract.version === delivery.event.version &&
|
|
400
|
+
contract.fingerprint === delivery.event.fingerprint &&
|
|
401
|
+
grant.eventId === delivery.event.id &&
|
|
402
|
+
grant.payloadDigest === delivery.event.payloadDigest &&
|
|
403
|
+
grant.payloadDigest ===
|
|
404
|
+
`sha256:${createHash('sha256').update(canonicalEventJson(delivery.event.payload)).digest('hex')}` &&
|
|
405
|
+
grant.bindingId === delivery.bindingId &&
|
|
406
|
+
grant.deliveryId === delivery.deliveryId &&
|
|
407
|
+
grant.attemptNumber === delivery.attemptNumber;
|
|
408
|
+
if (!identityMatches) {
|
|
409
|
+
return jsonResponse(403, {
|
|
410
|
+
ok: false,
|
|
411
|
+
failure: { code: 'grant_mismatch', message: 'the grant does not bind this Event delivery' },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const result = await dispatchEventDelivery(delivery, handlers, {
|
|
416
|
+
authority: grant.authority,
|
|
417
|
+
publication: options.coreUrl
|
|
418
|
+
? {
|
|
419
|
+
coreUrl: options.coreUrl,
|
|
420
|
+
token,
|
|
421
|
+
deliveryId: delivery.deliveryId,
|
|
422
|
+
eventId: delivery.event.id,
|
|
423
|
+
tenantId: delivery.event.tenantId,
|
|
424
|
+
correlationId: delivery.event.correlationId,
|
|
425
|
+
causation: delivery.event.causation,
|
|
426
|
+
}
|
|
427
|
+
: null,
|
|
428
|
+
});
|
|
429
|
+
return jsonResponse(200, { ok: true, result: result ?? null });
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
433
|
+
const dispatchError = error instanceof EventDispatchError
|
|
434
|
+
? error
|
|
435
|
+
: new EventDispatchError('handler_failed', 500, true, message);
|
|
436
|
+
return jsonResponse(dispatchError.status, {
|
|
437
|
+
ok: false,
|
|
438
|
+
failure: {
|
|
439
|
+
code: dispatchError.code,
|
|
440
|
+
message,
|
|
441
|
+
retryable: dispatchError.retryable,
|
|
442
|
+
...(dispatchError.detail ? { detail: dispatchError.detail } : {}),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
export async function publishEvent(context, contract, payload, options, dependencies = {}) {
|
|
448
|
+
if (!options.idempotencyKey?.trim())
|
|
449
|
+
throw new Error('publishEvent requires an idempotencyKey');
|
|
450
|
+
const validated = contract.validate(payload);
|
|
451
|
+
const response = await (dependencies.fetch ?? fetch)(`${context.coreUrl.replace(/\/$/, '')}/api/events/publish`, {
|
|
452
|
+
method: 'POST',
|
|
453
|
+
headers: {
|
|
454
|
+
authorization: `Bearer ${context.token}`,
|
|
455
|
+
'content-type': 'application/json',
|
|
456
|
+
'idempotency-key': options.idempotencyKey,
|
|
457
|
+
},
|
|
458
|
+
body: JSON.stringify({
|
|
459
|
+
contract: {
|
|
460
|
+
owner: contract.owner,
|
|
461
|
+
key: contract.key,
|
|
462
|
+
version: contract.version,
|
|
463
|
+
fingerprint: contract.fingerprint,
|
|
464
|
+
},
|
|
465
|
+
payload: validated,
|
|
466
|
+
causation: { parentDeliveryId: context.deliveryId },
|
|
467
|
+
}),
|
|
468
|
+
});
|
|
469
|
+
const body = (await response.json());
|
|
470
|
+
if (!response.ok || !body.eventId) {
|
|
471
|
+
throw new Error(`Core rejected Event publication (${response.status})`);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
status: body.status ?? 'accepted',
|
|
475
|
+
eventId: body.eventId,
|
|
476
|
+
deliveryIds: body.deliveryIds ?? [],
|
|
477
|
+
};
|
|
478
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keemakr/event-sdk",
|
|
3
|
+
"version": "0.2.0-poc-20260911235414",
|
|
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
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/fsztpartners/keemakr.git",
|
|
26
|
+
"directory": "packages/event-sdk"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.json",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepack": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"zod": "^4.4.3"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"jose": "^6.2.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20.19.43",
|
|
41
|
+
"typescript": "^5",
|
|
42
|
+
"zod": "^4.4.3"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.9"
|
|
46
|
+
}
|
|
47
|
+
}
|