@reventlessdev/reventless-aws 3.0.0-alpha.384 → 3.0.0-alpha.385

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 CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.385 (2026-10-16)
7
+
8
+ * feat!(inbound): a translation slice can declare a signed webhook URL ([0ad6994](https://github.com/ReventlessDev/reventless-core/commit/0ad69949fcc91c2756c60ca8cb120dd468841d8b))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * InboundTranslationSlice.Spec requires `endpoint`. Specs under
13
+ @@reventless.spec get it from the PPX (republish reventless-ppx with this
14
+ release); hand-written specs add `let endpoint = None`.
15
+
16
+
17
+
6
18
  # 3.0.0-alpha.384 (2026-10-16)
7
19
 
8
20
  * fix(auth)!: only operators read the framework's own stores ([a2fd202](https://github.com/ReventlessDev/reventless-core/commit/a2fd2022b6876dae855b185df7f8263353fc971d))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.384",
3
+ "version": "3.0.0-alpha.385",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -23,18 +23,18 @@
23
23
  "uuid": "^13.0.0",
24
24
  "yaml": "^2.8.3",
25
25
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.32",
26
- "@reventlessdev/rescript-node": "2.0.0-alpha.22",
27
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.29",
28
26
  "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
29
27
  "@reventlessdev/rescript-effect": "1.0.0-alpha.0",
30
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
28
+ "@reventlessdev/rescript-node": "2.0.0-alpha.22",
29
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.30",
31
30
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
31
+ "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
32
32
  "@reventlessdev/rescript-web": "1.0.0-alpha.7",
33
- "@reventlessdev/reventless-core": "3.0.0-alpha.297",
34
- "@reventlessdev/reventless-infra": "3.0.0-alpha.194",
35
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.161",
36
- "@reventlessdev/reventless-interop": "3.0.0-alpha.40",
37
- "@reventlessdev/reventless-spec": "3.0.0-alpha.166"
33
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.195",
34
+ "@reventlessdev/reventless-core": "3.0.0-alpha.298",
35
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.41",
36
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.162",
37
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.167"
38
38
  },
39
39
  "devDependencies": {
40
40
  "rescript": "12.3.1",
package/src/Platform.res CHANGED
@@ -148,6 +148,11 @@ let groupOf = Reventless.Role.groupOf
148
148
  /** `groupOf(Role.admin)`. */
149
149
  let adminGroup = Reventless.Role.adminGroup
150
150
 
151
+ /** The plugin's webhook base URL — each inbound slice's endpoint is its `path`
152
+ under it — or `""` when no slice declares one. Read after `deployPlugin`, to
153
+ export it and register it with the sender. */
154
+ let webhookUrl = (): Pulumi.Output.t<string> => PluginRuntime_Builder.webhookUrlRef.contents
155
+
151
156
  module MakeWithConfig = (
152
157
  Config: {
153
158
  let splitApi: bool
@@ -171,6 +171,10 @@ function loadProvidedGroups() {
171
171
  return Platform_ProvidedGroups$ReventlessAws.load(platformStackReference());
172
172
  }
173
173
 
174
+ function webhookUrl() {
175
+ return PluginRuntime_Builder$ReventlessAws.webhookUrlRef.contents;
176
+ }
177
+
174
178
  function MakeWithConfig(Config) {
175
179
  OwnerScope$Reventless.defaultElevatedRoles([Role$Reventless.admin]);
176
180
  Role$Reventless.provideGroupsFrom(() => [Role$Reventless.adminGroup()].concat(AccountsManifest$Reventless.declaredGroups()));
@@ -3008,6 +3012,7 @@ export {
3008
3012
  loadProvidedGroups,
3009
3013
  groupOf,
3010
3014
  adminGroup,
3015
+ webhookUrl,
3011
3016
  MakeWithConfig,
3012
3017
  Make,
3013
3018
  }
@@ -30,7 +30,7 @@ import { handleQueueEvent, publishJsons as sqsPublishJsons } from "@reventlessde
30
30
  // per-slice handler building (functor + decode + handleCommands), all
31
31
  // compiler-checked against the framework signatures (see the module header and
32
32
  // docs/plans/done/minimize-lambda-entrypoint-mjs-shell.md).
33
- import { deriveScope, commandTypeNames, makeStorageOps, buildSliceHandler, buildInboundReceiver } from "./DcbCommandTopicEntryPoint_Ops.res.mjs";
33
+ import { deriveScope, commandTypeNames, makeStorageOps, buildSliceHandler, buildInboundReceiver, buildWebhookSlice, serveWebhook } from "./DcbCommandTopicEntryPoint_Ops.res.mjs";
34
34
  import { makeDynamoQueryDbOps } from "./QueryDbEntryPoint_Ops.res.mjs";
35
35
 
36
36
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
@@ -177,6 +177,8 @@ export async function buildHandlersForConfig(config, opts = {}) {
177
177
  // follow-up, so on that backend the receiver runs without persistence.
178
178
  const inboundModules = config.inboundTranslationSliceModules || [];
179
179
  const inboundReceiversByField = {};
180
+ // Route W registry: the slices whose spec declares an endpoint.
181
+ const webhookSlices = [];
180
182
  await Promise.all(inboundModules.map(async ({ spec, translation, auditTableName }) => {
181
183
  const specModule = await loadModule(spec);
182
184
  const translationModule = await loadModule(translation);
@@ -203,6 +205,8 @@ export async function buildHandlersForConfig(config, opts = {}) {
203
205
  publishJsons,
204
206
  auditOps,
205
207
  );
208
+ const webhook = buildWebhookSlice(specModule, translationModule, publishJsons, auditOps);
209
+ if (webhook != null) webhookSlices.push(webhook);
206
210
  }));
207
211
 
208
212
  // Sync (default): inline-dispatch the command via the same composite handler
@@ -276,8 +280,9 @@ export async function buildHandlersForConfig(config, opts = {}) {
276
280
  // Elements past the first two are additive — existing callers (the integration
277
281
  // tests) keep destructuring the first two. No deploy-time `plugin` fragment
278
282
  // needed here: this Lambda serves exactly one plugin and HANDLER_CONFIG already
279
- // names it. The 5th element is the Route 0 inbound-translation registry.
280
- return [sqsHandler, cmdGenHandler, dcbComp(config.pluginName), config.pluginName, inboundReceiversByField];
283
+ // names it. The 5th element is the Route 0 inbound-translation registry, the
284
+ // 6th the Route W webhook slices.
285
+ return [sqsHandler, cmdGenHandler, dcbComp(config.pluginName), config.pluginName, inboundReceiversByField, webhookSlices];
281
286
  }
282
287
 
283
288
  async function buildHandler() {
@@ -313,7 +318,23 @@ initPromise.catch(() => {});
313
318
 
314
319
  export async function handler(event, context) {
315
320
  setRequestId(context?.awsRequestId);
316
- const [sqsHandler, cmdGenHandler, comp, plugin, inboundReceivers] = await initPromise;
321
+ const [sqsHandler, cmdGenHandler, comp, plugin, inboundReceivers, webhookSlices] = await initPromise;
322
+
323
+ // Route W: a request on the Lambda's Function URL, for an inbound slice's
324
+ // webhook. The body stays raw until its signature is checked.
325
+ if (event.requestContext?.http != null && typeof event.rawPath === "string") {
326
+ const body = event.body == null
327
+ ? ""
328
+ : event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body;
329
+ const headers = {};
330
+ for (const [name, value] of Object.entries(event.headers ?? {})) headers[name.toLowerCase()] = value;
331
+ return await serveWebhook(webhookSlices ?? [], {
332
+ method: event.requestContext.http.method,
333
+ path: event.rawPath,
334
+ headers,
335
+ body,
336
+ });
337
+ }
317
338
 
318
339
  // Route 0: InboundTranslationSlice mutation — the AppSync resolver invokes this
319
340
  // Lambda with `{__inboundTranslation: true, fieldName, arguments, identity}`.
@@ -258,7 +258,14 @@ type inboundCallback = {
258
258
  ReventlessInfra.CommandTopic.publishJsons,
259
259
  JSON.t,
260
260
  ~caller: Reventless.Identity.t=?,
261
+ ~door: ReventlessInfra.InboundTranslationSlice.door=?,
261
262
  ) => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
263
+ refuse: (
264
+ ~body: string,
265
+ ~errorCode: string,
266
+ string,
267
+ ) => ReventlessInfra.InboundTranslationSlice.receiveResult,
268
+ webhook: option<ReventlessCore.InboundTranslationSlice_Webhook.route>,
262
269
  auditLog: dict<ReventlessCore.InboundTranslationSlice_Callback.auditRow>,
263
270
  }
264
271
  @module(
@@ -266,9 +273,49 @@ type inboundCallback = {
266
273
  )
267
274
  external makeInboundCallback: specModule => translationModule => inboundCallback = "Make"
268
275
 
276
+ // The request's audit row to its table, when one was threaded. A failed write is
277
+ // logged, not raised: the commands are published.
278
+ let persistAudit = async (
279
+ callback: inboundCallback,
280
+ auditQueryDbOps: option<ReventlessCore.QueryDb_Adapter.operations>,
281
+ result: ReventlessInfra.InboundTranslationSlice.receiveResult,
282
+ ) =>
283
+ switch auditQueryDbOps {
284
+ | Some(ops) =>
285
+ let id = result->ReventlessCore.InboundTranslationSlice_Callback.requestIdOf
286
+ switch callback.auditLog->ReventlessCore.InboundTranslationSlice_Callback.takeAuditRow(id) {
287
+ | Some(row) =>
288
+ let json =
289
+ row->Reventless.Util_Sury.toJson(
290
+ ReventlessCore.InboundTranslationSlice_Callback.auditRowSchema,
291
+ )
292
+ // `save` resolves `Error(_)` rather than throwing, so its result is read.
293
+ try {
294
+ switch await ops.save(id, json, ReventlessCore.QueryDb.Overwrite, None) {
295
+ | Ok() => ()
296
+ | Error(err) =>
297
+ ReventlessCore.EffectLogger.logError(
298
+ ~comp="InboundTranslationSlice.audit",
299
+ `failed to persist audit row ${id}: ${err->ReventlessCore.QueryDb.storageErrorToString}`,
300
+ )->Effect.runSync
301
+ }
302
+ } catch {
303
+ | exn =>
304
+ let detail =
305
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
306
+ ReventlessCore.EffectLogger.logError(
307
+ ~comp="InboundTranslationSlice.audit",
308
+ `failed to persist audit row ${id}: ${detail}`,
309
+ )->Effect.runSync
310
+ }
311
+ | None => ()
312
+ }
313
+ | None => ()
314
+ }
315
+
269
316
  // Builds the inbound receive handler: `receive` for the resolver's caller, then
270
- // the request's audit row to its table when one was threaded. Answers the
271
- // `commandOutcome` JSON the AppSync direct-invocation route answers with.
317
+ // the request's audit row. Answers the `commandOutcome` JSON the AppSync
318
+ // direct-invocation route answers with.
272
319
  let buildInboundReceiver = (
273
320
  spec: specModule,
274
321
  translation: translationModule,
@@ -282,42 +329,56 @@ let buildInboundReceiver = (
282
329
  args,
283
330
  ~caller=ReventlessCore.InboundTranslationSlice_Callback.doorCaller(identity),
284
331
  )
285
- switch auditQueryDbOps {
286
- | Some(ops) =>
287
- let id = result->ReventlessCore.InboundTranslationSlice_Callback.requestIdOf
288
- switch callback.auditLog->ReventlessCore.InboundTranslationSlice_Callback.takeAuditRow(id) {
289
- | Some(row) =>
290
- let json =
291
- row->Reventless.Util_Sury.toJson(
292
- ReventlessCore.InboundTranslationSlice_Callback.auditRowSchema,
293
- )
294
- // A failed audit write is logged, not raised: the commands are published.
295
- // `save` resolves `Error(_)` rather than throwing, so its result is read.
296
- try {
297
- switch await ops.save(id, json, ReventlessCore.QueryDb.Overwrite, None) {
298
- | Ok() => ()
299
- | Error(err) =>
300
- ReventlessCore.EffectLogger.logError(
301
- ~comp="InboundTranslationSlice.audit",
302
- `failed to persist audit row ${id}: ${err->ReventlessCore.QueryDb.storageErrorToString}`,
303
- )->Effect.runSync
304
- }
305
- } catch {
306
- | exn =>
307
- let detail =
308
- exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
309
- ReventlessCore.EffectLogger.logError(
310
- ~comp="InboundTranslationSlice.audit",
311
- `failed to persist audit row ${id}: ${detail}`,
312
- )->Effect.runSync
313
- }
314
- | None => ()
315
- }
316
- | None => ()
317
- }
332
+ await persistAudit(callback, auditQueryDbOps, result)
318
333
  result
319
334
  ->ReventlessCore.InboundTranslationSlice_Callback.receiveResultToOutcome
320
335
  // `CommandTopic_Helpers`, not `CommandTopic`, which imports `@pulumi/pulumi`.
321
336
  ->ReventlessCore.CommandTopic_Helpers.commandOutcomeToJson
322
337
  }
323
338
  }
339
+
340
+ // The slice's webhook door, when its spec declares an endpoint: both operations
341
+ // persist the audit row they write.
342
+ let buildWebhookSlice = (
343
+ spec: specModule,
344
+ translation: translationModule,
345
+ ~publishJsons: ReventlessInfra.CommandTopic.publishJsons,
346
+ ~auditQueryDbOps: option<ReventlessCore.QueryDb_Adapter.operations>,
347
+ ): Nullable.t<ReventlessCore.InboundTranslationSlice_Webhook.slice> => {
348
+ let callback = makeInboundCallback(spec)(translation)
349
+ let persisted = async result => {
350
+ await persistAudit(callback, auditQueryDbOps, result)
351
+ result
352
+ }
353
+ callback.webhook
354
+ ->Option.map((route): ReventlessCore.InboundTranslationSlice_Webhook.slice => {
355
+ route,
356
+ receive: async (input, ~caller) =>
357
+ await persisted(await callback.receive(publishJsons, input, ~caller, ~door=Webhook)),
358
+ refuse: async (~body, ~errorCode, error) =>
359
+ await persisted(callback.refuse(~body, ~errorCode, error)),
360
+ })
361
+ ->Nullable.fromOption
362
+ }
363
+
364
+ /** Serve a Function URL request on the plugin's webhook slices. Answers the
365
+ response shape a Function URL expects. */
366
+ let serveWebhook = async (
367
+ slices: array<ReventlessCore.InboundTranslationSlice_Webhook.slice>,
368
+ request: ReventlessCore.InboundTranslationSlice_Webhook.request,
369
+ ): JSON.t => {
370
+ let {status, body} = await ReventlessCore.InboundTranslationSlice_Webhook.dispatch(
371
+ slices,
372
+ request,
373
+ )
374
+ Dict.fromArray([
375
+ ("statusCode", JSON.Encode.int(status)),
376
+ (
377
+ "headers",
378
+ Dict.fromArray([
379
+ ("content-type", JSON.Encode.string("application/json")),
380
+ ])->JSON.Encode.object,
381
+ ),
382
+ ("body", JSON.Encode.string(body)),
383
+ ])->JSON.Encode.object
384
+ }
@@ -5,6 +5,7 @@ import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Effect from "effect/Effect";
7
7
  import * as Stream from "effect/Stream";
8
+ import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
8
9
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
10
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
10
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
@@ -13,6 +14,7 @@ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Mess
13
14
  import * as QueryDb$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb.res.mjs";
14
15
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
15
16
  import * as CommandTopic_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
17
+ import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
16
18
  import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
17
19
  import * as DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws from "../DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
18
20
  import * as DcbEventLogStorage_Postgres_Runtime$ReventlessAws from "../DcbEventLog/DcbEventLogStorage_Postgres_Runtime.res.mjs";
@@ -118,31 +120,73 @@ function buildSliceHandler(spec, behavior, scope, dcbEventLog) {
118
120
  };
119
121
  }
120
122
 
123
+ async function persistAudit(callback, auditQueryDbOps, result) {
124
+ if (auditQueryDbOps === undefined) {
125
+ return;
126
+ }
127
+ let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
128
+ let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(callback.auditLog, id);
129
+ if (row === undefined) {
130
+ return;
131
+ }
132
+ let json = Util_Sury$Reventless.toJson(row, InboundTranslationSlice_Callback$ReventlessCore.auditRowSchema);
133
+ try {
134
+ let err = await auditQueryDbOps.save(id, json, "Overwrite", undefined);
135
+ if (err.TAG === "Ok") {
136
+ return;
137
+ } else {
138
+ return Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + QueryDb$ReventlessCore.storageErrorToString(err._0)));
139
+ }
140
+ } catch (raw_exn) {
141
+ let exn = Primitive_exceptions.internalToException(raw_exn);
142
+ let detail = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown error");
143
+ return Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + detail));
144
+ }
145
+ }
146
+
121
147
  function buildInboundReceiver(spec, translation, publishJsons, auditQueryDbOps) {
122
148
  let callback = InboundTranslationSlice_CallbackResMjs.Make(spec)(translation);
123
149
  return async (args, identity) => {
124
- let result = await callback.receive(publishJsons, args, InboundTranslationSlice_Callback$ReventlessCore.doorCaller(identity));
125
- if (auditQueryDbOps !== undefined) {
126
- let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
127
- let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(callback.auditLog, id);
128
- if (row !== undefined) {
129
- let json = Util_Sury$Reventless.toJson(row, InboundTranslationSlice_Callback$ReventlessCore.auditRowSchema);
130
- try {
131
- let err = await auditQueryDbOps.save(id, json, "Overwrite", undefined);
132
- if (err.TAG !== "Ok") {
133
- Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + QueryDb$ReventlessCore.storageErrorToString(err._0)));
134
- }
135
- } catch (raw_exn) {
136
- let exn = Primitive_exceptions.internalToException(raw_exn);
137
- let detail = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown error");
138
- Effect.runSync(EffectLogger$ReventlessCore.logError("InboundTranslationSlice.audit", undefined, `failed to persist audit row ` + id + `: ` + detail));
139
- }
140
- }
141
- }
150
+ let result = await callback.receive(publishJsons, args, InboundTranslationSlice_Callback$ReventlessCore.doorCaller(identity), undefined);
151
+ await persistAudit(callback, auditQueryDbOps, result);
142
152
  return CommandTopic_Helpers$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result));
