@reventlessdev/reventless-local 3.0.0-alpha.287 → 3.0.0-alpha.289

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,26 @@
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.289 (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
+
18
+ # 3.0.0-alpha.288 (2026-10-16)
19
+
20
+ **Note:** Version bump only for package @reventlessdev/reventless-local
21
+
22
+
23
+
24
+
25
+
6
26
  # 3.0.0-alpha.287 (2026-10-16)
7
27
 
8
28
  * feat!(automation): schedules set by events ([53bc436](https://github.com/ReventlessDev/reventless-core/commit/53bc4363dffce81ef36475728bf0dae704716021))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.287",
3
+ "version": "3.0.0-alpha.289",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,23 +35,23 @@
35
35
  "sury": "11.0.0",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "1.0.0-alpha.0",
38
- "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
39
38
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
40
39
  "@reventlessdev/rescript-node": "2.0.0-alpha.22",
40
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.12",
41
41
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
42
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.144",
44
- "@reventlessdev/reventless-core": "3.0.0-alpha.296",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.244",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.193",
42
+ "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.298",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.146",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.246",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.195",
47
47
  "@reventlessdev/reventless-seed": "1.0.0-alpha.34",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.165",
49
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.160"
48
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.162",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.167"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
53
53
  "sury-ppx": "11.0.0",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.101"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.102"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "^12.3.1"
@@ -0,0 +1,64 @@
1
+ // The local platform's webhook routes, served in process: no HTTP server, so a
2
+ // test (or a dev tool) posts a raw body with `post`, which runs the same
3
+ // signature check and dispatch as the AWS Function URL.
4
+
5
+ open ReventlessCore
6
+
7
+ let slices: dict<InboundTranslationSlice_Webhook.slice> = Dict.make()
8
+
9
+ /** Register a slice's route now, and bind its operations when they resolve. A
10
+ request posted before then waits for them rather than failing. Two slices may
11
+ not share a path. */
12
+ let register = (
13
+ ~route: InboundTranslationSlice_Webhook.route,
14
+ ~operations: Pulumi.Output.t<ReventlessInfra.InboundTranslationSlice.operations>,
15
+ ) => {
16
+ switch slices->Dict.get(route.path) {
17
+ | Some({route: taken}) =>
18
+ JsError.throwWithMessage(
19
+ `webhook path /${route.path} is declared by both ${taken.slice} and ${route.slice}`,
20
+ )
21
+ | None => ()
22
+ }
23
+ let bind = ref(_ => ())
24
+ let bound: promise<ReventlessInfra.InboundTranslationSlice.operations> = Promise.make((
25
+ resolve,
26
+ _,
27
+ ) => bind := resolve)
28
+ let _ = operations->Pulumi.Output.apply(ops => bind.contents(ops))
29
+ slices->Dict.set(
30
+ route.path,
31
+ {
32
+ route,
33
+ receive: async (input, ~caller) => await (await bound).receive(input, ~caller, ~door=Webhook),
34
+ refuse: async (~body, ~errorCode, error) =>
35
+ await (await bound).refuse(~body, ~errorCode, error),
36
+ },
37
+ )
38
+ }
39
+
40
+ /** Serve one request. Header names are matched case-insensitively. */
41
+ let post = (
42
+ ~path: string,
43
+ ~method: string="POST",
44
+ ~headers: dict<string>=Dict.make(),
45
+ ~secretOf=?,
46
+ ~nowSeconds=?,
47
+ body: string,
48
+ ): promise<InboundTranslationSlice_Webhook.response> =>
49
+ InboundTranslationSlice_Webhook.dispatch(
50
+ slices->Dict.valuesToArray,
51
+ ~secretOf?,
52
+ ~nowSeconds?,
53
+ {
54
+ method,
55
+ path,
56
+ headers: headers
57
+ ->Dict.toArray
58
+ ->Array.map(((name, value)) => (name->String.toLowerCase, value))
59
+ ->Dict.fromArray,
60
+ body,
61
+ },
62
+ )
63
+
64
+ let reset = () => slices->Dict.keysToArray->Array.forEach(path => slices->Dict.delete(path))
@@ -0,0 +1,52 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
+ import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
6
+
7
+ let slices = {};
8
+
9
+ function register(route, operations) {
10
+ let match = slices[route.path];
11
+ if (match !== undefined) {
12
+ Stdlib_JsError.throwWithMessage(`webhook path /` + route.path + ` is declared by both ` + match.route.slice + ` and ` + route.slice);
13
+ }
14
+ let bind = {
15
+ contents: param => {}
16
+ };
17
+ let bound = new Promise((resolve, param) => {
18
+ bind.contents = resolve;
19
+ });
20
+ operations.apply(ops => bind.contents(ops));
21
+ slices[route.path] = {
22
+ route: route,
23
+ receive: async (input, caller) => await (await bound).receive(input, caller, "Webhook"),
24
+ refuse: async (body, errorCode, error) => await (await bound).refuse(body, errorCode, error)
25
+ };
26
+ }
27
+
28
+ function post(path, methodOpt, headersOpt, secretOf, nowSeconds, body) {
29
+ let method = methodOpt !== undefined ? methodOpt : "POST";
30
+ let headers = headersOpt !== undefined ? headersOpt : ({});
31
+ return InboundTranslationSlice_Webhook$ReventlessCore.dispatch(Object.values(slices), secretOf, nowSeconds, {
32
+ method: method,
33
+ path: path,
34
+ headers: Object.fromEntries(Object.entries(headers).map(param => [
35
+ param[0].toLowerCase(),
36
+ param[1]
37
+ ])),
38
+ body: body
39
+ });
40
+ }
41
+
42
+ function reset() {
43
+ Object.keys(slices).forEach(path => Stdlib_Dict.$$delete(slices, path));
44
+ }
45
+
46
+ export {
47
+ slices,
48
+ register,
49
+ post,
50
+ reset,
51
+ }
52
+ /* InboundTranslationSlice_Webhook-ReventlessCore Not a pure module */
@@ -26,7 +26,16 @@ module Make = (Bus: LocalBus.T) => {
26
26
  module Translation = Translation
27
27
  type component = Inner.component
28
28
  let queryDbName = Inner.queryDbName
29
- let make = Inner.make
29
+ let make = (~publishJsons, ~runtime=?, ~opts=?) => {
30
+ let component = Inner.make(~publishJsons, ~runtime?, ~opts?)
31
+ Spec.endpoint->Option.forEach(endpoint =>
32
+ LocalWebhookRegistry.register(
33
+ ~route=ReventlessCore.InboundTranslationSlice_Webhook.routeOf(~slice=Spec.name, endpoint),
34
+ ~operations=component->ReventlessCore.Component.operations,
35
+ )
36
+ )
37
+ component
38
+ }
30
39
  // Re-expose operations for test resolution
31
40
  let operations: component => Pulumi.Output.t<
32
41
  ReventlessCore.InboundTranslationSlice.operations,
@@ -1,9 +1,12 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
4
5
  import * as LocalQueryDbStorage$ReventlessLocal from "../adapter/QueryDb/LocalQueryDbStorage.res.mjs";
6
+ import * as LocalWebhookRegistry$ReventlessLocal from "../adapter/Webhook/LocalWebhookRegistry.res.mjs";
5
7
  import * as QueryDbResolvers_GraphQL$ReventlessLocal from "../adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs";
6
8
  import * as InboundTranslationSlice_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res.mjs";
9
+ import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
7
10
 
8
11
  function Make(Bus) {
9
12
  let QueryDbStorage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
@@ -21,12 +24,17 @@ function Make(Bus) {
21
24
  })(Api);
22
25
  let Make$1 = Spec => (Translation => {
23
26
  let Inner = CoreMaker.Make(Spec)(Translation);
27
+ let make = (publishJsons, runtime, opts) => {
28
+ let component = Inner.make(publishJsons, runtime, opts);
29
+ Stdlib_Option.forEach(Spec.endpoint, endpoint => LocalWebhookRegistry$ReventlessLocal.register(InboundTranslationSlice_Webhook$ReventlessCore.routeOf(Spec.name, endpoint), Component$ReventlessCore.operations(component)));
30
+ return component;
31
+ };
24
32
  return {
25
33
  Inner: Inner,
26
34
  Spec: Spec,
27
35
  Translation: Translation,
28
36
  queryDbName: Inner.queryDbName,
29
- make: Inner.make,
37
+ make: make,
30
38
  operations: Component$ReventlessCore.operations
31
39
  };
32
40
  });
@@ -29,7 +29,8 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
29
29
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
30
30
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
31
31
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
32
- authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf
32
+ authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf,
33
+ endpoint: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.endpoint
33
34
  })(PaymentWebhookTranslation);
34
35
 
35
36
  let scope = "Payments";
@@ -106,7 +107,7 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
106
107
  DomainGraphQL_Server$ReventlessLocal.setScope(scope);
107
108
  InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, "AllowAuthenticated", DomainGraphQL_Server$ReventlessLocal.asInterface);
108
109
  DomainGraphQL_Server$ReventlessLocal.resetScope();
109
- InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller));
110
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller, undefined));
110
111
  });
