webhook-delivery-engine 0.0.0-stage → 1.0.0

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/dist/src/app/bootstrap.d.ts +14 -0
  4. package/dist/src/app/bootstrap.js +13 -0
  5. package/dist/src/app/embedded.d.ts +45 -0
  6. package/dist/src/app/embedded.js +228 -0
  7. package/dist/src/app/http.server.d.ts +10 -0
  8. package/dist/src/app/http.server.js +65 -0
  9. package/dist/src/app/lifecycle.d.ts +108 -0
  10. package/dist/src/app/lifecycle.js +138 -0
  11. package/dist/src/config/engine.config.d.ts +45 -0
  12. package/dist/src/config/engine.config.js +1 -0
  13. package/dist/src/core/errors.d.ts +4 -0
  14. package/dist/src/core/errors.js +7 -0
  15. package/dist/src/core/ports.d.ts +217 -0
  16. package/dist/src/core/ports.js +1 -0
  17. package/dist/src/core/types.d.ts +98 -0
  18. package/dist/src/core/types.js +10 -0
  19. package/dist/src/features/delivery/delivery.backoff.d.ts +74 -0
  20. package/dist/src/features/delivery/delivery.backoff.js +56 -0
  21. package/dist/src/features/delivery/delivery.classify.d.ts +28 -0
  22. package/dist/src/features/delivery/delivery.classify.js +38 -0
  23. package/dist/src/features/delivery/delivery.dispatcher.d.ts +57 -0
  24. package/dist/src/features/delivery/delivery.dispatcher.js +155 -0
  25. package/dist/src/features/delivery/delivery.hmac.d.ts +62 -0
  26. package/dist/src/features/delivery/delivery.hmac.js +31 -0
  27. package/dist/src/features/delivery/delivery.worker.d.ts +60 -0
  28. package/dist/src/features/delivery/delivery.worker.js +136 -0
  29. package/dist/src/features/http/http.responses.d.ts +6 -0
  30. package/dist/src/features/http/http.responses.js +45 -0
  31. package/dist/src/features/ingestion/ingestion.handler.d.ts +81 -0
  32. package/dist/src/features/ingestion/ingestion.handler.js +59 -0
  33. package/dist/src/features/ingestion/ingestion.routes.d.ts +12 -0
  34. package/dist/src/features/ingestion/ingestion.routes.js +25 -0
  35. package/dist/src/features/ingestion/ingestion.schema.d.ts +26 -0
  36. package/dist/src/features/ingestion/ingestion.schema.js +24 -0
  37. package/dist/src/features/query/query.routes.d.ts +11 -0
  38. package/dist/src/features/query/query.routes.js +99 -0
  39. package/dist/src/features/scheduler/scheduler.d.ts +110 -0
  40. package/dist/src/features/scheduler/scheduler.js +153 -0
  41. package/dist/src/index.d.ts +8 -0
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/security/auth.d.ts +40 -0
  44. package/dist/src/security/auth.js +39 -0
  45. package/dist/src/security/ssrf.d.ts +47 -0
  46. package/dist/src/security/ssrf.js +173 -0
  47. package/dist/src/shared/logger.d.ts +11 -0
  48. package/dist/src/shared/logger.js +52 -0
  49. package/dist/src/storage/migrations/runner.d.ts +51 -0
  50. package/dist/src/storage/migrations/runner.js +155 -0
  51. package/dist/src/storage/migrations/sql/sqlite/001_initial_schema.sql +52 -0
  52. package/dist/src/storage/sqlite.adapter.d.ts +51 -0
  53. package/dist/src/storage/sqlite.adapter.js +478 -0
  54. package/package.json +76 -4