143
153
  };
144
154
  }
145
155
 
156
+ function buildWebhookSlice(spec, translation, publishJsons, auditQueryDbOps) {
157
+ let callback = InboundTranslationSlice_CallbackResMjs.Make(spec)(translation);
158
+ let persisted = async result => {
159
+ await persistAudit(callback, auditQueryDbOps, result);
160
+ return result;
161
+ };
162
+ return Stdlib_Nullable.fromOption(Stdlib_Option.map(callback.webhook, route => ({
163
+ route: route,
164
+ receive: async (input, caller) => await persisted(await callback.receive(publishJsons, input, caller, "Webhook")),
165
+ refuse: async (body, errorCode, error) => await persisted(callback.refuse(body, errorCode, error))
166
+ })));
167
+ }
168
+
169
+ async function serveWebhook(slices, request) {
170
+ let match = await InboundTranslationSlice_Webhook$ReventlessCore.dispatch(slices, undefined, undefined, request);
171
+ return Object.fromEntries([
172
+ [
173
+ "statusCode",
174
+ match.status
175
+ ],
176
+ [
177
+ "headers",
178
+ Object.fromEntries([[
179
+ "content-type",
180
+ "application/json"
181
+ ]])
182
+ ],
183
+ [
184
+ "body",
185
+ match.body
186
+ ]
187
+ ]);
188
+ }
189
+
146
190
  export {
147
191
  deriveScope,
148
192
  commandTypeNames,
@@ -150,6 +194,9 @@ export {
150
194
  makePostgresStorageOps,
151
195
  makeStorageOps,
152
196
  buildSliceHandler,
197
+ persistAudit,
153
198
  buildInboundReceiver,
199
+ buildWebhookSlice,
200
+ serveWebhook,
154
201
  }
155
202
  /* Stdlib_JsExn Not a pure module */
@@ -41,6 +41,11 @@ let forDcbCommandTopic = (
41
41
  // the per-flavor commandHandlerConfig below (whichever is higher wins).
42
42
  ~sliceMemoryFloor: int=0,
43
43
  ~sliceTimeoutFloor: int=0,
44
+ // The webhook secrets, by environment variable, when some inbound slice declares
45
+ // an endpoint: the sync Lambda then gets a public Function URL, handed to
46
+ // `onWebhookUrl`. The entry point routes its requests by path.
47
+ ~webhookSecrets: option<dict<Pulumi.Output.t<string>>>=?,
48
+ ~onWebhookUrl: Pulumi.Output.t<string> => unit=_ => (),
44
49
  ~connect,
45
50
  dcbCommandTopic,
46
51
  ) =>
@@ -107,6 +112,12 @@ let forDcbCommandTopic = (
107
112
  }
108
113
  })