111
112
  globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
112
113
  let result = await runMutation("completed");
@@ -157,7 +158,8 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
157
158
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
158
159
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
159
160
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
160
- authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf
161
+ authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf,
162
+ endpoint: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.endpoint
161
163
  })(CatalogFeedTranslation);
162
164
 
163
165
  let feedField = "Catalog_CatalogFeed";
@@ -173,7 +175,7 @@ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
173
175
  DomainGraphQL_Server$ReventlessLocal.setScope("Catalog");
174
176
  InboundTranslationResolvers_GraphQL$ReventlessLocal.register(feedField, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalInputSchema, Dcb_Builder$ReventlessCore.inboundDoorPermission(InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf), DomainGraphQL_Server$ReventlessLocal.asInterface);
175
177
  DomainGraphQL_Server$ReventlessLocal.resetScope();
176
- InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(feedField, (inputJson, caller) => CatalogFeedCallback.receive(publishJsons, inputJson, caller));
178
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(feedField, (inputJson, caller) => CatalogFeedCallback.receive(publishJsons, inputJson, caller, undefined));
177
179
  });
178
180
  globalThis.test("a caller holding none of the commands' roles is refused", async () => {
179
181
  let node = payload(await runFeed(signedIn(["Ops"]), "product"));
@@ -215,6 +215,7 @@ describe("InboundTranslationSlice Callback", () => {
215
215
 
216
216
  let targetName = "ConfirmPayment"
217
217
  let externalSystem = None
218
+ let endpoint = None
218
219
 
219
220
  let translate = (input: externalInput) => Ok(
220
221
  input.items->Array.map(
@@ -285,6 +286,7 @@ describe("InboundTranslationSlice Callback", () => {
285
286
 
286
287
  let targetName = "ConfirmPayment"
287
288
  let externalSystem = None
289
+ let endpoint = None
288
290
 
289
291
  let translate = (_input: externalInput) => Ok([])
290
292
 
@@ -26,7 +26,8 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
26
26
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
27
27
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
28
28
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
29
- authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf
29
+ authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.authorizationOf,
30
+ endpoint: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.endpoint
30
31
  })(PaymentWebhookTranslation);
31
32
 
32
33
  globalThis.describe("InboundTranslationSlice Callback", () => {
@@ -46,7 +47,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
46
47
  orderId: "ord-1",
47
48
  status: "completed"
48
49
  };
49
- let result = await Callback.receive(mockPublish, inputJson, undefined);
50
+ let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
50
51
  if (result.TAG === "Ok") {
51
52
  let match = result._0;
52
53
  globalThis.expect(match.targetIds).toEqual(["ord-1"]);
@@ -77,7 +78,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
77
78
  orderId: "ord-1",
78
79
  status: "pending"
79
80
  };
80
- let result = await Callback.receive(mockPublish, inputJson, undefined);
81
+ let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
81
82
  if (result.TAG === "Ok") {
82
83
  globalThis.expect(true).toBe(false);
83
84
  } else {
@@ -94,7 +95,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
94
95
  let inputJson = {
95
96
  unexpected: "data"
96
97
  };
97
- let result = await Callback.receive(mockPublish, inputJson, undefined);
98
+ let result = await Callback.receive(mockPublish, inputJson, undefined, undefined);
98
99
  if (result.TAG === "Ok") {
99
100
  globalThis.expect(true).toBe(false);
100
101
  } else {
@@ -112,7 +113,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
112
113
  orderId: "ord-1",
113
114
  status: "completed"
114
115
  };
115
- let result = await Callback.receive(failingPublish, inputJson, undefined);
116
+ let result = await Callback.receive(failingPublish, inputJson, undefined, undefined);
116
117
  if (result.TAG === "Ok") {
117
118
  globalThis.expect(true).toBe(false);
118
119
  } else {
@@ -131,7 +132,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
131
132
  paymentId: "pay-1",
132
133
  orderId: "ord-1",
133
134
  status: "completed"
134
- }, undefined);
135
+ }, undefined, undefined);
135
136
  let firstId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(first);
136
137
  globalThis.expect(Stdlib_Option.isSome(InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, firstId))).toBe(true);
137
138
  globalThis.expect(Object.entries(Callback.auditLog).length).toBe(0);
@@ -139,7 +140,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
139
140
  paymentId: "pay-1",
140
141
  orderId: "ord-1",
141
142
  status: "completed"
142
- }, undefined);
143
+ }, undefined, undefined);
143
144
  let secondId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(second);
