@reventlessdev/reventless-local 3.0.0-alpha.281 → 3.0.0-alpha.282

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,13 @@
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.282 (2026-10-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * an inbound translation is gated alike on every door, and encodes its commands by schema ([6ec3ce6](https://github.com/ReventlessDev/reventless-core/commit/6ec3ce672e53e2c162c64a90d103be691391be5e))
11
+
12
+
6
13
  # 3.0.0-alpha.281 (2026-10-08)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.281",
3
+ "version": "3.0.0-alpha.282",
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": {
@@ -40,13 +40,13 @@
40
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
41
41
  "@reventlessdev/rescript-node": "2.0.0-alpha.22",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.291",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.139",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.238",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.188",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.155",
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.292",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.140",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.239",
48
46
  "@reventlessdev/reventless-seed": "1.0.0-alpha.33",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.160"
47
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.189",
48
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.156",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.161"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
package/src/Platform.res CHANGED
@@ -314,10 +314,11 @@ module MakeWithConfig = (
314
314
  // Phase 2: bind generateCommand when Output.apply resolves.
315
315
  mutationBindHook: CommandGeneratorResolvers_GraphQL.bindHandler,
316
316
  // InboundTranslationSlice hooks — pass resolved server for correct target routing.
317
- inboundMutationResolverHook: (~fieldName, ~externalInputSchema) =>
317
+ inboundMutationResolverHook: (~fieldName, ~externalInputSchema, ~permission=?) =>
318
318
  InboundTranslationResolvers_GraphQL.register(
319
319
  ~fieldName,
320
320
  ~externalInputSchema,
321
+ ~permission?,
321
322
  ~server=resolveTargetGraphQL(),
322
323
  ),
323
324
  inboundMutationBindReceiveHook: InboundTranslationResolvers_GraphQL.bindReceive,
@@ -233,7 +233,7 @@ function MakeWithConfig(Config) {
233
233
  fields.forEach(field => CommandGeneratorResolvers_GraphQL$ReventlessLocal.registerDcb(field, commandSchema, commandAuthorization, server));
234
234
  };
235
235
  let hooks_mutationBindHook = CommandGeneratorResolvers_GraphQL$ReventlessLocal.bindHandler;
236
- let hooks_inboundMutationResolverHook = (fieldName, externalInputSchema) => InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, externalInputSchema, resolveTargetGraphQL());
236
+ let hooks_inboundMutationResolverHook = (fieldName, externalInputSchema, permission) => InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, externalInputSchema, permission, resolveTargetGraphQL());
237
237
  let hooks_inboundMutationBindReceiveHook = InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive;
238
238
  let hooks_schemaTypeRegistrationHook = sdlTypes => resolveTargetGraphQL().registerTypes(sdlTypes);
239
239
  let hooks_mcpSchemaRegistrationHook = param => {
@@ -1993,7 +1993,7 @@ function Make($star) {
1993
1993
  fields.forEach(field => CommandGeneratorResolvers_GraphQL$ReventlessLocal.registerDcb(field, commandSchema, commandAuthorization, server));
1994
1994
  };
1995
1995
  let hooks_mutationBindHook = CommandGeneratorResolvers_GraphQL$ReventlessLocal.bindHandler;
1996
- let hooks_inboundMutationResolverHook = (fieldName, externalInputSchema) => InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, externalInputSchema, resolveTargetGraphQL());
1996
+ let hooks_inboundMutationResolverHook = (fieldName, externalInputSchema, permission) => InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, externalInputSchema, permission, resolveTargetGraphQL());
1997
1997
  let hooks_inboundMutationBindReceiveHook = InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive;
1998
1998
  let hooks_schemaTypeRegistrationHook = sdlTypes => resolveTargetGraphQL().registerTypes(sdlTypes);
1999
1999
  let hooks_mcpSchemaRegistrationHook = param => {
@@ -1,39 +1,35 @@
1
- // GraphQL mutation resolvers for in-memory InboundTranslationSlices.
2
- // Registers one GraphQL mutation field per InboundTranslationSlice.
3
- // Each resolver calls `receive(argsJson)` directly — the InboundTranslationSlice
4
- // handles parsing, validation, translation, and command publishing internally.
1
+ // GraphQL mutation resolvers for in-memory InboundTranslationSlices: one field per
2
+ // slice, gated by the door's rule and handing the caller on to `receive`.
5
3
 
6
4
  @@warning("-44")
7
5
  open ReventlessCore
8
6
 
9
- // Mutable registry: fieldName → receive function.
10
- // Pre-populated synchronously in Phase 1 via a queuing forwarder so callers
11
- // (e.g. PlatformInspector's onPlatformDeployedHook) can invoke receive before
12
- // Phase 2 bindReceive runs. Calls park in a Promise queue and drain once bound.
13
- let receiveRegistry: dict<
14
- JSON.t => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
15
- > = Dict.make()
7
+ type receive = (
8
+ JSON.t,
9
+ ~caller: Reventless.Identity.t=?,
10
+ ) => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>
11
+
12
+ // fieldName → receive. Pre-populated with a queuing forwarder so a call made
13
+ // before `bindReceive` parks until it runs.
14
+ let receiveRegistry: dict<receive> = Dict.make()
16
15
 
17
16
  type pendingCall = {
18
17
  inputJson: JSON.t,
18
+ caller: option<Reventless.Identity.t>,
19
19
  resolve: ReventlessInfra.InboundTranslationSlice.receiveResult => unit,
20
20
  }
21
21
 
22
- // Per-field pending-call queues — drained when bindReceive fires.
23
22
  let pendingQueueRegistry: dict<ref<array<pendingCall>>> = Dict.make()
24
23
 
25
- // Phase 1: Register SDL + resolver stub synchronously (before server starts).
26
- // Also pre-registers a queuing forwarder in receiveRegistry so callers can
27
- // invoke receive immediately; calls are parked until bindReceive drains them.
24
+ // Phase 1: register SDL + resolver stub synchronously (before server starts).
25
+ // `permission` is the door's rule; without one the field admits nobody.
28
26
  let register = (
29
27
  ~fieldName: string,
30
28
  ~externalInputSchema: S.t<unknown>,
29
+ ~permission: option<Reventless.Authorization.permission>=?,
31
30
  ~server: ReventlessGraphqlServer.GraphQL_ServerInstance.t,
32
31
  ) => {
33
- // The mutation field returns CommandResult, so its union members must exist in
34
- // this scope's document. Registered here rather than relying on a plugin's
35
- // command handlers having registered them first — a plugin whose only mutation
36
- // is an inbound translation must not depend on registration order.
32
+ // Registered here, so a plugin whose only mutation is inbound has its result union.
37
33
  CommandGeneratorResolvers_GraphQL.ensureCommandResultTypes(server)
38
34
 
39
35
  let sdlFields = switch GraphQL_FragmentGenerator.deriveMutationFieldFromObject(
@@ -46,28 +42,32 @@ let register = (
46
42
  | None => [` ${fieldName}: CommandResult!`]
47
43
  }
48
44
 
49
- // Queue of pending calls when receive is not yet bound.
50
45
  let pendingQueue: ref<array<pendingCall>> = ref([])
51
46
  pendingQueueRegistry->Dict.set(fieldName, pendingQueue)
52
47
 
53
- // Queuing forwarder: parks calls until bindReceive populates receiveRegistry.
54
- let queuingReceive = (inputJson: JSON.t) =>
48
+ let queuingReceive: receive = (inputJson, ~caller=?) =>
55
49
  Promise.make((resolve, _reject) => {
56
- pendingQueue.contents->Array.push({inputJson, resolve})
50
+ pendingQueue.contents->Array.push({inputJson, caller, resolve})
57
51
  })
58
52
  receiveRegistry->Dict.set(fieldName, queuingReceive)
59
53
 
60
54
  let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (
61
55
  _root,
62
56
  args,
63
- _ctx,
57
+ ctx,
64
58
  ) => {
65
- let inputJson: JSON.t = args->Obj.magic
66
- let receive = receiveRegistry->Dict.getUnsafe(fieldName)
67
- let result = await receive(inputJson)
68
- result
69
- ->InboundTranslationSlice_Callback.receiveResultToOutcome
70
- ->CommandTopic.commandOutcomeToJson
59
+ let caller = CommandGeneratorResolvers_GraphQL.extractIdentity(ctx)
60
+ let admitted =
61
+ permission->Option.mapOr(false, rule => Reventless.Authorization.isAllowed(rule, caller))
62
+ if !admitted {
63
+ CommandGeneratorResolvers_GraphQL.rejectForbidden(~field=fieldName)
64
+ } else {
65
+ let receive = receiveRegistry->Dict.getUnsafe(fieldName)
66
+ let result = await receive(args->Obj.magic, ~caller)
67
+ result
68
+ ->InboundTranslationSlice_Callback.receiveResultToOutcome
69
+ ->CommandTopic.commandOutcomeToJson
70
+ }
71
71
  }
72
72
 
73
73
  let resolvers = Dict.make()
@@ -75,19 +75,15 @@ let register = (
75
75
  server.registerMutations(~sdlFields, ~resolvers)
76
76
  }
77
77
 
78
- // Phase 2: Bind the real receive function once Output.apply resolves.
79
- // Replaces the queuing forwarder in receiveRegistry and drains pending calls.
80
- let bindReceive = (
81
- ~fieldName: string,
82
- ~receive: JSON.t => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
83
- ) => {
78
+ // Phase 2: bind the real receive, replacing the forwarder and draining its queue.
79
+ let bindReceive = (~fieldName: string, ~receive: receive) => {
84
80
  receiveRegistry->Dict.set(fieldName, receive)
85
81
  switch pendingQueueRegistry->Dict.get(fieldName) {
86
82
  | Some(pendingQueue) =>
87
83
  let pending = pendingQueue.contents
88
84
  pendingQueue.contents = []
89
- pending->Array.forEach(({inputJson, resolve}) => {
90
- let _ = receive(inputJson)->Promise.thenResolve(result => resolve(result))
85
+ pending->Array.forEach(({inputJson, caller, resolve}) => {
86
+ let _ = receive(inputJson, ~caller?)->Promise.thenResolve(result => resolve(result))
91
87
  })
92
88
  | None => ()
93
89
  }
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
3
5
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
4
6
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
5
7
  import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
@@ -9,7 +11,7 @@ let receiveRegistry = {};
9
11
 
10
12
  let pendingQueueRegistry = {};
11
13
 
12
- function register(fieldName, externalInputSchema, server) {
14
+ function register(fieldName, externalInputSchema, permission, server) {
13
15
  CommandGeneratorResolvers_GraphQL$ReventlessLocal.ensureCommandResultTypes(server);
14
16
  let field = GraphQL_FragmentGenerator$ReventlessCore.deriveMutationFieldFromObject(fieldName, [], new Set(), externalInputSchema);
15
17
  let sdlFields = field !== undefined ? [field] : [` ` + fieldName + `: CommandResult!`];
@@ -17,16 +19,22 @@ function register(fieldName, externalInputSchema, server) {
17
19
  contents: []
18
20
  };
19
21
  pendingQueueRegistry[fieldName] = pendingQueue;
20
- let queuingReceive = inputJson => new Promise((resolve, _reject) => {
22
+ let queuingReceive = (inputJson, caller) => new Promise((resolve, _reject) => {
21
23
  pendingQueue.contents.push({
22
24
  inputJson: inputJson,
25
+ caller: caller,
23
26
  resolve: resolve
24
27
  });
25
28
  });
26
29
  receiveRegistry[fieldName] = queuingReceive;
27
- let resolver = async (_root, args, _ctx) => {
30
+ let resolver = async (_root, args, ctx) => {
31
+ let caller = CommandGeneratorResolvers_GraphQL$ReventlessLocal.extractIdentity(ctx);
32
+ let admitted = Stdlib_Option.mapOr(permission, false, rule => Authorization$Reventless.isAllowed(rule, caller));
33
+ if (!admitted) {
34
+ return CommandGeneratorResolvers_GraphQL$ReventlessLocal.rejectForbidden(fieldName);
35
+ }
28
36
  let receive = receiveRegistry[fieldName];
29
- let result = await receive(args);
37
+ let result = await receive(args, caller);
30
38
  return CommandTopic$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result));
31
39
  };
32
40
  let resolvers = {};
@@ -44,7 +52,7 @@ function bindReceive(fieldName, receive) {
44
52
  pendingQueue.contents = [];
45
53
  pending.forEach(param => {
46
54
  let resolve = param.resolve;
47
- receive(param.inputJson).then(result => resolve(result));
55
+ receive(param.inputJson, param.caller).then(result => resolve(result));
48
56
  });
49
57
  }
50
58
 
@@ -54,4 +62,4 @@ export {
54
62
  register,
55
63
  bindReceive,
56
64
  }
57
- /* CommandTopic-ReventlessCore Not a pure module */
65
+ /* Authorization-Reventless Not a pure module */
@@ -44,16 +44,36 @@ let selection = `__typename
44
44
  ... on CommandAccepted { msgId entityId eventCount }
45
45
  ... on CommandRejected { msgId errorCode errorDetail }`
46
46
 
47
- let runMutation = async (~status: string) => {
48
- let source = `mutation {
49
- r: ${fieldName}(paymentId: "pay-1", orderId: "ord-1", status: "${status}") { ${selection} }
50
- }`
47
+ let signedIn = (groups): Reventless.Identity.t => {
48
+ userId: "u-1",
49
+ username: "alice",
50
+ groups,
51
+ provider: InMemory,
52
+ }
53
+
54
+ let contextOf = (identity: option<Reventless.Identity.t>): JSON.t =>
55
+ switch identity {
56
+ | Some(identity) =>
57
+ Dict.fromArray([
58
+ ("identity", identity->Reventless.Util_Sury.toJson(Reventless.Identity.schema)),
59
+ ])->JSON.Encode.object
60
+ | None => JSON.Encode.null
61
+ }
62
+
63
+ let execute = async (~caller, source) =>
51
64
  await GraphqlYoga.graphql({
52
65
  "schema": DomainGraphQL_Server.composeSchema(),
53
66
  "source": source,
54
- "contextValue": JSON.Encode.null,
67
+ "contextValue": contextOf(caller),
55
68
  })
56
- }
69
+
70
+ let runMutation = async (~status: string) =>
71
+ await execute(
72
+ ~caller=Some(signedIn([])),
73
+ `mutation {
74
+ r: ${fieldName}(paymentId: "pay-1", orderId: "ord-1", status: "${status}") { ${selection} }
75
+ }`,
76
+ )
57
77
 
58
78
  let errorMessages = (result: GraphqlYoga.executionResult): array<string> =>
59
79
  result.errors
@@ -86,13 +106,14 @@ describe("InboundTranslationSlice mutation — response envelope", () => {
86
106
  InboundTranslationResolvers_GraphQL.register(
87
107
  ~fieldName,
88
108
  ~externalInputSchema=PaymentWebhookSpec.externalInputSchema->S.castToUnknown,
109
+ ~permission=AllowAuthenticated,
89
110
  ~server=DomainGraphQL_Server.asInterface,
90
111
  )
91
112
  DomainGraphQL_Server.resetScope()
92
113
 
93
114
  InboundTranslationResolvers_GraphQL.bindReceive(
94
115
  ~fieldName,
95
- ~receive=inputJson => Callback.receive(publishJsons, inputJson),
116
+ ~receive=(inputJson, ~caller=?) => Callback.receive(publishJsons, inputJson, ~caller?),
96
117
  )
97
118
  })
98
119
 
@@ -140,3 +161,70 @@ describe("InboundTranslationSlice mutation — response envelope", () => {
140
161
  expect(fieldLine->Option.map(l => l->String.endsWith(": CommandResult!")))->toEqual(Some(true))
141
162
  })
142
163
  })
164
+
165
+ module CatalogFeedTranslation = {
166
+ let translate = CatalogFeedSpec.translate
167
+ let moduleUrl = CatalogFeedSpec.moduleUrl
168
+ }
169
+ module CatalogFeedCallback = ReventlessCore.InboundTranslationSlice_Callback.Make(
170
+ CatalogFeedSpec,
171
+ CatalogFeedTranslation,
172
+ )
173
+
174
+ let feedField = "Catalog_CatalogFeed"
175
+
176
+ let runFeed = async (~caller, ~kind) =>
177
+ await execute(
178
+ ~caller,
179
+ `mutation { r: ${feedField}(kind: "${kind}", id: "x-1") { ${selection} } }`,
180
+ )
181
+
182
+ describe("InboundTranslationSlice mutation — the door", () => {
183
+ beforeEach(() => {
184
+ DomainGraphQL_Server.asInterface.reset()
185
+ published.contents = []
186
+
187
+ DomainGraphQL_Server.setScope("Catalog")
188
+ InboundTranslationResolvers_GraphQL.register(
189
+ ~fieldName=feedField,
190
+ ~externalInputSchema=CatalogFeedSpec.externalInputSchema->S.castToUnknown,
191
+ ~permission=?ReventlessCore.Dcb_Builder.inboundDoorPermission(
192
+ CatalogFeedSpec.commandSchema,
193
+ CatalogFeedSpec.commandAuthorization,
194
+ ),
195
+ ~server=DomainGraphQL_Server.asInterface,
196
+ )
197
+ DomainGraphQL_Server.resetScope()
198
+
199
+ InboundTranslationResolvers_GraphQL.bindReceive(
200
+ ~fieldName=feedField,
201
+ ~receive=(inputJson, ~caller=?) =>
202
+ CatalogFeedCallback.receive(publishJsons, inputJson, ~caller?),
203
+ )
204
+ })
205
+
206
+ testPromise("a caller holding none of the commands' roles is refused", async () => {
207
+ let node = payload(await runFeed(~caller=Some(signedIn(["Ops"])), ~kind="product"))
208
+ expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
209
+ expect(published.contents->Array.length)->toBe(0)
210
+ })
211
+
212
+ testPromise("an unauthenticated caller is refused", async () => {
213
+ let node = payload(await runFeed(~caller=None, ~kind="product"))
214
+ expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
215
+ expect(published.contents->Array.length)->toBe(0)
216
+ })
217
+
218
+ testPromise("a caller holding a command's role is let through", async () => {
219
+ let node = payload(await runFeed(~caller=Some(signedIn(["Merchandiser"])), ~kind="product"))
220
+ expect(node->str("__typename"))->toEqual(Some("CommandAccepted"))
221
+ expect(published.contents->Array.length)->toBe(1)
222
+ })
223
+
224
+ // The caller reaches `receive`, which refuses the command the door could not see.
225
+ testPromise("the caller reaches the per-command check", async () => {
226
+ let node = payload(await runFeed(~caller=Some(signedIn(["Merchandiser"])), ~kind="both"))
227
+ expect(node->str("errorCode"))->toEqual(Some("Forbidden"))
228
+ expect(published.contents->Array.length)->toBe(0)
229
+ })
230
+ })
@@ -5,6 +5,9 @@ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
5
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
6
6
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
9
+ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
10
+ import * as Dcb_Builder$ReventlessCore from "@reventlessdev/reventless-core/src/components/Dcb/Dcb_Builder.res.mjs";
8
11
  import * as DomainGraphQL_Server$ReventlessLocal from "../../src/adapter/DomainGraphQL_Server.res.mjs";
9
12
  import * as InboundTranslationSliceFixtures$ReventlessLocal from "../components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs";
10
13
  import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
@@ -26,8 +29,7 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
26
29
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
27
30
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
28
31
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
29
- commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization,
30
- commandTransition: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandTransition
32
+ commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
31
33
  })(PaymentWebhookTranslation);
32
34
 
33
35
  let scope = "Payments";
@@ -46,17 +48,40 @@ let selection = `__typename
46
48
  ... on CommandAccepted { msgId entityId eventCount }
47
49
  ... on CommandRejected { msgId errorCode errorDetail }`;
48
50
 
49
- async function runMutation(status) {
50
- let source = `mutation {
51
- r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
52
- }`;
51
+ function signedIn(groups) {
52
+ return {
53
+ userId: "u-1",
54
+ username: "alice",
55
+ groups: groups,
56
+ provider: "InMemory"
57
+ };
58
+ }
59
+
60
+ function contextOf(identity) {
61
+ if (identity !== undefined) {
62
+ return Object.fromEntries([[
63
+ "identity",
64
+ Util_Sury$Reventless.toJson(identity, Identity$Reventless.schema)
65
+ ]]);
66
+ } else {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async function execute(caller, source) {
53
72
  return await Graphql.graphql({
54
73
  schema: DomainGraphQL_Server$ReventlessLocal.composeSchema(),
55
74
  source: source,
56
- contextValue: null
75
+ contextValue: contextOf(caller)
57
76
  });
58
77
  }
59
78
 
79
+ async function runMutation(status) {
80
+ return await execute(signedIn([]), `mutation {
81
+ r: ` + fieldName + `(paymentId: "pay-1", orderId: "ord-1", status: "` + status + `") { ` + selection + ` }
82
+ }`);
83
+ }
84
+
60
85
  function errorMessages(result) {
61
86
  return Stdlib_Option.getOr(result.errors, []).map(e => Stdlib_Option.getOr(Stdlib_JsExn.message(e), "unknown error"));
62
87
  }
@@ -79,9 +104,9 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
79
104
  published.contents = [];
80
105
  Object.keys(Callback.auditLog).forEach(k => Stdlib_Dict.$$delete(Callback.auditLog, k));
81
106
  DomainGraphQL_Server$ReventlessLocal.setScope(scope);
82
- InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, DomainGraphQL_Server$ReventlessLocal.asInterface);
107
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.register(fieldName, InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema, "AllowAuthenticated", DomainGraphQL_Server$ReventlessLocal.asInterface);
83
108
  DomainGraphQL_Server$ReventlessLocal.resetScope();
84
- InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, inputJson => Callback.receive(publishJsons, inputJson));
109
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(fieldName, (inputJson, caller) => Callback.receive(publishJsons, inputJson, caller));
85
110
  });
86
111
  globalThis.test("a successful translation resolves as CommandAccepted, with no errors", async () => {
87
112
  let result = await runMutation("completed");
@@ -116,6 +141,62 @@ globalThis.describe("InboundTranslationSlice mutation — response envelope", ()
116
141
  });
117
142
  });
118
143
 
144
+ let translate$1 = InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.translate;
145
+
146
+ let moduleUrl$1 = InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.moduleUrl;
147
+
148
+ let CatalogFeedTranslation = {
149
+ translate: translate$1,
150
+ moduleUrl: moduleUrl$1
151
+ };
152
+
153
+ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
154
+ name: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.name,
155
+ moduleUrl: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.moduleUrl,
156
+ externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalInputSchema,
157
+ commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
158
+ targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
159
+ externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
160
+ commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandAuthorization
161
+ })(CatalogFeedTranslation);
162
+
163
+ let feedField = "Catalog_CatalogFeed";
164
+
165
+ async function runFeed(caller, kind) {
166
+ return await execute(caller, `mutation { r: ` + feedField + `(kind: "` + kind + `", id: "x-1") { ` + selection + ` } }`);
167
+ }
168
+
169
+ globalThis.describe("InboundTranslationSlice mutation — the door", () => {
170
+ globalThis.beforeEach(() => {
171
+ DomainGraphQL_Server$ReventlessLocal.asInterface.reset();
172
+ published.contents = [];
173
+ DomainGraphQL_Server$ReventlessLocal.setScope("Catalog");
174
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.register(feedField, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalInputSchema, Dcb_Builder$ReventlessCore.inboundDoorPermission(InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema, InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandAuthorization), DomainGraphQL_Server$ReventlessLocal.asInterface);
175
+ DomainGraphQL_Server$ReventlessLocal.resetScope();
176
+ InboundTranslationResolvers_GraphQL$ReventlessLocal.bindReceive(feedField, (inputJson, caller) => CatalogFeedCallback.receive(publishJsons, inputJson, caller));
177
+ });
178
+ globalThis.test("a caller holding none of the commands' roles is refused", async () => {
179
+ let node = payload(await runFeed(signedIn(["Ops"]), "product"));
180
+ globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
181
+ globalThis.expect(published.contents.length).toBe(0);
182
+ });
183
+ globalThis.test("an unauthenticated caller is refused", async () => {
184
+ let node = payload(await runFeed(undefined, "product"));
185
+ globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
186
+ globalThis.expect(published.contents.length).toBe(0);
187
+ });
188
+ globalThis.test("a caller holding a command's role is let through", async () => {
189
+ let node = payload(await runFeed(signedIn(["Merchandiser"]), "product"));
190
+ globalThis.expect(str(node, "__typename")).toEqual("CommandAccepted");
191
+ globalThis.expect(published.contents.length).toBe(1);
192
+ });
193
+ globalThis.test("the caller reaches the per-command check", async () => {
194
+ let node = payload(await runFeed(signedIn(["Merchandiser"]), "both"));
195
+ globalThis.expect(str(node, "errorCode")).toEqual("Forbidden");
196
+ globalThis.expect(published.contents.length).toBe(0);
197
+ });
198
+ });
199
+
119
200
  export {
120
201
  PaymentWebhookTranslation,
121
202
  Callback,
@@ -124,10 +205,17 @@ export {
124
205
  published,
125
206
  publishJsons,
126
207
  selection,
208
+ signedIn,
209
+ contextOf,
210
+ execute,
127
211
  runMutation,
128
212
  errorMessages,
129
213
  payload,
130
214
  str,
131
215
  num,
216
+ CatalogFeedTranslation,
217
+ CatalogFeedCallback,
218
+ feedField,
219
+ runFeed,
132
220
  }
133
221
  /* Callback Not a pure module */
@@ -226,9 +226,6 @@ describe("InboundTranslationSlice Callback", () => {
226
226
 
227
227
  let commandAuthorization = (_: command): Reventless.Authorization.rule<role> =>
228
228
  AllowAuthenticated
229
- type lifecycleState = unit
230
- let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> =>
231
- Unrestricted
232
229
  }
233
230
 
234
231
  module MultiTranslation = {
@@ -295,9 +292,6 @@ describe("InboundTranslationSlice Callback", () => {
295
292
 
296
293
  let commandAuthorization = (_: command): Reventless.Authorization.rule<role> =>
297
294
  AllowAuthenticated
298
- type lifecycleState = unit
299
- let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> =>
300
- Unrestricted
301
295
  }
302
296
 
303
297
  module EmptyTranslation = {
@@ -329,3 +323,117 @@ describe("InboundTranslationSlice Callback", () => {
329
323
  )
330
324
  })
331
325
  })
326
+
327
+ module CatalogFeedTranslation = {
328
+ let translate = CatalogFeedSpec.translate
329
+ let moduleUrl = CatalogFeedSpec.moduleUrl
330
+ }
331
+ module CatalogFeedCallback = ReventlessCore.InboundTranslationSlice_Callback.Make(
332
+ CatalogFeedSpec,
333
+ CatalogFeedTranslation,
334
+ )
335
+
336
+ let caller = (groups): Reventless.Identity.t => {
337
+ userId: "u-1",
338
+ username: "alice",
339
+ groups,
340
+ provider: InMemory,
341
+ }
342
+
343
+ let catalogInput = (kind): JSON.t =>
344
+ Dict.fromArray([
345
+ ("kind", JSON.Encode.string(kind)),
346
+ ("id", JSON.Encode.string("x-1")),
347
+ ])->JSON.Encode.object
348
+
349
+ describe("receive checks each command against its own rule", () => {
350
+ let published = ref([])
351
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
352
+ published := published.contents->Array.concat(cmds)
353
+ let _ = beforeEach(() => published := [])
354
+
355
+ testPromise(
356
+ "a caller lacking one command's role is refused, and nothing is published",
357
+ async () => {
358
+ let result = await CatalogFeedCallback.receive(
359
+ publish,
360
+ catalogInput("both"),
361
+ ~caller=caller(["Merchandiser"]),
362
+ )
363
+ switch result {
364
+ | Error({requestId, ?errorCode}) =>
365
+ expect(errorCode)->toEqual(Some("Forbidden"))
366
+ let row = CatalogFeedCallback.auditLog->Dict.get(requestId)
367
+ expect(row->Option.map(r => r.status))->toEqual(
368
+ Some(ReventlessCore.InboundTranslationSlice_Callback.Failure),
369
+ )
370
+ | Ok(_) => expect(true)->toBe(false)
371
+ }
372
+ expect(published.contents->Array.length)->toBe(0)
373
+ },
374
+ )
375
+
376
+ testPromise("a caller holding every command's role publishes them all", async () => {
377
+ let result = await CatalogFeedCallback.receive(
378
+ publish,
379
+ catalogInput("both"),
380
+ ~caller=caller(["Merchandiser", "Admin"]),
381
+ )
382
+ expect(result->Result.isOk)->toBe(true)
383
+ expect(published.contents->Array.length)->toBe(2)
384
+ })
385
+
386
+ testPromise("the role one command needs is enough when the input yields only it", async () => {
387
+ let result = await CatalogFeedCallback.receive(
388
+ publish,
389
+ catalogInput("product"),
390
+ ~caller=caller(["Merchandiser"]),
391
+ )
392
+ expect(result->Result.isOk)->toBe(true)
393
+ expect(published.contents->Array.length)->toBe(1)
394
+ })
395
+
396
+ testPromise("no caller is the platform itself, and publishes", async () => {
397
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"))
398
+ expect(result->Result.isOk)->toBe(true)
399
+ expect(published.contents->Array.length)->toBe(2)
400
+ })
401
+
402
+ testPromise("a refusal answers CommandRejected with Forbidden", async () => {
403
+ let result = await CatalogFeedCallback.receive(
404
+ publish,
405
+ catalogInput("both"),
406
+ ~caller=Reventless.Identity.anonymous,
407
+ )
408
+ switch result->ReventlessCore.InboundTranslationSlice_Callback.receiveResultToOutcome {
409
+ | Rejected({errorCode}) => expect(errorCode)->toBe("Forbidden")
410
+ | _ => expect(true)->toBe(false)
411
+ }
412
+ })
413
+ })
414
+
415
+ module NoteFeedTranslation = {
416
+ let translate = NoteFeedSpec.translate
417
+ let moduleUrl = NoteFeedSpec.moduleUrl
418
+ }
419
+ module NoteFeedCallback = ReventlessCore.InboundTranslationSlice_Callback.Make(
420
+ NoteFeedSpec,
421
+ NoteFeedTranslation,
422
+ )
423
+
424
+ describe("receive encodes commands with their schema", () => {
425
+ testPromise("an absent option the schema writes as null decodes at the target", async () => {
426
+ let published = ref([])
427
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async cmds => published := cmds
428
+ let _ = await NoteFeedCallback.receive(
429
+ publish,
430
+ Dict.fromArray([("orderId", JSON.Encode.string("ord-1"))])->JSON.Encode.object,
431
+ )
432
+
433
+ let commandJson = (published.contents->Array.getUnsafe(0)).commandJson
434
+ // The target decodes with the same schema; the runtime value would omit `note`.
435
+ let decoded = commandJson->S.parseOrThrow(~to=NoteFeedSpec.commandSchema)
436
+ expect(decoded)->toEqual(NoteFeedSpec.Annotate({orderId: "ord-1", note: None}))
437
+ expect(commandJson->JSON.stringify->String.includes(`"note":null`))->toBe(true)
438
+ })
439
+ })
@@ -3,8 +3,10 @@
3
3
  import * as Sury from "sury";
4
4
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
9
+ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
8
10
  import * as InboundTranslationSliceFixtures$ReventlessLocal from "./InboundTranslationSliceFixtures.res.mjs";
9
11
  import * as InboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs";
10
12
 
@@ -24,8 +26,7 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
24
26
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
25
27
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
26
28
  externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
27
- commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization,
28
- commandTransition: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandTransition
29
+ commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
29
30
  })(PaymentWebhookTranslation);
30
31
 
31
32
  globalThis.describe("InboundTranslationSlice Callback", () => {
@@ -45,7 +46,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
45
46
  orderId: "ord-1",
46
47
  status: "completed"
47
48
  };
48
- let result = await Callback.receive(mockPublish, inputJson);
49
+ let result = await Callback.receive(mockPublish, inputJson, undefined);
49
50
  if (result.TAG === "Ok") {
50
51
  let match = result._0;
51
52
  globalThis.expect(match.targetIds).toEqual(["ord-1"]);
@@ -76,7 +77,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
76
77
  orderId: "ord-1",
77
78
  status: "pending"
78
79
  };
79
- let result = await Callback.receive(mockPublish, inputJson);
80
+ let result = await Callback.receive(mockPublish, inputJson, undefined);
80
81
  if (result.TAG === "Ok") {
81
82
  globalThis.expect(true).toBe(false);
82
83
  } else {
@@ -93,7 +94,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
93
94
  let inputJson = {
94
95
  unexpected: "data"
95
96
  };
96
- let result = await Callback.receive(mockPublish, inputJson);
97
+ let result = await Callback.receive(mockPublish, inputJson, undefined);
97
98
  if (result.TAG === "Ok") {
98
99
  globalThis.expect(true).toBe(false);
99
100
  } else {
@@ -111,7 +112,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
111
112
  orderId: "ord-1",
112
113
  status: "completed"
113
114
  };
114
- let result = await Callback.receive(failingPublish, inputJson);
115
+ let result = await Callback.receive(failingPublish, inputJson, undefined);
115
116
  if (result.TAG === "Ok") {
116
117
  globalThis.expect(true).toBe(false);
117
118
  } else {
@@ -130,7 +131,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
130
131
  paymentId: "pay-1",
131
132
  orderId: "ord-1",
132
133
  status: "completed"
133
- });
134
+ }, undefined);
134
135
  let firstId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(first);
135
136
  globalThis.expect(Stdlib_Option.isSome(InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, firstId))).toBe(true);
136
137
  globalThis.expect(Object.entries(Callback.auditLog).length).toBe(0);
@@ -138,7 +139,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
138
139
  paymentId: "pay-1",
139
140
  orderId: "ord-1",
140
141
  status: "completed"
141
- });
142
+ }, undefined);
142
143
  let secondId = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(second);