@@ -0,0 +1,81 @@
1
+ import type { DatabaseAdapter } from '../../core/ports.js';
2
+ /**
3
+ * Input required to accept one validated ingestion request.
4
+ *
5
+ * The host-configured client identity is supplied separately from parsed
6
+ * request data. The database dependency is limited to the canonical atomic
7
+ * upsert operation.
8
+ */
9
+ export interface HandleIngestionInput {
10
+ /** Tenant identity configured by the host for this engine instance. */
11
+ readonly clientId: string;
12
+ /** Parsed request body, validated by the handler before use. */
13
+ readonly body: unknown;
14
+ /** Configured HTTPS destination used when the request omits its destination. */
15
+ readonly defaultDestinationUrl: string;
16
+ /** Persistence capability used to atomically insert or replay the event. */
17
+ readonly database: Pick<DatabaseAdapter, 'upsertEvent'>;
18
+ }
19
+ /**
20
+ * Accepted ingestion acknowledgment returned for new events and replays.
21
+ *
22
+ * `status` represents the accepted enqueue acknowledgment, not a current event
23
+ * state lookup; a previously accepted event may already have advanced.
24
+ */
25
+ export interface IngestionAcceptedResponse {
26
+ /** HTTP status code for an eventual transport adapter. */
27
+ readonly statusCode: 202;
28
+ /** Stable acknowledgment fields for an accepted or replayed event. */
29
+ readonly body: {
30
+ /** System-generated id of the stored event. */
31
+ readonly id: string;
32
+ /** Tenant-scoped client idempotency key. */
33
+ readonly idempotency_key: string;
34
+ /** Whether the database returned an existing tenant/key match. */
35
+ readonly idempotent_replay: boolean;
36
+ /** Enqueue acknowledgment state required by the ingestion contract. */
37
+ readonly status: 'pending';
38
+ };
39
+ }
40
+ /**
41
+ * Non-storage failure results returned by the ingestion handler.
42
+ *
43
+ * Persistence failures reject the handler promise so callers cannot mistake
44
+ * an unavailable database for an accepted event.
45
+ */
46
+ export type IngestionHandlerFailure = {
47
+ readonly statusCode: 400;
48
+ readonly body: {
49
+ readonly error: 'invalid_request';
50
+ };
51
+ } | {
52
+ readonly statusCode: 422;
53
+ readonly body: {
54
+ readonly error: 'invalid_destination';
55
+ };
56
+ } | {
57
+ readonly statusCode: 500;
58
+ readonly body: {
59
+ readonly error: 'invalid_default_destination';
60
+ };
61
+ };
62
+ /**
63
+ * Discriminated result of handling one ingestion request.
64
+ */
65
+ export type IngestionHandlerResult = IngestionAcceptedResponse | IngestionHandlerFailure;
66
+ /**
67
+ * Validates and atomically persists one webhook event request.
68
+ *
69
+ * The request schema rejects caller-supplied identity and unknown properties.
70
+ * An omitted destination uses the configured fallback, and both supplied and
71
+ * fallback URLs are checked by the same fail-closed SSRF validator before
72
+ * persistence. The system-generated event id and UTC timestamps are created
73
+ * in this function. Idempotency and replay identity are delegated to the
74
+ * adapter's atomic `(clientId, idempotencyKey)` upsert.
75
+ *
76
+ * @param input Host-configured identity, parsed input, configured fallback,
77
+ * and the adapter upsert capability.
78
+ * @returns A stable accepted response or a safe validation failure result.
79
+ * @throws When the database adapter rejects the upsert operation.
80
+ */
81
+ export declare function handleIngestion(input: HandleIngestionInput): Promise<IngestionHandlerResult>;
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { validateDestinationUrl } from '../../security/ssrf.js';
3
+ import { ingestionRequestSchema } from './ingestion.schema.js';
4
+ /**
5
+ * Validates and atomically persists one webhook event request.
6
+ *
7
+ * The request schema rejects caller-supplied identity and unknown properties.
8
+ * An omitted destination uses the configured fallback, and both supplied and
9
+ * fallback URLs are checked by the same fail-closed SSRF validator before
10
+ * persistence. The system-generated event id and UTC timestamps are created
11
+ * in this function. Idempotency and replay identity are delegated to the
12
+ * adapter's atomic `(clientId, idempotencyKey)` upsert.
13
+ *
14
+ * @param input Host-configured identity, parsed input, configured fallback,
15
+ * and the adapter upsert capability.
16
+ * @returns A stable accepted response or a safe validation failure result.
17
+ * @throws When the database adapter rejects the upsert operation.
18
+ */
19
+ export async function handleIngestion(input) {
20
+ const parsed = ingestionRequestSchema.safeParse(input.body);
21
+ if (!parsed.success) {
22
+ return { statusCode: 400, body: { error: 'invalid_request' } };
23
+ }
24
+ const usesDefaultDestination = parsed.data.destination_url === undefined;
25
+ const destinationUrl = parsed.data.destination_url ?? input.defaultDestinationUrl;
26
+ const validation = await validateDestinationUrl(destinationUrl);
27
+ if (!validation.valid) {
28
+ return usesDefaultDestination
29
+ ? { statusCode: 500, body: { error: 'invalid_default_destination' } }
30
+ : { statusCode: 422, body: { error: 'invalid_destination' } };
31
+ }
32
+ const timestamp = new Date().toISOString();
33
+ const event = {
34
+ id: randomUUID(),
35
+ idempotencyKey: parsed.data.idempotency_key,
36
+ clientId: input.clientId,
37
+ destinationUrl: validation.url,
38
+ payload: parsed.data.payload,
39
+ metadata: {},
40
+ headers: {},
41
+ status: 'pending',
42
+ attemptCount: 0,
43
+ nextRetryAt: null,
44
+ lockedAt: null,
45
+ lockedBy: null,
46
+ createdAt: timestamp,
47
+ updatedAt: timestamp,
48
+ };
49
+ const upsertResult = await input.database.upsertEvent(event);
50
+ return {
51
+ statusCode: 202,
52
+ body: {
53
+ id: upsertResult.event.id,
54
+ idempotency_key: upsertResult.event.idempotencyKey,
55
+ idempotent_replay: !upsertResult.inserted,
56
+ status: 'pending',
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,12 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DatabaseAdapter } from '../../core/ports.js';
3
+ import type { ApiKeyAuthenticationResult } from '../../security/auth.js';
4
+ export interface IngestionRouteOptions {
5
+ readonly request: IncomingMessage;
6
+ readonly response: ServerResponse;
7
+ readonly authentication: ApiKeyAuthenticationResult;
8
+ readonly database: Pick<DatabaseAdapter, 'upsertEvent'>;
9
+ readonly defaultDestinationUrl: string;
10
+ }
11
+ /** Maps a raw HTTP ingestion request onto the canonical ingestion handler. */
12
+ export declare function handleIngestionRoute(options: IngestionRouteOptions): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { handleIngestion } from './ingestion.handler.js';
2
+ import { readJsonBody, sendJson } from '../http/http.responses.js';
3
+ /** Maps a raw HTTP ingestion request onto the canonical ingestion handler. */
4
+ export async function handleIngestionRoute(options) {
5
+ let body;
6
+ try {
7
+ body = await readJsonBody(options.request);
8
+ }
9
+ catch {
10
+ sendJson(options.response, 400, { error: 'invalid_request' });
11
+ return;
12
+ }
13
+ try {
14
+ const result = await handleIngestion({
15
+ authentication: options.authentication,
16
+ body,
17
+ database: options.database,
18
+ defaultDestinationUrl: options.defaultDestinationUrl,
19
+ });
20
+ sendJson(options.response, result.statusCode, result.body);
21
+ }
22
+ catch {
23
+ sendJson(options.response, 503, { error: 'service_unavailable' });
24
+ }
25
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import type { JsonValue } from '../../core/types.js';
3
+ /**
4
+ * Validates the strict public request body for webhook ingestion.
5
+ *
6
+ * Client identity and timestamps are deliberately absent because the
7
+ * configured engine context supplies them. The optional destination URL
8
+ * is resolved to configuration by the ingestion handler before SSRF checks.
9
+ */
10
+ export declare const ingestionRequestSchema: z.ZodReadonly<z.ZodObject<{
11
+ idempotency_key: z.ZodEffects<z.ZodString, string, string>;
12
+ payload: z.ZodType<JsonValue, z.ZodTypeDef, JsonValue>;
13
+ destination_url: z.ZodOptional<z.ZodString>;
14
+ }, "strict", z.ZodTypeAny, {
15
+ payload: JsonValue;
16
+ idempotency_key: string;
17
+ destination_url?: string | undefined;
18
+ }, {
19
+ payload: JsonValue;
20
+ idempotency_key: string;
21
+ destination_url?: string | undefined;
22
+ }>>;
23
+ /**
24
+ * The validated shape of the parsed JSON ingestion request body.
25
+ */
26
+ export type IngestionRequest = z.infer<typeof ingestionRequestSchema>;
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ const jsonValueSchema = z.lazy(() => z.union([
3
+ z.string(),
4
+ z.number().finite(),
5
+ z.boolean(),
6
+ z.null(),
7
+ z.array(jsonValueSchema).readonly(),
8
+ z.record(z.string(), jsonValueSchema).readonly(),
9
+ ]));
10
+ /**
11
+ * Validates the strict public request body for webhook ingestion.
12
+ *
13
+ * Client identity and timestamps are deliberately absent because the
14
+ * configured engine context supplies them. The optional destination URL
15
+ * is resolved to configuration by the ingestion handler before SSRF checks.
16
+ */
17
+ export const ingestionRequestSchema = z
18
+ .object({
19
+ idempotency_key: z.string().refine((value) => value.trim().length > 0),
20
+ payload: jsonValueSchema,
21
+ destination_url: z.string().optional(),
22
+ })
23
+ .strict()
24
+ .readonly();
@@ -0,0 +1,11 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import type { DatabaseAdapter } from '../../core/ports.js';
3
+ import type { ApiKeyAuthenticationResult } from '../../security/auth.js';
4
+ export interface QueryRouteOptions {
5
+ readonly response: ServerResponse;
6
+ readonly url: URL;
7
+ readonly authentication: ApiKeyAuthenticationResult;
8
+ readonly database: Pick<DatabaseAdapter, 'findEventById' | 'findEventByIdempotencyKey' | 'listEvents' | 'listAttempts'>;
9
+ }
10
+ /** Handles GET collection, event detail, and event-attempt routes. */
11
+ export declare function handleQueryRoute(options: QueryRouteOptions): Promise<void>;
@@ -0,0 +1,99 @@
1
+ import { EVENT_STATUSES } from '../../core/types.js';
2
+ import { InvalidPageCursorError } from '../../core/errors.js';
3
+ import { sendJson, serializeAttempt, serializeEvent } from '../http/http.responses.js';
4
+ const maxPageSize = 1000;
5
+ /** Handles GET collection, event detail, and event-attempt routes. */
6
+ export async function handleQueryRoute(options) {
7
+ if (!options.authentication.authenticated) {
8
+ sendJson(options.response, 401, { error: 'unauthorized' });
9
+ return;
10
+ }
11
+ try {
12
+ if (options.url.pathname === '/webhooks') {
13
+ await listEvents(options, options.authentication.clientId);
14
+ return;
15
+ }
16
+ const segments = options.url.pathname.split('/').slice(2);
17
+ if (segments.length < 1 || segments.length > 2) {
18
+ sendJson(options.response, 404, { error: 'not_found' });
19
+ return;
20
+ }
21
+ let identifier;
22
+ try {
23
+ identifier = decodeURIComponent(segments[0] ?? '');
24
+ }
25
+ catch {
26
+ sendJson(options.response, 400, { error: 'invalid_request' });
27
+ return;
28
+ }
29
+ if (identifier.length === 0) {
30
+ sendJson(options.response, 404, { error: 'not_found' });
31
+ return;
32
+ }
33
+ const isAttempts = segments.length === 2 && segments[1] === 'attempts';
34
+ if (segments.length === 2 && !isAttempts) {
35
+ sendJson(options.response, 404, { error: 'not_found' });
36
+ return;
37
+ }
38
+ const clientId = options.authentication.clientId;
39
+ const event = (await options.database.findEventById(identifier, clientId)) ??
40
+ (await options.database.findEventByIdempotencyKey(clientId, identifier));
41
+ if (event === null) {
42
+ sendJson(options.response, 404, { error: 'not_found' });
43
+ return;
44
+ }
45
+ if (isAttempts) {
46
+ const attempts = await options.database.listAttempts(event.id, clientId);
47
+ sendJson(options.response, 200, { items: attempts.map(serializeAttempt) });
48
+ return;
49
+ }
50
+ sendJson(options.response, 200, serializeEvent(event));
51
+ }
52
+ catch (error) {
53
+ if (error instanceof InvalidPageCursorError) {
54
+ sendJson(options.response, 400, { error: 'invalid_request' });
55
+ return;
56
+ }
57
+ sendJson(options.response, 503, { error: 'service_unavailable' });
58
+ }
59
+ }
60
+ async function listEvents(options, clientId) {
61
+ const allowed = new Set(['status', 'limit', 'cursor']);
62
+ for (const key of options.url.searchParams.keys()) {
63
+ if (!allowed.has(key) || options.url.searchParams.getAll(key).length !== 1) {
64
+ sendJson(options.response, 400, { error: 'invalid_request' });
65
+ return;
66
+ }
67
+ }
68
+ const statusValue = options.url.searchParams.get('status') ?? undefined;
69
+ if (statusValue !== undefined && !EVENT_STATUSES.includes(statusValue)) {
70
+ sendJson(options.response, 400, { error: 'invalid_request' });
71
+ return;
72
+ }
73
+ const limitValue = options.url.searchParams.get('limit');
74
+ const limit = limitValue === null ? 100 : Number(limitValue);
75
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxPageSize) {
76
+ sendJson(options.response, 400, { error: 'invalid_request' });
77
+ return;
78
+ }
79
+ const cursor = options.url.searchParams.get('cursor') ?? undefined;
80
+ try {
81
+ const page = await options.database.listEvents({
82
+ clientId,
83
+ ...(statusValue === undefined ? {} : { status: statusValue }),
84
+ limit,
85
+ ...(cursor === undefined ? {} : { cursor }),
86
+ });
87
+ sendJson(options.response, 200, {
88
+ items: page.items.map(serializeEvent),
89
+ next_cursor: page.nextCursor,
90
+ });
91
+ }
92
+ catch (error) {
93
+ if (error instanceof InvalidPageCursorError) {
94
+ sendJson(options.response, 400, { error: 'invalid_request' });
95
+ return;
96
+ }
97
+ sendJson(options.response, 503, { error: 'service_unavailable' });
98
+ }
99
+ }
@@ -0,0 +1,110 @@
1
+ import type { WebhookEvent } from '../../core/types.js';
2
+ import type { DatabaseAdapter } from '../../core/ports.js';
3
+ import type { EngineConfig } from '../../config/engine.config.js';
4
+ /**
5
+ * Callback seam invoked once per claimed event.
6
+ *
7
+ * The scheduler hands each claimed `WebhookEvent` to this function. Delivery,
8
+ * HMAC signing, attempt recording, and state transitions are the
9
+ * responsibility of the worker registered here (WBS 5.x). The scheduler does
10
+ * not await the callback's promise; in-flight work is the worker's concern.
11
+ */
12
+ export type OnClaimCallback = (event: WebhookEvent) => void;
13
+ /**
14
+ * Optional callback invoked when a poll cycle catches a transient adapter
15
+ * error.
16
+ *
17
+ * The scheduler always continues regardless of whether this callback is
18
+ * supplied. Callers may use this seam to log errors via the structured logger
19
+ * (WBS 6.3) without introducing a direct logger dependency inside the
20
+ * scheduler.
21
+ */
22
+ export type OnPollErrorCallback = (error: unknown) => void;
23
+ /** Callback invoked after stale-lease recovery completes. */
24
+ export type OnRecoveryCallback = (recoveredCount: number) => void;
25
+ /**
26
+ * Polling loop that coordinates stale-lease recovery and due-event claiming.
27
+ *
28
+ * Each cycle:
29
+ * 1. Calls `DatabaseAdapter.recoverStaleLeases` with the current UTC time and
30
+ * the configured `timeoutLimitMs` to return expired processing leases to
31
+ * the retry queue.
32
+ * 2. Calls `DatabaseAdapter.claimDueEvents` with a stable worker id, the
33
+ * current UTC time, and the configured `batchSize` to atomically acquire
34
+ * up to `batchSize` due events.
35
+ * 3. Passes each claimed event to the `onClaim` callback (the worker seam).
36
+ * 4. Sleeps for `pollIntervalMs` before the next cycle when the batch is
37
+ * empty; starts the next cycle immediately when work was found.
38
+ *
39
+ * Transient errors thrown by either adapter operation are passed to the
40
+ * optional error callback so the loop continues rather than crashing. The
41
+ * scheduler does not own logging or register OS signal listeners (SIGINT/
42
+ * SIGTERM); those responsibilities belong to the composition root and
43
+ * graceful-shutdown module (WBS 4.2).
44
+ */
45
+ export declare class ClaimScheduler {
46
+ private readonly adapter;
47
+ private readonly config;
48
+ private readonly onClaim;
49
+ private readonly onError?;
50
+ private readonly onRecovery?;
51
+ /** Stable id stamped on every lease this scheduler instance claims. */
52
+ private readonly workerId;
53
+ /** Whether the scheduler's poll loop is currently active. */
54
+ private running;
55
+ /** Promise that resolves when the active loop iteration completes. */
56
+ private loopPromise;
57
+ /** AbortController used to signal the sleeping poll interval to wake early. */
58
+ private abortController;
59
+ /**
60
+ * Creates a scheduler bound to the given adapter, config, and worker seam.
61
+ *
62
+ * @param adapter - The persistence adapter implementing atomic claiming and
63
+ * stale-lease recovery operations.
64
+ * @param config - Immutable engine configuration; `concurrency.batchSize`
65
+ * and `concurrency.pollIntervalMs` govern this scheduler.
66
+ * @param onClaim - Worker seam called once per claimed event. The scheduler
67
+ * does not await this callback.
68
+ * @param onError - Optional callback invoked when a poll cycle catches a
69
+ * transient adapter error. The scheduler always continues regardless of
70
+ * whether this is supplied.
71
+ */
72
+ constructor(adapter: Pick<DatabaseAdapter, 'recoverStaleLeases' | 'claimDueEvents'>, config: EngineConfig, onClaim: OnClaimCallback, onError?: OnPollErrorCallback | undefined, onRecovery?: OnRecoveryCallback | undefined);
73
+ /**
74
+ * Whether the poll loop is currently active.
75
+ *
76
+ * Returns `true` from the moment `start()` is called until `stop()` fully
77
+ * resolves.
78
+ */
79
+ get isRunning(): boolean;
80
+ /**
81
+ * Starts the poll loop.
82
+ *
83
+ * Has no effect when the scheduler is already running — only one loop runs
84
+ * at a time.
85
+ */
86
+ start(): void;
87
+ /**
88
+ * Signals the poll loop to stop and waits for the current cycle to finish.
89
+ *
90
+ * Safe to call multiple times; resolves immediately when the loop is not
91
+ * running.
92
+ */
93
+ stop(): Promise<void>;
94
+ /**
95
+ * The main poll loop. Runs until `signal.aborted`.
96
+ *
97
+ * Errors thrown by adapter operations are caught and reported via
98
+ * `console.error`; the loop continues to the next cycle in all cases.
99
+ *
100
+ * @param signal - Abort signal set by `stop()` to terminate the loop.
101
+ */
102
+ private runLoop;
103
+ /**
104
+ * Sleeps for `ms` milliseconds or until the abort signal fires.
105
+ *
106
+ * @param ms - Duration to wait.
107
+ * @param signal - Abort signal that cancels the sleep early.
108
+ */
109
+ private sleep;
110
+ }
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Polling loop that coordinates stale-lease recovery and due-event claiming.
4
+ *
5
+ * Each cycle:
6
+ * 1. Calls `DatabaseAdapter.recoverStaleLeases` with the current UTC time and
7
+ * the configured `timeoutLimitMs` to return expired processing leases to
8
+ * the retry queue.
9
+ * 2. Calls `DatabaseAdapter.claimDueEvents` with a stable worker id, the
10
+ * current UTC time, and the configured `batchSize` to atomically acquire
11
+ * up to `batchSize` due events.
12
+ * 3. Passes each claimed event to the `onClaim` callback (the worker seam).
13
+ * 4. Sleeps for `pollIntervalMs` before the next cycle when the batch is
14
+ * empty; starts the next cycle immediately when work was found.
15
+ *
16
+ * Transient errors thrown by either adapter operation are passed to the
17
+ * optional error callback so the loop continues rather than crashing. The
18
+ * scheduler does not own logging or register OS signal listeners (SIGINT/
19
+ * SIGTERM); those responsibilities belong to the composition root and
20
+ * graceful-shutdown module (WBS 4.2).
21
+ */
22
+ export class ClaimScheduler {
23
+ adapter;
24
+ config;
25
+ onClaim;
26
+ onError;
27
+ onRecovery;
28
+ /** Stable id stamped on every lease this scheduler instance claims. */
29
+ workerId;
30
+ /** Whether the scheduler's poll loop is currently active. */
31
+ running = false;
32
+ /** Promise that resolves when the active loop iteration completes. */
33
+ loopPromise = null;
34
+ /** AbortController used to signal the sleeping poll interval to wake early. */
35
+ abortController = null;
36
+ /**
37
+ * Creates a scheduler bound to the given adapter, config, and worker seam.
38
+ *
39
+ * @param adapter - The persistence adapter implementing atomic claiming and
40
+ * stale-lease recovery operations.
41
+ * @param config - Immutable engine configuration; `concurrency.batchSize`
42
+ * and `concurrency.pollIntervalMs` govern this scheduler.
43
+ * @param onClaim - Worker seam called once per claimed event. The scheduler
44
+ * does not await this callback.
45
+ * @param onError - Optional callback invoked when a poll cycle catches a
46
+ * transient adapter error. The scheduler always continues regardless of
47
+ * whether this is supplied.
48
+ */
49
+ constructor(adapter, config, onClaim, onError, onRecovery) {
50
+ this.adapter = adapter;
51
+ this.config = config;
52
+ this.onClaim = onClaim;
53
+ this.onError = onError;
54
+ this.onRecovery = onRecovery;
55
+ this.workerId = randomUUID();
56
+ }
57
+ /**
58
+ * Whether the poll loop is currently active.
59
+ *
60
+ * Returns `true` from the moment `start()` is called until `stop()` fully
61
+ * resolves.
62
+ */
63
+ get isRunning() {
64
+ return this.running;
65
+ }
66
+ /**
67
+ * Starts the poll loop.
68
+ *
69
+ * Has no effect when the scheduler is already running — only one loop runs
70
+ * at a time.
71
+ */
72
+ start() {
73
+ if (this.running)
74
+ return;
75
+ this.running = true;
76
+ this.abortController = new AbortController();
77
+ this.loopPromise = this.runLoop(this.abortController.signal);
78
+ }
79
+ /**
80
+ * Signals the poll loop to stop and waits for the current cycle to finish.
81
+ *
82
+ * Safe to call multiple times; resolves immediately when the loop is not
83
+ * running.
84
+ */
85
+ async stop() {
86
+ if (!this.running)
87
+ return;
88
+ this.running = false;
89
+ this.abortController?.abort();
90
+ await this.loopPromise;
91
+ this.loopPromise = null;
92
+ this.abortController = null;
93
+ }
94
+ /**
95
+ * The main poll loop. Runs until `signal.aborted`.
96
+ *
97
+ * Errors thrown by adapter operations are caught and reported via
98
+ * `console.error`; the loop continues to the next cycle in all cases.
99
+ *
100
+ * @param signal - Abort signal set by `stop()` to terminate the loop.
101
+ */
102
+ async runLoop(signal) {
103
+ while (!signal.aborted) {
104
+ let claimedCount = 0;
105
+ try {
106
+ const now = new Date().toISOString();
107
+ const recoveredCount = await this.adapter.recoverStaleLeases({
108
+ now,
109
+ timeoutLimitMs: this.config.timeoutLimitMs,
110
+ });
111
+ this.onRecovery?.(recoveredCount);
112
+ const claimed = await this.adapter.claimDueEvents({
113
+ workerId: this.workerId,
114
+ now,
115
+ limit: this.config.concurrency.batchSize,
116
+ });
117
+ claimedCount = claimed.length;
118
+ for (const event of claimed) {
119
+ this.onClaim(event);
120
+ }
121
+ }
122
+ catch (error) {
123
+ // Transient adapter failure — invoke optional error handler and
124
+ // continue to the next cycle regardless.
125
+ this.onError?.(error);
126
+ }
127
+ // Sleep between cycles when no work was found; skip sleep when the
128
+ // batch was non-empty to process available work without delay.
129
+ if (claimedCount === 0 && !signal.aborted) {
130
+ await this.sleep(this.config.concurrency.pollIntervalMs, signal);
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Sleeps for `ms` milliseconds or until the abort signal fires.
136
+ *
137
+ * @param ms - Duration to wait.
138
+ * @param signal - Abort signal that cancels the sleep early.
139
+ */
140
+ sleep(ms, signal) {
141
+ return new Promise((resolve) => {
142
+ if (signal.aborted) {
143
+ resolve();
144
+ return;
145
+ }
146
+ const timer = setTimeout(resolve, ms);
147
+ signal.addEventListener('abort', () => {
148
+ clearTimeout(timer);
149
+ resolve();
150
+ });
151
+ });
152
+ }
153
+ }
@@ -0,0 +1,8 @@
1
+ export { createWebhookEngine } from './app/embedded.js';
2
+ export type { CreateWebhookEngineOptions, EngineListEventsInput, WebhookEngine, } from './app/embedded.js';
3
+ export type { EngineConfig, EngineConcurrency, RetryPolicy } from './config/engine.config.js';
4
+ export type { EventPage, ListEventsInput } from './core/ports.js';
5
+ export type { AttemptRecord, EventStatus, WebhookEvent } from './core/types.js';
6
+ export type { IngestionRequest } from './features/ingestion/ingestion.schema.js';
7
+ export type { LogLevel } from './shared/logger.js';
8
+ export type { IngestionAcceptedResponse, IngestionHandlerFailure, IngestionHandlerResult, } from './features/ingestion/ingestion.handler.js';
@@ -0,0 +1 @@
1
+ export { createWebhookEngine } from './app/embedded.js';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Configuration for the v1 single-key authenticator.
3
+ */
4
+ export interface ApiKeyAuthenticatorConfig {
5
+ /** Configured API key accepted by the authenticator. */
6
+ readonly apiKey: string;
7
+ /** Tenant identity returned after successful authentication. */
8
+ readonly clientId: string;
9
+ }
10
+ /**
11
+ * Result of checking a presented API key.
12
+ *
13
+ * Failed authentication always has the same result shape and carries no
14
+ * credential material.
15
+ */
16
+ export type ApiKeyAuthenticationResult = {
17
+ readonly authenticated: true;
18
+ readonly clientId: string;
19
+ } | {
20
+ readonly authenticated: false;
21
+ readonly statusCode: 401;
22
+ };
23
+ /**
24
+ * Function that authenticates a candidate API key.
25
+ */
26
+ export type ApiKeyAuthenticator = (candidateToken: string | undefined) => ApiKeyAuthenticationResult;
27
+ /**
28
+ * Creates an authenticator for one configured API key and tenant identity.
29
+ *
30
+ * The configured key is reduced to a fixed-length SHA-256 digest before the
31
+ * returned function is exposed. Each candidate, including an absent token,
32
+ * is hashed and compared with `timingSafeEqual`; credentials are never
33
+ * returned or logged. The HTTP layer maps an unauthorized result to a 401
34
+ * response.
35
+ *
36
+ * @param config - API key and its configured tenant identity.
37
+ * @returns A function that returns the tenant identity only for a valid key.
38
+ * @throws {@link TypeError} when either configured value is not a non-empty string.
39
+ */
40
+ export declare function createApiKeyAuthenticator(config: ApiKeyAuthenticatorConfig): ApiKeyAuthenticator;