144
145
  globalThis.expect(secondId === firstId).toBe(false);
145
146
  globalThis.expect(Object.entries(Callback.auditLog).length).toBe(1);
@@ -152,7 +153,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
152
153
  paymentId: "pay-1",
153
154
  orderId: "ord-1",
154
155
  status: "pending"
155
- }, undefined);
156
+ }, undefined, undefined);
156
157
  let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
157
158
  let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, id);
158
159
  if (row !== undefined) {
@@ -198,7 +199,8 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
198
199
  commandSchema: commandSchema,
199
200
  targetName: "ConfirmPayment",
200
201
  externalSystem: undefined,
201
- authorizationOf: authorizationOf
202
+ authorizationOf: authorizationOf,
203
+ endpoint: undefined
202
204
  })(MultiTranslation);
203
205
  let publishedCommands = {
204
206
  contents: []
@@ -214,7 +216,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
214
216
  "pay-3"
215
217
  ]
216
218
  };
217
- let result = await MultiCallback.receive(mockPublish, inputJson, undefined);
219
+ let result = await MultiCallback.receive(mockPublish, inputJson, undefined, undefined);
218
220
  if (result.TAG === "Ok") {
219
221
  let match = result._0;
220
222
  globalThis.expect(match.targetIds).toEqual([
@@ -260,7 +262,8 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
260
262
  commandSchema: commandSchema,
261
263
  targetName: "ConfirmPayment",
262
264
  externalSystem: undefined,
263
- authorizationOf: authorizationOf
265
+ authorizationOf: authorizationOf,
266
+ endpoint: undefined
264
267
  })(EmptyTranslation);
265
268
  let publishedCommands = {
266
269
  contents: []
@@ -271,7 +274,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
271
274
  let inputJson = {
272
275
  orderId: "ord-1"
273
276
  };
274
- let result = await EmptyCallback.receive(mockPublish, inputJson, undefined);
277
+ let result = await EmptyCallback.receive(mockPublish, inputJson, undefined, undefined);
275
278
  if (result.TAG === "Ok") {
276
279
  let match = result._0;
277
280
  globalThis.expect(match.targetIds).toEqual([]);
@@ -300,7 +303,8 @@ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
300
303
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
301
304
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
302
305
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
303
- authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf
306
+ authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.authorizationOf,
307
+ endpoint: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.endpoint
304
308
  })(CatalogFeedTranslation);
305
309
 
306
310
  function caller(groups) {
@@ -336,7 +340,7 @@ globalThis.describe("receive checks each command against its own rule", () => {
336
340
  published.contents = [];
337
341
  });
338
342
  globalThis.test("a caller lacking one command's role is refused, and nothing is published", async () => {
339
- let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller(["Merchandiser"]));
343
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller(["Merchandiser"]), undefined);
340
344
  if (result.TAG === "Ok") {
341
345
  globalThis.expect(true).toBe(false);
342
346
  } else {
@@ -351,22 +355,22 @@ globalThis.describe("receive checks each command against its own rule", () => {
351
355
  let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller([
352
356
  "Merchandiser",
353
357
  "Admin"
354
- ]));
358
+ ]), undefined);
355
359
  globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
356
360
  globalThis.expect(published.contents.length).toBe(2);
357
361
  });
358
362
  globalThis.test("the role one command needs is enough when the input yields only it", async () => {
359
- let result = await CatalogFeedCallback.receive(publish, catalogInput("product"), caller(["Merchandiser"]));
363
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("product"), caller(["Merchandiser"]), undefined);
360
364
  globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
361
365
  globalThis.expect(published.contents.length).toBe(1);
362
366
  });
363
367
  globalThis.test("no caller is the platform itself, and publishes", async () => {
364
- let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), undefined);
368
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), undefined, undefined);
365
369
  globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
366
370
  globalThis.expect(published.contents.length).toBe(2);
367
371
  });
368
372
  globalThis.test("a refusal answers CommandRejected with Forbidden", async () => {
369
- let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), Identity$Reventless.anonymous);
373
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), Identity$Reventless.anonymous, undefined);
370
374
  let match = InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result);
371
375
  switch (match.TAG) {
372
376
  case "Rejected" :
@@ -396,7 +400,8 @@ let NoteFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
396
400
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema,
397
401
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.targetName,
398
402
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.externalSystem,
399
- authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.authorizationOf
403
+ authorizationOf: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.authorizationOf,
404
+ endpoint: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.endpoint
400
405
  })(NoteFeedTranslation);
401
406
 
402
407
  globalThis.describe("receive encodes commands with their schema", () => {
@@ -410,7 +415,7 @@ globalThis.describe("receive encodes commands with their schema", () => {
410
415
  await NoteFeedCallback.receive(publish, Object.fromEntries([[
411
416
  "orderId",
412
417
  "ord-1"
413
- ]]), undefined);
418
+ ]]), undefined, undefined);
414
419
  let commandJson = published.contents[0].commandJson;
415
420
  let decoded = Sury.parseOrThrow(commandJson, InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema);
