@objectstack/trigger-api 17.0.0-rc.0 → 17.0.0-rc.2
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/CHANGELOG.md +378 -0
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -5
- package/.turbo/turbo-build.log +0 -22
- package/src/api-trigger.test.ts +0 -132
- package/src/api-trigger.ts +0 -216
- package/src/index.ts +0 -10
- package/src/plugin.ts +0 -105
- package/tsconfig.json +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,383 @@
|
|
|
1
1
|
# @objectstack/trigger-api
|
|
2
2
|
|
|
3
|
+
## 17.0.0-rc.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [430dcc2]
|
|
8
|
+
- Updated dependencies [e6ac4bd]
|
|
9
|
+
- Updated dependencies [80334c7]
|
|
10
|
+
- Updated dependencies [ce5242c]
|
|
11
|
+
- Updated dependencies [a7163ea]
|
|
12
|
+
- Updated dependencies [e6e9379]
|
|
13
|
+
- Updated dependencies [98877c9]
|
|
14
|
+
- Updated dependencies [98877c9]
|
|
15
|
+
- Updated dependencies [e6b1b69]
|
|
16
|
+
- Updated dependencies [ad047d2]
|
|
17
|
+
- Updated dependencies [2826d1e]
|
|
18
|
+
- Updated dependencies [5a84d41]
|
|
19
|
+
- Updated dependencies [20b1a9e]
|
|
20
|
+
- Updated dependencies [203a449]
|
|
21
|
+
- Updated dependencies [ac37fc6]
|
|
22
|
+
- Updated dependencies [4820f55]
|
|
23
|
+
- Updated dependencies [462d9c4]
|
|
24
|
+
- Updated dependencies [7d21581]
|
|
25
|
+
- Updated dependencies [f2445c9]
|
|
26
|
+
- Updated dependencies [23338c3]
|
|
27
|
+
- Updated dependencies [5b843fb]
|
|
28
|
+
- Updated dependencies [b4487aa]
|
|
29
|
+
- Updated dependencies [65ca83a]
|
|
30
|
+
- Updated dependencies [67bf2e2]
|
|
31
|
+
- Updated dependencies [c6d1cb4]
|
|
32
|
+
- Updated dependencies [36030ff]
|
|
33
|
+
- Updated dependencies [6117f7b]
|
|
34
|
+
- Updated dependencies [e533b0b]
|
|
35
|
+
- Updated dependencies [cdf4d9a]
|
|
36
|
+
- Updated dependencies [aee1806]
|
|
37
|
+
- Updated dependencies [c13350b]
|
|
38
|
+
- Updated dependencies [c13350b]
|
|
39
|
+
- Updated dependencies [9ca2d85]
|
|
40
|
+
- Updated dependencies [c13350b]
|
|
41
|
+
- Updated dependencies [891d345]
|
|
42
|
+
- Updated dependencies [a52e2ef]
|
|
43
|
+
- Updated dependencies [5293114]
|
|
44
|
+
- Updated dependencies [20bc357]
|
|
45
|
+
- Updated dependencies [5966c2a]
|
|
46
|
+
- Updated dependencies [2382580]
|
|
47
|
+
- Updated dependencies [d9fa683]
|
|
48
|
+
- Updated dependencies [3c7bcc0]
|
|
49
|
+
- Updated dependencies [4b6cac7]
|
|
50
|
+
- Updated dependencies [7631964]
|
|
51
|
+
- Updated dependencies [ac471a0]
|
|
52
|
+
- Updated dependencies [60ae58e]
|
|
53
|
+
- Updated dependencies [ce92674]
|
|
54
|
+
- Updated dependencies [9f601e8]
|
|
55
|
+
- Updated dependencies [51c5227]
|
|
56
|
+
- Updated dependencies [a4a85c8]
|
|
57
|
+
- Updated dependencies [07a4e26]
|
|
58
|
+
- Updated dependencies [ec975f1]
|
|
59
|
+
- Updated dependencies [eb4204b]
|
|
60
|
+
- Updated dependencies [4f13be2]
|
|
61
|
+
- Updated dependencies [61cc079]
|
|
62
|
+
- Updated dependencies [0e96e46]
|
|
63
|
+
- Updated dependencies [d52d4fe]
|
|
64
|
+
- Updated dependencies [742cebb]
|
|
65
|
+
- Updated dependencies [ce92674]
|
|
66
|
+
- Updated dependencies [cf2c9b7]
|
|
67
|
+
- Updated dependencies [833b512]
|
|
68
|
+
- Updated dependencies [0f9faa2]
|
|
69
|
+
- Updated dependencies [7cf42fe]
|
|
70
|
+
- Updated dependencies [5966c2a]
|
|
71
|
+
- Updated dependencies [f78dd83]
|
|
72
|
+
- Updated dependencies [a2cd18a]
|
|
73
|
+
- Updated dependencies [4638aaa]
|
|
74
|
+
- Updated dependencies [0222d3c]
|
|
75
|
+
- Updated dependencies [071d0dc]
|
|
76
|
+
- Updated dependencies [0a936ea]
|
|
77
|
+
- Updated dependencies [023c00b]
|
|
78
|
+
- Updated dependencies [155507e]
|
|
79
|
+
- Updated dependencies [7bba90b]
|
|
80
|
+
- Updated dependencies [7e05d8e]
|
|
81
|
+
- Updated dependencies [061406d]
|
|
82
|
+
- Updated dependencies [c1f344b]
|
|
83
|
+
- Updated dependencies [9c93465]
|
|
84
|
+
- Updated dependencies [ebb209c]
|
|
85
|
+
- Updated dependencies [63b33e6]
|
|
86
|
+
- Updated dependencies [2a44c1d]
|
|
87
|
+
- Updated dependencies [695cfbd]
|
|
88
|
+
- Updated dependencies [7445149]
|
|
89
|
+
- Updated dependencies [071d0dc]
|
|
90
|
+
- Updated dependencies [0848bea]
|
|
91
|
+
- Updated dependencies [d51bed2]
|
|
92
|
+
- Updated dependencies [b8b3c64]
|
|
93
|
+
- Updated dependencies [0c0fbd9]
|
|
94
|
+
- Updated dependencies [f3141d8]
|
|
95
|
+
- Updated dependencies [5a84d41]
|
|
96
|
+
- Updated dependencies [fd3013a]
|
|
97
|
+
- Updated dependencies [21676eb]
|
|
98
|
+
- Updated dependencies [e336549]
|
|
99
|
+
- Updated dependencies [d40f43a]
|
|
100
|
+
- Updated dependencies [e5e7ee0]
|
|
101
|
+
- Updated dependencies [a2ebea2]
|
|
102
|
+
- Updated dependencies [800bdb0]
|
|
103
|
+
- Updated dependencies [04f1182]
|
|
104
|
+
- Updated dependencies [5647006]
|
|
105
|
+
- Updated dependencies [38f7e4f]
|
|
106
|
+
- Updated dependencies [c57f3cf]
|
|
107
|
+
- Updated dependencies [97faca3]
|
|
108
|
+
- Updated dependencies [ad5fe25]
|
|
109
|
+
- Updated dependencies [ea90179]
|
|
110
|
+
- Updated dependencies [ce92674]
|
|
111
|
+
- Updated dependencies [5ef0b5b]
|
|
112
|
+
- Updated dependencies [48fbacb]
|
|
113
|
+
- Updated dependencies [355e951]
|
|
114
|
+
- Updated dependencies [dadb43f]
|
|
115
|
+
- @objectstack/spec@17.0.0-rc.2
|
|
116
|
+
- @objectstack/core@17.0.0-rc.2
|
|
117
|
+
|
|
118
|
+
## 17.0.0-rc.1
|
|
119
|
+
|
|
120
|
+
### Minor Changes
|
|
121
|
+
|
|
122
|
+
- f5a4ef0: refactor!: ADR-0112 batch 2 — sweep the lowercase error-code emitters (#4003)
|
|
123
|
+
|
|
124
|
+
Continues #3841 per ADR-0112. Batch 1 (#3988) settled the vocabulary and closed
|
|
125
|
+
the set; this batch moves the emitters that still spoke lowercase `snake_case`
|
|
126
|
+
onto it.
|
|
127
|
+
|
|
128
|
+
**Wire-visible change.** Error codes on these surfaces change spelling. Generic
|
|
129
|
+
conditions collapse onto the standard catalog rather than keeping a synonym:
|
|
130
|
+
`unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` →
|
|
131
|
+
`PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` →
|
|
132
|
+
`INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` →
|
|
133
|
+
`NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST`. Domain conditions get codes
|
|
134
|
+
registered in `ERROR_CODE_LEDGER` (`MARKETPLACE_STORAGE_FAILED`,
|
|
135
|
+
`PLUGIN_MANIFEST_INVALID`, `ITEM_LOCKED`, `DELIVERY_NOT_ELIGIBLE`, …). Swept:
|
|
136
|
+
`cloud-connection`, `plugin-auth`, `hono`, `metadata-protocol`, `rest`,
|
|
137
|
+
`service-messaging`, `service-automation`, `trigger-api`.
|
|
138
|
+
|
|
139
|
+
Branch on `error.code` values rather than pattern-matching their case: the
|
|
140
|
+
console's fix for the same rename (objectui#2977) reads codes case-insensitively
|
|
141
|
+
for exactly this reason, and that is the pattern to copy in your own consumers if
|
|
142
|
+
you support servers on both sides of the change.
|
|
143
|
+
|
|
144
|
+
**Four routes stop putting a code in the message slot.** The webhook redeliver
|
|
145
|
+
route, the API-trigger webhook, and two `rest` routes answered
|
|
146
|
+
`{ success: false, error: '<code>', message }` — the code occupying `error`, the
|
|
147
|
+
declared object envelope nowhere. They now emit `error: { code, message }`, and
|
|
148
|
+
three API-trigger branches gained a message they never had. Clients reading
|
|
149
|
+
`body.error` as a string on those routes must read `body.error.code`.
|
|
150
|
+
|
|
151
|
+
**`ConnectorErrorCategory` / `ConnectorRetryStrategy`** (ADR-0112 D9a):
|
|
152
|
+
`@objectstack/spec` exported two mutually incompatible `ErrorCategory` types and
|
|
153
|
+
two `RetryStrategy` types. The connector-side pair is renamed; importers of the
|
|
154
|
+
`integration` subpath update the name. Side effect: the api-side `ErrorCategory`
|
|
155
|
+
and `RetryStrategy` now appear in the generated API reference at all — the name
|
|
156
|
+
collision had been silently dropping them.
|
|
157
|
+
|
|
158
|
+
**`OAUTH_REGISTER_FAILED` replaces an unbounded code source.** The OAuth client
|
|
159
|
+
registration route put better-auth's arbitrary `body.error` string straight into
|
|
160
|
+
`error.code`. The code is now ours and the upstream discriminator moved to
|
|
161
|
+
`details.upstreamError`.
|
|
162
|
+
|
|
163
|
+
**Not swept, deliberately.** `sys_metadata_audit.code` keeps its lowercase values
|
|
164
|
+
(ADR-0112 D6b): it is persisted audit history, and the same column holds
|
|
165
|
+
non-error outcomes (`ok`, `lock_override`). Diagnostics records that ship inside a
|
|
166
|
+
200 keep theirs (D6c), as do field-level codes (D6, #3977) and the CLI's
|
|
167
|
+
`--json` output contract.
|
|
168
|
+
|
|
169
|
+
A `check:error-code-casing` CI guard now fails on a new lowercase literal in a
|
|
170
|
+
code position, since the ledger's casing rule can only police codes that someone
|
|
171
|
+
registers.
|
|
172
|
+
|
|
173
|
+
### Patch Changes
|
|
174
|
+
|
|
175
|
+
- 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
|
|
176
|
+
|
|
177
|
+
The AGENTS.md post-task checklist requires breaking changesets to carry their
|
|
178
|
+
FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
|
|
179
|
+
inside the npm package and is what an upgrading agent greps after the tombstone
|
|
180
|
+
error." That delivery path was severed for 68 of the 69 publishable packages:
|
|
181
|
+
npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
|
|
182
|
+
older npm versions — not `CHANGELOG.md`, and the canonical
|
|
183
|
+
`"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
|
|
184
|
+
10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
|
|
185
|
+
70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
|
|
186
|
+
explicitly.
|
|
187
|
+
|
|
188
|
+
The tombstone-error scenario is precisely the one where the repo is out of
|
|
189
|
+
reach — the upgrading agent has `node_modules` and nothing else — so the
|
|
190
|
+
migration text has to ride in the tarball. Every publishable package now
|
|
191
|
+
declares `CHANGELOG.md` in `files`, and the canonical whitelist is
|
|
192
|
+
`["dist", "README.md", "CHANGELOG.md"]`.
|
|
193
|
+
|
|
194
|
+
The other half is the gate: `check:published-files` gains a fifth invariant,
|
|
195
|
+
COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
|
|
196
|
+
always-required lint job, so the next package cannot silently sever the path
|
|
197
|
+
again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
|
|
198
|
+
into the canonical set.
|
|
199
|
+
|
|
200
|
+
Consumer-visible change: one more file per install (the package's changelog,
|
|
201
|
+
e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
|
|
202
|
+
node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
|
|
203
|
+
promised.
|
|
204
|
+
|
|
205
|
+
- cc2de0e: chore(packaging): 20 packages stop publishing their sources, tests and build tooling (#4248)
|
|
206
|
+
|
|
207
|
+
These 20 packages declared no `files` field, so npm fell back to packing the
|
|
208
|
+
whole package directory. `npm pack --dry-run` on `@objectstack/plugin-webhooks`
|
|
209
|
+
listed **21 files** — 15 under `src/`, three of them unit tests
|
|
210
|
+
(`auto-enqueuer.test.ts`, `bootstrap-declared-webhooks.test.ts`, …), plus the
|
|
211
|
+
build-time `scripts/i18n-extract.config.ts`. `dist/` lands on top of that at
|
|
212
|
+
publish time rather than instead of it, so consumers were installing the
|
|
213
|
+
TypeScript sources and the test suite alongside the artifact they asked for.
|
|
214
|
+
|
|
215
|
+
Each now declares `"files": ["dist", "README.md"]`, matching the 29 packages
|
|
216
|
+
that already did. Nothing a consumer imports moves: every `main` / `types` /
|
|
217
|
+
`exports` target in all 20 already resolved inside `dist/`, which the new
|
|
218
|
+
`check:published-files` guard verifies rather than assumes. The visible change
|
|
219
|
+
is a smaller install and a smaller dependency-scanning surface — `npm pack` on
|
|
220
|
+
`@objectstack/plugin-webhooks` now yields 2 files plus `dist/`.
|
|
221
|
+
|
|
222
|
+
The other half of the fix is the gate. Half the packages declaring `files` and
|
|
223
|
+
half not was the #3786 shape — a hand-copied convention with nothing enforcing
|
|
224
|
+
it, where whoever forgets the line gets no signal at all. `check:published-files`
|
|
225
|
+
(new, wired into the always-required `lint` job) holds every non-private
|
|
226
|
+
workspace package to four invariants: `files` is **declared**; it is
|
|
227
|
+
**sufficient** (covers every entry point, so tightening a whitelist cannot ship
|
|
228
|
+
a package that fails to resolve); it is **minimal** (admits no test, test-harness
|
|
229
|
+
config or build script); and anything beyond `dist` + `README.md` is
|
|
230
|
+
**registered** with a reason, reconciled in both directions so a stale exemption
|
|
231
|
+
is an error rather than dead text. `@objectstack/spec` is the one package with
|
|
232
|
+
registered extras — its `.zod.ts` sources, JSON Schemas, liveness ledgers and
|
|
233
|
+
`CHANGELOG.md` are product, not build input.
|
|
234
|
+
|
|
235
|
+
This also closes an assumption #4206 was resting on. Excluding `<pkg>/scripts/**`
|
|
236
|
+
from the docs-drift implementation test is sound only while no package publishes
|
|
237
|
+
`scripts/` as runtime code; that held, but it held because someone read all three
|
|
238
|
+
offenders by hand. It is now checked on every PR.
|
|
239
|
+
|
|
240
|
+
- Updated dependencies [6a67d7a]
|
|
241
|
+
- Updated dependencies [0ecc656]
|
|
242
|
+
- Updated dependencies [06772eb]
|
|
243
|
+
- Updated dependencies [270650f]
|
|
244
|
+
- Updated dependencies [3aef718]
|
|
245
|
+
- Updated dependencies [1ea6bce]
|
|
246
|
+
- Updated dependencies [c1dcacd]
|
|
247
|
+
- Updated dependencies [ad303ed]
|
|
248
|
+
- Updated dependencies [32ccb23]
|
|
249
|
+
- Updated dependencies [f5a4ef0]
|
|
250
|
+
- Updated dependencies [2d3e255]
|
|
251
|
+
- Updated dependencies [7d7521f]
|
|
252
|
+
- Updated dependencies [5dc4d02]
|
|
253
|
+
- Updated dependencies [05154a1]
|
|
254
|
+
- Updated dependencies [9b6fe7c]
|
|
255
|
+
- Updated dependencies [8c711fb]
|
|
256
|
+
- Updated dependencies [09e4547]
|
|
257
|
+
- Updated dependencies [91f4c78]
|
|
258
|
+
- Updated dependencies [820eff9]
|
|
259
|
+
- Updated dependencies [8d895ff]
|
|
260
|
+
- Updated dependencies [f6472d7]
|
|
261
|
+
- Updated dependencies [78caf51]
|
|
262
|
+
- Updated dependencies [62a789b]
|
|
263
|
+
- Updated dependencies [789ad63]
|
|
264
|
+
- Updated dependencies [2af1988]
|
|
265
|
+
- Updated dependencies [0af50a3]
|
|
266
|
+
- Updated dependencies [2e836de]
|
|
267
|
+
- Updated dependencies [12a19a8]
|
|
268
|
+
- Updated dependencies [41dcda3]
|
|
269
|
+
- Updated dependencies [c8124e5]
|
|
270
|
+
- Updated dependencies [a1a4140]
|
|
271
|
+
- Updated dependencies [217e2e6]
|
|
272
|
+
- Updated dependencies [86a71d1]
|
|
273
|
+
- Updated dependencies [d5c75e2]
|
|
274
|
+
- Updated dependencies [03d26f7]
|
|
275
|
+
- Updated dependencies [4384921]
|
|
276
|
+
- Updated dependencies [3c628ce]
|
|
277
|
+
- Updated dependencies [7cb922e]
|
|
278
|
+
- Updated dependencies [1d22114]
|
|
279
|
+
- Updated dependencies [b5f9397]
|
|
280
|
+
- Updated dependencies [ed77493]
|
|
281
|
+
- Updated dependencies [58a03d2]
|
|
282
|
+
- Updated dependencies [dc530b4]
|
|
283
|
+
- Updated dependencies [e59786e]
|
|
284
|
+
- Updated dependencies [bcf1112]
|
|
285
|
+
- Updated dependencies [9774b78]
|
|
286
|
+
- Updated dependencies [b07d829]
|
|
287
|
+
- Updated dependencies [a648e96]
|
|
288
|
+
- Updated dependencies [a47ac06]
|
|
289
|
+
- Updated dependencies [e4c61a7]
|
|
290
|
+
- Updated dependencies [cc60165]
|
|
291
|
+
- Updated dependencies [081aa6f]
|
|
292
|
+
- Updated dependencies [91f4c78]
|
|
293
|
+
- Updated dependencies [e8d0c21]
|
|
294
|
+
- Updated dependencies [45dc446]
|
|
295
|
+
- Updated dependencies [c1d44f7]
|
|
296
|
+
- Updated dependencies [ab9fb5c]
|
|
297
|
+
- Updated dependencies [f985b3f]
|
|
298
|
+
- Updated dependencies [9a4932a]
|
|
299
|
+
- Updated dependencies [f9fc874]
|
|
300
|
+
- Updated dependencies [011b386]
|
|
301
|
+
- Updated dependencies [7777e8f]
|
|
302
|
+
- Updated dependencies [507b92a]
|
|
303
|
+
- Updated dependencies [7309c81]
|
|
304
|
+
- Updated dependencies [20bc1ec]
|
|
305
|
+
- Updated dependencies [90c2b15]
|
|
306
|
+
- Updated dependencies [42eeb7d]
|
|
307
|
+
- Updated dependencies [01e124d]
|
|
308
|
+
- Updated dependencies [7ce02eb]
|
|
309
|
+
- Updated dependencies [a13827e]
|
|
310
|
+
- Updated dependencies [7733604]
|
|
311
|
+
- Updated dependencies [40e420f]
|
|
312
|
+
- Updated dependencies [d13004a]
|
|
313
|
+
- Updated dependencies [be7360c]
|
|
314
|
+
- Updated dependencies [5b47ab5]
|
|
315
|
+
- Updated dependencies [b09d8d9]
|
|
316
|
+
- Updated dependencies [b09d8d9]
|
|
317
|
+
- Updated dependencies [8675db6]
|
|
318
|
+
- Updated dependencies [b09d8d9]
|
|
319
|
+
- Updated dependencies [3eb1b2b]
|
|
320
|
+
- Updated dependencies [59b85c0]
|
|
321
|
+
- Updated dependencies [6e357ed]
|
|
322
|
+
- Updated dependencies [d6938bf]
|
|
323
|
+
- Updated dependencies [31e0be9]
|
|
324
|
+
- Updated dependencies [4bfd455]
|
|
325
|
+
- Updated dependencies [ffd2ce2]
|
|
326
|
+
- Updated dependencies [62f8017]
|
|
327
|
+
- Updated dependencies [a831df1]
|
|
328
|
+
- Updated dependencies [f752ee3]
|
|
329
|
+
- Updated dependencies [a1b61e0]
|
|
330
|
+
- Updated dependencies [cd6b9f2]
|
|
331
|
+
- Updated dependencies [2cb6d3c]
|
|
332
|
+
- Updated dependencies [af2a095]
|
|
333
|
+
- Updated dependencies [ec796d5]
|
|
334
|
+
- Updated dependencies [e87fea1]
|
|
335
|
+
- Updated dependencies [c65e529]
|
|
336
|
+
- Updated dependencies [3ca34c1]
|
|
337
|
+
- Updated dependencies [239c3a3]
|
|
338
|
+
- Updated dependencies [94a0bbc]
|
|
339
|
+
- Updated dependencies [d6bfb3d]
|
|
340
|
+
- Updated dependencies [a2266a6]
|
|
341
|
+
- Updated dependencies [d25a0ec]
|
|
342
|
+
- Updated dependencies [667b83e]
|
|
343
|
+
- Updated dependencies [627b188]
|
|
344
|
+
- Updated dependencies [8d4eae7]
|
|
345
|
+
- Updated dependencies [857a6cf]
|
|
346
|
+
- Updated dependencies [65a3a84]
|
|
347
|
+
- Updated dependencies [ccd9397]
|
|
348
|
+
- Updated dependencies [bca935b]
|
|
349
|
+
- Updated dependencies [d92c72d]
|
|
350
|
+
- Updated dependencies [c54c822]
|
|
351
|
+
- Updated dependencies [8dcc0f5]
|
|
352
|
+
- Updated dependencies [75b9e51]
|
|
353
|
+
- Updated dependencies [0a2f233]
|
|
354
|
+
- Updated dependencies [8621cdd]
|
|
355
|
+
- Updated dependencies [6f23667]
|
|
356
|
+
- Updated dependencies [5d21a48]
|
|
357
|
+
- Updated dependencies [19365b7]
|
|
358
|
+
- Updated dependencies [b7ed26d]
|
|
359
|
+
- Updated dependencies [b3a3d83]
|
|
360
|
+
- Updated dependencies [7a55913]
|
|
361
|
+
- Updated dependencies [35accbf]
|
|
362
|
+
- Updated dependencies [6038de7]
|
|
363
|
+
- Updated dependencies [eb95d97]
|
|
364
|
+
- Updated dependencies [e4c2dc8]
|
|
365
|
+
- Updated dependencies [1bd2795]
|
|
366
|
+
- Updated dependencies [8186a70]
|
|
367
|
+
- Updated dependencies [a329cca]
|
|
368
|
+
- Updated dependencies [6eec18c]
|
|
369
|
+
- Updated dependencies [4d7bebf]
|
|
370
|
+
- Updated dependencies [821ac7a]
|
|
371
|
+
- Updated dependencies [8f81731]
|
|
372
|
+
- Updated dependencies [8b50cb3]
|
|
373
|
+
- Updated dependencies [8c2db68]
|
|
374
|
+
- Updated dependencies [22b5e54]
|
|
375
|
+
- Updated dependencies [0166bd5]
|
|
376
|
+
- Updated dependencies [9b702dc]
|
|
377
|
+
- Updated dependencies [ab16331]
|
|
378
|
+
- @objectstack/spec@17.0.0-rc.1
|
|
379
|
+
- @objectstack/core@17.0.0-rc.1
|
|
380
|
+
|
|
3
381
|
## 17.0.0-rc.0
|
|
4
382
|
|
|
5
383
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -108,24 +108,24 @@ var ApiTrigger = class {
|
|
|
108
108
|
async handleRequest(input) {
|
|
109
109
|
const hook = this.hooks.get(input.flowName);
|
|
110
110
|
if (!hook || !safeEqual(hook.hookId, input.hookId)) {
|
|
111
|
-
return { status: 404, body: { error: "
|
|
111
|
+
return { status: 404, body: { success: false, error: { code: "RESOURCE_NOT_FOUND", message: "No such hook." } } };
|
|
112
112
|
}
|
|
113
113
|
if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) {
|
|
114
|
-
return { status: 401, body: { error: "
|
|
114
|
+
return { status: 401, body: { success: false, error: { code: "INVALID_SIGNATURE", message: "Signature verification failed." } } };
|
|
115
115
|
}
|
|
116
116
|
let payload;
|
|
117
117
|
try {
|
|
118
118
|
const parsed = input.rawBody.trim() ? JSON.parse(input.rawBody) : {};
|
|
119
119
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
120
|
-
return { status: 400, body: { error: "
|
|
120
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_REQUEST", message: "Body must be a JSON object." } } };
|
|
121
121
|
}
|
|
122
122
|
payload = parsed;
|
|
123
123
|
} catch {
|
|
124
|
-
return { status: 400, body: { error: "
|
|
124
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_REQUEST", message: "Body must be valid JSON." } } };
|
|
125
125
|
}
|
|
126
126
|
const q = this.getQueue();
|
|
127
127
|
if (!q) {
|
|
128
|
-
return { status: 503, body: { error: "
|
|
128
|
+
return { status: 503, body: { success: false, error: { code: "SERVICE_UNAVAILABLE", message: "No queue service is registered." } } };
|
|
129
129
|
}
|
|
130
130
|
try {
|
|
131
131
|
const messageId = await q.publish(hook.queue, { payload }, {
|
|
@@ -134,7 +134,7 @@ var ApiTrigger = class {
|
|
|
134
134
|
return { status: 202, body: { accepted: true, messageId } };
|
|
135
135
|
} catch (err) {
|
|
136
136
|
this.logger.warn(`[trigger-api] enqueue failed for '${hook.queue}': ${err?.message ?? err}`);
|
|
137
|
-
return { status: 503, body: { error: "
|
|
137
|
+
return { status: 503, body: { success: false, error: { code: "ENQUEUE_FAILED", message: "Could not enqueue the trigger message." } } };
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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":[]}
|
|
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: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No such hook.' } } };\n }\n if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) {\n return { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } };\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: { success: false, error: { code: 'INVALID_REQUEST', message: 'Body must be a JSON object.' } } };\n }\n payload = parsed as Record<string, unknown>;\n } catch {\n return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Body must be valid JSON.' } } };\n }\n\n const q = this.getQueue();\n if (!q) {\n return { status: 503, body: { success: false, error: { code: 'SERVICE_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: { success: false, error: { code: 'ENQUEUE_FAILED', message: 'Could not enqueue the trigger message.' } } };\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
CHANGED
|
@@ -79,24 +79,24 @@ var ApiTrigger = class {
|
|
|
79
79
|
async handleRequest(input) {
|
|
80
80
|
const hook = this.hooks.get(input.flowName);
|
|
81
81
|
if (!hook || !safeEqual(hook.hookId, input.hookId)) {
|
|
82
|
-
return { status: 404, body: { error: "
|
|
82
|
+
return { status: 404, body: { success: false, error: { code: "RESOURCE_NOT_FOUND", message: "No such hook." } } };
|
|
83
83
|
}
|
|
84
84
|
if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) {
|
|
85
|
-
return { status: 401, body: { error: "
|
|
85
|
+
return { status: 401, body: { success: false, error: { code: "INVALID_SIGNATURE", message: "Signature verification failed." } } };
|
|
86
86
|
}
|
|
87
87
|
let payload;
|
|
88
88
|
try {
|
|
89
89
|
const parsed = input.rawBody.trim() ? JSON.parse(input.rawBody) : {};
|
|
90
90
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
91
|
-
return { status: 400, body: { error: "
|
|
91
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_REQUEST", message: "Body must be a JSON object." } } };
|
|
92
92
|
}
|
|
93
93
|
payload = parsed;
|
|
94
94
|
} catch {
|
|
95
|
-
return { status: 400, body: { error: "
|
|
95
|
+
return { status: 400, body: { success: false, error: { code: "INVALID_REQUEST", message: "Body must be valid JSON." } } };
|
|
96
96
|
}
|
|
97
97
|
const q = this.getQueue();
|
|
98
98
|
if (!q) {
|
|
99
|
-
return { status: 503, body: { error: "
|
|
99
|
+
return { status: 503, body: { success: false, error: { code: "SERVICE_UNAVAILABLE", message: "No queue service is registered." } } };
|
|
100
100
|
}
|
|
101
101
|
try {
|
|
102
102
|
const messageId = await q.publish(hook.queue, { payload }, {
|
|
@@ -105,7 +105,7 @@ var ApiTrigger = class {
|
|
|
105
105
|
return { status: 202, body: { accepted: true, messageId } };
|
|
106
106
|
} catch (err) {
|
|
107
107
|
this.logger.warn(`[trigger-api] enqueue failed for '${hook.queue}': ${err?.message ?? err}`);
|
|
108
|
-
return { status: 503, body: { error: "
|
|
108
|
+
return { status: 503, body: { success: false, error: { code: "ENQUEUE_FAILED", message: "Could not enqueue the trigger message." } } };
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
};
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +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":";AAEA,SAAS,YAAY,uBAAuB;AA6C5C,IAAM,eAAe;AAYrB,SAAS,UAAU,GAAW,GAAoB;AAC9C,QAAM,KAAK,OAAO,KAAK,GAAG,MAAM;AAChC,QAAM,KAAK,OAAO,KAAK,GAAG,MAAM;AAChC,MAAI,GAAG,WAAW,GAAG,OAAQ,QAAO;AACpC,SAAO,gBAAgB,IAAI,EAAE;AACjC;AAOO,SAAS,gBAAgB,QAAgB,SAAiB,QAAqC;AAClG,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,WAAW,YAAY,WAAW,UAAU,MAAM,EAAE,OAAO,SAAS,MAAM,EAAE,OAAO,KAAK;AAC9F,SAAO,UAAU,UAAU,OAAO,KAAK,CAAC;AAC5C;AA4BO,IAAM,aAAN,MAAwC;AAAA,EAK3C,YACqB,UACA,QACnB;AAFmB;AACA;AANrB,SAAS,OAAO;AAEhB,SAAQ,QAAQ,oBAAI,IAAuB;AAAA,EAKxC;AAAA;AAAA,EAGH,YAA0E;AACtE,WAAO,CAAC,GAAG,KAAK,MAAM,OAAO,CAAC,EAAE,IAAI,QAAM;AAAA,MACtC,UAAU,EAAE;AAAA,MAAU,QAAQ,EAAE;AAAA,MAAQ,QAAQ,CAAC,CAAC,EAAE;AAAA,IACxD,EAAE;AAAA,EACN;AAAA,EAEA,MAAM,SAA6B,UAA2D;AAC1F,UAAM,MAAO,QAAQ,UAAU,CAAC;AAChC,UAAM,SAAS,OAAO,IAAI,WAAW,YAAY,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,IAAI;AACzF,UAAM,SAAS,OAAO,IAAI,WAAW,YAAY,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,IAAI;AACzF,UAAM,QAAQ,GAAG,YAAY,IAAI,QAAQ,QAAQ;AAEjD,UAAM,OAAkB,EAAE,UAAU,QAAQ,UAAU,QAAQ,QAAQ,OAAO,SAAS;AACtF,SAAK,MAAM,IAAI,QAAQ,UAAU,IAAI;AAErC,UAAM,IAAI,KAAK,SAAS;AACxB,QAAI,CAAC,GAAG;AACJ,WAAK,OAAO;AAAA,QACR,iEAA4D,QAAQ,QAAQ;AAAA,MAChF;AAAA,IACJ,OAAO;AACH,WAAK,EAAE,UAAgD,OAAO,OAAO,YAAY;AAC7E,cAAM,UAAW,SAAS,MAAc,WAAW,CAAC;AACpD,cAAM,KAAK,SAAS;AAAA;AAAA;AAAA;AAAA,UAIhB,QAAQ;AAAA,UACR,QAAQ,EAAE,GAAG,QAAQ;AAAA,UACrB,OAAO;AAAA,QACX,CAAsB;AAAA,MAC1B,CAAC,EAAE,MAAM,CAAC,QAAa;AACnB,aAAK,OAAO,KAAK,uCAAuC,KAAK,MAAM,KAAK,WAAW,GAAG,EAAE;AAAA,MAC5F,CAAC;AAAA,IACL;AAEA,QAAI,CAAC,QAAQ;AACT,WAAK,OAAO;AAAA,QACR,uBAAuB,QAAQ,QAAQ;AAAA,MAC3C;AAAA,IACJ;AACA,SAAK,OAAO;AAAA,MACR,kDAAkD,QAAQ,QAAQ,IAAI,MAAM,GAAG,SAAS,qBAAqB,EAAE;AAAA,IACnH;AAAA,EACJ;AAAA,EAEA,KAAK,UAAwB;AACzB,UAAM,OAAO,KAAK,MAAM,IAAI,QAAQ;AACpC,QAAI,CAAC,KAAM;AACX,SAAK,MAAM,OAAO,QAAQ;AAC1B,UAAM,IAAI,KAAK,SAAS;AACxB,QAAI,EAAG,MAAK,EAAE,YAAY,KAAK,KAAK,EAAE,MAAM,MAAM;AAAA,IAAqB,CAAC;AACxE,SAAK,OAAO,KAAK,iCAAiC,QAAQ,GAAG;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,cAAc,OAM2C;AAC3D,UAAM,OAAO,KAAK,MAAM,IAAI,MAAM,QAAQ;AAG1C,QAAI,CAAC,QAAQ,CAAC,UAAU,KAAK,QAAQ,MAAM,MAAM,GAAG;AAChD,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,YAAY,EAAE;AAAA,IACvD;AACA,QAAI,KAAK,UAAU,CAAC,gBAAgB,KAAK,QAAQ,MAAM,SAAS,MAAM,eAAe,GAAG;AACpF,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,oBAAoB,EAAE;AAAA,IAC/D;AAEA,QAAI;AACJ,QAAI;AACA,YAAM,SAAkB,MAAM,QAAQ,KAAK,IAAI,KAAK,MAAM,MAAM,OAAO,IAAI,CAAC;AAC5E,UAAI,UAAU,QAAQ,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,GAAG;AACvE,eAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,gBAAgB,SAAS,8BAA8B,EAAE;AAAA,MAClG;AACA,gBAAU;AAAA,IACd,QAAQ;AACJ,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,gBAAgB,SAAS,2BAA2B,EAAE;AAAA,IAC/F;AAEA,UAAM,IAAI,KAAK,SAAS;AACxB,QAAI,CAAC,GAAG;AACJ,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,qBAAqB,SAAS,kCAAkC,EAAE;AAAA,IAC3G;AACA,QAAI;AACA,YAAM,YAAY,MAAM,EAAE,QAAQ,KAAK,OAAO,EAAE,QAAQ,GAAG;AAAA,QACvD,gBAAgB,MAAM;AAAA,MAC1B,CAAC;AACD,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,UAAU,MAAM,UAAU,EAAE;AAAA,IAC9D,SAAS,KAAU;AACf,WAAK,OAAO,KAAK,qCAAqC,KAAK,KAAK,MAAM,KAAK,WAAW,GAAG,EAAE;AAC3F,aAAO,EAAE,QAAQ,KAAK,MAAM,EAAE,OAAO,iBAAiB,EAAE;AAAA,IAC5D;AAAA,EACJ;AACJ;;;AC/LO,IAAM,aAAa;AAuBnB,IAAM,mBAAN,MAAyC;AAAA,EAAzC;AACH,gBAAO;AACP,gBAAO;AACP,mBAAU;AACV,wBAAe,CAAC,+BAA+B;AAAA;AAAA,EAE/C,MAAM,KAAK,KAAmC;AAC1C,QAAI,OAAO,KAAK,gCAAgC;AAAA,EACpD;AAAA,EAEA,MAAM,MAAM,KAAmC;AAG3C,QAAI,KAAK,gBAAgB,YAAY;AACjC,YAAM,aAAa,KAAK,eAA0C,KAAK,YAAY;AACnF,UAAI,CAAC,cAAc,OAAO,WAAW,oBAAoB,YAAY;AACjE,YAAI,OAAO,KAAK,qFAAgF;AAChG;AAAA,MACJ;AACA,UAAI,CAAC,KAAK,eAAoC,KAAK,OAAO,GAAG;AACzD,YAAI,OAAO,KAAK,qGAAgG;AAAA,MACpH;AAEA,YAAM,UAAU,IAAI;AAAA,QAChB,MAAM,KAAK,eAAoC,KAAK,OAAO;AAAA,QAC3D,IAAI;AAAA,MACR;AACA,iBAAW,gBAAgB,OAAO;AAElC,YAAM,OAAO,KAAK,eAAkC,KAAK,aAAa;AACtE,YAAM,SAAS,QAAQ,OAAO,KAAK,cAAc,aAAa,KAAK,UAAU,IAAI;AACjF,UAAI,CAAC,QAAQ;AACT,YAAI,OAAO,KAAK,+EAA0E;AAC1F;AAAA,MACJ;AACA,aAAO,KAAK,YAAY,OAAO,MAAW;AACtC,cAAM,UAAU,MAAM,EAAE,IAAI,KAAK;AACjC,cAAM,MAAM,MAAM,QAAQ,cAAc;AAAA,UACpC,UAAU,EAAE,IAAI,MAAM,UAAU;AAAA,UAChC,QAAQ,EAAE,IAAI,MAAM,QAAQ;AAAA,UAC5B;AAAA,UACA,iBAAiB,EAAE,IAAI,OAAO,yBAAyB;AAAA,UACvD,gBAAgB,EAAE,IAAI,OAAO,mBAAmB,KAAK;AAAA,QACzD,CAAC;AACD,eAAO,EAAE,KAAK,IAAI,MAAM,IAAI,MAAM;AAAA,MACtC,CAAC;AACD,UAAI,OAAO,KAAK,mEAAmE,UAAU,EAAE;AAAA,IACnG,CAAC;AAAA,EACL;AAAA,EAEQ,eAAkB,KAAoB,MAAwB;AAClE,QAAI;AACA,aAAO,IAAI,WAAc,IAAI,KAAK;AAAA,IACtC,QAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AACJ;","names":[]}
|
|
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: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No such hook.' } } };\n }\n if (hook.secret && !verifySignature(hook.secret, input.rawBody, input.signatureHeader)) {\n return { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } };\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: { success: false, error: { code: 'INVALID_REQUEST', message: 'Body must be a JSON object.' } } };\n }\n payload = parsed as Record<string, unknown>;\n } catch {\n return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Body must be valid JSON.' } } };\n }\n\n const q = this.getQueue();\n if (!q) {\n return { status: 503, body: { success: false, error: { code: 'SERVICE_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: { success: false, error: { code: 'ENQUEUE_FAILED', message: 'Could not enqueue the trigger message.' } } };\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/trigger-api",
|
|
3
|
-
"version": "17.0.0-rc.
|
|
3
|
+
"version": "17.0.0-rc.2",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "17.0.0-rc.
|
|
17
|
-
"@objectstack/spec": "17.0.0-rc.
|
|
16
|
+
"@objectstack/core": "17.0.0-rc.2",
|
|
17
|
+
"@objectstack/spec": "17.0.0-rc.2"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@types/node": "^26.1.
|
|
20
|
+
"@types/node": "^26.1.2",
|
|
21
21
|
"typescript": "^6.0.3",
|
|
22
22
|
"vitest": "^4.1.10"
|
|
23
23
|
},
|
|
@@ -27,8 +27,14 @@
|
|
|
27
27
|
"webhook",
|
|
28
28
|
"automation"
|
|
29
29
|
],
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
30
35
|
"scripts": {
|
|
31
36
|
"build": "tsup --config ../../../tsup.config.ts",
|
|
32
|
-
"test": "vitest run --passWithNoTests"
|
|
37
|
+
"test": "vitest run --passWithNoTests",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
33
39
|
}
|
|
34
40
|
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @objectstack/trigger-api@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/triggers/trigger-api
|
|
3
|
-
> tsup --config ../../../tsup.config.ts
|
|
4
|
-
|
|
5
|
-
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2020
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m6.30 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m17.74 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 53ms
|
|
16
|
-
[32mCJS[39m [1mdist/index.js [22m[32m7.46 KB[39m
|
|
17
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m18.14 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 56ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 14563ms
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.mts [22m[32m5.76 KB[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m5.76 KB[39m
|
package/src/api-trigger.test.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
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
|
-
});
|
package/src/api-trigger.ts
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|