109
114
  )
115
+ let webhookSecrets = isAsync ? None : webhookSecrets
116
+ webhookSecrets->Option.forEach(secrets =>
117
+ secrets->Dict.forEachWithKey((value, key) =>
118
+ envVars->Dict.set(key, value->Pulumi.Output.asInput)
119
+ )
120
+ )
110
121
  // First-class log-level override → LOG_LEVEL, winning over any generic
111
122
  // envVars entry. When unset, makeFromCodeAsset applies the tier default.
112
123
  cfg.logLevel->Option.forEach(level =>
@@ -319,6 +330,41 @@ let forDcbCommandTopic = (
319
330
  )
320
331
  })
321
332
 
333
+ if webhookSecrets->Option.isSome {
334
+ let customOpts =
335
+ opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
336
+ let functionName =
337
+ runtime.parts.lambda->Pulumi.Output.flatMap(lambda => lambda.name)->Pulumi.Output.asInput
338
+ let url = PulumiAws.FunctionUrl.make(
339
+ ~name=`${name}-webhook`,
340
+ ~args={authorizationType: None, functionName},
341
+ ~opts=customOpts,
342
+ )
343
+ // A public URL needs both grants: the URL invoke, and the function invoke
344
+ // limited to calls arriving through that URL.
345
+ let _ = PulumiAws.Lambda.Permission.make(
346
+ ~name=`${name}-webhookUrl`,
347
+ ~args={
348
+ action: "lambda:InvokeFunctionUrl",
349
+ function: functionName,
350
+ principal: "*",
351
+ functionUrlAuthType: "NONE",
352
+ },
353
+ ~opts=customOpts,
354
+ )
355
+ let _ = PulumiAws.Lambda.Permission.make(
356
+ ~name=`${name}-webhookInvoke`,
357
+ ~args={
358
+ action: "lambda:InvokeFunction",
359
+ function: functionName,
360
+ principal: "*",
361
+ invokedViaFunctionUrl: true,
362
+ },
363
+ ~opts=customOpts,
364
+ )
365
+ onWebhookUrl(url.functionUrl)
366
+ }
367
+
322
368
  connect(~runtime)