143
144
  globalThis.expect(secondId === firstId).toBe(false);
144
145
  globalThis.expect(Object.entries(Callback.auditLog).length).toBe(1);
@@ -151,7 +152,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
151
152
  paymentId: "pay-1",
152
153
  orderId: "ord-1",
153
154
  status: "pending"
154
- });
155
+ }, undefined);
155
156
  let id = InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result);
156
157
  let row = InboundTranslationSlice_Callback$ReventlessCore.takeAuditRow(Callback.auditLog, id);
157
158
  if (row !== undefined) {
@@ -186,7 +187,6 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
186
187
  ])
187
188
  });
188
189
  let commandAuthorization = param => "AllowAuthenticated";
189
- let commandTransition = param => "Unrestricted";
190
190
  let MultiTranslation = {
191
191
  translate: translate,
192
192
  moduleUrl: moduleUrl
@@ -198,8 +198,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
198
198
  commandSchema: commandSchema,
199
199
  targetName: "ConfirmPayment",
200
200
  externalSystem: undefined,
201
- commandAuthorization: commandAuthorization,
202
- commandTransition: commandTransition
201
+ commandAuthorization: commandAuthorization
203
202
  })(MultiTranslation);
204
203
  let publishedCommands = {
205
204
  contents: []
@@ -215,7 +214,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
215
214
  "pay-3"
216
215
  ]
