@reventlessdev/reventless-local 3.0.0-alpha.298 → 3.0.0-alpha.299

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +10 -10
  3. package/src/EmitCapabilities.res +3 -3
  4. package/src/LocalPlatformRegistry.res +1 -1
  5. package/src/LocalSeedTarget.res +1 -1
  6. package/src/Platform.res +57 -3
  7. package/src/Platform.res.mjs +61 -6
  8. package/src/ShellConfig.res +11 -1
  9. package/src/ShellConfig.res.mjs +10 -4
  10. package/src/adapter/Auth/Auth_GraphqlContext.res +18 -2
  11. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +8 -1
  12. package/src/adapter/Auth/UserStore.res +102 -3
  13. package/src/adapter/Auth/UserStore.res.mjs +128 -2
  14. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +33 -0
  15. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +26 -0
  16. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +11 -3
  17. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +3 -0
  18. package/src/adapter/DomainGraphQL_Server.res +1 -1
  19. package/src/adapter/GraphQL_CallerError.res +4 -0
  20. package/src/adapter/GraphQL_CallerError.res.mjs +9 -0
  21. package/src/adapter/LocalAnonymousLimit.res +35 -0
  22. package/src/adapter/LocalAnonymousLimit.res.mjs +52 -0
  23. package/src/adapter/LocalCapabilities.res +14 -2
  24. package/src/adapter/LocalCapabilities.res.mjs +21 -5
  25. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +22 -0
  26. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +7 -0
  27. package/src/components/InboundTranslationSlice_Builder.res +1 -0
  28. package/src/components/InboundTranslationSlice_Builder.res.mjs +4 -1
  29. package/tests/EmitCapabilitiesTest.res +4 -6
  30. package/tests/EmitCapabilitiesTest.res.mjs +1 -2
  31. package/tests/HostShellDistTest.res.mjs +1 -1
  32. package/tests/LocalPlatformRegistryTest.res +3 -5
  33. package/tests/LocalPlatformRegistryTest.res.mjs +2 -4
  34. package/tests/LocalPlatformStartTest.res +2 -2
  35. package/tests/LocalPlatformStartTest.res.mjs +1 -2
  36. package/tests/ShellConfigTest.res.mjs +27 -27
  37. package/tests/adapter/InboundTranslationMutationTest.res +13 -2
  38. package/tests/adapter/InboundTranslationMutationTest.res.mjs +7 -4
  39. package/tests/adapter/LocalAnonymousLimitTest.res +70 -0
  40. package/tests/adapter/LocalAnonymousLimitTest.res.mjs +85 -0
  41. package/tests/adapter/LocalAuthLoginTest.res +2 -2
  42. package/tests/adapter/LocalAuthUserStoreTest.res +88 -0
  43. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +83 -0
  44. package/tests/capability/LocalCapabilitiesTest.res +2 -1
  45. package/tests/capability/LocalCapabilitiesTest.res.mjs +3 -2
  46. package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res +136 -0
  47. package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res.mjs +183 -0
  48. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +143 -11
  49. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +129 -22
  50. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +18 -5
  51. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +12 -3
  52. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res +26 -6
  53. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res.mjs +48 -13
  54. package/tests/plugin/Plugin_Fixtures.res +0 -4
  55. package/tests/plugin/Plugin_Fixtures.res.mjs +13 -33
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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.299 (2026-10-25)
7
+
8
+ * refactor!: internal results and tool records whose parts may be absent are optional fields ([7009ddf](https://github.com/ReventlessDev/reventless-core/commit/7009ddf5d522f39ec9946a3b5cd062ceae889bbf))
9
+ * refactor(aws)!: command-line arguments a tool may not be given are optional fields ([82d5880](https://github.com/ReventlessDev/reventless-core/commit/82d5880e9aeda73fe9625dee73a1ca633b20a59a))
10
+ * refactor(spec)!: the plugin structure's optional parts are optional fields ([130bad5](https://github.com/ReventlessDev/reventless-core/commit/130bad581caf725a9d61b5a3df3d012e4f8ba402))
11
+ * feat!: an inbound translation is async and handed the platform's capabilities, and its audit row withholds sensitive fields ([dae6049](https://github.com/ReventlessDev/reventless-core/commit/dae6049e084f81408a494a2cd2011ec564dda65c))
12
+ ### Features
13
+
14
+ * a door open to anonymous callers is limited per source address, and answers Throttled past the limit ([259a671](https://github.com/ReventlessDev/reventless-core/commit/259a671969dd25579d26227a8fe80cba3194f0aa))
15
+ * a plugin declares per role how a person is admitted, and the local platform honors the self-service and provisioned halves ([9c37bf5](https://github.com/ReventlessDev/reventless-core/commit/9c37bf50f15cac1703bf2c351713518121a56211))
16
+ * a stash capability holds a value for minutes outside the log and hands it over once ([627f285](https://github.com/ReventlessDev/reventless-core/commit/627f28529ead2d2d31bdd091c9f42e7faca40f6f))
17
+ * **local:** the platform reads its accounts file again when it changes ([eeb2b5f](https://github.com/ReventlessDev/reventless-core/commit/eeb2b5f8ec9d5baca2d2d8bfb81260f07713a3a6))
18
+
19
+ ### BREAKING CHANGES
20
+
21
+ * code that builds these records drops `name: None` and
22
+ writes `name: x` for `name: Some(x)`; a destructure of one of them binds a
23
+ field that may be absent with `?name`.
24
+ * code that builds these records drops `name: None` and
25
+ writes `name: x` for `name: Some(x)`.
26
+ * code that builds these records drops `name: None`, writes
27
+ `name: x` for `name: Some(x)`, and `name: ?x` for a value already of type
28
+ option.
29
+ * an inbound translation's translate takes ~capabilities and
30
+ returns a promise, and its spec must declare capabilityNeeds ([] for one that
31
+ only maps input). FromSlice steps return promises.
32
+
33
+
34
+
6
35
  # 3.0.0-alpha.298 (2026-10-23)
7
36
 
8
37
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.298",
3
+ "version": "3.0.0-alpha.299",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,18 +35,18 @@
35
35
  "sury": "11.0.0",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "1.0.0-alpha.1",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
39
38
  "@reventlessdev/rescript-jest": "1.0.0-alpha.13",
40
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.23",
39
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
41
40
  "@reventlessdev/rescript-node": "2.0.0-alpha.23",
41
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.155",
44
- "@reventlessdev/reventless-core": "3.0.0-alpha.307",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.255",
46
- "@reventlessdev/reventless-infra": "3.0.0-alpha.204",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.171",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.175",
49
- "@reventlessdev/reventless-seed": "1.0.0-alpha.36"
43
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.156",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.256",
45
+ "@reventlessdev/reventless-core": "3.0.0-alpha.308",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.172",
47
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.176",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.37",
49
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.205"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.1",
@@ -61,7 +61,7 @@ let usage = `Usage: emit-capabilities <srcDir> [<compositionModule>]
61
61
 
62
62
  Run from the plugin package, after rescript build.`
63
63
 
64
- type args = {srcDir: string, compositionModule: option<string>}
64
+ type args = {srcDir: string, compositionModule?: string}
65
65
 
66
66
  let parseArgs = (argv: array<string>): result<args, string> =>
67
67
  Reventless.CliArgs.parse(argv)
@@ -70,14 +70,14 @@ let parseArgs = (argv: array<string>): result<args, string> =>
70
70
  let positionals = a->Reventless.CliArgs.positionals
71
71
  switch positionals->Array.get(0) {
72
72
  | None | Some("") => Error("<srcDir> is required.")
73
- | Some(srcDir) => Ok({srcDir, compositionModule: positionals->Array.get(1)})
73
+ | Some(srcDir) => Ok({srcDir, compositionModule: ?(positionals->Array.get(1))})
74
74
  }
75
75
  })
76
76
 
77
77
  let cli: Reventless.CliArgs.cli<args> = {bin: "emit-capabilities", usage, parse: parseArgs}
78
78
 
79
79
  let main = () =>
80
- Reventless.CliArgs.run(cli, async ({srcDir: srcDirArg, compositionModule}) => {
80
+ Reventless.CliArgs.run(cli, async ({srcDir: srcDirArg, ?compositionModule}) => {
81
81
  // The local platform defaults to Debug-level logging; a build step should not.
82
82
  switch NodeProcess.env->Dict.get("LOG_LEVEL") {
83
83
  | Some(_) => ()
@@ -11,7 +11,7 @@ type store = {
11
11
  // "sqlite" | "memory" | "postgres". A `:memory:` SQLite file reports "memory".
12
12
  kind: string,
13
13
  // Some only for a store this machine can open by name.
14
- path: option<string>,
14
+ path?: string,
15
15
  }
16
16
 
17
17
  @schema
@@ -147,7 +147,7 @@ let announce = (t: t): unit =>
147
147
  collapsed them would tell an in-memory platform to check its disk. */
148
148
  let storePath = (t: t): result<string, string> =>
149
149
  switch t.origin {
150
- | Running({store: {kind: "sqlite", path: Some(path)}}) => Ok(path)
150
+ | Running({store: {kind: "sqlite", path: ?Some(path)}}) => Ok(path)
151
151
  | Running({store: {kind: "memory"}, endpoint}) =>
152
152
  Error(`the platform at ${endpoint} keeps its store in memory; restart it to empty it.`)
153
153
  | Running({store: {kind: "postgres"}, endpoint}) =>
package/src/Platform.res CHANGED
@@ -949,6 +949,34 @@ module MakeWithConfig = (
949
949
  })
950
950
  }
951
951
 
952
+ // Runs `f` once every plugin's structure has landed, with the plugin names
953
+ // and structures. The same moment the bake waits for, for the same reason: a
954
+ // structure only exists once its Output resolves, and anything read off all of
955
+ // them has to wait for the last.
956
+ let whenStructuresLanded = (
957
+ ~pluginComponents: array<ReventlessCore.Plugin.component>,
958
+ f: array<(string, Reventless.Plugin.pluginStructure)> => unit,
959
+ ) => {
960
+ let resolved: array<(string, Reventless.Plugin.pluginStructure)> = []
961
+ let expected = pluginComponents->Array.length
962
+ pluginComponents->Array.forEach(plugin => {
963
+ let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
964
+ let _ =
965
+ (outputs.id, outputs.pluginStructure)
966
+ ->Pulumi.Output.all2
967
+ ->Pulumi.Output.apply(((id, ps)) => {
968
+ let name = id->String.split("@")->Array.get(0)->Option.getOr(id)
969
+ switch ps {
970
+ | Some(def) => resolved->Array.push((name, def))
971
+ | None => ()
972
+ }
973
+ if resolved->Array.length === expected {
974
+ f(resolved)
975
+ }
976
+ })
977
+ })
978
+ }
979
+
952
980
  // The object stores a plugin's fields declared, as `{plugin}.{store}` keys.
953
981
  let declaredStoresOf = (structure: Reventless.Plugin.pluginStructure) =>
954
982
  structure.requiredStores->Option.getOr([])
@@ -1238,7 +1266,6 @@ module MakeWithConfig = (
1238
1266
  ~store="pluginApiFragments",
1239
1267
  ),
1240
1268
  ),
1241
- apiTarget: None,
1242
1269
  structure: pluginStructure->Option.map(
1243
1270
  s =>
1244
1271
  s->ReventlessCore.Plugin_Helpers.offloadPayload(
@@ -1246,7 +1273,6 @@ module MakeWithConfig = (
1246
1273
  ~store="pluginStructures",
1247
1274
  ),
1248
1275
  ),
1249
- dcbEventLog: None,
1250
1276
  kind: Domain,
1251
1277
  }
1252
1278
  // Dispatch Connect(def) to the Plugin aggregate (id = plugin NAME).
@@ -1384,7 +1410,7 @@ module MakeWithConfig = (
1384
1410
  ~port=domainPort,
1385
1411
  ~endpoint=`http://localhost:${domainPort->Int.toString}/graphql`,
1386
1412
  ~loginEndpoint=`http://localhost:${domainPort->Int.toString}/__inmemory/login`,
1387
- ~store={kind, path},
1413
+ ~store={kind, ?path},
1388
1414
  )
1389
1415
  // After the register, and it fills the tap port in once the socket is bound:
1390
1416
  // the port is ephemeral by default, so it is not known until `listen` calls
@@ -1771,6 +1797,34 @@ module MakeWithConfig = (
1771
1797
  ~uiSlotsFile=?hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile),
1772
1798
  ~shellConfig=hostUiBundle->Option.flatMap(cfg => cfg.shellConfig),
1773
1799
  )
1800
+ // What the plugins declare about admission is read once their structures
1801
+ // have landed. First the refusal: a role declared self-service or by
1802
+ // invitation that nothing here honors stops the platform, as a role no
1803
+ // group stands for does. Then the shell is told where the doors are and
1804
+ // which groups a visitor may sign up for, by writing `config.json` again
1805
+ // with the one key the declaration alone could not supply.
1806
+ whenStructuresLanded(~pluginComponents=plugins, structures => {
1807
+ Reventless.AdmissionCheck.check(
1808
+ ~structures,
1809
+ ~provisioned=LocalCapabilities.provisioned(),
1810
+ ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
1811
+ ~adminGroup=Reventless.Role.adminGroup(),
1812
+ // This platform admits callers who are not signed in on every door
1813
+ // whose rule says so.
1814
+ ~anonymousDoors=true,
1815
+ )
1816
+ ReventlessCore.Platform_SelfRegistration.fromStructures(
1817
+ structures,
1818
+ )->Option.forEach(selfRegistration =>
1819
+ ShellConfig.emit(
1820
+ ~dir=?hostUiDir,
1821
+ ~bakedManifest=hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest),
1822
+ ~uiSlotsFile=?hostUiBundle->Option.flatMap(cfg => cfg.uiSlotsFile),
1823
+ ~shellConfig=hostUiBundle->Option.flatMap(cfg => cfg.shellConfig),
1824
+ ~selfRegistration,
1825
+ )
1826
+ )
1827
+ })
1774
1828
  // Unconditional for the same reason, and one file over: it is also what
1775
1829
  // restores the shell package's own hints for a platform that has stopped
1776
1830
  // declaring its own.
@@ -18,6 +18,7 @@ import * as AnsiStyle$Reventless from "@reventlessdev/reventless-spec/src/AnsiSt
18
18
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
19
19
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
20
20
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
21
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
21
22
  import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin.res.mjs";
22
23
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
23
24
  import * as Backend$ReventlessLocal from "./adapter/Backend.res.mjs";
@@ -25,6 +26,7 @@ import * as UiHints$ReventlessLocal from "./UiHints.res.mjs";
25
26
  import * as UiSlots$ReventlessLocal from "./UiSlots.res.mjs";
26
27
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
27
28
  import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
29
+ import * as AdmissionCheck$Reventless from "@reventlessdev/reventless-spec/src/types/AdmissionCheck.res.mjs";
28
30
  import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
29
31
  import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
30
32
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
@@ -94,6 +96,7 @@ import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventl
94
96
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
95
97
  import * as LocalCommandTopicChannel$ReventlessLocal from "./adapter/CommandTopic/LocalCommandTopicChannel.res.mjs";
96
98
  import * as LocalEventTopicPublisher$ReventlessLocal from "./adapter/EventTopic/LocalEventTopicPublisher.res.mjs";
99
+ import * as Platform_SelfRegistration$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_SelfRegistration.res.mjs";
97
100
  import * as StateChangeSlice_Builder$ReventlessLocal from "./components/StateChangeSlice_Builder.res.mjs";
98
101
  import * as DcbEventLogStorage_Sqlite$ReventlessLocal from "./adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs";
99
102
  import * as LocalEventCollectorChannel$ReventlessLocal from "./adapter/EventCollector/LocalEventCollectorChannel.res.mjs";
@@ -884,6 +887,30 @@ function MakeWithConfig(Config) {
884
887
  });
885
888
  });
886
889
  };
890
+ let whenStructuresLanded = (pluginComponents, f) => {
891
+ let resolved = [];
892
+ let expected = pluginComponents.length;
893
+ pluginComponents.forEach(plugin => {
894
+ let outputs = Component$ReventlessCore.outputs(plugin);
895
+ Pulumi.all([
896
+ outputs.id,
897
+ outputs.pluginStructure
898
+ ]).apply(param => {
899
+ let ps = param[1];
900
+ let id = param[0];
901
+ let name = Stdlib_Option.getOr(id.split("@")[0], id);
902
+ if (ps !== undefined) {
903
+ resolved.push([
904
+ name,
905
+ ps
906
+ ]);
907
+ }
908
+ if (resolved.length === expected) {
909
+ return f(resolved);
910
+ }
911
+ });
912
+ });
913
+ };
887
914
  let seedPluginStructuresStore = pluginComponents => {
888
915
  pluginComponents.forEach(plugin => {
889
916
  let outputs = Component$ReventlessCore.outputs(plugin);
@@ -1043,9 +1070,7 @@ function MakeWithConfig(Config) {
1043
1070
  eventCollector: param[2].name,
1044
1071
  extensionProtocols: [],
1045
1072
  apiSchemaFragment: Stdlib_Option.map(param[5], f => Plugin_Helpers$ReventlessCore.offloadPayload(f, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments")),
1046
- apiTarget: undefined,
1047
1073
  structure: Stdlib_Option.map(param[7], s => Plugin_Helpers$ReventlessCore.offloadPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures")),
1048
- dcbEventLog: undefined,
1049
1074
  kind: "Domain"
1050
1075
  };
1051
1076
  dispatchPluginCommand(pluginName, {
@@ -1341,7 +1366,11 @@ function MakeWithConfig(Config) {
1341
1366
  });
1342
1367
  subscribeToPluginEvents();
1343
1368
  let hostUiDir = HostShellDist$ReventlessLocal.resolve(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.assetsDir));
1344
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), hostUiDir);
1369
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined, hostUiDir);
1370
+ whenStructuresLanded(plugins$1, structures => {
1371
+ AdmissionCheck$Reventless.check(structures, LocalCapabilities$ReventlessLocal.provisioned(), OwnerScope$Reventless.elevatedGroups(), Role$Reventless.adminGroup(), true);
1372
+ Stdlib_Option.forEach(Platform_SelfRegistration$ReventlessCore.fromStructures(structures), selfRegistration => ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), selfRegistration, hostUiDir));
1373
+ });
1345
1374
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir);
1346
1375
  UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
1347
1376
  UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir);
@@ -2653,6 +2682,30 @@ function Make($star) {
2653
2682
  });
2654
2683
  });
2655
2684
  };
2685
+ let whenStructuresLanded = (pluginComponents, f) => {
2686
+ let resolved = [];
2687
+ let expected = pluginComponents.length;
2688
+ pluginComponents.forEach(plugin => {
2689
+ let outputs = Component$ReventlessCore.outputs(plugin);
2690
+ Pulumi.all([
2691
+ outputs.id,
2692
+ outputs.pluginStructure
2693
+ ]).apply(param => {
2694
+ let ps = param[1];
2695
+ let id = param[0];
2696
+ let name = Stdlib_Option.getOr(id.split("@")[0], id);
2697
+ if (ps !== undefined) {
2698
+ resolved.push([
2699
+ name,
2700
+ ps
2701
+ ]);
2702
+ }
2703
+ if (resolved.length === expected) {
2704
+ return f(resolved);
2705
+ }
2706
+ });
2707
+ });
2708
+ };
2656
2709
  let seedPluginStructuresStore = pluginComponents => {
2657
2710
  pluginComponents.forEach(plugin => {
2658
2711
  let outputs = Component$ReventlessCore.outputs(plugin);
@@ -2812,9 +2865,7 @@ function Make($star) {
2812
2865
  eventCollector: param[2].name,
2813
2866
  extensionProtocols: [],
2814
2867
  apiSchemaFragment: Stdlib_Option.map(param[5], f => Plugin_Helpers$ReventlessCore.offloadPayload(f, Plugin$Reventless.apiSchemaFragmentSchema, "pluginApiFragments")),
2815
- apiTarget: undefined,
2816
2868
  structure: Stdlib_Option.map(param[7], s => Plugin_Helpers$ReventlessCore.offloadPayload(s, Plugin$Reventless.pluginStructureSchema, "pluginStructures")),
2817
- dcbEventLog: undefined,
2818
2869
  kind: "Domain"
2819
2870
  };
2820
2871
  dispatchPluginCommand(pluginName, {
@@ -3105,7 +3156,11 @@ function Make($star) {
3105
3156
  });
3106
3157
  subscribeToPluginEvents();
3107
3158
  let hostUiDir = HostShellDist$ReventlessLocal.resolve(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.assetsDir));
3108
- ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), hostUiDir);
3159
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined, hostUiDir);
3160
+ whenStructuresLanded(plugins$1, structures => {
3161
+ AdmissionCheck$Reventless.check(structures, LocalCapabilities$ReventlessLocal.provisioned(), OwnerScope$Reventless.elevatedGroups(), Role$Reventless.adminGroup(), true);
3162
+ Stdlib_Option.forEach(Platform_SelfRegistration$ReventlessCore.fromStructures(structures), selfRegistration => ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), selfRegistration, hostUiDir));
3163
+ });
3109
3164
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir);
3110
3165
  UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), hostUiDir, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
