@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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +202 -0
- package/LICENSE.apache +202 -0
- package/dist/index.d.mts +145 -0
- package/dist/index.d.ts +145 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +174 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -0
- package/src/api-trigger.test.ts +132 -0
- package/src/api-trigger.ts +216 -0
- package/src/index.ts +10 -0
- package/src/plugin.ts +105 -0
- package/tsconfig.json +18 -0
|
@@ -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
|
+
}
|