217
216
  };
218
- let result = await MultiCallback.receive(mockPublish, inputJson);
217
+ let result = await MultiCallback.receive(mockPublish, inputJson, undefined);
219
218
  if (result.TAG === "Ok") {
220
219
  let match = result._0;
221
220
  globalThis.expect(match.targetIds).toEqual([
@@ -250,7 +249,6 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
250
249
  _0: []
251
250
  });
252
251
  let commandAuthorization = param => "AllowAuthenticated";
253
- let commandTransition = param => "Unrestricted";
254
252
  let EmptyTranslation = {
255
253
  translate: translate,
256
254
  moduleUrl: moduleUrl
@@ -262,8 +260,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
262
260
  commandSchema: commandSchema,
263
261
  targetName: "ConfirmPayment",
264
262
  externalSystem: undefined,
265
- commandAuthorization: commandAuthorization,
266
- commandTransition: commandTransition
263
+ commandAuthorization: commandAuthorization
267
264
  })(EmptyTranslation);
268
265
  let publishedCommands = {
269
266
  contents: []
@@ -274,7 +271,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
274
271
  let inputJson = {
275
272
  orderId: "ord-1"
276
273
  };
277
- let result = await EmptyCallback.receive(mockPublish, inputJson);
274
+ let result = await EmptyCallback.receive(mockPublish, inputJson, undefined);
278
275
  if (result.TAG === "Ok") {
279
276
  let match = result._0;
280
277
  globalThis.expect(match.targetIds).toEqual([]);
@@ -287,8 +284,152 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
287
284
  });
