@reventlessdev/reventless-gwt 1.0.0-alpha.231 → 1.0.0-alpha.233

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,28 @@
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
+ # 1.0.0-alpha.233 (2026-10-01)
7
+
8
+ * feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * AllowGroups and AdminGroup are removed — write AllowRoles over
13
+ the plugin's Roles.t, and use Role.admin / Platform.adminGroup(). Spec module
14
+ types declare `type role`, and their rules are Authorization.rule<role>; a spec
15
+ that writes its own rule declares `type role` (Reventless.Role.name for a rule
16
+ over Authorization.permission). Role.name is a private string: make one from a
17
+ string with Role.make.
18
+
19
+
20
+
21
+ # 1.0.0-alpha.232 (2026-10-01)
22
+
23
+ ### Features
24
+
25
+ * **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
26
+
27
+
6
28
  # 1.0.0-alpha.231 (2026-09-29)
7
29
 
8
30
  **Note:** Version bump only for package @reventlessdev/reventless-gwt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-gwt",
3
- "version": "1.0.0-alpha.231",
3
+ "version": "1.0.0-alpha.233",
4
4
  "description": "Given-When-Then DSLs and test harness for Reventless slice testing",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -16,15 +16,15 @@
16
16
  "sury": "11.0.0",
17
17
  "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
18
18
  "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
19
- "@reventlessdev/reventless-infra": "3.0.0-alpha.182",
20
- "@reventlessdev/reventless-spec": "3.0.0-alpha.154",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.284",
22
- "@reventlessdev/rescript-node": "2.0.0-alpha.20"
19
+ "@reventlessdev/rescript-node": "2.0.0-alpha.20",
20
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.184",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.286",
22
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.156"
23
23
  },