323
369
  CommandTopicChannel.SQS.clearBatchSize()
324
370
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Pulumi from "@pulumi/pulumi";
7
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
@@ -11,16 +12,18 @@ import * as DcbBackend$ReventlessAws from "../DcbEventLog/DcbBackend.res.mjs";
11
12
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
12
13
  import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
13
14
  import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
15
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
14
16
  import * as Util_LambdaEnvBudget$ReventlessAws from "../../util/Util_LambdaEnvBudget.res.mjs";
15
17
  import * as CommandTopicChannel_SQS$ReventlessAws from "../CommandTopic/CommandTopicChannel_SQS.res.mjs";
16
18
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "./RuntimeEnvironment_Lambda.res.mjs";
17
19
 
18
20
  let log = Logger$ReventlessCore.fromEnv();
19
21
 
20
- function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginName, syncConfig, asyncConfig, sliceMemoryFloorOpt, sliceTimeoutFloorOpt, connect, dcbCommandTopic) {
22
+ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginName, syncConfig, asyncConfig, sliceMemoryFloorOpt, sliceTimeoutFloorOpt, webhookSecrets, onWebhookUrlOpt, connect, dcbCommandTopic) {
21
23
  let inboundSlices = inboundSlicesOpt !== undefined ? inboundSlicesOpt : [];
22
24
  let sliceMemoryFloor = sliceMemoryFloorOpt !== undefined ? sliceMemoryFloorOpt : 0;
23
25
  let sliceTimeoutFloor = sliceTimeoutFloorOpt !== undefined ? sliceTimeoutFloorOpt : 0;
26
+ let onWebhookUrl = onWebhookUrlOpt !== undefined ? onWebhookUrlOpt : param => {};
24
27
  if (slicePaths.length === 0 && inboundSlices.length === 0) {
25
28
  return log.warn("StateChangeSliceRuntime_Builder_Single", undefined, "forDcbCommandTopic skipped (no slice specs)");
26
29
  }
@@ -54,6 +57,10 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
54
57
  return;
55
58
  }