288
285
  });
289
286
 
287
+ let translate$1 = InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.translate;
288
+
289
+ let moduleUrl$1 = InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.moduleUrl;
290
+
291
+ let CatalogFeedTranslation = {
292
+ translate: translate$1,
293
+ moduleUrl: moduleUrl$1
294
+ };
295
+
296
+ let CatalogFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
297
+ name: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.name,
298
+ moduleUrl: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.moduleUrl,
299
+ externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalInputSchema,
300
+ commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandSchema,
301
+ targetName: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.targetName,
302
+ externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.externalSystem,
303
+ commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.CatalogFeedSpec.commandAuthorization
304
+ })(CatalogFeedTranslation);
305
+
306
+ function caller(groups) {
307
+ return {
308
+ userId: "u-1",
309
+ username: "alice",
310
+ groups: groups,
311
+ provider: "InMemory"
312
+ };
313
+ }
314
+
315
+ function catalogInput(kind) {
316
+ return Object.fromEntries([
317
+ [
318
+ "kind",
319
+ kind
320
+ ],
321
+ [
322
+ "id",
323
+ "x-1"
324
+ ]
325
+ ]);
326
+ }
327
+
328
+ globalThis.describe("receive checks each command against its own rule", () => {
329
+ let published = {
330
+ contents: []
331
+ };
332
+ let publish = async cmds => {
333
+ published.contents = published.contents.concat(cmds);
334
+ };
335
+ globalThis.beforeEach(() => {
336
+ published.contents = [];
337
+ });
338
+ 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"]));
340
+ if (result.TAG === "Ok") {
341
+ globalThis.expect(true).toBe(false);
342
+ } else {
343
+ let match = result._0;
344
+ globalThis.expect(match.errorCode).toEqual("Forbidden");
345
+ let row = CatalogFeedCallback.auditLog[match.requestId];
346
+ globalThis.expect(Stdlib_Option.map(row, r => r.status)).toEqual("Failure");
347
+ }
348
+ globalThis.expect(published.contents.length).toBe(0);
349
+ });
350
+ globalThis.test("a caller holding every command's role publishes them all", async () => {
351
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), caller([
352
+ "Merchandiser",
353
+ "Admin"
354
+ ]));
355
+ globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
356
+ globalThis.expect(published.contents.length).toBe(2);
357
+ });
358
+ 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"]));
360
+ globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
361
+ globalThis.expect(published.contents.length).toBe(1);
362
+ });
363
+ globalThis.test("no caller is the platform itself, and publishes", async () => {
364
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), undefined);
365
+ globalThis.expect(Stdlib_Result.isOk(result)).toBe(true);
366
+ globalThis.expect(published.contents.length).toBe(2);
367
+ });
368
+ globalThis.test("a refusal answers CommandRejected with Forbidden", async () => {
369
+ let result = await CatalogFeedCallback.receive(publish, catalogInput("both"), Identity$Reventless.anonymous);
370
+ let match = InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result);
371
+ switch (match.TAG) {
372
+ case "Rejected" :
373
+ globalThis.expect(match.errorCode).toBe("Forbidden");
374
+ return;
375
+ case "Accepted" :
376
+ case "Pending" :
377
+ globalThis.expect(true).toBe(false);
378
+ return;
379
+ }
380
+ });
381
+ });
382
+
383
+ let translate$2 = InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.translate;
384
+
385
+ let moduleUrl$2 = InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.moduleUrl;
386
+
387
+ let NoteFeedTranslation = {
388
+ translate: translate$2,
389
+ moduleUrl: moduleUrl$2
390
+ };
391
+
392
+ let NoteFeedCallback = InboundTranslationSlice_Callback$ReventlessCore.Make({
393
+ name: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.name,
394
+ moduleUrl: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.moduleUrl,
395
+ externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.externalInputSchema,
396
+ commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema,
397
+ targetName: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.targetName,
398
+ externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.externalSystem,
399
+ commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandAuthorization
400
+ })(NoteFeedTranslation);
401
+
402
+ globalThis.describe("receive encodes commands with their schema", () => {
403
+ globalThis.test("an absent option the schema writes as null decodes at the target", async () => {
404
+ let published = {
405
+ contents: []
406
+ };
407
+ let publish = async cmds => {
408
+ published.contents = cmds;
409
+ };
410
+ await NoteFeedCallback.receive(publish, Object.fromEntries([[
411
+ "orderId",
412
+ "ord-1"
413
+ ]]), undefined);
414
+ let commandJson = published.contents[0].commandJson;
415
+ let decoded = Sury.parseOrThrow(commandJson, InboundTranslationSliceFixtures$ReventlessLocal.NoteFeedSpec.commandSchema);
416
+ globalThis.expect(decoded).toEqual({
417
+ TAG: "Annotate",
418
+ orderId: "ord-1",
419
+ note: undefined
420
+ });
421
+ globalThis.expect(JSON.stringify(commandJson).includes(`"note":null`)).toBe(true);
422
+ });
423
+ });
424
+
290
425
  export {
291
426
  PaymentWebhookTranslation,
292
427
  Callback,
428
+ CatalogFeedTranslation,
429
+ CatalogFeedCallback,
430
+ caller,
431
+ catalogInput,
432
+ NoteFeedTranslation,
433
+ NoteFeedCallback,
293
434
  }
