@reventlessdev/reventless-local 3.0.0-alpha.275 → 3.0.0-alpha.276

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,21 @@
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.276 (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
+
6
21
  # 3.0.0-alpha.275 (2026-10-01)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.275",
3
+ "version": "3.0.0-alpha.276",
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": {
@@ -36,22 +36,22 @@
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.34",
38
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
39
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
39
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
40
41
  "@reventlessdev/rescript-node": "2.0.0-alpha.20",
41
- "@reventlessdev/rescript-jest": "1.0.0-alpha.11",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.22",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.133",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.232",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.183",
46
- "@reventlessdev/reventless-core": "3.0.0-alpha.285",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.149",
43
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.233",
44
+ "@reventlessdev/reventless-core": "3.0.0-alpha.286",
45
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.134",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.184",
47
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.156",
48
48
  "@reventlessdev/reventless-seed": "1.0.0-alpha.31",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.155"
49
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.150"
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.95"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.96"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "^12.3.1"
package/src/Platform.res CHANGED
@@ -73,6 +73,24 @@ let decodeUiFragmentRegistryEventEnvelope = (eventJson: JSON.t): option<
73
73
  // splitApi=true to serve core and plugin APIs on separate ports,
74
74
  // backend=Backend.Sqlite({...}) to opt into file-backed SQLite persistence.
75
75
  // Usage: module Platform = Platform.MakeWithConfig({let silent = true; let splitApi = false; let cloner = false; let backend = Backend.Memory})
76
+ /** Which group stands for which role, only where the two differ; every other
77
+ role maps to the group of its own name. Call before `Make()`: the platform
78
+ resolves roles while it is built, and a mapping stated later is refused. */
79
+ let roleGroups = Reventless.Role.setGroups
80
+
81
+ /** The groups of a user pool this deployment did not create, which no accounts
82
+ manifest names. They count as provided by the check that every role a plugin
83
+ needs has a group. */
84
+ let providedGroups = Reventless.Role.provideGroups
85
+
86
+ /** The group a role resolves to — the name this platform writes into its
87
+ directives, its shell configuration and the elevated groups. For code that
88
+ grants access by group, so it never spells one itself. */
89
+ let groupOf = Reventless.Role.groupOf
90
+
91
+ /** `groupOf(Role.admin)`. */
92
+ let adminGroup = Reventless.Role.adminGroup
93
+
76
94
  module MakeWithConfig = (
77
95
  Config: {
78
96
  let silent: bool
@@ -87,6 +105,14 @@ module MakeWithConfig = (
87
105
  // …) are no-ops in-memory. envVars are not propagated here because the
88
106
  // local platform has no per-component process boundary.
89
107
  let _ = Config.commandHandlerConfig
108
+
109
+ // What this platform provides for the roles its plugins need: every account it
110
+ // can sign in as, built-in and manifest alike. The manifest is read here rather
111
+ // than from the user store, which loads only when the servers start — after
112
+ // the plugins are built and checked.
113
+ Reventless.Role.provideGroupsFrom(() =>
114
+ LocalAuth.knownGroups()->Array.concat(Reventless.AccountsManifest.declaredGroups())
115
+ )
90
116
  // Activate Pulumi mock mode — must happen before any component creation.
91
117
  // Idempotent, so safe to call even if TestRunner.setup() was already called.
92
118
  let _ = TestRunner.setup()
@@ -1789,7 +1815,7 @@ module MakeWithConfig = (
1789
1815
  // structure manually.
1790
1816
  pluginStructuresStore.contents->Dict.set(
1791
1817
  ReventlessCore.Platform_Admin_Structure.pluginId,
1792
- ReventlessCore.Platform_Admin_Structure.structure,
1818
+ ReventlessCore.Platform_Admin_Structure.structure(),
1793
1819
  )
1794
1820
 
1795
1821
  let pluginQueryDbName = ReventlessCore.PluginsReadModelSpec.name
@@ -12,6 +12,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
12
12
  import * as Effect from "effect/Effect";
13
13
  import * as Pulumi from "@pulumi/pulumi";
14
14
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
15
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
15
16
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
16
17
  import * as AnsiStyle$Reventless from "@reventlessdev/reventless-spec/src/AnsiStyle.res.mjs";
17
18
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
@@ -25,10 +26,12 @@ import * as UiSlots$ReventlessLocal from "./UiSlots.res.mjs";
25
26
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
26
27
  import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
27
28
  import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
29
+ import * as LocalAuth$ReventlessLocal from "./adapter/Auth/LocalAuth.res.mjs";
28
30
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
29
31
  import * as UserStore$ReventlessLocal from "./adapter/Auth/UserStore.res.mjs";
30
32
  import * as TestRunner$ReventlessLocal from "./test/TestRunner.res.mjs";
31
33
  import * as UiFragments$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs";
34
+ import * as AccountsManifest$Reventless from "@reventlessdev/reventless-spec/src/types/AccountsManifest.res.mjs";
32
35
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
33
36
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
34
37
  import * as ShellConfig$ReventlessLocal from "./ShellConfig.res.mjs";
@@ -140,6 +143,7 @@ function decodeUiFragmentRegistryEventEnvelope(eventJson) {
140
143
  }
141
144
 
142
145
  function MakeWithConfig(Config) {
146
+ Role$Reventless.provideGroupsFrom(() => LocalAuth$ReventlessLocal.knownGroups().concat(AccountsManifest$Reventless.declaredGroups()));
143
147
  TestRunner$ReventlessLocal.setup();
144
148
  let match = Config.backend;
145
149
  if (typeof match !== "object") {
@@ -1360,7 +1364,7 @@ function MakeWithConfig(Config) {
1360
1364
  connectPlugin(plugins$1);
1361
1365
  seedPluginStructuresStore(plugins$1);
1362
1366
  }
1363
- pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure;
1367
+ pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure();
1364
1368
  CommandGeneratorResolvers_GraphQL$ReventlessLocal.setPluginStatusGate(field => {
1365
1369
  let parts = field.split("_");
1366
1370
  let pluginPrefix = Stdlib_Option.getOr(parts[0], "");
@@ -1905,6 +1909,7 @@ function MakeWithConfig(Config) {
1905
1909
 
1906
1910
  function Make($star) {
1907
1911
  let backend = Backend$ReventlessLocal.fromEnv();
1912
+ Role$Reventless.provideGroupsFrom(() => LocalAuth$ReventlessLocal.knownGroups().concat(AccountsManifest$Reventless.declaredGroups()));
1908
1913
  TestRunner$ReventlessLocal.setup();
1909
1914
  if (typeof backend !== "object") {
1910
1915
  BackendState$ReventlessLocal.setMemory();
@@ -3113,7 +3118,7 @@ function Make($star) {
3113
3118
  connectPlugin(plugins$1);
3114
3119
  seedPluginStructuresStore(plugins$1);
3115
3120
  }
3116
- pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure;
3121
+ pluginStructuresStore.contents[Platform_Admin_Structure$ReventlessCore.pluginId] = Platform_Admin_Structure$ReventlessCore.structure();
3117
3122
  CommandGeneratorResolvers_GraphQL$ReventlessLocal.setPluginStatusGate(field => {
3118
3123
  let parts = field.split("_");
3119
3124
  let pluginPrefix = Stdlib_Option.getOr(parts[0], "");
@@ -3642,6 +3647,14 @@ function Make($star) {
3642
3647
  };
3643
3648
  }
3644
3649
 
3650
+ let roleGroups = Role$Reventless.setGroups;
3651
+
3652
+ let providedGroups = Role$Reventless.provideGroups;
3653
+
3654
+ let groupOf = Role$Reventless.groupOf;
3655
+
3656
+ let adminGroup = Role$Reventless.adminGroup;
3657
+
3645
3658
  export {
3646
3659
  log,
3647
3660
  platformGraphQLRef,
@@ -3649,6 +3662,10 @@ export {
3649
3662
  platformMCPRef,
3650
3663
  decodePluginEventEnvelope,
3651
3664
  decodeUiFragmentRegistryEventEnvelope,
3665
+ roleGroups,
3666
+ providedGroups,
3667
+ groupOf,
3668
+ adminGroup,
3652
3669
  MakeWithConfig,
3653
3670
  Make,
3654
3671
  }
@@ -41,7 +41,12 @@ let manifestUrlOf = (config: ReventlessInfra.Platform.bakedManifest): string =>
41
41
  // caller matching no declared group gets and what every existing deployment has.
42
42
  let journeyManifestsKey = "journeyManifestUrls"
43
43
 
44
- let computedKeys = ["manifestUrl", journeyManifestsKey, ReventlessCore.Platform_UiSlots.configKey]
44
+ let computedKeys = [
45
+ "manifestUrl",
46
+ journeyManifestsKey,
47
+ ReventlessCore.Platform_UiSlots.configKey,
48
+ ReventlessCore.Platform_AdminGroup.configKey,
49
+ ]
45
50
 
46
51
  /**
47
52
  The overlay this platform puts on top of the shipped `config.json`.
@@ -69,6 +74,8 @@ let overlay = (
69
74
  )
70
75
  )
71
76
 
77
+ ReventlessCore.Platform_AdminGroup.configEntry()->Option.forEach(((k, v)) => out->Dict.set(k, v))
78
+
72
79
  bakedManifest->Option.forEach(config => {
73
80
  out->Dict.set("manifestUrl", JSON.Encode.string(manifestUrlOf(config)))
74
81
  // Omitted entirely when nothing is declared, so a single-audience deployment
@@ -9,6 +9,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
9
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
10
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
11
11
  import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
12
+ import * as Platform_AdminGroup$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminGroup.res.mjs";
12
13
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
13
14
 
14
15
  let log = Logger$ReventlessCore.fromEnv();
@@ -26,7 +27,8 @@ let journeyManifestsKey = "journeyManifestUrls";
26
27
  let computedKeys = [
27
28
  "manifestUrl",
28
29
  journeyManifestsKey,
29
- Platform_UiSlots$ReventlessCore.configKey
30
+ Platform_UiSlots$ReventlessCore.configKey,
31
+ Platform_AdminGroup$ReventlessCore.configKey
30
32
  ];
31
33
 
32
34
  function overlay(bakedManifest, uiSlotsFile, shellConfig) {
@@ -34,6 +36,9 @@ function overlay(bakedManifest, uiSlotsFile, shellConfig) {
34
36
  Stdlib_Option.forEach(uiSlotsFile, param => {
35
37
  out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
36
38
  });
39
+ Stdlib_Option.forEach(Platform_AdminGroup$ReventlessCore.configEntry(), param => {
40
+ out[param[0]] = param[1];
41
+ });
37
42
  Stdlib_Option.forEach(bakedManifest, config => {
38
43
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
39
44
  let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(config);
@@ -21,19 +21,22 @@ let defaultUser: Identity.t = {
21
21
  provider: InMemory,
22
22
  }
23
23
 
24
- let adminUser: Identity.t = {
24
+ // A function, not a value: the administrator's group is the one the platform
25
+ // root maps `Role.admin` to, and this module is evaluated before the root runs.
26
+ let adminUser = (): Identity.t => {
25
27
  userId: "local-admin",
26
28
  username: "admin",
27
- groups: [AdminGroup.name, "User"],
29
+ groups: [Role.adminGroup(), "User"],
28
30
  provider: InMemory,
29
31
  }
30
32
 
31
33
  // ── User registry ─────────────────────────────────────────────────────────
32
34
  //
33
35
  // A `dict<Identity.t>` keyed by `username`. A4 hydrates this from
34
- // `.reventless/users.yaml`; tests inject via `registerUser`.
36
+ // `.reventless/users.yaml`; tests inject via `registerUser`. The built-in
37
+ // `admin` is answered on lookup, for the reason above, unless one is registered.
35
38
 
36
- let initialUsers = () => Dict.fromArray([("user", defaultUser), ("admin", adminUser)])
39
+ let initialUsers = () => Dict.fromArray([("user", defaultUser)])
37
40
 
38
41
  let users: ref<dict<Identity.t>> = ref(initialUsers())
39
42
 
@@ -42,7 +45,19 @@ let registerUser = (~username: string, ~identity: Identity.t): unit =>
42
45
 
43
46
  let resetUsers = (): unit => users := initialUsers()
44
47
 
45
- let lookupUser = (username: string): option<Identity.t> => users.contents->Dict.get(username)
48
+ let lookupUser = (username: string): option<Identity.t> =>
49
+ switch users.contents->Dict.get(username) {
50
+ | None if username == "admin" => Some(adminUser())
51
+ | found => found
52
+ }
53
+
54
+ /** The groups of every account this adapter can authenticate as, built-in
55
+ included — what the local platform provides. */
56
+ let knownGroups = (): array<string> =>
57
+ users.contents
58
+ ->Dict.valuesToArray
59
+ ->Array.concat([adminUser()])
60
+ ->Array.flatMap(identity => identity.groups)
46
61
 
47
62
  // ── Header parsing ────────────────────────────────────────────────────────
48
63
 
@@ -6,9 +6,9 @@ import * as Nodepath from "node:path";
6
6
  import * as Nodecrypto from "node:crypto";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
8
  import * as Pulumi from "@pulumi/pulumi";
9
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
9
10
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
11
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
11
- import * as AdminGroup$Reventless from "@reventlessdev/reventless-spec/src/types/AdminGroup.res.mjs";
12
12
  import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
13
13
 
14
14
  let defaultUser_groups = ["User"];
@@ -20,29 +20,23 @@ let defaultUser = {
20
20
  provider: "InMemory"
21
21
  };
22
22
 
23
- let adminUser_groups = [
24
- AdminGroup$Reventless.name,
25
- "User"
26
- ];
27
-
28
- let adminUser = {
29
- userId: "local-admin",
30
- username: "admin",
31
- groups: adminUser_groups,
32
- provider: "InMemory"
33
- };
23
+ function adminUser() {
24
+ return {
25
+ userId: "local-admin",
26
+ username: "admin",
27
+ groups: [
28
+ Role$Reventless.adminGroup(),
29
+ "User"
30
+ ],
31
+ provider: "InMemory"
32
+ };
33
+ }
34
34
 
35
35
  function initialUsers() {
36
- return Object.fromEntries([
37
- [
36
+ return Object.fromEntries([[
38
37
  "user",
39
38
  defaultUser
40
- ],
41
- [
42
- "admin",
43
- adminUser
44
- ]
45
- ]);
39
+ ]]);
46
40
  }
47
41
 
48
42
  let users = {
@@ -58,7 +52,16 @@ function resetUsers() {
58
52
  }
59
53
 
60
54
  function lookupUser(username) {
61
- return users.contents[username];
55
+ let found = users.contents[username];
56
+ if (found !== undefined || username !== "admin") {
57
+ return found;
58
+ } else {
59
+ return adminUser();
60
+ }
61
+ }
62
+
63
+ function knownGroups() {
64
+ return Object.values(users.contents).concat([adminUser()]).flatMap(identity => identity.groups);
62
65
  }
63
66
 
64
67
  function parseGroups(s) {
@@ -329,7 +332,7 @@ async function authenticate(ctx) {
329
332
  let userHeader = ctx.headers["X-User".toLowerCase()];
330
333
  let groupsHeader = ctx.headers["X-Groups".toLowerCase()];
331
334
  if (userHeader !== undefined) {
332
- let identity$1 = users.contents[userHeader];
335
+ let identity$1 = lookupUser(userHeader);
333
336
  if (identity$1 === undefined) {
334
337
  return "Anonymous";
335
338
  }
@@ -371,6 +374,7 @@ export {
371
374
  registerUser,
372
375
  resetUsers,
373
376
  lookupUser,
377
+ knownGroups,
374
378
  parseGroups,
375
379
  getHeader,
376
380
  Login,
@@ -41,7 +41,7 @@ let wrapAdmin = (resolvers: dict<ReventlessGraphqlServer.GraphQL_ServerInstance.
41
41
  resolvers
42
42
  ->Dict.toArray
43
43
  ->Array.forEach(((k, v)) =>
44
- wrapped->Dict.set(k, Auth_GraphqlContext.requireGroup(~group=Reventless.AdminGroup.name, v))
44
+ wrapped->Dict.set(k, Auth_GraphqlContext.requireGroup(~group=Reventless.Role.adminGroup(), v))
45
45
  )
46
46
  wrapped
47
47
  }
@@ -1,6 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as AdminGroup$Reventless from "@reventlessdev/reventless-spec/src/types/AdminGroup.res.mjs";
3
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
4
4
  import * as Auth_GraphqlContext$ReventlessLocal from "./Auth/Auth_GraphqlContext.res.mjs";
5
5
  import * as GraphQL_ServerInstance$ReventlessGraphqlServer from "@reventlessdev/reventless-graphql-server/src/GraphQL_ServerInstance.res.mjs";
6
6
 
@@ -9,7 +9,7 @@ let instance = GraphQL_ServerInstance$ReventlessGraphqlServer.make("GraphQL:Plat
9
9
  function wrapAdmin(resolvers) {
10
10
  let wrapped = {};
11
11
  Object.entries(resolvers).forEach(param => {
12
- wrapped[param[0]] = Auth_GraphqlContext$ReventlessLocal.requireGroup(AdminGroup$Reventless.name, param[1]);
12
+ wrapped[param[0]] = Auth_GraphqlContext$ReventlessLocal.requireGroup(Role$Reventless.adminGroup(), param[1]);
13
13
  });
14
14
  return wrapped;
15
15
  }
@@ -268,3 +268,34 @@ describe("ShellConfig.overlay — journeys", () => {
268
268
  )->toBe(true)
269
269
  )
270
270
  })
271
+
272
+ describe("ShellConfig.overlay — adminGroup", () => {
273
+ testSync("is absent while the administrator role keeps its own name", () => {
274
+ Reventless.Role.clearGroups()
275
+ expect(
276
+ ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)->Dict.keysToArray,
277
+ )->toEqual([])
278
+ })
279
+
280
+ testSync("carries the group a renamed administrator role maps to", () => {
281
+ Reventless.Role.clearGroups()
282
+ Reventless.Role.setGroups([(Reventless.Role.admin, "shop-admins")])
283
+ let out = ShellConfig.overlay(~bakedManifest=None, ~shellConfig=None)
284
+ Reventless.Role.clearGroups()
285
+ expect(out->str("adminGroup"))->toEqual(Some("shop-admins"))
286
+ })
287
+
288
+ // Computed from the role mapping, so a passthrough cannot point the shell at
289
+ // another group than the one the server gates on.
290
+ testSync("a shellConfig naming it is refused", () =>
291
+ expect(
292
+ threw(
293
+ () =>
294
+ ShellConfig.overlay(
295
+ ~bakedManifest=None,
296
+ ~shellConfig=Some(Dict.fromArray([("adminGroup", JSON.Encode.string("x"))])),
297
+ )->ignore,
298
+ ),
299
+ )->toBe(true)
300
+ )
301
+ })
@@ -5,6 +5,7 @@ import * as Nodeos from "node:os";
5
5
  import * as Nodepath from "node:path";
6
6
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
7
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
8
9
  import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
9
10
  import * as ShellConfig$ReventlessLocal from "../src/ShellConfig.res.mjs";
10
11
 
@@ -215,6 +216,31 @@ globalThis.describe("ShellConfig.overlay — journeys", () => {
215
216
  });
216
217
  });
217
218
 
219
+ globalThis.describe("ShellConfig.overlay — adminGroup", () => {
220
+ globalThis.test("is absent while the administrator role keeps its own name", () => {
221
+ Role$Reventless.clearGroups();
222
+ globalThis.expect(Object.keys(ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined))).toEqual([]);
223
+ });
224
+ globalThis.test("carries the group a renamed administrator role maps to", () => {
225
+ Role$Reventless.clearGroups();
226
+ Role$Reventless.setGroups([[
227
+ Role$Reventless.admin,
228
+ "shop-admins"
229
+ ]]);
230
+ let out = ShellConfig$ReventlessLocal.overlay(undefined, undefined, undefined);
231
+ Role$Reventless.clearGroups();
232
+ globalThis.expect(str(out, "adminGroup")).toEqual("shop-admins");
233
+ });
234
+ globalThis.test("a shellConfig naming it is refused", () => {
235
+ globalThis.expect(threw(() => {
236
+ ShellConfig$ReventlessLocal.overlay(undefined, undefined, Object.fromEntries([[
237
+ "adminGroup",
238
+ "x"
239
+ ]]));
240
+ })).toBe(true);
241
+ });
242
+ });
243
+
218
244
  export {
219
245
  manifest,
220
246
  str,
@@ -1,7 +1,7 @@
1
1
  // Resolver-boundary tests for per-constructor authorization (A3.3 + A3.3b).
2
2
  // Mirrors the catalog Category aggregate's command shape: a record-payload
3
3
  // constructor `Add({name})` defaulting to AllowAuthenticated, plus a
4
- // payload-less `Archive` constrained to AllowGroups(["Admin"]).
4
+ // payload-less `Archive` constrained to AllowRoles([Admin]).
5
5
  //
6
6
  // The PPX emits a `commandAuthorization` switch at compile time (see
7
7
  // examples/online-shop-hybrid/catalog/.../Category.res.mjs). This test hand-
@@ -23,11 +23,11 @@ type command =
23
23
  | Rename({name: string})
24
24
  | Archive
25
25
 
26
- // Matches the shape the PPX emits for `@authorize(AllowGroups(["Admin"]))` on
26
+ // Matches the shape the PPX emits for `@authorize(AllowRoles([Admin]))` on
27
27
  // the payload-less `Archive` constructor with a file-level default.
28
28
  let commandAuthorization = (cmd: unknown): Reventless.Authorization.permission =>
29
29
  if (cmd->Obj.magic: 'a) === "Archive" {
30
- AllowGroups(["Admin"])
30
+ AllowRoles([Reventless.Role.make("Admin")])
31
31
  } else {
32
32
  AllowAuthenticated
33
33
  }
@@ -189,6 +189,30 @@ describe("CommandGeneratorResolvers_GraphQL — per-constructor authorization",
189
189
  expect(calls.contents)->toEqual(["Add"])
190
190
  })
191
191
 
192
+ // The rule names the role; the resolver checks the group the platform maps it
193
+ // to, so a deployment that calls its administrators "ops" admits those.
194
+ testPromise("a renamed role admits its group and refuses the old name", async () => {
195
+ let (_, _, archiveResolver, calls) = buildFixture(~namespace="Cat8")
196
+ Reventless.Role.clearGroups()
197
+ Reventless.Role.setGroups([(Reventless.Role.make("Admin"), "ops")])
198
+ let asOps = await archiveResolver(
199
+ JSON.Encode.null,
200
+ JSON.Encode.object(Dict.make()),
201
+ ctxFor({...userIdentity, groups: ["ops"]}),
202
+ )
203
+ let asAdminGroup = await archiveResolver(
204
+ JSON.Encode.null,
205
+ JSON.Encode.object(Dict.make()),
206
+ ctxFor(adminIdentity),
207
+ )
208
+ Reventless.Role.clearGroups()
209
+ expect((getTypename(asOps), getErrorCode(asAdminGroup)))->toEqual((
210
+ "CommandAccepted",
211
+ "Forbidden",
212
+ ))
213
+ expect(calls.contents)->toEqual(["Archive"])
214
+ })
215
+
192
216
  testPromise(
193
217
  "payload-less Archive registered as a GraphQL mutation field (no longer filtered)",
194
218
  async () => {
@@ -6,6 +6,7 @@ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Effect from "effect/Effect";
8
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";
9
10
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
12
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
@@ -30,7 +31,7 @@ let commandSchema = Sury.union([
30
31
  function commandAuthorization(cmd) {
31
32
  if (cmd === "Archive") {
32
33
  return {
33
- TAG: "AllowGroups",
34
+ TAG: "AllowRoles",
34
35
  _0: ["Admin"]
35
36
  };
36
37
  } else {
@@ -170,6 +171,27 @@ globalThis.describe("CommandGeneratorResolvers_GraphQL — per-constructor autho
170
171
  globalThis.expect(getTypename(response)).toEqual("CommandAccepted");
171
172
  globalThis.expect(match[3].contents).toEqual(["Add"]);
172
173
  });
174
+ globalThis.test("a renamed role admits its group and refuses the old name", async () => {
175
+ let match = buildFixture("Cat8");
176
+ let archiveResolver = match[2];
177
+ Role$Reventless.clearGroups();
178
+ Role$Reventless.setGroups([[
179
+ "Admin",
180
+ "ops"
181
+ ]]);
182
+ let newrecord = {...userIdentity};
183
+ let asOps = await archiveResolver(null, {}, ctxFor((newrecord.groups = ["ops"], newrecord)));
184
+ let asAdminGroup = await archiveResolver(null, {}, ctxFor(adminIdentity));
185
+ Role$Reventless.clearGroups();
186
+ globalThis.expect([
187
+ getTypename(asOps),
188
+ getErrorCode(asAdminGroup)
189
+ ]).toEqual([
190
+ "CommandAccepted",
191
+ "Forbidden"
192
+ ]);
193
+ globalThis.expect(match[3].contents).toEqual(["Archive"]);
194
+ });
173
195
  globalThis.test("payload-less Archive registered as a GraphQL mutation field (no longer filtered)", async () => {
174
196
  buildFixture("Cat7");
175
197
  let sdl = DomainGraphQL_Server$ReventlessLocal.asInterface.buildSdl();
@@ -145,7 +145,8 @@ let buildFixture = async (~name: string, ~indexes: array<Reventless.ReadModel.in
145
145
  type state = rowState
146
146
  let config = Reventless.ReadModel.config()
147
147
  let subIdConfig = None
148
- let authorization: Reventless.Authorization.permission = AllowAuthenticated
148
+ type role = Reventless.Role.name
149
+ let authorization: Reventless.Authorization.rule<role> = AllowAuthenticated
149
150
  let visibility: Reventless.Visibility.t = Public
150
151
  }
151
152
 
@@ -646,7 +647,8 @@ let buildSubIdFixture = async (~name: string) => {
646
647
  Reventless.ReadModel.subIdField: "lineNo",
647
648
  getSubId: (state: lineState) => state.lineNo,
648
649
  })
649
- let authorization: Reventless.Authorization.permission = AllowAuthenticated
650
+ type role = Reventless.Role.name
651
+ let authorization: Reventless.Authorization.rule<role> = AllowAuthenticated
650
652
  let visibility: Reventless.Visibility.t = Public
651
653
  }
652
654
 
@@ -96,7 +96,8 @@ let buildFixture = async (
96
96
  type state = productRow
97
97
  let config = Reventless.ReadModel.config()
98
98
  let subIdConfig = None
99
- let authorization: Reventless.Authorization.permission = AllowAuthenticated
99
+ type role = Reventless.Role.name
100
+ let authorization: Reventless.Authorization.rule<role> = AllowAuthenticated
100
101
  let visibility: Reventless.Visibility.t = Public
101
102
  }
102
103
 
@@ -125,7 +126,8 @@ let buildFixture = async (
125
126
  ],
126
127
  )
127
128
  let subIdConfig = None
128
- let authorization: Reventless.Authorization.permission = AllowAuthenticated
129
+ type role = Reventless.Role.name
130
+ let authorization: Reventless.Authorization.rule<role> = AllowAuthenticated
129
131
  let visibility: Reventless.Visibility.t = Public
130
132
  }
131
133
 
@@ -222,7 +222,9 @@ describe("InboundTranslationSlice Callback", () => {
222
222
  ),
223
223
  )
224
224
 
225
- let commandAuthorization = (_: command): Reventless.Authorization.permission =>
225
+ type role = Reventless.Role.name
226
+
227
+ let commandAuthorization = (_: command): Reventless.Authorization.rule<role> =>
226
228
  AllowAuthenticated
227
229
  type lifecycleState = unit
228
230
  let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> =>
@@ -289,7 +291,9 @@ describe("InboundTranslationSlice Callback", () => {
289
291
 
290
292
  let translate = (_input: externalInput) => Ok([])
291
293
 
292
- let commandAuthorization = (_: command): Reventless.Authorization.permission =>
294
+ type role = Reventless.Role.name
295
+
296
+ let commandAuthorization = (_: command): Reventless.Authorization.rule<role> =>
293
297
  AllowAuthenticated
294
298
  type lifecycleState = unit
295
299
  let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> =>
@@ -9,13 +9,15 @@ let PluginTest = Behavior_GWT$ReventlessGwt.MakeFromAggregate({
9
9
  name: PluginSpec$ReventlessCore.name,
10
10
  commandSchema: PluginSpec$ReventlessCore.commandSchema,
11
11
  eventSchema: PluginSpec$ReventlessCore.eventSchema,
12
- errorSchema: PluginSpec$ReventlessCore.errorSchema
12
+ errorSchema: PluginSpec$ReventlessCore.errorSchema,
13
+ commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization
13
14
  })({
14
15
  Spec: {
15
16
  name: PluginSpec$ReventlessCore.name,
16
17
  commandSchema: PluginSpec$ReventlessCore.commandSchema,
17
18
  eventSchema: PluginSpec$ReventlessCore.eventSchema,
18
- errorSchema: PluginSpec$ReventlessCore.errorSchema
19
+ errorSchema: PluginSpec$ReventlessCore.errorSchema,
20
+ commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization
19
21
  },
20
22
  initialState: PluginBehavior$ReventlessCore.initialState,
21
23
  evolve: PluginBehavior$ReventlessCore.evolve,
@@ -10,7 +10,8 @@ let Test = Behavior_GWT$ReventlessGwt.Make({
10
10
  consumedEventSchema: UiFragmentRegistry$ReventlessCore.consumedEventSchema,
11
11
  commandSchema: UiFragmentRegistry$ReventlessCore.commandSchema,
12
12
  errorSchema: UiFragmentRegistry$ReventlessCore.errorSchema,
13
- eventSchema: UiFragmentRegistry$ReventlessCore.eventSchema
13
+ eventSchema: UiFragmentRegistry$ReventlessCore.eventSchema,
14
+ commandAuthorization: UiFragmentRegistry$ReventlessCore.commandAuthorization
14
15
  })({
15
16
  initialState: undefined,
16
17
  evolve: UiFragmentRegistry_Behavior$ReventlessCore.evolve,