56
59
  }));
60
+ let webhookSecrets$1 = isAsync ? undefined : webhookSecrets;
61
+ Stdlib_Option.forEach(webhookSecrets$1, secrets => Stdlib_Dict.forEachWithKey(secrets, (value, key) => {
62
+ envVars[key] = value;
63
+ }));
57
64
  Stdlib_Option.forEach(cfg.logLevel, level => {
58
65
  envVars["LOG_LEVEL"] = Pulumi.output(level);
59
66
  });
@@ -129,6 +136,27 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
129
136
  }])),
130
137
  role: runtime.parts.lambdaRole.id
131
138
  }));
139
+ if (Stdlib_Option.isSome(webhookSecrets$1)) {
140
+ let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
141
+ let functionName = Output$Pulumi.flatMap(runtime.parts.lambda, lambda => lambda.name);
142
+ let url = new (Aws.lambda.FunctionUrl)(name + `-webhook`, {
143
+ authorizationType: "NONE",
144
+ functionName: functionName
145
+ }, customOpts);
146
+ new (Aws.lambda.Permission)(name + `-webhookUrl`, {
147
+ action: "lambda:InvokeFunctionUrl",
148
+ function: functionName,
149
+ principal: "*",
150
+ functionUrlAuthType: "NONE"
151
+ }, customOpts);
152
+ new (Aws.lambda.Permission)(name + `-webhookInvoke`, {
153
+ action: "lambda:InvokeFunction",
154
+ function: functionName,
155
+ principal: "*",
156
+ invokedViaFunctionUrl: true
157
+ }, customOpts);
158
+ onWebhookUrl(url.functionUrl);
159
+ }
132
160
  connect(runtime);
