@reventlessdev/reventless-local 3.0.0-alpha.222 → 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 +11 -0
- package/package.json +9 -9
- package/src/BakedManifest.res +1 -0
- package/src/BakedManifest.res.mjs +2 -1
- package/src/Platform.res +13 -0
- package/src/Platform.res.mjs +6 -4
- package/src/UiHints.res +82 -0
- package/src/UiHints.res.mjs +43 -0
- package/src/adapter/Api/LocalEvents_Server.res +24 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +11 -0
- package/src/adapter/Auth/LocalAuth.res +69 -10
- package/src/adapter/Auth/LocalAuth.res.mjs +58 -2
- package/tests/UiHintsTest.res +94 -0
- package/tests/UiHintsTest.res.mjs +93 -0
- package/tests/adapter/LocalAuthLoginTest.res +54 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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
|
+
|
|
6
17
|
# 3.0.0-alpha.222 (2026-08-14)
|
|
7
18
|
|
|
8
19
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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": {
|
|
@@ -37,16 +37,16 @@
|
|
|
37
37
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
38
38
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
39
39
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
|
|
40
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.6",
|
|
41
40
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
|
|
42
41
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
43
|
-
"@reventlessdev/
|
|
44
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.
|
|
45
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
46
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
47
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-
|
|
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",
|
package/src/BakedManifest.res
CHANGED
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)
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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
|
+
})
|
package/src/UiHints.res.mjs
CHANGED
|
@@ -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,
|
|
@@ -89,22 +89,81 @@ module Login = {
|
|
|
89
89
|
|
|
90
90
|
// ── HMAC secret (lazy) ──
|
|
91
91
|
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// secret
|
|
97
|
-
//
|
|
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 =
|
|
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
|
|
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,
|
package/tests/UiHintsTest.res
CHANGED
|
@@ -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,
|