416
421
  globalThis.expect(decoded).toEqual({
@@ -28,6 +28,7 @@ module PaymentWebhookSpec = {
28
28
 
29
29
  let targetName = "ConfirmPayment"
30
30
  let externalSystem = None
31
+ let endpoint = None
31
32
 
32
33
  let translate = (input: externalInput) =>
33
34
  switch input.status {
@@ -52,6 +53,7 @@ module CatalogFeedSpec = {
52
53
 
53
54
  let targetName = "AddProduct"
54
55
  let externalSystem = None
56
+ let endpoint = None
55
57
 
56
58
  type role = Reventless.Role.name
57
59
  let merchandiser = Reventless.Role.make("Merchandiser")
@@ -89,6 +91,7 @@ module NoteFeedSpec = {
89
91
 
90
92
  let targetName = "Annotate"
91
93
  let externalSystem = None
94
+ let endpoint = None
92
95
 
93
96
  let translate = (input: externalInput) => Ok([
94
97
  (input.orderId, Annotate({orderId: input.orderId, note: None})),
@@ -65,6 +65,7 @@ let PaymentWebhookSpec = {
65
65
  commandSchema: commandSchema,
66
66
  targetName: "ConfirmPayment",
67
67
  externalSystem: undefined,
68
+ endpoint: undefined,
68
69
  translate: translate,
69
70
  authorizationOf: authorizationOf,
70
71
  traits: traits
@@ -160,6 +161,7 @@ let CatalogFeedSpec = {
160
161
  commandSchema: commandSchema$1,
161
162
  targetName: "AddProduct",
162
163
  externalSystem: undefined,
164
+ endpoint: undefined,
163
165
  merchandiser: merchandiser,
164
166
  admin: admin,
165
167
  authorizationOf: authorizationOf$1,
@@ -206,6 +208,7 @@ let NoteFeedSpec = {
206
208
  commandSchema: commandSchema$2,
207
209
  targetName: "Annotate",
208
210
  externalSystem: undefined,
211
+ endpoint: undefined,
209
212
  translate: translate$2,
210
213
  authorizationOf: authorizationOf$2,
211
214
  traits: traits$2
@@ -0,0 +1,305 @@
1
+ // The webhook door of an inbound translation slice: signature checks over the raw
2
+ // body, dispatch by path through the local registry, the caller a signed request
3
+ // acts as, and the audit row telling the two doors apart.
4
+
5
+ @@warning("-44")
6
+
7
+ open JestGlobals
8
+
9
+ let _ = TestRunner.setup()
10
+
11
+ module Webhook = ReventlessCore.InboundTranslationSlice_Webhook
12
+ module Callback = ReventlessCore.InboundTranslationSlice_Callback
13
+
14
+ let merchandiser = Reventless.Role.make("Merchandiser")
15
+
16
+ module FeedSpec = {
17
+ let name = "SignedFeed"
18
+ let moduleUrl = "signed-feed://spec"
19
+ @schema type externalInput = {kind: string, id: string}
20
+ @schema
21
+ type command =
22
+ | AddProduct({productId: @s.matches(Reventless.DcbTag.string) string})
23
+ | AddCategory({categoryId: @s.matches(Reventless.DcbTag.string) string})
24
+ let targetName = "AddProduct"
25
+ let externalSystem = None
26
+ type role = Reventless.Role.name
27
+ let authorizationOf = (name: string): Reventless.Authorization.rule<role> =>
28
+ switch name {
29
+ | "AddProduct" => AllowRoles([merchandiser])
30
+ | _ => AllowRoles([Reventless.Role.admin])
31
+ }
32
+ let endpoint = Some({
33
+ Reventless.InboundTranslationSlice.path: "/feeds/signed/",
34
+ auth: SignedBody({
35
+ header: "X-Signature",
36
+ prefix: "sha256=",
37
+ timestampHeader: "X-Timestamp",
38
+ secret: "feed",
39
+ }),
40
+ role: merchandiser,
41
+ })
42
+ }
43
+
44
+ let translateFeed = (input: FeedSpec.externalInput) =>
45
+ switch input.kind {
46
+ | "product" => Ok([(input.id, FeedSpec.AddProduct({productId: input.id}))])
47
+ | "category" => Ok([(input.id, FeedSpec.AddCategory({categoryId: input.id}))])
48
+ | kind => Error(`unknown kind ${kind}`)
49
+ }
50
+
51
+ module FeedCallback = Callback.Make(
52
+ FeedSpec,
53
+ {
54
+ let translate = translateFeed
55
+ let moduleUrl = FeedSpec.moduleUrl
56
+ },
57
+ )
58
+
59
+ module OpenSpec = {
60
+ let name = "OpenFeed"
61
+ let moduleUrl = "open-feed://spec"
62
+ @schema type externalInput = {kind: string, id: string}
63
+ @schema type command = AddProduct({productId: @s.matches(Reventless.DcbTag.string) string})
64
+ let targetName = "AddProduct"
65
+ let externalSystem = None
66
+ type role = Reventless.Role.name
67
+ let authorizationOf = (_: string): Reventless.Authorization.rule<role> => AllowAuthenticated
68
+ let endpoint = Some({Reventless.InboundTranslationSlice.path: "open", auth: Open})
69
+ }
70
+
71
+ module OpenCallback = Callback.Make(
72
+ OpenSpec,
73
+ {
74
+ let translate = (input: OpenSpec.externalInput) => Ok([
75
+ (input.id, OpenSpec.AddProduct({productId: input.id})),
76
+ ])
77
+ let moduleUrl = OpenSpec.moduleUrl
78
+ },
79
+ )
80
+
81
+ @schema type input = {id: string}
82
+
83
+ let published: ref<array<Reventless.Message.commandJson>> = ref([])
84
+ let publishJsons: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
85
+ published := published.contents->Array.concat(cmds)
86
+
87
+ let feedOps: ReventlessInfra.InboundTranslationSlice.operations = {
88
+ receive: (input, ~caller=?, ~door=?) =>
89
+ FeedCallback.receive(publishJsons, input, ~caller?, ~door?),
90
+ refuse: async (~body, ~errorCode, error) => FeedCallback.refuse(~body, ~errorCode, error),
91
+ }
92
+ let openOps: ReventlessInfra.InboundTranslationSlice.operations = {
93
+ receive: (input, ~caller=?, ~door=?) =>
94
+ OpenCallback.receive(publishJsons, input, ~caller?, ~door?),
95
+ refuse: async (~body, ~errorCode, error) => OpenCallback.refuse(~body, ~errorCode, error),
96
+ }
97
+
98
+ // The door an audit row records.
99
+ let viaWebhook: option<ReventlessInfra.InboundTranslationSlice.door> = Some(Webhook)
100
+ let viaMutation: option<ReventlessInfra.InboundTranslationSlice.door> = Some(Mutation)
101
+
102
+ let secret = "s3cret"
103
+ let now = 1_700_000_000.
104
+ let secretOf = name => name == "feed" ? Some(secret) : None
105
+
106
+ let signature: Reventless.InboundTranslationSlice.signature = {
107
+ header: "X-Signature",
108
+ prefix: "sha256=",
109
+ timestampHeader: "X-Timestamp",
110
+ secret: "feed",
111
+ }
112
+
113
+ let hmac = (~secret, text) =>
114
+ NodeCrypto.createHmac("sha256", secret)->NodeCrypto.hmacUpdate(text)->NodeCrypto.hmacDigest("hex")
115
+
116
+ let signedHeaders = (~secret=secret, ~at=now, body) => {
117
+ let at = at->Float.toString
118
+ Dict.fromArray([
119
+ ("x-timestamp", at),
120
+ ("x-signature", "sha256=" ++ hmac(~secret, `${at}.${body}`)),
121
+ ])
122
+ }
123
+
124
+ let body = (~kind="product", ~id="p-1") => `{"kind":"${kind}","id":"${id}"}`
125
+
126
+ let post = (~path="feeds/signed", ~method=?, ~headers=?, body) =>
127
+ LocalWebhookRegistry.post(~path, ~method?, ~headers?, ~secretOf, ~nowSeconds=now, body)
128
+
129
+ let throws = (run: unit => unit): bool =>
130
+ try {
131
+ run()
132
+ false
133
+ } catch {
134
+ | _ => true
135
+ }
136
+
137
+ let rows = (log: dict<Callback.auditRow>) => log->Dict.valuesToArray
138
+
139
+ let clear = (log: dict<Callback.auditRow>) =>
140
+ log->Dict.keysToArray->Array.forEach(key => log->Dict.delete(key))
141
+
142
+ describe("webhook signature", () => {
143
+ let check = (~secrets=[secret], ~headers, text) =>
144
+ Webhook.verify(signature, ~secrets, ~headers, ~body=text, ~nowSeconds=now)
145
+
146
+ testSync("a signature by the shared secret passes", () =>
147
+ expect(check(~headers=signedHeaders(body()), body()))->toEqual(Ok())
148
+ )
149
+
150
+ testSync("a signature by another secret is refused", () =>
151
+ expect(check(~headers=signedHeaders(~secret="other", body()), body())->Result.isError)->toBe(
152
+ true,
153
+ )
154
+ )
155
+
156
+ testSync("a body changed after signing is refused", () =>
157
+ expect(check(~headers=signedHeaders(body()), body(~id="p-2"))->Result.isError)->toBe(true)
158
+ )
159
+
160
+ testSync(
161
+ "a timestamp outside the window is refused, so a captured request cannot be replayed",
162
+ () =>
163
+ expect(check(~headers=signedHeaders(~at=now -. 301., body()), body())->Result.isError)->toBe(
164
+ true,
165
+ ),
166
+ )
167
+
168
+ testSync("a request without the signature header is refused", () => {
169
+ let headers = signedHeaders(body())
170
+ headers->Dict.delete("x-signature")
171
+ expect(check(~headers, body())->Result.isError)->toBe(true)
172
+ })
173
+
174
+ testSync("while a secret is rotated, either one passes", () =>
175
+ expect(
176
+ check(
177
+ ~secrets=Webhook.secretsOf("new, old"),
178
+ ~headers=signedHeaders(~secret="old", body()),
179
+ body(),
180
+ ),
181
+ )->toEqual(Ok())
182
+ )
183
+
184
+ testSync("a secret is read from a variable named after it", () =>
185
+ expect(Webhook.secretEnvKey("supplier-feed"))->toBe("REVENTLESS_WEBHOOK_SECRET_SUPPLIER_FEED")
186
+ )
187
+ })
188
+
189
+ describe("webhook door", () => {
190
+ beforeAll(() => {
191
+ LocalWebhookRegistry.reset()
192
+ switch (FeedCallback.webhook, OpenCallback.webhook) {
193
+ | (Some(feed), Some(open_)) =>
194
+ LocalWebhookRegistry.register(~route=feed, ~operations=Pulumi.Output.make(feedOps))
195
+ LocalWebhookRegistry.register(~route=open_, ~operations=Pulumi.Output.make(openOps))
196
+ | _ => JsError.throwWithMessage("fixture specs declare endpoints")
197
+ }
198
+ })
199
+ afterAll(() => LocalWebhookRegistry.reset())
200
+ beforeEach(() => {
201
+ published := []
202
+ clear(FeedCallback.auditLog)
203
+ clear(OpenCallback.auditLog)
204
+ })
205
+
206
+ testPromise("a signed request publishes its commands as the endpoint's role", async () => {
207
+ let response = await post(~headers=signedHeaders(body()), body())
208
+ expect(response.status)->toBe(200)
209
+ expect(published.contents->Array.map(c => c.id))->toEqual(["p-1"])
210
+ expect(rows(FeedCallback.auditLog)->Array.map(row => row.door))->toEqual([viaWebhook])
211
+ })
212
+
213
+ testPromise("a command the endpoint's role may not send is refused", async () => {
214
+ let text = body(~kind="category")
215
+ let response = await post(~headers=signedHeaders(text), text)
216
+ expect(response.status)->toBe(403)
217
+ expect(published.contents)->toEqual([])
218
+ })
219
+
220
+ testPromise("a bad signature is refused before the body is read, with an audit row", async () => {
221
+ let response = await post(~headers=signedHeaders(~secret="other", body()), body())
222
+ expect(response.status)->toBe(401)
223
+ expect(published.contents)->toEqual([])
224
+ expect(
225
+ rows(FeedCallback.auditLog)->Array.map(row => (row.status, row.door, row.input)),
226
+ )->toEqual([(Callback.Failure, viaWebhook, body())])
227
+ })
228
+
229
+ testPromise("a signed body that is not JSON is refused", async () => {
230
+ let response = await post(~headers=signedHeaders("not json"), "not json")
231
+ expect(response.status)->toBe(400)
232
+ })
233
+
234
+ testPromise("an unknown path and a wrong method are refused", async () => {
235
+ let unknown = await post(~path="nowhere", body())
236
+ let wrongMethod = await post(~method="PUT", ~headers=signedHeaders(body()), body())
237
+ expect((unknown.status, wrongMethod.status))->toEqual((404, 405))
238
+ })
239
+
240
+ testPromise("a secret the platform lacks fails closed, without an audit row", async () => {
241
+ let response = await LocalWebhookRegistry.post(
242
+ ~path="feeds/signed",
243
+ ~headers=signedHeaders(body()),
244
+ ~secretOf=_ => None,
245
+ ~nowSeconds=now,
246
+ body(),
247
+ )
248
+ expect(response.status)->toBe(500)
249
+ expect(published.contents)->toEqual([])
250
+ expect(rows(FeedCallback.auditLog))->toEqual([])
251
+ })
252
+
253
+ testPromise("an open URL identifies nobody, so a signed-in rule refuses it", async () => {
254
+ let response = await post(~path="open", body())
255
+ expect(response.status)->toBe(403)
256
+ })
257
+
258
+ testPromise("both doors publish the same commands and are told apart in the audit", async () => {
259
+ let caller: Reventless.Identity.t = {
260
+ userId: "u-1",
261
+ username: "alice",
262
+ groups: [(merchandiser :> string)],
263
+ provider: InMemory,
264
+ }
265
+ let mutationResult = await feedOps.receive(JSON.parseOrThrow(body()), ~caller)
266
+ let _ = await post(~headers=signedHeaders(body()), body())
267
+ expect(mutationResult->Result.isOk)->toBe(true)
268
+ switch published.contents->Array.map(c => c.commandJson) {
269
+ | [viaMutationCommand, viaWebhookCommand] =>
270
+ expect(viaWebhookCommand)->toEqual(viaMutationCommand)
271
+ | commands => expect(commands->Array.length)->toBe(2)
272
+ }
273
+ expect(rows(FeedCallback.auditLog)->Array.map(row => row.door))->toEqual([
274
+ viaMutation,
275
+ viaWebhook,
276
+ ])
277
+ })
278
+
279
+ testSync("two slices may not share a path", () =>
280
+ switch FeedCallback.webhook {
281
+ | Some(route) =>
282
+ expect(
283
+ throws(
284
+ () => LocalWebhookRegistry.register(~route, ~operations=Pulumi.Output.make(feedOps)),
285
+ ),
286
+ )->toBe(true)
287
+ | None => JsError.throwWithMessage("fixture spec declares an endpoint")
288
+ }
289
+ )
290
+ })
291
+
292
+ describe("which doors a slice has", () => {
293
+ testSync("@noApi on externalInput withholds the mutation", () =>
294
+ expect((
295
+ Callback.exposesMutation(inputSchema->ReventlessInfra.Api.markNoApi->S.castToUnknown),
296
+ Callback.exposesMutation(inputSchema->S.castToUnknown),
297
+ ))->toEqual((false, true))
298
+ )
299
+
300
+ testSync("a slice with neither door is refused", () => {
301
+ let check = (~webhook) =>
302
+ throws(() => Callback.checkReachable(~plugin="P", ~slice="S", ~mutation=false, ~webhook))
303
+ expect((check(~webhook=false), check(~webhook=true)))->toEqual((true, false))
304
+ })
305
+ })
@@ -0,0 +1,473 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
+ import * as Nodecrypto from "node:crypto";
6
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
7
+ import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
10
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
11
+ import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
12
+ import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
13
+ import * as LocalWebhookRegistry$ReventlessLocal from "../../../src/adapter/Webhook/LocalWebhookRegistry.res.mjs";
14
+ import * as InboundTranslationSlice_Webhook$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs";
15
+ import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
16
+
17
+ TestRunner$ReventlessLocal.setup();
18
+
19
+ let merchandiser = "Merchandiser";
20
+
21
+ let moduleUrl = "signed-feed://spec";
22
+
23
+ let externalInputSchema = Sury.$schema(s => ({
24
+ kind: s.m(Sury.string),
25
+ id: s.m(Sury.string)
26
+ }));
27
+
28
+ let commandSchema = Sury.union([
29
+ Sury.$schema(s => ({
30
+ TAG: "AddProduct",
31
+ productId: s.m(DcbTag$Reventless.string)
32
+ })),
33
+ Sury.$schema(s => ({
34
+ TAG: "AddCategory",
35
+ categoryId: s.m(DcbTag$Reventless.string)
36
+ }))
37
+ ]);
38
+
39
+ function authorizationOf(name) {
40
+ if (name === "AddProduct") {
41
+ return {
42
+ TAG: "AllowRoles",
43
+ _0: [merchandiser]
44
+ };
45
+ } else {
46
+ return {
47
+ TAG: "AllowRoles",
48
+ _0: [Role$Reventless.admin]
49
+ };
50
+ }
51
+ }
52
+
53
+ let endpoint = {
54
+ path: "/feeds/signed/",
55
+ auth: {
56
+ TAG: "SignedBody",
57
+ _0: {
58
+ header: "X-Signature",
59
+ prefix: "sha256=",
60
+ timestampHeader: "X-Timestamp",
61
+ secret: "feed"
62
+ }
63
+ },
64
+ role: merchandiser
65
+ };
66
+
67
+ let traits = [];
68
+
69
+ let FeedSpec = {
70
+ name: "SignedFeed",
71
+ moduleUrl: moduleUrl,
72
+ externalInputSchema: externalInputSchema,
73
+ commandSchema: commandSchema,
74
+ targetName: "AddProduct",
75
+ externalSystem: undefined,
76
+ authorizationOf: authorizationOf,
77
+ endpoint: endpoint,
78
+ traits: traits
79
+ };
80
+
81
+ function translateFeed(input) {
82
+ let kind = input.kind;
83
+ switch (kind) {
84
+ case "category" :
85
+ return {
86
+ TAG: "Ok",
87
+ _0: [[
88
+ input.id,
89
+ {
90
+ TAG: "AddCategory",
91
+ categoryId: input.id
92
+ }
93
+ ]]
94
+ };
95
+ case "product" :
96
+ return {
97
+ TAG: "Ok",
98
+ _0: [[
99
+ input.id,
100
+ {
101
+ TAG: "AddProduct",
102
+ productId: input.id
103
+ }
104
+ ]]
105
+ };
106
+ default:
107
+ return {
108
+ TAG: "Error",
109
+ _0: `unknown kind ` + kind
110
+ };
111
+ }
112
+ }
113
+
114
+ let FeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make(FeedSpec)({
115
+ translate: translateFeed,
116
+ moduleUrl: moduleUrl
117
+ });
118
+
119
+ let moduleUrl$1 = "open-feed://spec";
120
+
121
+ let externalInputSchema$1 = Sury.$schema(s => ({
122
+ kind: s.m(Sury.string),
123
+ id: s.m(Sury.string)
124
+ }));
125
+
126
+ let commandSchema$1 = Sury.$schema(s => ({
127
+ TAG: "AddProduct",
128
+ productId: s.m(DcbTag$Reventless.string)
129
+ }));
130
+
131
+ function authorizationOf$1(param) {
132
+ return "AllowAuthenticated";
133
+ }
134
+
135
+ let traits$1 = [];
136
+
137
+ let OpenSpec_endpoint = {
138
+ path: "open",
139
+ auth: "Open"
140
+ };
141
+
142
+ let OpenSpec = {
143
+ name: "OpenFeed",
144
+ moduleUrl: moduleUrl$1,
145
+ externalInputSchema: externalInputSchema$1,
146
+ commandSchema: commandSchema$1,
147
+ targetName: "AddProduct",
148
+ externalSystem: undefined,
149
+ authorizationOf: authorizationOf$1,
150
+ endpoint: OpenSpec_endpoint,
151
+ traits: traits$1
152
+ };
153
+
154
+ function translate(input) {
155
+ return {
156
+ TAG: "Ok",
157
+ _0: [[
158
+ input.id,
159
+ {
160
+ TAG: "AddProduct",
161
+ productId: input.id
162
+ }
163
+ ]]
164
+ };
165
+ }
166
+
167
+ let OpenCallback = InboundTranslationSlice_Callback$ReventlessCore.Make(OpenSpec)({
168
+ translate: translate,
169
+ moduleUrl: moduleUrl$1
170
+ });
171
+
172
+ let inputSchema = Sury.$schema(s => ({
173
+ id: s.m(Sury.string)
174
+ }));
175
+
176
+ let published = {
177
+ contents: []
178
+ };
179
+
180
+ async function publishJsons(cmds) {
181
+ published.contents = published.contents.concat(cmds);
182
+ }
183
+
184
+ function feedOps_receive(input, caller, door) {
185
+ return FeedCallback.receive(publishJsons, input, caller, door);
186
+ }
187
+
188
+ async function feedOps_refuse(body, errorCode, error) {
189
+ return FeedCallback.refuse(body, errorCode, error);
190
+ }
191
+
192
+ let feedOps = {
193
+ receive: feedOps_receive,
194
+ refuse: feedOps_refuse
195
+ };
196
+
197
+ function openOps_receive(input, caller, door) {
198
+ return OpenCallback.receive(publishJsons, input, caller, door);
199
+ }
200
+
201
+ async function openOps_refuse(body, errorCode, error) {
202
+ return OpenCallback.refuse(body, errorCode, error);
203
+ }
204
+
205
+ let openOps = {
206
+ receive: openOps_receive,
207
+ refuse: openOps_refuse
208
+ };
209
+
210
+ let viaWebhook = "Webhook";
211
+
212
+ let viaMutation = "Mutation";
213
+
214
+ let secret = "s3cret";
215
+
216
+ function secretOf(name) {
217
+ if (name === "feed") {
218
+ return secret;
219
+ }
220
+ }
221
+
222
+ let signature = {
223
+ header: "X-Signature",
224
+ prefix: "sha256=",
225
+ timestampHeader: "X-Timestamp",
226
+ secret: "feed"
227
+ };
228
+
229
+ function hmac(secret, text) {
230
+ return Nodecrypto.createHmac("sha256", secret).update(text).digest("hex");
231
+ }
232
+
233
+ function signedHeaders(secretOpt, atOpt, body) {
234
+ let secret$1 = secretOpt !== undefined ? secretOpt : secret;
235
+ let at = atOpt !== undefined ? atOpt : 1700000000;
236
+ let at$1 = at.toString();
237
+ return Object.fromEntries([
238
+ [
239
+ "x-timestamp",
240
+ at$1
241
+ ],
242
+ [
243
+ "x-signature",
244
+ "sha256=" + hmac(secret$1, at$1 + `.` + body)
245
+ ]
246
+ ]);
247
+ }
248
+
249
+ function body(kindOpt, idOpt) {
250
+ let kind = kindOpt !== undefined ? kindOpt : "product";
251
+ let id = idOpt !== undefined ? idOpt : "p-1";
252
+ return `{"kind":"` + kind + `","id":"` + id + `"}`;
253
+ }
254
+
255
+ function post(pathOpt, method, headers, body) {
256
+ let path = pathOpt !== undefined ? pathOpt : "feeds/signed";
257
+ return LocalWebhookRegistry$ReventlessLocal.post(path, method, headers, secretOf, 1700000000, body);
258
+ }
259
+
260
+ function throws(run) {
261
+ try {
262
+ run();
263
+ return false;
264
+ } catch (exn) {
265
+ return true;
266
+ }
267
+ }
268
+
269
+ function rows(log) {
270
+ return Object.values(log);
271
+ }
272
+
273
+ function clear(log) {
274
+ Object.keys(log).forEach(key => Stdlib_Dict.$$delete(log, key));
275
+ }
276
+
277
+ globalThis.describe("webhook signature", () => {
278
+ let check = (secretsOpt, headers, text) => {
279
+ let secrets = secretsOpt !== undefined ? secretsOpt : [secret];
280
+ return InboundTranslationSlice_Webhook$ReventlessCore.verify(signature, secrets, headers, text, 1700000000);
281
+ };
282
+ globalThis.test("a signature by the shared secret passes", () => {
283
+ globalThis.expect(check(undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined))).toEqual({
284
+ TAG: "Ok",
285
+ _0: undefined
286
+ });
287
+ });
288
+ globalThis.test("a signature by another secret is refused", () => {
289
+ globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders("other", undefined, body(undefined, undefined)), body(undefined, undefined)))).toBe(true);
290
+ });
291
+ globalThis.test("a body changed after signing is refused", () => {
292
+ globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, "p-2")))).toBe(true);
293
+ });
294
+ globalThis.test("a timestamp outside the window is refused, so a captured request cannot be replayed", () => {
295
+ globalThis.expect(Stdlib_Result.isError(check(undefined, signedHeaders(undefined, 1700000000 - 301, body(undefined, undefined)), body(undefined, undefined)))).toBe(true);
296
+ });
297
+ globalThis.test("a request without the signature header is refused", () => {
298
+ let headers = signedHeaders(undefined, undefined, body(undefined, undefined));
299
+ Stdlib_Dict.$$delete(headers, "x-signature");
300
+ globalThis.expect(Stdlib_Result.isError(check(undefined, headers, body(undefined, undefined)))).toBe(true);
301
+ });
302
+ globalThis.test("while a secret is rotated, either one passes", () => {
303
+ globalThis.expect(check(InboundTranslationSlice_Webhook$ReventlessCore.secretsOf("new, old"), signedHeaders("old", undefined, body(undefined, undefined)), body(undefined, undefined))).toEqual({
304
+ TAG: "Ok",
305
+ _0: undefined
306
+ });
307
+ });
308
+ globalThis.test("a secret is read from a variable named after it", () => {
309
+ globalThis.expect(InboundTranslationSlice_Webhook$ReventlessCore.secretEnvKey("supplier-feed")).toBe("REVENTLESS_WEBHOOK_SECRET_SUPPLIER_FEED");
310
+ });
311
+ });
312
+
313
+ globalThis.describe("webhook door", () => {
314
+ globalThis.beforeAll(() => {
315
+ LocalWebhookRegistry$ReventlessLocal.reset();
316
+ let match = FeedCallback.webhook;
317
+ let match$1 = OpenCallback.webhook;
318
+ if (match !== undefined && match$1 !== undefined) {
319
+ LocalWebhookRegistry$ReventlessLocal.register(match, Pulumi.output(feedOps));
320
+ return LocalWebhookRegistry$ReventlessLocal.register(match$1, Pulumi.output(openOps));
321
+ } else {
322
+ return Stdlib_JsError.throwWithMessage("fixture specs declare endpoints");
323
+ }
324
+ });
325
+ globalThis.afterAll(() => LocalWebhookRegistry$ReventlessLocal.reset());
326
+ globalThis.beforeEach(() => {
327
+ published.contents = [];
328
+ clear(FeedCallback.auditLog);
329
+ clear(OpenCallback.auditLog);
330
+ });
331
+ globalThis.test("a signed request publishes its commands as the endpoint's role", async () => {
332
+ let response = await post(undefined, undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
333
+ globalThis.expect(response.status).toBe(200);
334
+ globalThis.expect(published.contents.map(c => c.id)).toEqual(["p-1"]);
335
+ globalThis.expect(Object.values(FeedCallback.auditLog).map(row => row.door)).toEqual([viaWebhook]);
336
+ });
337
+ globalThis.test("a command the endpoint's role may not send is refused", async () => {
338
+ let text = body("category", undefined);
339
+ let response = await post(undefined, undefined, signedHeaders(undefined, undefined, text), text);
340
+ globalThis.expect(response.status).toBe(403);
341
+ globalThis.expect(published.contents).toEqual([]);
342
+ });
343
+ globalThis.test("a bad signature is refused before the body is read, with an audit row", async () => {
344
+ let response = await post(undefined, undefined, signedHeaders("other", undefined, body(undefined, undefined)), body(undefined, undefined));
345
+ globalThis.expect(response.status).toBe(401);
346
+ globalThis.expect(published.contents).toEqual([]);
347
+ globalThis.expect(Object.values(FeedCallback.auditLog).map(row => [
348
+ row.status,
349
+ row.door,
350
+ row.input
351
+ ])).toEqual([[
352
+ "Failure",
353
+ viaWebhook,
354
+ body(undefined, undefined)
355
+ ]]);
356
+ });
357
+ globalThis.test("a signed body that is not JSON is refused", async () => {
358
+ let response = await post(undefined, undefined, signedHeaders(undefined, undefined, "not json"), "not json");
359
+ globalThis.expect(response.status).toBe(400);
360
+ });
361
+ globalThis.test("an unknown path and a wrong method are refused", async () => {
362
+ let unknown = await post("nowhere", undefined, undefined, body(undefined, undefined));
363
+ let wrongMethod = await post(undefined, "PUT", signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
364
+ globalThis.expect([
365
+ unknown.status,
366
+ wrongMethod.status
367
+ ]).toEqual([
368
+ 404,
369
+ 405
370
+ ]);
371
+ });
372
+ globalThis.test("a secret the platform lacks fails closed, without an audit row", async () => {
373
+ let response = await LocalWebhookRegistry$ReventlessLocal.post("feeds/signed", undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), param => {}, 1700000000, body(undefined, undefined));
374
+ globalThis.expect(response.status).toBe(500);
375
+ globalThis.expect(published.contents).toEqual([]);
376
+ globalThis.expect(Object.values(FeedCallback.auditLog)).toEqual([]);
377
+ });
378
+ globalThis.test("an open URL identifies nobody, so a signed-in rule refuses it", async () => {
379
+ let response = await post("open", undefined, undefined, body(undefined, undefined));
380
+ globalThis.expect(response.status).toBe(403);
381
+ });
382
+ globalThis.test("both doors publish the same commands and are told apart in the audit", async () => {
383
+ let caller_groups = [merchandiser];
384
+ let caller = {
385
+ userId: "u-1",
386
+ username: "alice",
387
+ groups: caller_groups,
388
+ provider: "InMemory"
389
+ };
390
+ let mutationResult = await feedOps_receive(JSON.parse(body(undefined, undefined)), caller, undefined);
391
+ await post(undefined, undefined, signedHeaders(undefined, undefined, body(undefined, undefined)), body(undefined, undefined));
392
+ globalThis.expect(Stdlib_Result.isOk(mutationResult)).toBe(true);
393
+ let commands = published.contents.map(c => c.commandJson);
394
+ if (commands.length !== 2) {
395
+ globalThis.expect(commands.length).toBe(2);
396
+ } else {
397
+ let viaMutationCommand = commands[0];
398
+ let viaWebhookCommand = commands[1];
399
+ globalThis.expect(viaWebhookCommand).toEqual(viaMutationCommand);
400
+ }
401
+ globalThis.expect(Object.values(FeedCallback.auditLog).map(row => row.door)).toEqual([
402
+ viaMutation,
403
+ viaWebhook
404
+ ]);
405
+ });
406
+ globalThis.test("two slices may not share a path", () => {
407
+ let route = FeedCallback.webhook;
408
+ if (route !== undefined) {
409
+ globalThis.expect(throws(() => LocalWebhookRegistry$ReventlessLocal.register(route, Pulumi.output(feedOps)))).toBe(true);
410
+ return;
411
+ } else {
412
+ return Stdlib_JsError.throwWithMessage("fixture spec declares an endpoint");
413
+ }
414
+ });
415
+ });
416
+
417
+ globalThis.describe("which doors a slice has", () => {
418
+ globalThis.test("@noApi on externalInput withholds the mutation", () => {
419
+ globalThis.expect([
420
+ InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(Api$ReventlessInfra.markNoApi(inputSchema)),
421
+ InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(inputSchema)
422
+ ]).toEqual([
423
+ false,
424
+ true
425
+ ]);
426
+ });
427
+ globalThis.test("a slice with neither door is refused", () => {
428
+ let check = webhook => throws(() => InboundTranslationSlice_Callback$ReventlessCore.checkReachable("P", "S", false, webhook));
429
+ globalThis.expect([
430
+ check(false),
431
+ check(true)
432
+ ]).toEqual([
433
+ true,
434
+ false
435
+ ]);
436
+ });
437
+ });
438
+
439
+ let Webhook;
440
+
441
+ let Callback;
442
+
443
+ let now = 1700000000;
444
+
445
+ export {
446
+ Webhook,
447
+ Callback,
448
+ merchandiser,
449
+ FeedSpec,
450
+ translateFeed,
451
+ FeedCallback,
452
+ OpenSpec,
453
+ OpenCallback,
454
+ inputSchema,
455
+ published,
456
+ publishJsons,
457
+ feedOps,
458
+ openOps,
459
+ viaWebhook,
460
+ viaMutation,
461
+ secret,
462
+ now,
463
+ secretOf,
464
+ signature,
465
+ hmac,
466
+ signedHeaders,
467
+ body,
468
+ post,
469
+ throws,
470
+ rows,
471
+ clear,
472
+ }
473
+ /* Not a pure module */