@reventlessdev/reventless-local 3.0.0-alpha.221 → 3.0.0-alpha.223

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,24 @@
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.223 (2026-08-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **local:** keep a dev session alive across a platform restart ([9f08206](https://github.com/ReventlessDev/reventless-core/commit/9f082060d73664720c77bb323a112a494bb68507))
11
+ ### Features
12
+
13
+ * **core:** curate the pages a shell builds across a plugin's views ([589835a](https://github.com/ReventlessDev/reventless-core/commit/589835a1ad428c5e2dea8bf6e4c64e49d2f67e0d))
14
+ * **local:** serve ui-hints.json edits without restarting the platform ([b29b104](https://github.com/ReventlessDev/reventless-core/commit/b29b1044f31f620071423c618486daef9692c614))
15
+
16
+
17
+ # 3.0.0-alpha.222 (2026-08-14)
18
+
19
+ ### Features
20
+
21
+ * **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
22
+
23
+
6
24
  # 3.0.0-alpha.221 (2026-08-14)
7
25
 
8
26
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.221",
3
+ "version": "3.0.0-alpha.223",
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-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
38
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
39
40
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
40
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.6",
42
41
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.233",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.81",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.180",
46
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.97",
47
- "@reventlessdev/reventless-seed": "1.0.0-alpha.13",
48
- "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.113"
42
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
43
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.83",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.182",
45
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.99",
47
+ "@reventlessdev/reventless-core": "3.0.0-alpha.235",
48
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.14",
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -22,6 +22,7 @@ let toSelections = (
22
22
  plugin: s.plugin,
23
23
  views: s.views,
24
24
  commands: s.commands,
25
+ derived: s.derived,
25
26
  })
26
27
 
27
28
  /**
@@ -14,7 +14,8 @@ function toSelections(components) {
14
14
  return components.map(s => ({
15
15
  plugin: s.plugin,
16
16
  views: s.views,
17
- commands: s.commands
17
+ commands: s.commands,
18
+ derived: s.derived
18
19
  }));
19
20
  }
20
21
 
package/src/Platform.res CHANGED
@@ -1642,6 +1642,19 @@ module MakeWithConfig = (
1642
1642
  // restores the shell package's own hints for a platform that has stopped
1643
1643
  // declaring its own.
1644
1644
  UiHints.emit(~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile))
1645
+ // The dev loop for the file just written. Editing hints is presentation
1646
+ // work — you change a label, you want to see the label — and routing that
1647
+ // through a platform restart puts fifteen seconds between the two. The
1648
+ // watcher re-serves the file and says so on the events channel every shell
1649
+ // already holds, so the menu restacks where it stands.
1650
+ //
1651
+ // Returned watcher deliberately dropped: it is unref'd and lives exactly as
1652
+ // long as the process, like the servers above it, and holding it to close
1653
+ // would imply a shutdown path this platform does not have.
1654
+ let _ = UiHints.watch(
1655
+ ~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile),
1656
+ ~onReload=LocalEvents_Server.broadcastUiHintsChanged,
1657
+ )
1645
1658
  switch hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest) {
1646
1659
  | None => ()
1647
1660
  | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg)
@@ -2251,7 +2264,7 @@ module MakeWithConfig = (
2251
2264
  // extracts identity from the bearer token, same as the Domain server.
2252
2265
  // Without this, Platform_* queries / mutations run as `anonymous` and
2253
2266
  // skip the group authorization that AppSync would enforce via
2254
- // `@aws_auth(cognito_groups: ["Admin"])` in production. The Domain
2267
+ // `@aws_cognito_user_pools(cognito_groups: ["Admin"])` in production. The Domain
2255
2268
  // server's `asInterface.start` accepts but ignores `~contextFactory`
2256
2269
  // because it always wires its own internal auth context.
2257
2270
  adminGraphQL.start(
@@ -1295,6 +1295,7 @@ function MakeWithConfig(Config) {
1295
1295
  subscribeToPluginEvents();
1296
1296
  ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
1297
1297
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
1298
+ UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
1298
1299
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
1299
1300
  if (cfg !== undefined) {
1300
1301
  bakeManifest(plugins$1, cfg);
@@ -1437,7 +1438,7 @@ function MakeWithConfig(Config) {
1437
1438
  });
1438
1439
  return Object.values(latestByName).map(param => param[1]);
1439
1440
  };
1440
- queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
1441
+ queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], undefined, param[1]));
1441
1442
  queryResolvers["Platform_PluginStructures"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_PluginStructuresApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
1442
1443
  queryResolvers["Platform_UIFragments"] = async (_root, _args, _ctx) => {
1443
1444
  let scanAll = Bus.getQueryDbScan(UiFragments$ReventlessCore.name);
@@ -1772,7 +1773,7 @@ function MakeWithConfig(Config) {
1772
1773
  return Platform_UIFragmentsApi$ReventlessCore.encodeUIFragmentEntry(state);
1773
1774
  });
1774
1775
  };
1775
- queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => Object.entries(pluginStructuresStore.contents).map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
1776
+ queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => Object.entries(pluginStructuresStore.contents).map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], undefined, param[1]));
1776
1777
  registerAdminItemsAndIndexResolvers(queryResolvers, true);
1777
1778
  adminGraphQL.registerQueries(baseParts.queries, queryResolvers);
1778
1779
  let mutationResolvers = {};
@@ -3020,6 +3021,7 @@ function Make($star) {
3020
3021
  subscribeToPluginEvents();
3021
3022
  ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
3022
3023
  UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
3024
+ UiHints$ReventlessLocal.watch(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined, LocalEvents_Server$ReventlessLocal.broadcastUiHintsChanged);
3023
3025
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
3024
3026
  if (cfg !== undefined) {
3025
3027
  bakeManifest(plugins$1, cfg);
@@ -3162,7 +3164,7 @@ function Make($star) {
3162
3164
  });
3163
3165
  return Object.values(latestByName).map(param => param[1]);
3164
3166
  };
3165
- queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
3167
+ queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], undefined, param[1]));
3166
3168
  queryResolvers["Platform_PluginStructures"] = async (_root, _args, _ctx) => connectedLatestStructures().map(param => Platform_PluginStructuresApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
3167
3169
  queryResolvers["Platform_UIFragments"] = async (_root, _args, _ctx) => {
3168
3170
  let scanAll = Bus.getQueryDbScan(UiFragments$ReventlessCore.name);
@@ -3488,7 +3490,7 @@ function Make($star) {
3488
3490
  return Platform_UIFragmentsApi$ReventlessCore.encodeUIFragmentEntry(state);
3489
3491
  });
3490
3492
  };
3491
- queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => Object.entries(pluginStructuresStore.contents).map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], param[1]));
3493
+ queryResolvers["Platform_ComponentDefinitions"] = async (_root, _args, _ctx) => Object.entries(pluginStructuresStore.contents).map(param => Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(param[0], undefined, param[1]));
3492
3494
  registerAdminItemsAndIndexResolvers(queryResolvers, true);
3493
3495
  adminGraphQL.registerQueries(baseParts.queries, queryResolvers);
3494
3496
  let mutationResolvers = {};
package/src/UiHints.res CHANGED
@@ -105,3 +105,85 @@ let emit = (
105
105
  }
106
106
  }
107
107
  }
108
+
109
+ /**
110
+ Re-copy the declared hints into the served `dist/` whenever the file changes,
111
+ so editing it is a browser refresh rather than a platform restart.
112
+
113
+ Local only, and deliberately so: on AWS the file is an object written once by a
114
+ deploy, and "the running deployment follows my working copy" is not a thing a
115
+ deployment should be able to do. This is the dev loop, in the package that is
116
+ the dev loop.
117
+
118
+ **Failures here are logged, not thrown**, which is the one place this parts
119
+ company with `emit`. At boot a declaration that does not resolve or does not
120
+ parse is the deployment's mistake and taking the process down is the whole
121
+ point. Mid-session it is almost always an editor: a save that writes in two
122
+ steps is briefly a truncated file, and reading it at that instant is normal
123
+ rather than wrong. Killing a running dev server over a keystroke would make the
124
+ feature worse than the restart it replaces — so a bad read is reported and the
125
+ previously served copy stands until the next event, which the save itself
126
+ produces.
127
+
128
+ `onReload` runs only after a re-copy actually succeeded, so a subscriber cannot
129
+ be told to re-fetch a file that did not change.
130
+ */
131
+ let watch = (
132
+ ~uiHintsFile: option<string>,
133
+ // The same seam `emit` takes, and threaded to it: a test has to be able to
134
+ // watch a file it wrote into a temp dir without a host-shell package on disk.
135
+ ~dir: option<string>=?,
136
+ ~onReload: unit => unit,
137
+ ): option<NodeFs.watcher> =>
138
+ uiHintsFile->Option.flatMap(path => {
139
+ // Named apart from the `~dir` above, which is where the file is SERVED. This
140
+ // is where it is AUTHORED, and the two are never the same place — letting
141
+ // one shadow the other would re-serve the hints into the source tree beside
142
+ // the file just edited.
143
+ let sourceDir = NodePath.dirname(path)
144
+ let base = NodePath.basename(path)
145
+ if !NodeFs.existsSync(sourceDir) {
146
+ // `emit` has already thrown on an unreadable declaration by the time this
147
+ // is reached, so this is the narrow case of a path whose directory went
148
+ // away between the two — worth a line, not worth a throw.
149
+ log.warn(
150
+ ~comp="UiHints",
151
+ `not watching ${base}: ${sourceDir} does not exist, so changes to the declared ` ++
152
+ `uiHintsFile will need a restart`,
153
+ )
154
+ None
155
+ } else {
156
+ // Editors coalesce badly: one save can raise `rename` and `change` within
157
+ // a millisecond of each other, and re-copying twice would publish twice
158
+ // and restack the menu twice. The trailing timer collapses a burst into
159
+ // the single reload the developer actually made.
160
+ let pending = ref(None)
161
+ let reload = () => {
162
+ pending := None
163
+ switch emit(~uiHintsFile, ~dir?) {
164
+ | () =>
165
+ log.info(~comp="UiHints", `${base} changed — re-served`)
166
+ onReload()
167
+ | exception JsExn(e) =>
168
+ log.warn(
169
+ ~comp="UiHints",
170
+ `${base} changed but could not be re-served: ` ++
171
+ e->JsExn.message->Option.getOr("unknown error"),
172
+ )
173
+ }
174
+ }
175
+ let watcher = NodeFs.watch(sourceDir, (_event, filename) =>
176
+ switch filename->Nullable.toOption {
177
+ | Some(name) if name == base =>
178
+ pending.contents->Option.forEach(clearTimeout)
179
+ pending := Some(setTimeout(reload, 50))
180
+ | _ => ()
181
+ }
182
+ )
183
+ log.info(~comp="UiHints", `watching ${path} — edits are served without a restart`)
184
+ // Never the reason a process stays alive. A platform booted by a test
185
+ // that happens to declare hints would otherwise hold the event loop open
186
+ // and hang the run.
187
+ Some(watcher->NodeFs.watcherUnref)
188
+ }
189
+ })
@@ -2,8 +2,11 @@
2
2
 
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
9
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
10
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
11
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
9
12
 
@@ -52,10 +55,50 @@ function emit(uiHintsFile, dir) {
52
55
  }
53
56
  }
54
57
 
58
+ function watch(uiHintsFile, dir, onReload) {
59
+ return Stdlib_Option.flatMap(uiHintsFile, path => {
60
+ let sourceDir = Nodepath.dirname(path);
61
+ let base = Nodepath.basename(path);
62
+ if (Nodefs.existsSync(sourceDir)) {
63
+ let pending = {
64
+ contents: undefined
65
+ };
66
+ let reload = () => {
67
+ pending.contents = undefined;
68
+ let val;
69
+ try {
70
+ val = emit(uiHintsFile, dir);
71
+ } catch (raw_e) {
72
+ let e = Primitive_exceptions.internalToException(raw_e);
73
+ if (e.RE_EXN_ID === "JsExn") {
74
+ return log.warn("UiHints", undefined, base + ` changed but could not be re-served: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(e._1), "unknown error"));
75
+ }
76
+ throw e;
77
+ }
78
+ log.info("UiHints", undefined, base + ` changed — re-served`);
79
+ onReload();
80
+ };
81
+ let watcher = Nodefs.watch(sourceDir, (_event, filename) => {
82
+ if (!(filename == null) && filename === base) {
83
+ Stdlib_Option.forEach(pending.contents, prim => {
84
+ clearTimeout(prim);
85
+ });
86
+ pending.contents = Primitive_option.some(setTimeout(reload, 50));
87
+ return;
88
+ }
89
+ });
90
+ log.info("UiHints", undefined, `watching ` + path + ` — edits are served without a restart`);
91
+ return Primitive_option.some(watcher.unref());
92
+ }
93
+ log.warn("UiHints", undefined, `not watching ` + base + `: ` + sourceDir + ` does not exist, so changes to the declared uiHintsFile will need a restart`);
94
+ });
95
+ }
96
+
55
97
  export {
56
98
  log,
57
99
  fileName,
58
100
  baselineFileName,
59
101
  emit,
102
+ watch,
60
103
  }
61
104
  /* log Not a pure module */
@@ -100,6 +100,30 @@ let broadcast = (~channel: string, ~event: string): unit =>
100
100
  )
101
101
  )
102
102
 
103
+ /** The channel a shell listens on to learn that this platform re-served its
104
+ hints file.
105
+
106
+ A fixed path rather than a derived one because it names no entity: there is
107
+ one hints file per deployment and one thing to say about it. Under `/dev/`
108
+ so it reads as what it is — a local development signal with no counterpart
109
+ on AWS, where the file is an object a deploy writes once.
110
+
111
+ Kept here, beside the broadcaster, because the string has to be identical on
112
+ both sides of a socket and a channel nothing listens to fails silently: the
113
+ connection stays open and the message simply lands nowhere. */
114
+ let devUiHintsChannel = "/default/dev/uiHints"
115
+
116
+ /** Tell every connected shell that `ui-hints.json` has been re-served.
117
+
118
+ The payload carries no hints. The shell re-fetches the file it already knows
119
+ how to fetch, which keeps this a cache-invalidation signal rather than a
120
+ second delivery path that could disagree with the first. */
121
+ let broadcastUiHintsChanged = (): unit =>
122
+ broadcast(
123
+ ~channel=devUiHintsChannel,
124
+ ~event=frame([("kind", JSON.Encode.string("uiHintsChanged"))]),
125
+ )
126
+
103
127
  /** LocalBus bridge: a Source B change descriptor becomes a publish on the
104
128
  same channel the AWS StateTopic Lambda would use. No-op without matching
105
129
  subscribers, so wiring order against server start doesn't matter. */
@@ -95,6 +95,15 @@ function broadcast(channel, event) {
95
95
  });
96
96
  }
97
97
 
98
+ let devUiHintsChannel = "/default/dev/uiHints";
99
+
100
+ function broadcastUiHintsChanged() {
101
+ broadcast(devUiHintsChannel, frame([[
102
+ "kind",
103
+ "uiHintsChanged"
104
+ ]]));
105
+ }
106
+
98
107
  function broadcastStateChange(name, descriptor) {
99
108
  let entityKey = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(descriptor), o => o["id"]), Stdlib_JSON.Decode.string), "");
100
109
  if (entityKey === "") {
@@ -328,6 +337,8 @@ export {
328
337
  connectionAckFrame,
329
338
  dataFrame,
330
339
  broadcast,
340
+ devUiHintsChannel,
341
+ broadcastUiHintsChanged,
331
342
  broadcastStateChange,
332
343
  decodeStringField,
333
344
  handleFrame,
@@ -7,8 +7,8 @@
7
7
  // - Invalid bearer is rejected at HTTP level before this runs (see
8
8
  // `DomainGraphQL_Server._dispatch`); on the admin server there is no
9
9
  // dispatch layer, so an invalid bearer reaches this as an `AuthError`.
10
- // AppSync enforces the same `@aws_auth(cognito_groups: ["Admin"])`
11
- // directive at the schema layer in production.
10
+ // AppSync enforces the same `@aws_cognito_user_pools(cognito_groups:
11
+ // ["Admin"])` directive at the schema layer in production.
12
12
  //
13
13
  // The outcome is carried onto the context beside the identity, not folded into
14
14
  // it. Whether credentials verified and whether the verified caller holds a group
@@ -97,8 +97,9 @@ let extractAuthenticated = (ctx: JSON.t): bool =>
97
97
  }
98
98
 
99
99
  // An unauthorized caller reads the reason: `GraphQL_CallerError` explains why a
100
- // resolver has to construct the error rather than throw a bare one. Mirrors the
101
- // directive-level `@aws_auth` rejection that AppSync surfaces in production.
100
+ // resolver has to construct the error rather than throw a bare one. The AWS
101
+ // counterpart is the field-level refusal AppSync returns for a caller who fails
102
+ // an `@aws_cognito_user_pools(cognito_groups: [...])` gate.
102
103
  let makeGraphqlError = GraphQL_CallerError.make
103
104
 
104
105
  /**
@@ -112,7 +113,7 @@ let makeGraphqlError = GraphQL_CallerError.make
112
113
  let unauthorizedError = (~group: string): exn =>
113
114
  makeGraphqlError(
114
115
  `Unauthorized: requires group "${group}"`,
115
- {"extensions": {"code": "UNAUTHORIZED"}},
116
+ {"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localIdentityCode}},
116
117
  )
117
118
 
118
119
  /**
@@ -128,13 +129,15 @@ let unauthorizedError = (~group: string): exn =>
128
129
  let forbiddenError = (~group: string): exn =>
129
130
  makeGraphqlError(
130
131
  `Forbidden: requires group "${group}"`,
131
- {"extensions": {"code": "FORBIDDEN"}},
132
+ {"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localEntitlementCode}},
132
133
  )
133
134
 
134
135
  // Wrap a resolver so it refuses a caller whose identity lacks the required
135
- // group. Mirrors AppSync's `@aws_auth(cognito_groups: [...])` semantics for
136
- // admin fields on the in-memory adapter, save that AppSync has one rejection to
137
- // give and this has two. Use for fields whose corresponding read-model entry
136
+ // group. Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: [...])`
137
+ // semantics for admin fields on the in-memory adapter. Both paths draw the same
138
+ // two-way distinction AppSync as HTTP 401 versus a field error, this as two
139
+ // `extensions.code` values — and `ReventlessCore.Auth_RefusalVocabulary` is the
140
+ // mapping between them. Use for fields whose corresponding read-model entry
138
141
  // carries `authorization: Some({group, ...})` — pass the group string here.
139
142
  let requireGroup = (~group: string, resolver: YG.resolverFn): YG.resolverFn =>
140
143
  async (root, args, ctx) => {
@@ -3,6 +3,7 @@
3
3
  import * as Graphql from "graphql";
4
4
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
5
5
  import * as LocalAuth$ReventlessLocal from "./LocalAuth.res.mjs";
6
+ import * as Auth_RefusalVocabulary$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
6
7
 
7
8
  function extractHeaders(headers) {
8
9
  let acc = {};
@@ -74,7 +75,7 @@ function makeGraphqlError(prim0, prim1) {
74
75
  function unauthorizedError(group) {
75
76
  return new Graphql.GraphQLError(`Unauthorized: requires group "` + group + `"`, {
76
77
  extensions: {
77
- code: "UNAUTHORIZED"
78
+ code: Auth_RefusalVocabulary$ReventlessCore.localIdentityCode
78
79
  }
79
80
  });
80
81
  }
@@ -82,7 +83,7 @@ function unauthorizedError(group) {
82
83
  function forbiddenError(group) {
83
84
  return new Graphql.GraphQLError(`Forbidden: requires group "` + group + `"`, {
84
85
  extensions: {
85
- code: "FORBIDDEN"
86
+ code: Auth_RefusalVocabulary$ReventlessCore.localEntitlementCode
86
87
  }
87
88
  });
88
89
  }
@@ -89,22 +89,81 @@ module Login = {
89
89
 
90
90
  // ── HMAC secret (lazy) ──
91
91
  //
92
- // Reads `REVENTLESS_INMEMORY_TOKEN_SECRET` once on first use. When the env
93
- // var is set (≥16 chars) the issued tokens survive process restarts, which
94
- // matches the dev loop's expectation that a logged-in tab keeps working
95
- // after a backend reload. When unset, falls back to a random per-process
96
- // secret secure for ephemeral test runs but invalidates every previously
97
- // issued token on every restart.
92
+ // Resolved once on first use, from three sources in order:
93
+ //
94
+ // 1. `REVENTLESS_INMEMORY_TOKEN_SECRET` (≥16 chars) an explicit pin, for
95
+ // a deployment or a test that wants to state the secret itself.
96
+ // 2. `.reventless/token-secret` beside the platform's other local state,
97
+ // minted on the first boot that finds the directory and reused by every
98
+ // boot after. This is what keeps a logged-in tab working across a
99
+ // restart, which is not a nicety: `tsx watch` re-execs the process on
100
+ // every ReScript rebuild, so a per-process secret logs the developer out
101
+ // several times an hour, mid-task, with a "reconnecting" spinner as the
102
+ // only explanation.
103
+ // 3. A random 32 bytes, per process — today's behaviour, kept for the run
104
+ // that has no `.reventless/` to write to.
105
+ //
106
+ // The directory is used but never created. Its presence is what distinguishes
107
+ // a platform someone develops against — `pnpm run setup` puts `users.yaml`
108
+ // there, so anything with logins to preserve has one — from a unit test, and
109
+ // an auth module that made directories would leave one in every test's cwd.
110
+ //
111
+ // Local dev only, as the whole module is: these tokens are not security-grade
112
+ // and AWS never sees them. The file sits in a gitignored directory beside
113
+ // `users.yaml`, which already holds plaintext dev passwords.
114
+
115
+ let _secretFileName = "token-secret"
116
+ let _secretDir = (): string => NodePath.join([NodeProcess.cwd(), ".reventless"])
117
+
118
+ let _mint = (): string => NodeCrypto.randomBytes(32)->NodeCrypto.bufferToString("hex")
119
+
120
+ // A short or empty file is treated as absent rather than as an error: it is
121
+ // either a half-written mint or somebody's experiment, and either way the
122
+ // recovery — mint a new one over it — is the same and costs one login.
123
+ let _readPersistedAt = (path: string): option<string> =>
124
+ switch NodeFs.readFileSync(path) {
125
+ | contents if String.length(String.trim(contents)) >= 16 => Some(String.trim(contents))
126
+ | _ => None
127
+ | exception _ => None
128
+ }
129
+
130
+ // Failure to write is not failure to boot. A read-only checkout still gets a
131
+ // working platform; what it loses is the session surviving the next restart,
132
+ // which is exactly what it had before this existed.
133
+ let _persistAt = (path: string, secret: string): unit =>
134
+ switch NodeFs.writeFileSync(path, secret) {
135
+ | () => ()
136
+ | exception _ => ()
137
+ }
138
+
139
+ /** The ladder, against a stated directory. Separate from `_getSecret` so a
140
+ test can exercise every rung without a `.reventless/` in its own working
141
+ directory — which it must not have, since the rule below is precisely that
142
+ the directory's presence decides whether anything is written at all. */
143
+ let _resolveIn = (~dir: string): string =>
144
+ switch NodeProcess.env->Dict.get("REVENTLESS_INMEMORY_TOKEN_SECRET") {
145
+ | Some(envSecret) if String.length(envSecret) >= 16 => envSecret
146
+ | _ =>
147
+ if NodeFs.existsSync(dir) {
148
+ let path = NodePath.join([dir, _secretFileName])
149
+ switch _readPersistedAt(path) {
150
+ | Some(persisted) => persisted
151
+ | None =>
152
+ let minted = _mint()
153
+ _persistAt(path, minted)
154
+ minted
155
+ }
156
+ } else {
157
+ _mint()
158
+ }
159
+ }
98
160
 
99
161
  let _secret: ref<option<string>> = ref(None)
100
162
  let _getSecret = (): string =>
101
163
  switch _secret.contents {
102
164
  | Some(s) => s
103
165
  | None =>
104
- let s = switch NodeProcess.env->Dict.get("REVENTLESS_INMEMORY_TOKEN_SECRET") {
105
- | Some(envSecret) if String.length(envSecret) >= 16 => envSecret
106
- | _ => NodeCrypto.randomBytes(32)->NodeCrypto.bufferToString("hex")
107
- }
166
+ let s = _resolveIn(~dir=_secretDir())
108
167
  _secret := Some(s)
109
168
  s
110
169
  }
@@ -1,6 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
+ import * as Nodefs from "node:fs";
5
+ import * as Nodepath from "node:path";
4
6
  import * as Nodecrypto from "node:crypto";
5
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
8
  import * as Pulumi from "@pulumi/pulumi";
@@ -81,6 +83,55 @@ function resetStore() {
81
83
  store.contents = {};
82
84
  }
83
85
 
86
+ let _secretFileName = "token-secret";
87
+
88
+ function _secretDir() {
89
+ return Nodepath.join(process.cwd(), ".reventless");
90
+ }
91
+
92
+ function _mint() {
93
+ return Nodecrypto.randomBytes(32).toString("hex");
94
+ }
95
+
96
+ function _readPersistedAt(path) {
97
+ let contents;
98
+ try {
99
+ contents = Nodefs.readFileSync(path, "utf8");
100
+ } catch (exn) {
101
+ return;
102
+ }
103
+ if (contents.trim().length >= 16) {
104
+ return contents.trim();
105
+ }
106
+ }
107
+
108
+ function _persistAt(path, secret) {
109
+ try {
110
+ Nodefs.writeFileSync(path, secret, "utf8");
111
+ return;
112
+ } catch (exn) {
113
+ return;
114
+ }
115
+ }
116
+
117
+ function _resolveIn(dir) {
118
+ let envSecret = process.env["REVENTLESS_INMEMORY_TOKEN_SECRET"];
119
+ if (envSecret !== undefined && envSecret.length >= 16) {
120
+ return envSecret;
121
+ }
122
+ if (!Nodefs.existsSync(dir)) {
123
+ return Nodecrypto.randomBytes(32).toString("hex");
124
+ }
125
+ let path = Nodepath.join(dir, _secretFileName);
126
+ let persisted = _readPersistedAt(path);
127
+ if (persisted !== undefined) {
128
+ return persisted;
129
+ }
130
+ let minted = Nodecrypto.randomBytes(32).toString("hex");
131
+ _persistAt(path, minted);
132
+ return minted;
133
+ }
134
+
84
135
  let _secret = {
85
136
  contents: undefined
86
137
  };
@@ -90,8 +141,7 @@ function _getSecret() {
90
141
  if (s !== undefined) {
91
142
  return s;
92
143
  }
93
- let envSecret = process.env["REVENTLESS_INMEMORY_TOKEN_SECRET"];
94
- let s$1 = envSecret !== undefined && envSecret.length >= 16 ? envSecret : Nodecrypto.randomBytes(32).toString("hex");
144
+ let s$1 = _resolveIn(_secretDir());
95
145
  _secret.contents = s$1;
96
146
  return s$1;
97
147
  }
@@ -231,6 +281,12 @@ let Login = {
231
281
  store: store,
232
282
  setCredentials: setCredentials,
233
283
  resetStore: resetStore,
284
+ _secretFileName: _secretFileName,
285
+ _secretDir: _secretDir,
286
+ _mint: _mint,
287
+ _readPersistedAt: _readPersistedAt,
288
+ _persistAt: _persistAt,
289
+ _resolveIn: _resolveIn,
234
290
  _secret: _secret,
235
291
  _getSecret: _getSecret,
236
292
  setTokenSecret: setTokenSecret,
@@ -11,8 +11,8 @@
11
11
  // Authorization: every query AND mutation resolver registered here is
12
12
  // wrapped with `Auth_GraphqlContext.requireGroup(~group="Admin")` so
13
13
  // non-Admin identities are refused before the underlying resolver runs.
14
- // Mirrors AppSync's `@aws_auth(cognito_groups: ["Admin"])` directive that
15
- // gates Platform_* fields in the AWS adapter.
14
+ // Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: ["Admin"])`
15
+ // directive that gates Platform_* fields in the AWS adapter.
16
16
  //
17
17
  // The refusal names which kind it is: `FORBIDDEN` for a caller the server
18
18
  // identified who does not hold the group, `UNAUTHORIZED` for one it could not
@@ -92,3 +92,97 @@ describe("UiHints.emit", () => {
92
92
  expect(NodeFs.existsSync(baselinePath(dir)))->toBe(false)
93
93
  })
94
94
  })
95
+
96
+ // The dev loop: the platform follows the declared file so editing hints is a
97
+ // browser refresh rather than a restart. Real `fs.watch` events rather than a
98
+ // stubbed clock, because what is being tested IS the plumbing — a debounce over
99
+ // a fake timer would pass with the watcher wired to nothing.
100
+ //
101
+ // The rule these turn on is the one place `watch` parts company with `emit`: at
102
+ // boot a bad file is the deployment's mistake and taking the process down is the
103
+ // point, while mid-session it is almost always an editor mid-save, and killing a
104
+ // running dev server over a keystroke is worse than the restart this replaces.
105
+
106
+ // Both exits close the watcher AND clear the deadline. A watcher is `unref`ed
107
+ // and would not hold the run open on its own, but a live 4-second timer would —
108
+ // and "Jest did not exit" on a suite that passed is exactly the noise that
109
+ // teaches a reader to ignore it.
110
+ let waitForReload = (~timeoutMs: int=4000, ~afterWatching: unit => unit, ~uiHintsFile, ~dir) =>
111
+ Promise.make((resolve, _) => {
112
+ let fired = ref(0)
113
+ let watcher = ref(None)
114
+ let deadline = ref(None)
115
+ // Resolves with a count rather than rejecting on the deadline: "nothing was
116
+ // re-served" is the expected answer in half these cases, and a rejection
117
+ // would make the assertion read as an infrastructure failure.
118
+ let finish = () => {
119
+ watcher.contents->Option.forEach(NodeFs.watcherClose)
120
+ deadline.contents->Option.forEach(clearTimeout)
121
+ resolve(fired.contents)
122
+ }
123
+ watcher :=
124
+ UiHints.watch(~uiHintsFile, ~dir, ~onReload=() => {
125
+ fired := fired.contents + 1
126
+ finish()
127
+ })
128
+ deadline := Some(setTimeout(finish, timeoutMs))
129
+ afterWatching()
130
+ })
131
+
132
+ describe("UiHints.watch", () => {
133
+ testSync("watches nothing when the platform declares no hints file", () =>
134
+ expect(UiHints.watch(~uiHintsFile=None, ~onReload=() => ()))->toEqual(None)
135
+ )
136
+
137
+ testSync("declines to watch a path whose directory is gone, without throwing", () => {
138
+ let missing = NodePath.join([tmpdir("reventless-uihints-gone-"), "nowhere", "ui-hints.json"])
139
+ expect(UiHints.watch(~uiHintsFile=Some(missing), ~onReload=() => ()))->toEqual(None)
140
+ })
141
+
142
+ test("re-serves the file when it changes, and says so", async () => {
143
+ let dir = distWithHints()
144
+ let path = declaredFile(declared)
145
+ UiHints.emit(~uiHintsFile=Some(path), ~dir)
146
+ let edited = `{"Catalog": {"views": {"Products": {"nav": {"label": "Edited"}}}}}`
147
+ let fired = await waitForReload(
148
+ ~uiHintsFile=Some(path),
149
+ ~dir,
150
+ ~afterWatching=() => NodeFs.writeFileSync(path, edited),
151
+ )
152
+ expect((fired > 0, served(dir)))->toEqual((true, edited))
153
+ })
154
+
155
+ // The save that arrives in two writes. Serving the truncated middle would put
156
+ // a file the shell cannot parse in front of every caller; throwing would end
157
+ // the session over a keystroke. Neither: the previous copy stands.
158
+ test("keeps serving the last good copy when the file is momentarily not JSON", async () => {
159
+ let dir = distWithHints()
160
+ let path = declaredFile(declared)
161
+ UiHints.emit(~uiHintsFile=Some(path), ~dir)
162
+ let fired = await waitForReload(
163
+ ~timeoutMs=1500,
164
+ ~uiHintsFile=Some(path),
165
+ ~dir,
166
+ ~afterWatching=() => NodeFs.writeFileSync(path, `{"Catalog": {"views":`),
167
+ )
168
+ expect((fired, served(dir)))->toEqual((0, declared))
169
+ })
170
+
171
+ // …and the save completing is itself the next event, so recovery needs no
172
+ // second trigger. This is why a failed reload can be silent about retrying.
173
+ test("recovers on the write that finishes the save", async () => {
174
+ let dir = distWithHints()
175
+ let path = declaredFile(declared)
176
+ UiHints.emit(~uiHintsFile=Some(path), ~dir)
177
+ let whole = `{"Catalog": {"views": {"Products": {"nav": {"label": "Whole"}}}}}`
178
+ let fired = await waitForReload(
179
+ ~uiHintsFile=Some(path),
180
+ ~dir,
181
+ ~afterWatching=() => {
182
+ NodeFs.writeFileSync(path, `{"Catalog": {"views":`)
183
+ let _ = setTimeout(() => NodeFs.writeFileSync(path, whole), 200)
184
+ },
185
+ )
186
+ expect((fired > 0, served(dir)))->toEqual((true, whole))
187
+ })
188
+ })
@@ -3,6 +3,8 @@
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodeos from "node:os";
5
5
  import * as Nodepath from "node:path";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
6
8
  import * as UiHints$ReventlessLocal from "../src/UiHints.res.mjs";
7
9
  import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
8
10
 
@@ -86,6 +88,96 @@ globalThis.describe("UiHints.emit", () => {
86
88
  });
87
89
  });
88
90
 
91
+ function waitForReload(timeoutMsOpt, afterWatching, uiHintsFile, dir) {
92
+ let timeoutMs = timeoutMsOpt !== undefined ? timeoutMsOpt : 4000;
93
+ return new Promise((resolve, param) => {
94
+ let fired = {
95
+ contents: 0
96
+ };
97
+ let watcher = {
98
+ contents: undefined
99
+ };
100
+ let deadline = {
101
+ contents: undefined
102
+ };
103
+ let finish = () => {
104
+ Stdlib_Option.forEach(watcher.contents, prim => {
105
+ prim.close();
106
+ });
107
+ Stdlib_Option.forEach(deadline.contents, prim => {
108
+ clearTimeout(prim);
109
+ });
110
+ resolve(fired.contents);
111
+ };
112
+ watcher.contents = UiHints$ReventlessLocal.watch(uiHintsFile, dir, () => {
113
+ fired.contents = fired.contents + 1 | 0;
114
+ finish();
115
+ });
116
+ deadline.contents = Primitive_option.some(setTimeout(finish, timeoutMs));
117
+ afterWatching();
118
+ });
119
+ }
120
+
121
+ globalThis.describe("UiHints.watch", () => {
122
+ globalThis.test("watches nothing when the platform declares no hints file", () => {
123
+ globalThis.expect(UiHints$ReventlessLocal.watch(undefined, undefined, () => {})).toEqual(undefined);
124
+ });
125
+ globalThis.test("declines to watch a path whose directory is gone, without throwing", () => {
126
+ let missing = Nodepath.join(tmpdir("reventless-uihints-gone-"), "nowhere", "ui-hints.json");
127
+ globalThis.expect(UiHints$ReventlessLocal.watch(missing, undefined, () => {})).toEqual(undefined);
128
+ });
129
+ globalThis.test("re-serves the file when it changes, and says so", async () => {
130
+ let dir = distWithHints();
131
+ let path = declaredFile(declared);
132
+ UiHints$ReventlessLocal.emit(path, dir);
133
+ let edited = `{"Catalog": {"views": {"Products": {"nav": {"label": "Edited"}}}}}`;
134
+ let fired = await waitForReload(undefined, () => {
135
+ Nodefs.writeFileSync(path, edited, "utf8");
136
+ }, path, dir);
137
+ globalThis.expect([
138
+ fired > 0,
139
+ served(dir)
140
+ ]).toEqual([
141
+ true,
142
+ edited
143
+ ]);
144
+ });
145
+ globalThis.test("keeps serving the last good copy when the file is momentarily not JSON", async () => {
146
+ let dir = distWithHints();
147
+ let path = declaredFile(declared);
148
+ UiHints$ReventlessLocal.emit(path, dir);
149
+ let fired = await waitForReload(1500, () => {
150
+ Nodefs.writeFileSync(path, `{"Catalog": {"views":`, "utf8");
151
+ }, path, dir);
152
+ globalThis.expect([
153
+ fired,
154
+ served(dir)
155
+ ]).toEqual([
156
+ 0,
157
+ declared
158
+ ]);
159
+ });
160
+ globalThis.test("recovers on the write that finishes the save", async () => {
161
+ let dir = distWithHints();
162
+ let path = declaredFile(declared);
163
+ UiHints$ReventlessLocal.emit(path, dir);
164
+ let whole = `{"Catalog": {"views": {"Products": {"nav": {"label": "Whole"}}}}}`;
165
+ let fired = await waitForReload(undefined, () => {
166
+ Nodefs.writeFileSync(path, `{"Catalog": {"views":`, "utf8");
167
+ setTimeout(() => {
168
+ Nodefs.writeFileSync(path, whole, "utf8");
169
+ }, 200);
170
+ }, path, dir);
171
+ globalThis.expect([
172
+ fired > 0,
173
+ served(dir)
174
+ ]).toEqual([
175
+ true,
176
+ whole
177
+ ]);
178
+ });
179
+ });
180
+
89
181
  export {
90
182
  shipped,
91
183
  declared,
@@ -95,5 +187,6 @@ export {
95
187
  served,
96
188
  baselinePath,
97
189
  threw,
190
+ waitForReload,
98
191
  }
99
192
  /* Not a pure module */
@@ -436,3 +436,57 @@ describe("the conformance table, minted locally", () => {
436
436
  })
437
437
  )
438
438
  })
439
+
440
+ // The secret the tokens are signed with, and the one thing that decides whether
441
+ // a logged-in tab survives a backend restart.
442
+ //
443
+ // It is not the manual restart that makes this matter. `tsx watch` re-execs the
444
+ // process on every ReScript rebuild, so a per-process secret signs the developer
445
+ // out several times an hour, mid-task — and the symptom is a socket that will
446
+ // not reconnect rather than anything that says "log in again".
447
+ describe("Login token secret", () => {
448
+ let tmpdir = prefix => NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), prefix]))
449
+ let secretPath = dir => NodePath.join([dir, "token-secret"])
450
+
451
+ testSync("mints one into a directory that exists, and reuses it next boot", () => {
452
+ let dir = tmpdir("reventless-secret-")
453
+ let first = LocalAuth.Login._resolveIn(~dir)
454
+ // A second call with no memoised value is what the next process does.
455
+ let second = LocalAuth.Login._resolveIn(~dir)
456
+ expect((first == second, NodeFs.existsSync(secretPath(dir)), String.length(first) >= 16))->toEqual((
457
+ true,
458
+ true,
459
+ true,
460
+ ))
461
+ })
462
+
463
+ testSync("reads a secret a previous boot left behind", () => {
464
+ let dir = tmpdir("reventless-secret-")
465
+ NodeFs.writeFileSync(secretPath(dir), "a-previous-boots-secret-value")
466
+ expect(LocalAuth.Login._resolveIn(~dir))->toEqual("a-previous-boots-secret-value")
467
+ })
468
+
469
+ // The directory is used, never created — its absence is what tells a unit test
470
+ // apart from a platform someone develops against, and an auth module that made
471
+ // directories would leave one in every test's working directory.
472
+ testSync("writes nothing when there is no .reventless directory", () => {
473
+ let dir = NodePath.join([tmpdir("reventless-secret-"), "absent"])
474
+ let secret = LocalAuth.Login._resolveIn(~dir)
475
+ expect((NodeFs.existsSync(dir), String.length(secret) >= 16))->toEqual((false, true))
476
+ })
477
+
478
+ testSync("gives a different secret per run when nothing is persisted", () => {
479
+ let dir = NodePath.join([tmpdir("reventless-secret-"), "absent"])
480
+ expect(LocalAuth.Login._resolveIn(~dir) == LocalAuth.Login._resolveIn(~dir))->toEqual(false)
481
+ })
482
+
483
+ // Ignoring a half-written file costs one login; treating it as the secret
484
+ // would sign tokens nothing can verify, which costs the same login and a
485
+ // debugging session.
486
+ testSync("mints over a file too short to be a secret", () => {
487
+ let dir = tmpdir("reventless-secret-")
488
+ NodeFs.writeFileSync(secretPath(dir), "short")
489
+ let resolved = LocalAuth.Login._resolveIn(~dir)
490
+ expect((resolved == "short", String.length(resolved) >= 16))->toEqual((false, true))
491
+ })
492
+ })
@@ -1,5 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
3
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
8
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
@@ -410,6 +413,56 @@ globalThis.describe("the conformance table, minted locally", () => {
410
413
  });
411
414
  });