24
24
  "devDependencies": {
25
25
  "rescript": "12.3.1",
26
26
  "sury-ppx": "11.0.0",
27
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.95"
27
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.96"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "rescript": "^12.3.1"
@@ -22,6 +22,204 @@ open ReventlessCore
22
22
  // and slice users *cannot* use [MakeFromAggregate] (their Spec/Behavior shape
23
23
  // differs). Pick the matching entry point.
24
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Who issues the command. `asCaller` names the caller before `whenCmd`; the
27
+ // command is then checked as production checks it: its `@authorize` rule against
28
+ // the caller's roles, then — for slices and aggregates that mark `@owner` on the
29
+ // events they decide on — against the owners the given events record. Without
30
+ // `asCaller` the command carries no caller claim, which the handler reads as the
31
+ // platform acting for itself — so every scenario written before this existed is
32
+ // unchanged.
33
+ // ---------------------------------------------------------------------------
34
+ module Caller = {
35
+ external unsafeAsString: 'a => string = "%identity"
36
+
37
+ type t = {
38
+ claim: Message.CallerClaim.t,
39
+ /** The roles held, by name. A scenario names them by its plugin's own
40
+ `Roles.t`, whose cases are their names at run time. */
41
+ roles: array<Reventless.Role.name>,
42
+ signedIn: bool,
43
+ }
44
+
45
+ // A plugin's roles are payload-less variants, strings at run time; anything
46
+ // else would be compared with a rule's role names and never match.
47
+ let roleNames = (roles: array<'role>): array<Reventless.Role.name> =>
48
+ roles->Array.map(role =>
49
+ if Reventless.OwnerScope.isJsString(role) {
50
+ Reventless.Role.make(role->unsafeAsString)
51
+ } else {
52
+ JsError.throwWithMessage(
53
+ "Caller: expected roles of the plugin's Roles.t (payload-less, so strings at runtime)",
54
+ )
55
+ }
56
+ )
57
+
58
+ /**
59
+ A caller who owns only what records their id, holding `roles` (none by default).
60
+
61
+ Takes the id as the scenario has it — `Caller.owner(c1)` with `c1: CustomerId.t`,
62
+ or a plain string — because every `Id.Make` identity is a string on the wire.
63
+ The type is not checked, and need not be: an id of the wrong entity matches no
64
+ recorded owner, so it can only be refused, which fails the scenario that
65
+ expected the owner to act. A value that is not a string at all is refused here.
66
+ */
67
+ let owner = (id: 'id, ~roles: array<'role>=[]): t =>
68
+ if Reventless.OwnerScope.isJsString(id) {
69
+ {claim: Owned({userId: id->unsafeAsString}), roles: roleNames(roles), signedIn: true}
70
+ } else {
71
+ JsError.throwWithMessage(
72
+ "Caller.owner: expected an id (a string at runtime, such as an Id.Make identity)",
73
+ )
74
+ }
75
+
76
+ /** A signed-in caller holding `roles`, who owns nothing the given events record. */
77
+ let inRoles = (roles: array<'role>): t => {
78
+ claim: Owned({userId: "caller-in-roles"}),
79
+ roles: roleNames(roles),
80
+ signedIn: true,
81
+ }
82
+
83
+ /** A caller exempt from ownership rules, acting on anyone's behalf. Holds no
84
+ role: elevation does not grant a command whose rule the caller fails. */
85
+ let operator: t = {claim: Exempt, roles: [], signedIn: true}
86
+ /** A caller with no identity. */
87
+ let anonymous: t = {claim: Unidentified, roles: [], signedIn: false}
88
+ }
89
+
90
+ /** `Caller` as a scenario sees it: roles typed by the spec's own `role`, so they
91
+ are written bare (`Caller.inRoles([Merchandiser])`) and another plugin's role
92
+ does not compile. */
93
+ module type Callers = {
94
+ type role
95
+ type t = Caller.t
96
+ let owner: ('id, ~roles: array<role>=?) => t
97
+ let inRoles: array<role> => t
98
+ let operator: t
99
+ let anonymous: t
100
+ }
101
+
102
+ module Callers = (
103
+ R: {
104
+ type role
105
+ },
106
+ ): (Callers with type role = R.role) => {
107
+ type role = R.role
108
+ type t = Caller.t
109
+ let owner = (id, ~roles: array<role>=[]) => Caller.owner(id, ~roles)
110
+ let inRoles = (roles: array<role>) => Caller.inRoles(roles)
111
+ let operator = Caller.operator
112
+ let anonymous = Caller.anonymous
113
+ }
114
+
115
+ module type ActingSpec = {
116
+ let name: string
117
+ @schema
118
+ type history
119
+ @schema
120
+ type command
121
+ type role
122
+ let commandAuthorization: command => Reventless.Authorization.rule<role>
123
+ }
124
+
125
+ module Acting = (Spec: ActingSpec) => {
126
+ let caller: ref<option<Caller.t>> = ref(None)
127
+ let refusal: ref<option<CommandTopic_Helpers.rejectedResult>> = ref(None)
128
+
129
+ let ownerFieldsByEventType = Reventless.Owner.fieldNamesByVariant(
130
+ Spec.historySchema->S.castToUnknown,
131
+ )
132
+
133
+ let asCaller = (history, who: Caller.t) => {
134
+ caller := Some(who)
135
+ history
136
+ }
137
+
138
+ let commandName = command =>
139
+ command->Message.encode(Spec.commandSchema)->Message.variantNameOfJson
140
+
141
+ // The rule first, as production checks it, against the roles alone: which group
142
+ // a role maps to is a deployment's fact, not the scenario's.
143
+ let refusedByRule = (who: Caller.t, command: Spec.command): bool =>
144
+ if (
145
+ Spec.commandAuthorization(command)
146
+ ->Reventless.Authorization.named
147
+ ->Reventless.Authorization.admits(~signedIn=who.signedIn, ~holds=role =>
148
+ who.roles->Array.includes(role)
149
+ )
150
+ ) {
151
+ false
152
+ } else {
153
+ refusal :=
154
+ Some({
155
+ errorCode: "Forbidden",
156
+ errorDetail: `${Spec.name}.${commandName(
157
+ command,
158
+ )}: the caller holds no role this command's rule admits`,
159
+ })
160
+ true
161
+ }
162
+
163
+ // Every given event is the command's own history here, so all of them count.
164
+ // The caller is consumed: the next scenario starts with no claim.
165
+ let refuses = (history: array<Spec.history>, command: Spec.command): bool => {
166
+ let who = caller.contents
167
+ caller := None
168
+ refusal := None
169
+ let claim = who->Option.map(w => w.claim)
170
+ if who->Option.mapOr(false, refusedByRule(_, command)) {
171
+ true
172
+ } else if ownerFieldsByEventType->Dict.keysToArray->Array.length == 0 {
173
+ false
174
+ } else {
175
+ let owners = history->Array.reduce([], (owners, event) => {
176
+ let (eventType, data) = event->Message.encode(Spec.historySchema)->Message.splitMessage
177
+ switch ownerFieldsByEventType->Dict.get(eventType) {
178
+ | Some(fields) => owners->Reventless.OwnerScope.recordOwners(~fields, data)
179
+ | None => owners
180
+ }
181
+ })
182
+ refusal :=
183
+ Reventless.OwnerScope.decideActing(~claim, ~recordedOwners=Some(owners))
184
+ ->CommandTopic_Helpers.ownershipRefusal(~component=Spec.name, ~command=commandName(command))
185
+ ->Option.map(r => r.rejected)
186
+ refusal.contents->Option.isSome
187
+ }
188
+ }
189
+
190
+ let encRefusal = (r: CommandTopic_Helpers.rejectedResult) =>
191
+ JSON.Encode.object(
192
+ Dict.fromArray([
193
+ ("errorCode", JSON.Encode.string(r.errorCode)),
194
+ ("errorDetail", JSON.Encode.string(r.errorDetail)),
195
+ ]),
196
+ )
197
+
198
+ // Any other `then*` after a refused command fails: `decide` never ran, so
199
+ // there is nothing for it to compare.
200
+ let unexpectedRefusal = (): option<Outcome.outcome> =>
201
+ refusal.contents->Option.map(r =>
202
+ Outcome.fail(
203
+ ErrorMismatch({expected: JSON.Encode.null, actual: Some(encRefusal(r)), actualEvents: []}),
204
+ )
205
+ )
206
+
207
+ let thenRefused = (~encEvents, ~decided: option<JSON.t>, events) =>
208
+ switch refusal.contents {
209
+ | Some(_) => Outcome.pass
210
+ | None =>
211
+ Outcome.fail(
212
+ ErrorMismatch({
213
+ expected: JSON.Encode.object(
214
+ Dict.fromArray([("errorCode", JSON.Encode.string("Forbidden"))]),
215
+ ),
216
+ actual: decided,
217
+ actualEvents: encEvents(events),
218
+ }),
219
+ )
220
+ }
221
+ }
222
+
25
223
  // ---------------------------------------------------------------------------
26
224
  // Slice form — replaces the legacy `StateChangeSlice_GWT`.
27
225
  // ---------------------------------------------------------------------------
@@ -40,6 +238,9 @@ module type BehaviorSpec = {
40
238
 
41
239
  @schema
42
240
  type event
241
+
242
+ type role
243
+ let commandAuthorization: command => Reventless.Authorization.rule<role>
43
244
  }
44
245
 
45
246
  module type Behavior = {
@@ -60,6 +261,10 @@ module type T = {
60
261
 
61
262
  let givenEvents: array<Spec.consumedEvent> => array<Spec.consumedEvent>
62
263
 
264
+ // Who issues the next `whenCmd`; see `Caller`.
265
+ module Caller: Callers with type role = Spec.role
266
+ let asCaller: (array<Spec.consumedEvent>, Caller.t) => array<Spec.consumedEvent>
267
+
63
268
  let whenCmd: (array<Spec.consumedEvent>, Spec.command) => array<Spec.event>
64
269
 
65
270
  let thenEvent: (array<Spec.event>, Spec.event) => Outcome.outcome
@@ -68,6 +273,9 @@ module type T = {
68
273
  let thenEventWithError: (array<Spec.event>, Spec.event, Spec.error) => Outcome.outcome
69
274
  let thenEventsWithError: (array<Spec.event>, array<Spec.event>, Spec.error) => Outcome.outcome
70
275
  let thenError: (array<Spec.event>, Spec.error) => Outcome.outcome
276
+ // Refused before `decide`: by the command's rule, or because the caller does not
277
+ // own what it acts on.
278
+ let thenRefused: array<Spec.event> => Outcome.outcome
71
279
 
72
280
  // DCB optimistic-concurrency assertions.
73
281
  let thenAppendsConditionedOn: (array<Spec.event>, Reventless.DcbTag.query) => Outcome.outcome
@@ -242,6 +450,20 @@ module Make = (Spec: BehaviorSpec, Behavior: Behavior with module Spec := Spec):
242
450
  module Core = AssertionCore(Spec)
243
451
  let errors = Core.errors
244
452
 
453
+ module Caller = Callers({
454
+ type role = Spec.role
455
+ })
456
+ module Guard = Acting({
457
+ let name = Spec.name
458
+ type history = Spec.consumedEvent
459
+ let historySchema = Spec.consumedEventSchema
460
+ type command = Spec.command
461
+ let commandSchema = Spec.commandSchema
462
+ type role = Spec.role
463
+ let commandAuthorization = Spec.commandAuthorization
464
+ })
465
+ let asCaller = Guard.asCaller
466
+
245
467
  // DCB append-condition derived inside [whenCmd]; [None] until first call.
246
468
  let derivedCondition: ref<option<Reventless.DcbTag.appendCondition>> = ref(None)
247
469
 
@@ -379,12 +601,15 @@ module Make = (Spec: BehaviorSpec, Behavior: Behavior with module Spec := Spec):
379
601
  }
380
602
  }
381
603
 
382
- let state = currentState(history)
383
- switch Behavior.decide(state, command) {
384
- | Ok(events) => events
385
- | Error(error) =>
386
- errors := [error]
604
+ if Guard.refuses(history, command) {
387
605
  []
606
+ } else {
607
+ switch Behavior.decide(currentState(history), command) {
608
+ | Ok(events) => events
609
+ | Error(error) =>
610
+ errors := [error]
611
+ []
612
+ }
388
613
  }
389
614
  }
390
615
 
@@ -392,7 +617,17 @@ module Make = (Spec: BehaviorSpec, Behavior: Behavior with module Spec := Spec):
392
617
  let whenCmd = (history, cmd) => history->exec(cmd)
393
618
 
394
619
  let checkAppendCondition = (): option<Outcome.outcome> =>
395
- appendConditionFailure.contents->Option.map(m => Outcome.fail(m))
620
+ switch appendConditionFailure.contents {
621
+ | Some(m) => Some(Outcome.fail(m))
622
+ | None => Guard.unexpectedRefusal()
623
+ }
624
+
625
+ let thenRefused = events =>
626
+ Guard.thenRefused(
627
+ ~encEvents=Core.encEvents,
628
+ ~decided=errors.contents->Array.get(0)->Option.map(Core.encError),
629
+ events,
630
+ )
396
631
 
397
632
  // The append-condition footgun is surfaced before the shared comparison core
398
633
  // runs; `thenAppends*` below bypass it deliberately.
@@ -493,6 +728,8 @@ module type AggregateSpec = {
493
728
  type event
494
729
  @schema
495
730
  type error
731
+ type role
732
+ let commandAuthorization: command => Reventless.Authorization.rule<role>
496
733
  }
497
734
 
498
735
  module type AggregateT = {
@@ -503,6 +740,10 @@ module type AggregateT = {
503
740
 
504
741
  let givenEvents: array<Spec.event> => array<Spec.event>
505
742
 
743
+ // Who issues the next `whenCmd`; see `Caller`.
744
+ module Caller: Callers with type role = Spec.role
745
+ let asCaller: (array<Spec.event>, Caller.t) => array<Spec.event>
746
+
506
747
  let whenCmd: (array<Spec.event>, Spec.command) => array<Spec.event>
507
748
 
508
749
  let thenEvent: (array<Spec.event>, Spec.event) => Outcome.outcome
@@ -521,6 +762,9 @@ module type AggregateT = {
521
762
  ) => Outcome.outcome
522
763
  let thenEventsWithError: (array<Spec.event>, array<Spec.event>, Spec.error) => Outcome.outcome
523
764
  let thenError: (array<Spec.event>, Spec.error) => Outcome.outcome
765
+ // Refused before `decide`: by the command's rule, or because the caller does not
766
+ // own what it acts on.
767
+ let thenRefused: array<Spec.event> => Outcome.outcome
524
768
  }
525
769
 
526
770
  module MakeFromAggregate = (Spec: AggregateSpec, Behavior: Behavior.T with module Spec = Spec): (
@@ -536,14 +780,31 @@ module MakeFromAggregate = (Spec: AggregateSpec, Behavior: Behavior.T with modul
536
780
  module Core = AssertionCore(Spec)
537
781
  let errors = Core.errors
538
782
 
783
+ module Caller = Callers({
784
+ type role = Spec.role
785
+ })
786
+ module Guard = Acting({
787
+ let name = Spec.name
788
+ type history = Spec.event
789
+ let historySchema = Spec.eventSchema
790
+ type command = Spec.command
791
+ let commandSchema = Spec.commandSchema
792
+ type role = Spec.role
793
+ let commandAuthorization = Spec.commandAuthorization
794
+ })
795
+ let asCaller = Guard.asCaller
796
+
539
797
  let exec = (history, command): array<Spec.event> => {
540
798
  errors := []
541
- let state = currentState(history)
542
- switch Behavior.decide(state, command) {
543
- | Ok(events) => events
544
- | Error(error) =>
545
- errors := [error]
799
+ if Guard.refuses(history, command) {
546
800
  []
801
+ } else {
802
+ switch Behavior.decide(currentState(history), command) {
803
+ | Ok(events) => events
804
+ | Error(error) =>
805
+ errors := [error]
806
+ []
807
+ }
547
808
  }
548
809
  }
549
810
 
@@ -551,20 +812,34 @@ module MakeFromAggregate = (Spec: AggregateSpec, Behavior: Behavior.T with modul
551
812
  let whenCmd = (history, cmd) => history->exec(cmd)
552
813
 
553
814
  // The aggregate flavour has no append-condition footgun, so the shared core's
554
- // comparisons are exposed directly; only `thenCompare*` (custom equality) is
555
- // specific to this surface.
556
- let thenEvents = Core.compareEvents
557
- let thenCompareEvents = Core.compareEventsWith
815
+ // comparisons are exposed directly once a refusal has been ruled out; only
816
+ // `thenCompare*` (custom equality) is specific to this surface.
817
+ let unlessRefused = (events, compare) =>
818
+ switch Guard.unexpectedRefusal() {
819
+ | Some(o) => o
820
+ | None => compare(events)
821
+ }
822
+ let thenEvents = (events, expected) => events->unlessRefused(Core.compareEvents(_, expected))
823
+ let thenCompareEvents = (events, expected, cmp) =>
824
+ events->unlessRefused(Core.compareEventsWith(_, expected, cmp))
558
825
  let thenEvent = (events, expectedEvent) => thenEvents(events, [expectedEvent])
559
826
  let thenCompareEvent = (events, expectedEvent, cmp) =>
560
827
  thenCompareEvents(events, [expectedEvent], cmp)
561
- let thenNoEvent = Core.compareNoEvent
828
+ let thenNoEvent = events => events->unlessRefused(Core.compareNoEvent)
562
829
 
563
- let thenError = (events, expectedError) => Core.matchesError(events, [], expectedError)
830
+ let thenError = (events, expectedError) =>
831
+ events->unlessRefused(Core.matchesError(_, [], expectedError))
564
832
 
565
833
  let thenEventWithError = (events, expectedEvent, expectedError) =>
566
- Core.matchesError(events, [expectedEvent], expectedError)
834
+ events->unlessRefused(Core.matchesError(_, [expectedEvent], expectedError))
567
835
 
568
836
  let thenEventsWithError = (events, expectedEvents, expectedError) =>
569
- Core.matchesError(events, expectedEvents, expectedError)
837
+ events->unlessRefused(Core.matchesError(_, expectedEvents, expectedError))
838
+
839
+ let thenRefused = events =>
840
+ Guard.thenRefused(
841
+ ~encEvents=Core.encEvents,
842
+ ~decided=errors.contents->Array.get(0)->Option.map(Core.encError),
843
+ events,
844
+ )
570
845
  }