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