412
415
 
416
+ globalThis.describe("Login token secret", () => {
417
+ let tmpdir = prefix => Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), prefix));
418
+ globalThis.test("mints one into a directory that exists, and reuses it next boot", () => {
419
+ let dir = tmpdir("reventless-secret-");
420
+ let first = LocalAuth$ReventlessLocal.Login._resolveIn(dir);
421
+ let second = LocalAuth$ReventlessLocal.Login._resolveIn(dir);
422
+ globalThis.expect([
423
+ first === second,
424
+ Nodefs.existsSync(Nodepath.join(dir, "token-secret")),
425
+ first.length >= 16
426
+ ]).toEqual([
427
+ true,
428
+ true,
429
+ true
430
+ ]);
431
+ });
432
+ globalThis.test("reads a secret a previous boot left behind", () => {
433
+ let dir = tmpdir("reventless-secret-");
434
+ Nodefs.writeFileSync(Nodepath.join(dir, "token-secret"), "a-previous-boots-secret-value", "utf8");
435
+ globalThis.expect(LocalAuth$ReventlessLocal.Login._resolveIn(dir)).toEqual("a-previous-boots-secret-value");
436
+ });
437
+ globalThis.test("writes nothing when there is no .reventless directory", () => {
438
+ let dir = Nodepath.join(tmpdir("reventless-secret-"), "absent");
439
+ let secret = LocalAuth$ReventlessLocal.Login._resolveIn(dir);
440
+ globalThis.expect([
441
+ Nodefs.existsSync(dir),
442
+ secret.length >= 16
443
+ ]).toEqual([
444
+ false,
445
+ true
446
+ ]);
447
+ });
448
+ globalThis.test("gives a different secret per run when nothing is persisted", () => {
449
+ let dir = Nodepath.join(tmpdir("reventless-secret-"), "absent");
450
+ globalThis.expect(LocalAuth$ReventlessLocal.Login._resolveIn(dir) === LocalAuth$ReventlessLocal.Login._resolveIn(dir)).toEqual(false);
451
+ });
452
+ globalThis.test("mints over a file too short to be a secret", () => {
453
+ let dir = tmpdir("reventless-secret-");
454
+ Nodefs.writeFileSync(Nodepath.join(dir, "token-secret"), "short", "utf8");
455
+ let resolved = LocalAuth$ReventlessLocal.Login._resolveIn(dir);
456
+ globalThis.expect([
457
+ resolved === "short",
458
+ resolved.length >= 16
459
+ ]).toEqual([
460
+ false,
461
+ true
462
+ ]);
463
+ });
464
+ });
465
+
413
466
  export {
414
467
  alice,
415
468
  bob,