3111
3166
  UiSlots$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiSlotsFile), hostUiDir);
@@ -50,6 +50,7 @@ let computedKeys = [
50
50
  journeyManifestsKey,
51
51
  ReventlessCore.Platform_UiSlots.configKey,
52
52
  ReventlessCore.Platform_AdminGroup.configKey,
53
+ ReventlessCore.Platform_SelfRegistration.configKey,
53
54
  ]
54
55
 
55
56
  /**
@@ -63,6 +64,9 @@ let overlay = (
63
64
  ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
64
65
  ~uiSlotsFile: option<string>=?,
65
66
  ~shellConfig: option<dict<JSON.t>>,
67
+ // Read off the plugin structures once they have landed, so the first write
68
+ // (from the declaration alone) omits it and the second carries it.
69
+ ~selfRegistration: option<ReventlessCore.Platform_SelfRegistration.config>=?,
66
70
  ): dict<JSON.t> => {
67
71
  let out =
68
72
  Reventless.ShellEnvironment.local
@@ -83,6 +87,11 @@ let overlay = (
83
87
 
84
88
  ReventlessCore.Platform_AdminGroup.configEntry()->Option.forEach(((k, v)) => out->Dict.set(k, v))
85
89
 
90
+ selfRegistration->Option.forEach(c => {
91
+ let (k, v) = ReventlessCore.Platform_SelfRegistration.configEntry(c)
92
+ out->Dict.set(k, v)
93
+ })
94
+
86
95
  bakedManifest->Option.forEach(config => {
87
96
  out->Dict.set("manifestUrl", JSON.Encode.string(manifestUrlOf(config)))
88
97
  // Omitted entirely when nothing is declared, so a single-audience deployment
@@ -138,10 +147,11 @@ let emit = (
138
147
  ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
139
148
  ~uiSlotsFile: option<string>=?,
140
149
  ~shellConfig: option<dict<JSON.t>>,
150
+ ~selfRegistration: option<ReventlessCore.Platform_SelfRegistration.config>=?,
141
151
  // The platform's chosen `assetsDir`; unset, the host-shell package's `dist/`.
142
152
  ~dir: option<string>=?,
143
153
  ) => {
144
- let overlay = overlay(~bakedManifest, ~uiSlotsFile?, ~shellConfig)
154
+ let overlay = overlay(~bakedManifest, ~uiSlotsFile?, ~shellConfig, ~selfRegistration?)
145
155
  let declared = overlay->Dict.keysToArray->Array.filter(k => !(environmentKeys->Array.includes(k)))
146
156
 
147
157
  switch switch dir {
@@ -13,6 +13,7 @@ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
13
13
  import * as Platform_UiSlots$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_UiSlots.res.mjs";
14
14
  import * as Platform_AdminGroup$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminGroup.res.mjs";
15
15
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
16
+ import * as Platform_SelfRegistration$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_SelfRegistration.res.mjs";
16
17
 
17
18
  let log = Logger$ReventlessCore.fromEnv();
18
19
 
@@ -37,11 +38,12 @@ let computedKeys = Belt_Array.concatMany([
37
38
  "manifestUrl",
38
39
  journeyManifestsKey,
39
40
  Platform_UiSlots$ReventlessCore.configKey,
40
- Platform_AdminGroup$ReventlessCore.configKey
41
+ Platform_AdminGroup$ReventlessCore.configKey,
42
+ Platform_SelfRegistration$ReventlessCore.configKey
41
43
  ]
42
44
  ]);
43
45
 
44
- function overlay(bakedManifest, uiSlotsFile, shellConfig) {
46
+ function overlay(bakedManifest, uiSlotsFile, shellConfig, selfRegistration) {
45
47
  let out = Object.fromEntries(ShellEnvironment$Reventless.configFields(ShellEnvironment$Reventless.local));
46
48
  Stdlib_Option.forEach(uiSlotsFile, param => {
47
49
  out[Platform_UiSlots$ReventlessCore.configKey] = Platform_UiSlots$ReventlessCore.url;
@@ -49,6 +51,10 @@ function overlay(bakedManifest, uiSlotsFile, shellConfig) {
49
51
  Stdlib_Option.forEach(Platform_AdminGroup$ReventlessCore.configEntry(), param => {
50
52
  out[param[0]] = param[1];
51
53
  });
54
+ Stdlib_Option.forEach(selfRegistration, c => {
55
+ let match = Platform_SelfRegistration$ReventlessCore.configEntry(c);
56
+ out[match[0]] = match[1];
57
+ });
52
58
  Stdlib_Option.forEach(bakedManifest, config => {
53
59
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
54
60
  let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(config);
@@ -86,8 +92,8 @@ function readObject(path, label) {
86
92
  }
87
93
  }
88
94
 
89
- function emit(bakedManifest, uiSlotsFile, shellConfig, dir) {
90
- let overlay$1 = overlay(bakedManifest, uiSlotsFile, shellConfig);
95
+ function emit(bakedManifest, uiSlotsFile, shellConfig, selfRegistration, dir) {
96
+ let overlay$1 = overlay(bakedManifest, uiSlotsFile, shellConfig, selfRegistration);
91
97
  let declared = Object.keys(overlay$1).filter(k => !environmentKeys.includes(k));
92
98
  let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
93
99
  if (dir$1 === undefined) {
@@ -20,7 +20,19 @@ module YG = GraphqlYoga
20
20
 
21
21
  type fetchHeaders
22
22
  type fetchRequest = {headers: fetchHeaders}
23
- type yogaInitialCtx = {request: fetchRequest}
23
+ type socket = {remoteAddress?: string}
24
+ type nodeRequest = {socket?: socket}
25
+ // `req` is the Node request yoga is handed when it serves over `http`; absent
26
+ // when the context is built some other way, such as in a test.
27
+ type yogaInitialCtx = {request: fetchRequest, req?: nodeRequest}
28
+
29
+ /** Where a request came from, as the socket says — not a forwarded header, which
30
+ the caller writes. What the limit on anonymous doors counts by. */
31
+ let sourceAddressOf = (ctx: yogaInitialCtx): string =>
32
+ ctx.req
33
+ ->Option.flatMap(r => r.socket)
34
+ ->Option.flatMap(s => s.remoteAddress)
35
+ ->Option.getOr("unknown")
24
36
 