294
435
  /* Callback Not a pure module */
@@ -36,3 +36,60 @@ module PaymentWebhookSpec = {
36
36
  | status => Error("Unknown payment status: " ++ status)
37
37
  }
38
38
  }
39
+
40
+ // Two constructors under different roles; `kind` picks which the input becomes.
41
+ module CatalogFeedSpec = {
42
+ let name = "CatalogFeed"
43
+ let moduleUrl: string = %raw(`import.meta.url`)
44
+
45
+ @schema
46
+ type externalInput = {kind: string, id: string}
47
+
48
+ @schema
49
+ type command =
50
+ | AddProduct({productId: @s.matches(Reventless.DcbTag.string) string})
51
+ | AddCategory({categoryId: @s.matches(Reventless.DcbTag.string) string})
52
+
53
+ let targetName = "AddProduct"
54
+ let externalSystem = None
55
+
56
+ type role = Reventless.Role.name
57
+ let merchandiser = Reventless.Role.make("Merchandiser")
58
+ let admin = Reventless.Role.make("Admin")
59
+ let commandAuthorization = (command: command): Reventless.Authorization.rule<role> =>
60
+ switch command {
61
+ | AddProduct(_) => AllowRoles([merchandiser])
62
+ | AddCategory(_) => AllowRoles([admin])
63
+ }
64
+
65
+ let translate = (input: externalInput) =>
66
+ switch input.kind {
67
+ | "product" => Ok([(input.id, AddProduct({productId: input.id}))])
68
+ | "both" =>
69
+ Ok([
70
+ (input.id, AddProduct({productId: input.id})),
71
+ (input.id, AddCategory({categoryId: input.id})),
72
+ ])
73
+ | kind => Error("Unknown kind: " ++ kind)
74
+ }
75
+ }
76
+
77
+ // A command whose schema writes an absent note as `null`, which the runtime value does not.
78
+ module NoteFeedSpec = {
79
+ let name = "NoteFeed"
80
+ let moduleUrl: string = %raw(`import.meta.url`)
81
+
82
+ @schema
83
+ type externalInput = {orderId: string}
84
+
85
+ @schema
86
+ type command =
87
+ Annotate({orderId: @s.matches(Reventless.DcbTag.string) string, note: @s.null option<string>})
88
+
89
+ let targetName = "Annotate"
90
+ let externalSystem = None
91
+
92
+ let translate = (input: externalInput) => Ok([
93
+ (input.orderId, Annotate({orderId: input.orderId, note: None})),
94
+ ])
95
+ }
@@ -56,10 +56,6 @@ function commandAuthorization(param) {
56
56
  return "AllowAuthenticated";
57
57
  }
