@objectstack/trigger-api 9.3.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.
@@ -0,0 +1,132 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { createHmac } from 'node:crypto';
4
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
5
+ import { ApiTrigger, verifySignature } from './api-trigger.js';
6
+ import type { QueueServiceSurface } from './api-trigger.js';
7
+
8
+ /** In-memory queue: publish stores, deliver() drains to subscribers. */
9
+ function makeFakeQueue() {
10
+ const subs = new Map<string, (m: { data: any }) => Promise<void> | void>();
11
+ const pending = new Map<string, any[]>();
12
+ let n = 0;
13
+ const q: QueueServiceSurface & { deliver(): Promise<number>; published: Array<{ queue: string; data: any; idempotencyKey?: string }> } = {
14
+ published: [],
15
+ async publish(queue, data, options) {
16
+ this.published.push({ queue, data, idempotencyKey: options?.idempotencyKey });
17
+ (pending.get(queue) ?? pending.set(queue, []).get(queue)!).push(data);
18
+ return `msg_${++n}`;
19
+ },
20
+ async subscribe(queue, handler) { subs.set(queue, handler as any); },
21
+ async unsubscribe(queue) { subs.delete(queue); },
22
+ async deliver() {
23
+ let delivered = 0;
24
+ for (const [queue, items] of pending) {
25
+ const handler = subs.get(queue);
26
+ if (!handler) continue;
27
+ for (const data of items.splice(0)) { await handler({ data }); delivered++; }
28
+ }
29
+ return delivered;
30
+ },
31
+ };
32
+ return q;
33
+ }
34
+
35
+ const logger = { info: vi.fn(), warn: vi.fn() };
36
+
37
+ function sig(secret: string, body: string): string {
38
+ return 'sha256=' + createHmac('sha256', secret).update(body, 'utf8').digest('hex');
39
+ }
40
+
41
+ describe('ApiTrigger', () => {
42
+ let queue: ReturnType<typeof makeFakeQueue>;
43
+ let trigger: ApiTrigger;
44
+ let runs: any[];
45
+
46
+ beforeEach(() => {
47
+ queue = makeFakeQueue();
48
+ trigger = new ApiTrigger(() => queue, logger as any);
49
+ runs = [];
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ function arm(config: Record<string, unknown> = {}) {
54
+ trigger.start({ flowName: 'lead_intake', config }, async (ctx) => { runs.push(ctx); });
55
+ }
56
+
57
+ it('verifySignature accepts a correct GitHub-style signature and rejects others', () => {
58
+ const body = '{"a":1}';
59
+ expect(verifySignature('s3cret', body, sig('s3cret', body))).toBe(true);
60
+ expect(verifySignature('s3cret', body, sig('wrong', body))).toBe(false);
61
+ expect(verifySignature('s3cret', body, undefined)).toBe(false);
62
+ expect(verifySignature('s3cret', body, 'sha256=zz')).toBe(false);
63
+ });
64
+
65
+ it('202-enqueues a valid post and the consumer runs the flow with the payload as record', async () => {
66
+ arm({ hookId: 'hk1', secret: 's3cret' });
67
+ const body = JSON.stringify({ title: 'New lead', amount: 5000 });
68
+ const res = await trigger.handleRequest({
69
+ flowName: 'lead_intake', hookId: 'hk1', rawBody: body, signatureHeader: sig('s3cret', body),
70
+ });
71
+ expect(res.status).toBe(202);
72
+ expect(res.body.accepted).toBe(true);
73
+ expect(runs).toHaveLength(0); // never executed in-band
74
+ expect(await queue.deliver()).toBe(1);
75
+ expect(runs).toHaveLength(1);
76
+ expect(runs[0].record).toEqual({ title: 'New lead', amount: 5000 });
77
+ expect(runs[0].params).toEqual({ title: 'New lead', amount: 5000 });
78
+ });
79
+
80
+ it('answers 404 identically for unknown flows and wrong hookIds (no probing oracle)', async () => {
81
+ arm({ hookId: 'hk1' });
82
+ const a = await trigger.handleRequest({ flowName: 'nope', hookId: 'hk1', rawBody: '{}' });
83
+ const b = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'wrong', rawBody: '{}' });
84
+ expect(a).toEqual(b);
85
+ expect(a.status).toBe(404);
86
+ });
87
+
88
+ it('401s a missing or bad signature when the flow declares a secret', async () => {
89
+ arm({ hookId: 'hk1', secret: 's3cret' });
90
+ const body = '{"x":1}';
91
+ expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'hk1', rawBody: body })).status).toBe(401);
92
+ expect((await trigger.handleRequest({
93
+ flowName: 'lead_intake', hookId: 'hk1', rawBody: body, signatureHeader: sig('other', body),
94
+ })).status).toBe(401);
95
+ });
96
+
97
+ it('accepts unsigned posts when no secret is configured (and warned at arm time)', async () => {
98
+ arm({});
99
+ const res = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: '{"x":1}' });
100
+ expect(res.status).toBe(202);
101
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('WITHOUT a secret'));
102
+ });
103
+
104
+ it('400s non-object or invalid JSON bodies', async () => {
105
+ arm({});
106
+ expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: 'not json' })).status).toBe(400);
107
+ expect((await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'default', rawBody: '[1,2]' })).status).toBe(400);
108
+ });
109
+
110
+ it('passes x-idempotency-key through to the queue dedup window', async () => {
111
+ arm({});
112
+ await trigger.handleRequest({
113
+ flowName: 'lead_intake', hookId: 'default', rawBody: '{}', idempotencyKey: 'evt_42',
114
+ });
115
+ expect(queue.published[0].idempotencyKey).toBe('evt_42');
116
+ });
117
+
118
+ it('503s when no queue service is registered', async () => {
119
+ const t = new ApiTrigger(() => null, logger as any);
120
+ t.start({ flowName: 'f', config: {} }, async () => {});
121
+ const res = await t.handleRequest({ flowName: 'f', hookId: 'default', rawBody: '{}' });
122
+ expect(res.status).toBe(503);
123
+ });
124
+
125
+ it('stop() disarms the hook and unsubscribes the queue', async () => {
126
+ arm({ hookId: 'hk1' });
127
+ trigger.stop('lead_intake');
128
+ const res = await trigger.handleRequest({ flowName: 'lead_intake', hookId: 'hk1', rawBody: '{}' });
129
+ expect(res.status).toBe(404);
130
+ expect(trigger.listHooks()).toHaveLength(0);
131
+ });
132
+ });
@@ -0,0 +1,216 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { createHmac, timingSafeEqual } from 'node:crypto';
4
+ import type { AutomationContext } from '@objectstack/spec/contracts';
5
+
6
+ /**
7
+ * Structural mirror of the automation engine's `FlowTriggerBinding` — same
8
+ * decoupling pattern as `trigger-schedule` / `trigger-record-change`: this
9
+ * package never imports `service-automation`.
10
+ */
11
+ export interface FlowTriggerBinding {
12
+ readonly flowName: string;
13
+ readonly object?: string;
14
+ readonly event?: string;
15
+ readonly condition?: string | { dialect?: string; source?: string; ast?: unknown };
16
+ readonly schedule?: unknown;
17
+ readonly config?: Record<string, unknown>;
18
+ }
19
+
20
+ /** Structural mirror of the engine's `FlowTrigger` extension point. */
21
+ export interface FlowTrigger {
22
+ readonly type: string;
23
+ start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void;
24
+ stop(flowName: string): void;
25
+ }
26
+
27
+ /**
28
+ * The slice of `IQueueService` this trigger needs. Boundary triggers MUST
29
+ * ingest through the queue (ADR-0041 §5): inbound HTTP is the first event
30
+ * source whose rate we don't control, and a slow flow execution may neither
31
+ * drop nor block inbound events. At-least-once delivery; flows should be
32
+ * authored idempotently (an `x-idempotency-key` header passes through to the
33
+ * queue's dedup window).
34
+ */
35
+ export interface QueueServiceSurface {
36
+ publish<T = unknown>(queue: string, data: T, options?: { idempotencyKey?: string }): Promise<string>;
37
+ subscribe<T = unknown>(queue: string, handler: (message: { data: T }) => Promise<void> | void): Promise<void>;
38
+ unsubscribe(queue: string): Promise<void>;
39
+ }
40
+
41
+ /** Minimal logger surface (matches core's `ctx.logger`). */
42
+ export interface TriggerLogger {
43
+ info(msg: string, ...args: unknown[]): void;
44
+ warn(msg: string, ...args: unknown[]): void;
45
+ debug?(msg: string, ...args: unknown[]): void;
46
+ }
47
+
48
+ const QUEUE_PREFIX = 'flow-api';
49
+
50
+ /** One armed inbound hook. */
51
+ interface ArmedHook {
52
+ flowName: string;
53
+ hookId: string;
54
+ secret?: string;
55
+ queue: string;
56
+ callback: (ctx: AutomationContext) => Promise<void>;
57
+ }
58
+
59
+ /** Constant-time string compare (length leak only). */
60
+ function safeEqual(a: string, b: string): boolean {
61
+ const ab = Buffer.from(a, 'utf8');
62
+ const bb = Buffer.from(b, 'utf8');
63
+ if (ab.length !== bb.length) return false;
64
+ return timingSafeEqual(ab, bb);
65
+ }
66
+
67
+ /**
68
+ * GitHub/Stripe-style HMAC verification: the sender computes
69
+ * `sha256=<hex hmac of the raw body>` with the shared per-flow secret and
70
+ * sends it as `x-objectstack-signature`.
71
+ */
72
+ export function verifySignature(secret: string, rawBody: string, header: string | undefined): boolean {
73
+ if (!header) return false;
74
+ const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
75
+ return safeEqual(expected, header.trim());
76
+ }
77
+
78
+ /**
79
+ * `api` flow trigger (ADR-0041 Tier 1) — inbound webhook/HTTP.
80
+ *
81
+ * The engine binds every `type: 'api'` flow to this trigger; `start()` arms a
82
+ * hook (URL path + optional HMAC secret from the start node's `config`) and
83
+ * subscribes a queue consumer that runs the flow. The HTTP side
84
+ * ({@link handleRequest}) validates and **enqueues** — it never executes the
85
+ * flow in-band:
86
+ *
87
+ * POST /api/v1/automation/hooks/:flowName/:hookId
88
+ * → 404 unknown flow / wrong hookId
89
+ * → 401 missing/bad HMAC signature (when the flow declares a `secret`)
90
+ * → 400 non-JSON body
91
+ * → 202 { accepted, messageId } — queued; a consumer executes the flow
92
+ *
93
+ * The JSON payload is exposed to the flow as the trigger record (`$record` /
94
+ * `record.*`, fields flattened to bare references) plus `params` — the same
95
+ * authoring surface record-change flows use.
96
+ *
97
+ * Start-node config keys:
98
+ * - `hookId` — URL path token (default `'default'`); rotate it to revoke
99
+ * old URLs without renaming the flow.
100
+ * - `secret` — HMAC-SHA256 shared secret. Strongly recommended; without it
101
+ * the endpoint accepts unsigned posts (the trigger logs a
102
+ * warning at arm time).
103
+ */
104
+ export class ApiTrigger implements FlowTrigger {
105
+ readonly type = 'api';
106
+
107
+ private hooks = new Map<string, ArmedHook>();
108
+
109
+ constructor(
110
+ private readonly getQueue: () => QueueServiceSurface | null,
111
+ private readonly logger: TriggerLogger,
112
+ ) {}
113
+
114
+ /** Currently armed hooks (for diagnostics/tests). */
115
+ listHooks(): Array<{ flowName: string; hookId: string; signed: boolean }> {
116
+ return [...this.hooks.values()].map(h => ({
117
+ flowName: h.flowName, hookId: h.hookId, signed: !!h.secret,
118
+ }));
119
+ }
120
+
121
+ start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise<void>): void {
122
+ const cfg = (binding.config ?? {}) as Record<string, unknown>;
123
+ const hookId = typeof cfg.hookId === 'string' && cfg.hookId.trim() ? cfg.hookId.trim() : 'default';
124
+ const secret = typeof cfg.secret === 'string' && cfg.secret.trim() ? cfg.secret.trim() : undefined;
125
+ const queue = `${QUEUE_PREFIX}:${binding.flowName}`;
126
+
127
+ const hook: ArmedHook = { flowName: binding.flowName, hookId, secret, queue, callback };
128
+ this.hooks.set(binding.flowName, hook);
129
+
130
+ const q = this.getQueue();
131
+ if (!q) {
132
+ this.logger.warn(
133
+ `[trigger-api] no queue service — inbound posts for flow '${binding.flowName}' will be rejected until one is registered`,
134
+ );
135
+ } else {
136
+ void q.subscribe<{ payload: Record<string, unknown> }>(queue, async (message) => {
137
+ const payload = (message?.data as any)?.payload ?? {};
138
+ await hook.callback({
139
+ // The webhook body IS the trigger record: `$record`, `record.*`,
140
+ // and bare field references all resolve, matching how
141
+ // record-change flows are authored.
142
+ record: payload,
143
+ params: { ...payload },
144
+ event: 'api',
145
+ } as AutomationContext);
146
+ }).catch((err: any) => {
147
+ this.logger.warn(`[trigger-api] subscribe failed for '${queue}': ${err?.message ?? err}`);
148
+ });
149
+ }
150
+
151
+ if (!secret) {
152
+ this.logger.warn(
153
+ `[trigger-api] flow '${binding.flowName}' armed WITHOUT a secret — endpoint accepts unsigned posts`,
154
+ );
155
+ }
156
+ this.logger.info(
157
+ `[trigger-api] armed: POST .../automation/hooks/${binding.flowName}/${hookId}${secret ? ' (HMAC required)' : ''}`,
158
+ );
159
+ }
160
+
161
+ stop(flowName: string): void {
162
+ const hook = this.hooks.get(flowName);
163
+ if (!hook) return;
164
+ this.hooks.delete(flowName);
165
+ const q = this.getQueue();
166
+ if (q) void q.unsubscribe(hook.queue).catch(() => { /* already gone */ });
167
+ this.logger.info(`[trigger-api] disarmed: flow '${flowName}'`);
168
+ }
169
+
170
+ /**
171
+ * Handle one inbound request. Transport-agnostic: the plugin adapts this
172
+ * to the host HTTP server. Returns the response to send.
173
+ */
174
+ async handleRequest(input: {
175
+ flowName: string;
176
+ hookId: string;
177
+ rawBody: string;
178
+ signatureHeader?: string;
179
+ idempotencyKey?: string;
180
+ }): Promise<{ status: number; body: Record<string, unknown> }> {
181
+ const hook = this.hooks.get(input.flowName);
182
+ // Unknown flow and wrong hookId answer identically — no oracle for
183
+ // probing which flows exist.
184
+ if (!hook || !safeEqual(hook.hookId, input.hookId)) {
185
+ return { status: 404, body: { error: 'not_found' } };
186
+ }
187
+ if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) {
188
+ return { status: 401, body: { error: 'invalid_signature' } };
189
+ }
190
+
191
+ let payload: Record<string, unknown>;
192
+ try {
193
+ const parsed: unknown = input.rawBody.trim() ? JSON.parse(input.rawBody) : {};
194
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
195
+ return { status: 400, body: { error: 'invalid_body', message: 'Body must be a JSON object.' } };
196
+ }
197
+ payload = parsed as Record<string, unknown>;
198
+ } catch {
199
+ return { status: 400, body: { error: 'invalid_body', message: 'Body must be valid JSON.' } };
200
+ }
201
+
202
+ const q = this.getQueue();
203
+ if (!q) {
204
+ return { status: 503, body: { error: 'queue_unavailable', message: 'No queue service is registered.' } };
205
+ }
206
+ try {
207
+ const messageId = await q.publish(hook.queue, { payload }, {
208
+ idempotencyKey: input.idempotencyKey,
209
+ });
210
+ return { status: 202, body: { accepted: true, messageId } };
211
+ } catch (err: any) {
212
+ this.logger.warn(`[trigger-api] enqueue failed for '${hook.queue}': ${err?.message ?? err}`);
213
+ return { status: 503, body: { error: 'enqueue_failed' } };
214
+ }
215
+ }
216
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { ApiTriggerPlugin, HOOKS_PATH } from './plugin.js';
4
+ export { ApiTrigger, verifySignature } from './api-trigger.js';
5
+ export type {
6
+ FlowTrigger,
7
+ FlowTriggerBinding,
8
+ QueueServiceSurface,
9
+ TriggerLogger,
10
+ } from './api-trigger.js';
package/src/plugin.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import { ApiTrigger } from './api-trigger.js';
5
+ import type { FlowTrigger, QueueServiceSurface } from './api-trigger.js';
6
+
7
+ /**
8
+ * The slice of the automation engine this plugin needs. Declared structurally
9
+ * so the plugin does not take a build dependency on
10
+ * `@objectstack/service-automation`.
11
+ */
12
+ interface AutomationTriggerRegistry {
13
+ registerTrigger(trigger: FlowTrigger): void;
14
+ unregisterTrigger?(type: string): void;
15
+ }
16
+
17
+ /** The slice of the Hono host server this plugin needs. */
18
+ interface HttpServerSurface {
19
+ getRawApp(): {
20
+ post(path: string, handler: (c: any) => Promise<unknown> | unknown): void;
21
+ } | null;
22
+ }
23
+
24
+ /** Mount point for inbound hooks on the host HTTP server. */
25
+ export const HOOKS_PATH = '/api/v1/automation/hooks/:flowName/:hookId';
26
+
27
+ /**
28
+ * ApiTriggerPlugin (ADR-0041 Tier 1)
29
+ *
30
+ * Makes `type: 'api'` flows actually fire. The automation engine derives an
31
+ * `api` binding from the flow; this plugin provides the concrete trigger:
32
+ *
33
+ * - mounts `POST /api/v1/automation/hooks/:flowName/:hookId` on the host
34
+ * Hono app (resolved via the `http-server` service);
35
+ * - validates hookId + HMAC signature (`x-objectstack-signature`,
36
+ * GitHub/Stripe style, constant-time) against the flow's start-node
37
+ * config;
38
+ * - **enqueues** the payload (`queue` service) and ACKs 202 — flow
39
+ * execution happens on the queue consumer, never in-band with the
40
+ * inbound request (ADR-0041 §5: boundary triggers must not let a slow
41
+ * flow drop or block events). `x-idempotency-key` passes through to the
42
+ * queue's dedup window.
43
+ *
44
+ * Webhook payloads surface to the flow as the trigger record (`$record` /
45
+ * `record.*` / bare field references) — the same authoring surface
46
+ * record-change flows use.
47
+ */
48
+ export class ApiTriggerPlugin implements Plugin {
49
+ name = 'com.objectstack.trigger.api';
50
+ type = 'standard';
51
+ version = '1.0.0';
52
+ dependencies = ['com.objectstack.service.queue'];
53
+
54
+ async init(ctx: PluginContext): Promise<void> {
55
+ ctx.logger.info('API trigger plugin initialized');
56
+ }
57
+
58
+ async start(ctx: PluginContext): Promise<void> {
59
+ // Resolve at kernel:ready — the automation engine, queue service, and
60
+ // HTTP server may all start after this plugin.
61
+ ctx.hook('kernel:ready', async () => {
62
+ const automation = this.resolveService<AutomationTriggerRegistry>(ctx, 'automation');
63
+ if (!automation || typeof automation.registerTrigger !== 'function') {
64
+ ctx.logger.warn('ApiTriggerPlugin: automation service not available — api trigger NOT installed');
65
+ return;
66
+ }
67
+ if (!this.resolveService<QueueServiceSurface>(ctx, 'queue')) {
68
+ ctx.logger.warn('ApiTriggerPlugin: queue service not available — inbound posts will 503 until one is registered');
69
+ }
70
+
71
+ const trigger = new ApiTrigger(
72
+ () => this.resolveService<QueueServiceSurface>(ctx, 'queue'),
73
+ ctx.logger,
74
+ );
75
+ automation.registerTrigger(trigger);
76
+
77
+ const http = this.resolveService<HttpServerSurface>(ctx, 'http-server');
78
+ const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
79
+ if (!rawApp) {
80
+ ctx.logger.warn('ApiTriggerPlugin: HTTP server not available — hooks endpoint not mounted');
81
+ return;
82
+ }
83
+ rawApp.post(HOOKS_PATH, async (c: any) => {
84
+ const rawBody = await c.req.text();
85
+ const out = await trigger.handleRequest({
86
+ flowName: c.req.param('flowName'),
87
+ hookId: c.req.param('hookId'),
88
+ rawBody,
89
+ signatureHeader: c.req.header('x-objectstack-signature'),
90
+ idempotencyKey: c.req.header('x-idempotency-key') || undefined,
91
+ });
92
+ return c.json(out.body, out.status);
93
+ });
94
+ ctx.logger.info(`ApiTriggerPlugin: api trigger registered, hooks mounted at POST ${HOOKS_PATH}`);
95
+ });
96
+ }
97
+
98
+ private resolveService<T>(ctx: PluginContext, name: string): T | null {
99
+ try {
100
+ return ctx.getService<T>(name) ?? null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": [
7
+ "node"
8
+ ]
9
+ },
10
+ "include": [
11
+ "src/**/*"
12
+ ],
13
+ "exclude": [
14
+ "dist",
15
+ "node_modules",
16
+ "**/*.test.ts"
17
+ ]
18
+ }