133
161
  CommandTopicChannel_SQS$ReventlessAws.clearBatchSize();
134
162
  }
@@ -25,6 +25,12 @@ module Make = (
25
25
  ~specPath=Util_Bundle.getModuleSpecifier(Spec.moduleUrl),
26
26
  ~translationPath=Util_Bundle.getModuleSpecifier(Translation.moduleUrl),
27
27
  )
28
+ switch Spec.endpoint {
29
+ | Some({auth: SignedBody({secret})}) =>
30
+ PluginRuntime_Builder.registerWebhook(~specName=Spec.name, ~secret=Some(secret))
31
+ | Some({auth: Open}) => PluginRuntime_Builder.registerWebhook(~specName=Spec.name, ~secret=None)
32
+ | None => ()
33
+ }
28
34
  module InnerMake = Inner.Make(Spec, Translation)
29
35
  module Spec = Spec
30
36
  module Translation = Translation
@@ -15,6 +15,15 @@ function Make(Api) {
15
15
  })(Api);
16
16
  let Make$1 = Spec => (Translation => {
17
17
  PluginRuntime_Builder$ReventlessAws.registerInboundTranslationSliceSpec(Spec.name, Util_Bundle$ReventlessAws.getModuleSpecifier(Spec.moduleUrl), Util_Bundle$ReventlessAws.getModuleSpecifier(Translation.moduleUrl));
18
+ let match = Spec.endpoint;
19
+ if (match !== undefined) {
20
+ let match$1 = match.auth;
21
+ if (typeof match$1 !== "object") {
22
+ PluginRuntime_Builder$ReventlessAws.registerWebhook(Spec.name, undefined);
23
+ } else {
24
+ PluginRuntime_Builder$ReventlessAws.registerWebhook(Spec.name, match$1._0.secret);
25
+ }
26
+ }
18
27
  let InnerMake = Inner.Make(Spec)(Translation);
19
28
  let make = (publishJsons, runtime, opts) => {
20
29
  let component = InnerMake.make(publishJsons, runtime, opts);
@@ -161,6 +161,29 @@ let registerInboundAuditTableName = (~specName: string, tableName: Pulumi.Output
161
161
  )
162
162
  }
163
163
 
164
+ // Webhook endpoints. Each slice's route is read off its spec at run time, so the
165
+ // deploy needs only whether any slice has one (the sync command Lambda then gets a
166
+ // Function URL) and the secrets they name, which become that Lambda's environment.
167
+ // A secret is read when the slice registers, outside any `apply`, so a missing one
168
+ // fails `pulumi preview` too.
169
+ let webhookSlices: array<string> = []
170
+ let webhookSecrets: dict<Pulumi.Output.t<string>> = Dict.make()
171
+
172
+ let registerWebhook = (~specName: string, ~secret: option<string>) => {
173
+ if !(webhookSlices->Array.includes(specName)) {
174
+ webhookSlices->Array.push(specName)
175
+ }
176
+ secret->Option.forEach(secret =>
177
+ webhookSecrets->Dict.set(
178
+ ReventlessCore.InboundTranslationSlice_Webhook.secretEnvKey(secret),
179
+ Pulumi.Config.make(Some("webhook"))->Pulumi.Config.requireSecret(secret),
180
+ )
181
+ )
182
+ }
183
+
184
+ // The plugin's webhook base URL, once its command Lambda has one; "" until then.
185
+ let webhookUrlRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
186
+
164
187
  /**
165
188
  Redundant with the seams that populate `dcbConfigRef` automatically:
166
189
  `pluginName` is set by `registerPluginName` (called from `Plugin_Builder.make`
@@ -933,6 +956,8 @@ module Make = (
933
956
  StateChangeSliceRuntime_Builder_Single.forDcbCommandTopic(
934
957
  ~slicePaths,
935
958
  ~inboundSlices,
959
+ ~webhookSecrets=?webhookSlices->Array.length > 0 ? Some(webhookSecrets) : None,
960
+ ~onWebhookUrl=url => webhookUrlRef := url,
936
961
  ~dcbTableName=dcbConfig.dcbTableName,
937
962
  ~pluginName=dcbConfig.pluginName,
938
963
  ~syncConfig=syncStateChangesConfigRef.contents,
@@ -22,6 +22,7 @@ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/s
22
22
  import * as Util_LambdaEnvBudget$ReventlessAws from "../../util/Util_LambdaEnvBudget.res.mjs";
23
23
  import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
24
24
  import * as RuntimeEnvironment_Lambda$ReventlessAws from "../../adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs";
25
+ import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
25
26
  import * as StateChangeSliceRuntime_Builder_Single$ReventlessAws from "../../adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs";
26
27
 
27
28
  let log = Logger$ReventlessCore.fromEnv();
@@ -139,6 +140,23 @@ function registerInboundAuditTableName(specName, tableName) {
139
140
  }
140
141
  }
141
142
 
143
+ let webhookSlices = [];
144
+
145
+ let webhookSecrets = {};
146
+
147
+ function registerWebhook(specName, secret) {
148
+ if (!webhookSlices.includes(specName)) {
149
+ webhookSlices.push(specName);
150
+ }
151
+ Stdlib_Option.forEach(secret, secret => {
152
+ webhookSecrets[InboundTranslationSlice_Webhook$ReventlessCore.secretEnvKey(secret)] = new Pulumi.Config("webhook").requireSecret(secret);
153
+ });
154
+ }
155
+
156
+ let webhookUrlRef = {
157
+ contents: Pulumi.output("")
158
+ };
159
+
142
160
  function registerDcbConfig(pluginName, dcbTableName, stateChangeSliceModulePathsOpt, param) {
143
161
  let stateChangeSliceModulePaths = stateChangeSliceModulePathsOpt !== undefined ? stateChangeSliceModulePathsOpt : [];
144
162
  dcbConfigRef.contents = {
@@ -542,7 +560,9 @@ function Make(EventCollectorChannel) {
542
560
  };
543
561
  }
544
562
  });
545
- StateChangeSliceRuntime_Builder_Single$ReventlessAws.forDcbCommandTopic(slicePaths, inboundSlices, dcbConfig.dcbTableName, dcbConfig.pluginName, syncStateChangesConfigRef.contents, asyncStateChangesConfigRef.contents, memorySize, timeout, connect, dcbCommandTopic);
563
+ StateChangeSliceRuntime_Builder_Single$ReventlessAws.forDcbCommandTopic(slicePaths, inboundSlices, dcbConfig.dcbTableName, dcbConfig.pluginName, syncStateChangesConfigRef.contents, asyncStateChangesConfigRef.contents, memorySize, timeout, webhookSlices.length !== 0 ? webhookSecrets : undefined, url => {
564
+ webhookUrlRef.contents = url;
565
+ }, connect, dcbCommandTopic);
546
566
  };
547
567
  let finish = () => {};
548
568
  return {
@@ -577,6 +597,10 @@ export {
577
597
  registeredInboundSlices,
578
598
  registerInboundTranslationSliceSpec,
579
599
  registerInboundAuditTableName,
600
+ webhookSlices,
601
+ webhookSecrets,
602
+ registerWebhook,
603
+ webhookUrlRef,
580
604
  registerDcbConfig,
581
605
  registerDcbTableName,
582
606
  heartbeatConfigRef,
@@ -15,6 +15,7 @@ type command = AddThing({thingId: string})
15
15
 
16
16
  let targetName = "AddThing"
17
17
  let externalSystem: option<string> = Some("TestFeed")
18
+ let endpoint = None
18
19
 
19
20
  type role = Reventless.Role.name
20
21
  let authorizationOf = (_: string): Reventless.Authorization.rule<role> => AllowRoles([
@@ -27,6 +27,8 @@ let targetName = "AddThing";
27
27
 
28
28
  let externalSystem = "TestFeed";
29
29
 
30
+ let endpoint;
31
+
30
32
  export {
31
33
  name,
32
34
  moduleUrl,
@@ -34,6 +36,7 @@ export {
34
36
  commandSchema,
35
37
  targetName,
36
38
  externalSystem,
39
+ endpoint,
37
40
  authorizationOf,
38
41
  }
39
42
  /* externalInputSchema Not a pure module */