25
37
  @send
26
38
  external headersForEach: (fetchHeaders, (string, string) => unit) => unit = "forEach"
@@ -65,7 +77,11 @@ let buildAuthContext = async (initial: YG.initialContext): JSON.t => {
65
77
  let requestContext: ReventlessCore.Auth_Adapter.requestContext = {headers: headers}
66
78
  let result = await LocalAuth.authenticate(requestContext)
67
79
  let identity = identityFromAuthResult(result)
68
- Obj.magic({"identity": identity, "authenticated": isAuthenticated(result)})
80
+ Obj.magic({
81
+ "identity": identity,
82
+ "authenticated": isAuthenticated(result),
83
+ "sourceAddress": sourceAddressOf(ctx),
84
+ })
69
85
  }
70
86
 
71
87
  // Read the identity attached by `buildAuthContext` from the resolver context.
@@ -1,10 +1,15 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Graphql from "graphql";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
5
6
  import * as LocalAuth$ReventlessLocal from "./LocalAuth.res.mjs";
6
7
  import * as Auth_RefusalVocabulary$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
7
8
 
9
+ function sourceAddressOf(ctx) {
10
+ return Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(ctx.req, r => r.socket), s => s.remoteAddress), "unknown");
11
+ }
12
+
8
13
  function extractHeaders(headers) {
9
14
  let acc = {};
10
15
  headers.forEach((value, key) => {
@@ -38,7 +43,8 @@ async function buildAuthContext(initial) {
38
43
  let identity = identityFromAuthResult(result);
39
44
  return {
40
45
  identity: identity,
41
- authenticated: isAuthenticated(result)
46
+ authenticated: isAuthenticated(result),
47
+ sourceAddress: sourceAddressOf(initial)
42
48
  };
43
49
  }
44
50
 
@@ -105,6 +111,7 @@ let YG;
105
111
 
106
112
  export {
107
113
  YG,
114
+ sourceAddressOf,
108
115
  extractHeaders,
109
116
  identityFromAuthResult,
110
117
  isAuthenticated,
@@ -26,6 +26,10 @@ let parseFile = AccountsManifest.parseFile
26
26
 
27
27
  // ── Resolution & loading ────────────────────────────────────────────────────
28
28
 
29
+ /** The usernames the accounts file gave, so a reload can forget those it no
30
+ longer lists without touching accounts registered some other way. */
31
+ let fromFile: ref<array<string>> = ref([])
32
+
29
33
  let _registerEntries = (entries: array<entry>): unit =>
30
34
  entries->Array.forEach(({username, password, groups, ?userId}) => {
31
35
  let id: Identity.t = {
@@ -63,6 +67,7 @@ let pluginChecked: ref<bool> = ref(false)
63
67
  let resetResolution = (): unit => {
64
68
  resolved := false
65
69
  pluginChecked := false
70
+ fromFile := []
66
71
  }
67
72
 
68
73
  /**
@@ -95,6 +100,7 @@ let load = (~users: option<array<entry>>=?, ~usersFile: option<string>=?, ()): r
95
100
  | Some(path) =>
96
101
  parseFile(path)->Result.map(entries => {
97
102
  _registerEntries(entries)
103
+ fromFile := entries->Array.map(e => e.username)
98
104
  UsersFile(path)
99
105
  })
100
106
  | None =>
@@ -102,6 +108,7 @@ let load = (~users: option<array<entry>>=?, ~usersFile: option<string>=?, ()): r
102
108
  if NodeFs.existsSync(defaultPath) {
103
109
  parseFile(defaultPath)->Result.map(entries => {
104
110
  _registerEntries(entries)
111
+ fromFile := entries->Array.map(e => e.username)
105
112
  DefaultFile(defaultPath)
106
113
  })
107
114
  } else {
@@ -114,13 +121,105 @@ let load = (~users: option<array<entry>>=?, ~usersFile: option<string>=?, ()): r
114
121
  }
115
122
  }
116
123
 
124
+ // ── Reloading ───────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ Reads the accounts file again: accounts it adds can sign in, changed ones sign in
128
+ with their new password and groups, and ones it no longer lists are forgotten. A
129
+ token already issued stays valid until it expires, as it carries its identity.
130
+ A file that does not parse changes nothing, so a half-saved edit never signs
131
+ everyone out. Says what it did, for the log.
132
+ */
133
+ let reload = (path: string): result<string, string> =>
134
+ switch parseFile(path) {
135
+ | Error(e) => Error(e)
136
+ | Ok(entries) =>
137
+ let names = entries->Array.map(e => e.username)
138
+ let dropped = fromFile.contents->Array.filter(n => !(names->Array.includes(n)))
139
+ dropped->Array.forEach(username => LocalAuth.Login.removeCredentials(~username))
140
+ _registerEntries(entries)
141
+ fromFile := names
142
+ Ok(
143
+ `${names->Array.length->Int.toString} accounts` ++ (
144
+ dropped == [] ? "" : `, removed: ${dropped->Array.join(", ")}`
145
+ ),
146
+ )
147
+ }
148
+
149
+ /** The roles the plugins need that the accounts no longer provide, as the
150
+ platform's start would have refused them; empty when all are provided. */
151
+ let unmetAfterReload = (): array<RoleCoverage.need> =>
152
+ switch Role.providedGroups() {
153
+ | None => []
154
+ | Some(provided) => RoleCoverage.unmet(RoleCoverage.checked.contents, ~provided)
155
+ }
156
+
157
+ let _watcher: ref<option<NodeFs.watcher>> = ref(None)
158
+ let _pending: ref<option<timeoutId>> = ref(None)
159
+
160
+ /** Stops following the accounts file. */
161
+ let unwatch = (): unit => {
162
+ _watcher.contents->Option.forEach(NodeFs.watcherClose)
163
+ _watcher := None
164
+ _pending.contents->Option.forEach(clearTimeout)
165
+ _pending := None
166
+ }
167
+
168
+ /**
169
+ Follows the accounts file, so an account the tools or the developer add can sign
170
+ in without a restart. The folder is watched, not the file, as an editor that
171
+ saves by replacing the file would silence a watch on it. One save fires several
172
+ events; they are read once, a moment after the last.
173
+ */
174
+ let watch = (path: string): unit => {
175
+ unwatch()
176
+ let dir = NodePath.dirname(path)
177
+ let base = NodePath.basename(path)
178
+ let last = ref(NodeFs.existsSync(path) ? NodeFs.readFileSync(path) : "")
179
+ switch NodeFs.watch(dir, (_, file) =>
180
+ if file->Nullable.toOption->Option.mapOr(true, f => f == base) {
181
+ _pending.contents->Option.forEach(clearTimeout)
182
+ _pending := Some(setTimeout(() => {
183
+ _pending := None
184
+ switch NodeFs.readFileSync(path) {
185
+ | text if text != last.contents =>
186
+ last := text
187
+ switch reload(path) {
188
+ | Ok(said) =>
189
+ Console.log(`[LocalAuth] ${base} changed — reloaded ${said}.`)
190
+ switch unmetAfterReload() {
191
+ | [] => ()
192
+ | missing =>
193
+ Console.warn(
194
+ `[LocalAuth] No account is in the group of: ` ++
195
+ missing
196
+ ->Array.map(RoleCoverage.describe)
197
+ ->Array.join("; ") ++ ". Every such caller is refused until one is.",
198
+ )
199
+ }
200
+ | Error(e) => Console.warn(`[LocalAuth] ${base} changed but was not read: ${e}`)
201
+ }
202
+ | _ => ()
203
+ | exception _ => ()
204
+ }
205
+ }, 100))
206
+ }
207
+ ) {
208
+ | w => _watcher := Some(w->NodeFs.watcherUnref)
209
+ | exception _ => ()
210
+ }
211
+ }
212
+
117
213
  /**
118
214
  * Idempotent auto-discovery. Platform.startServers calls this before
119
215
  * `DomainGraphQL_Server.start`, so a `.reventless/users.yaml` in cwd
120
- * hydrates the Login store automatically. No-op once `load` has been
121
- * called from anywhere.
216
+ * hydrates the Login store automatically, and follows the file from then on.
217
+ * No-op once `load` has been called from anywhere.
122
218
  */
123
219
  let autoLoadOnce = (): unit =>
124
220
  if !resolved.contents {
125
- let _ = load()
221
+ switch load() {
222
+ | Ok(DefaultFile(path)) | Ok(UsersFile(path)) => watch(path)
223
+ | _ => ()
224
+ }
126
225
  }