58
58
 
59
- function commandTransition(param) {
60
- return "Undeclared";
61
- }
62
-
63
59
  let traits = [];
64
60
 
65
61
  let PaymentWebhookSpec = {
@@ -71,12 +67,151 @@ let PaymentWebhookSpec = {
71
67
  externalSystem: undefined,
72
68
  translate: translate,
73
69
  commandAuthorization: commandAuthorization,
74
- commandTransition: commandTransition,
75
70
  traits: traits
76
71
  };
77
72
 
73
+ let moduleUrl$2 = import.meta.url;
74
+
75
+ let externalInputSchema$1 = Sury.$schema(s => ({
76
+ kind: s.m(Sury.string),
77
+ id: s.m(Sury.string)
78
+ }));
79
+
80
+ let commandSchema$1 = Sury.union([
81
+ Sury.$schema(s => ({
82
+ TAG: "AddProduct",
83
+ productId: s.m(DcbTag$Reventless.string)
84
+ })),
85
+ Sury.$schema(s => ({
86
+ TAG: "AddCategory",
87
+ categoryId: s.m(DcbTag$Reventless.string)
88
+ }))
89
+ ]);
90
+
91
+ let merchandiser = "Merchandiser";
92
+
93
+ let admin = "Admin";
94
+
95
+ function commandAuthorization$1(command) {
96
+ if (command.TAG === "AddProduct") {
97
+ return {
98
+ TAG: "AllowRoles",
99
+ _0: [merchandiser]
100
+ };
101
+ } else {
102
+ return {
103
+ TAG: "AllowRoles",
104
+ _0: [admin]
105
+ };
106
+ }
107
+ }
108
+
109
+ function translate$1(input) {
110
+ let kind = input.kind;
111
+ switch (kind) {
112
+ case "both" :
113
+ return {
114
+ TAG: "Ok",
115
+ _0: [
116
+ [
117
+ input.id,
118
+ {
119
+ TAG: "AddProduct",
120
+ productId: input.id
121
+ }
122
+ ],
123
+ [
124
+ input.id,
125
+ {
126
+ TAG: "AddCategory",
127
+ categoryId: input.id
128
+ }
129
+ ]
130
+ ]
131
+ };
132
+ case "product" :
133
+ return {
134
+ TAG: "Ok",
135
+ _0: [[
136
+ input.id,
137
+ {
138
+ TAG: "AddProduct",
139
+ productId: input.id
140
+ }
141
+ ]]
142
+ };
143
+ default:
144
+ return {
145
+ TAG: "Error",
146
+ _0: "Unknown kind: " + kind
147
+ };
148
+ }
149
+ }
150
+
151
+ let traits$1 = [];
152
+
153
+ let CatalogFeedSpec = {
154
+ name: "CatalogFeed",
155
+ moduleUrl: moduleUrl$2,
156
+ externalInputSchema: externalInputSchema$1,
157
+ commandSchema: commandSchema$1,
158
+ targetName: "AddProduct",
159
+ externalSystem: undefined,
160
+ merchandiser: merchandiser,
161
+ admin: admin,
162
+ commandAuthorization: commandAuthorization$1,
163
+ translate: translate$1,
164
+ traits: traits$1
165
+ };
166
+
167
+ let moduleUrl$3 = import.meta.url;
168
+
169
+ let externalInputSchema$2 = Sury.$schema(s => ({
170
+ orderId: s.m(Sury.string)
171
+ }));
172
+
173
+ let commandSchema$2 = Sury.$schema(s => ({
174
+ TAG: "Annotate",
175
+ orderId: s.m(DcbTag$Reventless.string),
176
+ note: s.m(Sury.$nullAsOption(Sury.string))
177
+ }));
178
+
179
+ function translate$2(input) {
180
+ return {
181
+ TAG: "Ok",
182
+ _0: [[
183
+ input.orderId,
184
+ {
185
+ TAG: "Annotate",
186
+ orderId: input.orderId,
187
+ note: undefined
188
+ }
189
+ ]]
190
+ };
191
+ }
192
+
193
+ function commandAuthorization$2(param) {
194
+ return "AllowAuthenticated";
195
+ }
196
+
197
+ let traits$2 = [];
198
+
199
+ let NoteFeedSpec = {
200
+ name: "NoteFeed",
201
+ moduleUrl: moduleUrl$3,
202
+ externalInputSchema: externalInputSchema$2,
203
+ commandSchema: commandSchema$2,
204
+ targetName: "Annotate",
205
+ externalSystem: undefined,
206
+ translate: translate$2,
207
+ commandAuthorization: commandAuthorization$2,
208
+ traits: traits$2
209
+ };
210
+
78
211
  export {
79
212
  OrderEventLog,
80
213
  PaymentWebhookSpec,
214
+ CatalogFeedSpec,
215
+ NoteFeedSpec,
81
216
  }
82
217
  /* moduleUrl Not a pure module */