@reventlessdev/reventless-local 3.0.0-alpha.217 → 3.0.0-alpha.218

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.
@@ -0,0 +1,114 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
6
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
9
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
10
+ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
11
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
12
+
13
+ let log = Logger$ReventlessCore.fromEnv();
14
+
15
+ let fileName = "config.json";
16
+
17
+ let baselineFileName = "config.base.json";
18
+
19
+ function manifestUrlOf(config) {
20
+ return Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
21
+ }
22
+
23
+ let journeyManifestsKey = "journeyManifestUrls";
24
+
25
+ let computedKeys = [
26
+ "manifestUrl",
27
+ journeyManifestsKey
28
+ ];
29
+
30
+ function overlay(bakedManifest, shellConfig) {
31
+ let out = {};
32
+ Stdlib_Option.forEach(bakedManifest, config => {
33
+ out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
34
+ let journeys = config.journeys;
35
+ if (journeys === undefined) {
36
+ return;
37
+ }
38
+ if (journeys.length === 0) {
39
+ return;
40
+ }
41
+ let map = {};
42
+ journeys.forEach(j => {
43
+ map[j.group] = Platform_BakedManifest$ReventlessCore.urlForKey(Stdlib_Option.getOr(j.key, Platform_BakedManifest$ReventlessCore.journeyKey(j.group)));
44
+ });
45
+ out[journeyManifestsKey] = map;
46
+ });
47
+ Stdlib_Option.forEach(shellConfig, extra => {
48
+ let collisions = Object.keys(extra).filter(k => computedKeys.includes(k));
49
+ if (collisions.length !== 0) {
50
+ Stdlib_JsError.throwWithMessage("host UI config.json: shellConfig sets key(s) the platform already computes — " + collisions.join(", ") + ". Remove them from shellConfig; a passthrough cannot redirect a computed key.");
51
+ }
52
+ Stdlib_Dict.forEachWithKey(extra, (v, k) => {
53
+ out[k] = v;
54
+ });
55
+ });
56
+ return out;
57
+ }
58
+
59
+ function readObject(path, label) {
60
+ let obj;
61
+ try {
62
+ obj = Stdlib_JSON.Decode.object(JSON.parse(Nodefs.readFileSync(path, "utf8")));
63
+ } catch (exn) {
64
+ return Stdlib_JsError.throwWithMessage(`host UI ` + label + `: cannot read ` + path + ` as JSON`);
65
+ }
66
+ if (obj !== undefined) {
67
+ return obj;
68
+ } else {
69
+ return Stdlib_JsError.throwWithMessage(`host UI ` + label + `: ` + path + ` is not a JSON object`);
70
+ }
71
+ }
72
+
73
+ function emit(bakedManifest, shellConfig, dir) {
74
+ let overlay$1 = overlay(bakedManifest, shellConfig);
75
+ let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
76
+ if (dir$1 === undefined) {
77
+ if (Object.keys(overlay$1).length !== 0) {
78
+ return Stdlib_JsError.throwWithMessage(`host UI config.json: cannot resolve ` + HostShellDist$ReventlessLocal.$$package + ` from ` + process.cwd() + ` — the local shell reads its config from that package's dist/, so declaring shell config without the package installed would write nothing and leave the shell configured as it shipped.`);
79
+ } else {
80
+ return;
81
+ }
82
+ }
83
+ let path = Nodepath.join(dir$1, fileName);
84
+ let baselinePath = Nodepath.join(dir$1, baselineFileName);
85
+ if (!Nodefs.existsSync(baselinePath) && Object.keys(overlay$1).length !== 0) {
86
+ if (!Nodefs.existsSync(path)) {
87
+ Stdlib_JsError.throwWithMessage(`host UI config.json: ` + HostShellDist$ReventlessLocal.$$package + ` ships no ` + fileName + ` at ` + dir$1 + ` — there is no baseline to overlay, so the shell would boot with only the keys declared here and none of the ones it expects.`);
88
+ }
89
+ Nodefs.writeFileSync(baselinePath, Nodefs.readFileSync(path, "utf8"), "utf8");
90
+ }
91
+ if (!Nodefs.existsSync(baselinePath)) {
92
+ return;
93
+ }
94
+ let merged = readObject(baselinePath, "config baseline");
95
+ Stdlib_Dict.forEachWithKey(overlay$1, (v, k) => {
96
+ merged[k] = v;
97
+ });
98
+ Nodefs.writeFileSync(path, JSON.stringify(merged, undefined, 2), "utf8");
99
+ let keys = Object.keys(overlay$1);
100
+ log.info("ShellConfig", undefined, keys.length === 0 ? `restored ` + fileName + ` from ` + baselineFileName + `: ` + path : `wrote ` + fileName + ` with ` + keys.join(", ") + `: ` + path);
101
+ }
102
+
103
+ export {
104
+ log,
105
+ fileName,
106
+ baselineFileName,
107
+ manifestUrlOf,
108
+ journeyManifestsKey,
109
+ computedKeys,
110
+ overlay,
111
+ readObject,
112
+ emit,
113
+ }
114
+ /* log Not a pure module */
@@ -0,0 +1,107 @@
1
+ // Serves the AutoUI hints file a deployment declared, where the local host
2
+ // shell serves its static assets from (`HostShellDist`).
3
+ //
4
+ // On AWS `uiHintsFile` is read and written verbatim as a `BucketObject` beside
5
+ // `config.json`, and the host-shell package's own `ui-hints.json` is excluded
6
+ // from the upload as the dev-mode fallback it is. Locally that fallback is in
7
+ // the mode it exists for, so an undeclared platform goes on serving it — but a
8
+ // declared file has to win, or the hints a deployment authors are the only ones
9
+ // its dev shell never applies.
10
+ //
11
+ // Hence the baseline, for the reason `ShellConfig` keeps one: without it a
12
+ // withdrawn declaration leaves yesterday's hints in place with nothing in the
13
+ // diff to explain them. Unlike `ShellConfig` there is no merge — AWS writes the
14
+ // declared file verbatim, and layering a deployment's hints over the shell
15
+ // package's demonstration hints would invent a third behaviour neither platform
16
+ // has.
17
+
18
+ let log = ReventlessCore.Logger.fromEnv()
19
+
20
+ let fileName = "ui-hints.json"
21
+
22
+ // Keeps the `.json` extension for the same reason `config.base.json` does: the
23
+ // dist is served as a static directory, and a dev can open the baseline in a
24
+ // browser to see what the declaration replaced.
25
+ let baselineFileName = "ui-hints.base.json"
26
+
27
+ /**
28
+ Write the declared hints into the served `dist/`, or restore the shipped file
29
+ when nothing is declared.
30
+
31
+ A no-op when there is nothing to say and nothing was said before, so a platform
32
+ that declares no hints is byte-identical to one built before this existed.
33
+ Once a file is declared the write happens or fails loudly: a path that does not
34
+ resolve, content that is not JSON, and a missing shell package are all the
35
+ deployment's own mistake, and all three produce the same symptom if swallowed —
36
+ hints that quietly are not applied.
37
+ */
38
+ let emit = (
39
+ ~uiHintsFile: option<string>,
40
+ // Test seam, as in `ShellConfig.emit`: the baseline dance is the part with
41
+ // state behind it, and "boot twice and the second write still starts from the
42
+ // shipped file" is not a property a pure function can carry.
43
+ ~dir: option<string>=?,
44
+ ) => {
45
+ // Read before anything is touched, so a bad declaration cannot leave the
46
+ // served file half-replaced.
47
+ let declared = uiHintsFile->Option.map(path => {
48
+ let source = switch NodeFs.readFileSync(path) {
49
+ | contents => contents
50
+ | exception _ =>
51
+ JsError.throwWithMessage(
52
+ `host UI ${fileName}: cannot read the declared uiHintsFile at ${path} — ` ++
53
+ `the shell fetches this file at boot, so a declaration pointing nowhere ` ++
54
+ `applies no hints and says nothing about why.`,
55
+ )
56
+ }
57
+ switch source->JSON.parseOrThrow {
58
+ | _ => ()
59
+ | exception _ =>
60
+ JsError.throwWithMessage(
61
+ `host UI ${fileName}: the declared uiHintsFile at ${path} is not JSON — ` ++
62
+ `the shell warns once and applies no hints, which reads as hints that do nothing.`,
63
+ )
64
+ }
65
+ source
66
+ })
67
+
68
+ switch (
69
+ switch dir {
70
+ | Some(_) as given => given
71
+ | None => HostShellDist.dir()
72
+ }
73
+ ) {
74
+ | None =>
75
+ // No shell installed is the ordinary case for a platform nobody points a
76
+ // browser at; only a declaration makes the missing package an error.
77
+ if declared->Option.isSome {
78
+ JsError.throwWithMessage(
79
+ `host UI ${fileName}: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
80
+ `the local shell reads its hints from that package's dist/, so declaring a ` ++
81
+ `uiHintsFile without the package installed would write nothing and leave the ` ++
82
+ `shell applying whatever the package shipped.`,
83
+ )
84
+ }
85
+ | Some(dir) =>
86
+ let path = NodePath.join([dir, fileName])
87
+ let baselinePath = NodePath.join([dir, baselineFileName])
88
+
89
+ // Seeded only when there is something to replace it with. A platform that
90
+ // declares nothing must not leave a baseline behind for the next one to
91
+ // read as authoritative.
92
+ if !NodeFs.existsSync(baselinePath) && declared->Option.isSome && NodeFs.existsSync(path) {
93
+ NodeFs.writeFileSync(baselinePath, NodeFs.readFileSync(path))
94
+ }
95
+
96
+ switch declared {
97
+ | Some(contents) =>
98
+ NodeFs.writeFileSync(path, contents)
99
+ log.info(~comp="UiHints", `wrote ${fileName} from the declared uiHintsFile: ${path}`)
100
+ | None =>
101
+ if NodeFs.existsSync(baselinePath) {
102
+ NodeFs.writeFileSync(path, NodeFs.readFileSync(baselinePath))
103
+ log.info(~comp="UiHints", `restored ${fileName} from ${baselineFileName}: ${path}`)
104
+ }
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,61 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
+ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
9
+
10
+ let log = Logger$ReventlessCore.fromEnv();
11
+
12
+ let fileName = "ui-hints.json";
13
+
14
+ let baselineFileName = "ui-hints.base.json";
15
+
16
+ function emit(uiHintsFile, dir) {
17
+ let declared = Stdlib_Option.map(uiHintsFile, path => {
18
+ let source;
19
+ try {
20
+ source = Nodefs.readFileSync(path, "utf8");
21
+ } catch (exn) {
22
+ source = Stdlib_JsError.throwWithMessage(`host UI ` + fileName + `: cannot read the declared uiHintsFile at ` + path + ` — the shell fetches this file at boot, so a declaration pointing nowhere applies no hints and says nothing about why.`);
23
+ }
24
+ try {
25
+ JSON.parse(source);
26
+ } catch (exn$1) {
27
+ Stdlib_JsError.throwWithMessage(`host UI ` + fileName + `: the declared uiHintsFile at ` + path + ` is not JSON — the shell warns once and applies no hints, which reads as hints that do nothing.`);
28
+ }
29
+ return source;
30
+ });
31
+ let dir$1 = dir !== undefined ? dir : HostShellDist$ReventlessLocal.dir();
32
+ if (dir$1 === undefined) {
33
+ if (Stdlib_Option.isSome(declared)) {
34
+ return Stdlib_JsError.throwWithMessage(`host UI ` + fileName + `: cannot resolve ` + HostShellDist$ReventlessLocal.$$package + ` from ` + process.cwd() + ` — the local shell reads its hints from that package's dist/, so declaring a uiHintsFile without the package installed would write nothing and leave the shell applying whatever the package shipped.`);
35
+ } else {
36
+ return;
37
+ }
38
+ }
39
+ let path = Nodepath.join(dir$1, fileName);
40
+ let baselinePath = Nodepath.join(dir$1, baselineFileName);
41
+ if (!Nodefs.existsSync(baselinePath) && Stdlib_Option.isSome(declared) && Nodefs.existsSync(path)) {
42
+ Nodefs.writeFileSync(baselinePath, Nodefs.readFileSync(path, "utf8"), "utf8");
43
+ }
44
+ if (declared !== undefined) {
45
+ Nodefs.writeFileSync(path, declared, "utf8");
46
+ return log.info("UiHints", undefined, `wrote ` + fileName + ` from the declared uiHintsFile: ` + path);
47
+ } else if (Nodefs.existsSync(baselinePath)) {
48
+ Nodefs.writeFileSync(path, Nodefs.readFileSync(baselinePath, "utf8"), "utf8");
49
+ return log.info("UiHints", undefined, `restored ` + fileName + ` from ` + baselineFileName + `: ` + path);
50
+ } else {
51
+ return;
52
+ }
53
+ }
54
+
55
+ export {
56
+ log,
57
+ fileName,
58
+ baselineFileName,
59
+ emit,
60
+ }
61
+ /* log Not a pure module */
@@ -138,17 +138,107 @@ module Login = {
138
138
  }
139
139
  }
140
140
 
141
- /** Verifies credentials and returns a signed token (or an error string). */
142
- let issue = async (~username: string, ~password: string): result<string, string> =>
141
+ /**
142
+ Claim naming the role the caller chose to act as. Present only on a narrowed
143
+ token, so its presence *is* the answer to "am I acting as one of my roles?".
144
+
145
+ Kept apart from `groups` even though a narrowed token's groups are exactly
146
+ this one role, because the two say different things: `groups` is what every
147
+ enforcement point evaluates, and this is what the caller asked for. A client
148
+ reading the choice out of the group array would be depending on the current
149
+ shape of the narrowing rather than on the choice itself.
150
+ */
151
+ let activeRoleClaim = "activeRole"
152
+
153
+ /**
154
+ Claim naming the roles the caller gave up by narrowing — their full
155
+ membership, comma-joined as the `X-Groups` header already joins groups.
156
+
157
+ 🚨 **Never read this for authorization.** It exists so a client can offer the
158
+ switch back, and it is by definition wider than what the caller is currently
159
+ permitted. Every enforcement point in the system reads `groups`; this claim is
160
+ the one piece of an identity that deliberately describes privilege the caller
161
+ does *not* currently have.
162
+
163
+ Present only on a narrowed token, which is also what keeps an ordinary login
164
+ byte-identical to what it minted before any of this existed: an unnarrowed
165
+ token's `groups` already *are* the full membership, so there is nothing to
166
+ remember.
167
+ */
168
+ let availableRolesClaim = "availableRoles"
169
+
170
+ /**
171
+ Narrow an identity to one of its own roles.
172
+
173
+ `Error` when the role is not one the caller holds. Refusing rather than
174
+ ignoring is the security-critical line of this feature: a request for a group
175
+ the user does not have is either confused or hostile, and a token that
176
+ silently does not match what was asked for serves neither. Because the check
177
+ is a subset test against actual membership, a tampering client can only ever
178
+ reduce its own privilege.
179
+ */
180
+ let narrow = (identity: Identity.t, ~activeRole: string): result<Identity.t, string> =>
181
+ if !(identity.groups->Array.includes(activeRole)) {
182
+ Error(`Cannot act as "${activeRole}": not a group this user holds`)
183
+ } else {
184
+ let claims = switch identity.claims {
185
+ | Some(existing) => Dict.fromArray(existing->Dict.toArray)
186
+ | None => Dict.make()
187
+ }
188
+ claims->Dict.set(activeRoleClaim, activeRole)
189
+ claims->Dict.set(availableRolesClaim, identity.groups->Array.join(","))
190
+ Ok({...identity, groups: [activeRole], claims})
191
+ }
192
+
193
+ /**
194
+ Verifies credentials and returns a signed token (or an error string).
195
+
196
+ `activeRole` narrows the minted token to one of the caller's own roles; unset
197
+ mints exactly what it has always minted.
198
+ */
199
+ let issue = async (
200
+ ~username: string,
201
+ ~password: string,
202
+ ~activeRole: option<string>=?,
203
+ ): result<string, string> =>
143
204
  switch store.contents->Dict.get(username) {
144
205
  | Some({password: stored, identity}) if stored === password =>
145
- let json = identity->S.reverseConvertToJsonOrThrow(Identity.schema)->JSON.stringify
146
- let payload = _b64urlEncode(json)
147
- let sig = _sign(payload)
148
- Ok(`${payload}.${sig}`)
206
+ switch switch activeRole {
207
+ | None => Ok(identity)
208
+ | Some(role) => identity->narrow(~activeRole=role)
209
+ } {
210
+ | Error(_) as e => e
211
+ | Ok(minted) =>
212
+ let json = minted->S.reverseConvertToJsonOrThrow(Identity.schema)->JSON.stringify
213
+ let payload = _b64urlEncode(json)
214
+ let sig = _sign(payload)
215
+ Ok(`${payload}.${sig}`)
216
+ }
149
217
  | _ => Error("Invalid credentials")
150
218
  }
151
219
 
220
+ /**
221
+ The identity a token carries, for a caller that has just been issued one.
222
+
223
+ Exists so the login response can echo the *minted* identity rather than the
224
+ stored one. They differ exactly when the token is narrowed, and a response
225
+ disagreeing with the token it accompanies would leave the client a step behind
226
+ the server from its very first request.
227
+ */
228
+ let mintedIdentity = (~username: string, ~activeRole: option<string>): option<Identity.t> =>
229
+ store.contents
230
+ ->Dict.get(username)
231
+ ->Option.flatMap(({identity, _}) =>
232
+ switch activeRole {
233
+ | None => Some(identity)
234
+ | Some(role) =>
235
+ switch identity->narrow(~activeRole=role) {
236
+ | Ok(narrowed) => Some(narrowed)
237
+ | Error(_) => None
238
+ }
239
+ }
240
+ )
241
+
152
242
  /**
153
243
  * Returns the embedded Identity if signature verifies and the payload
154
244
  * decodes; `None` for any tampered, malformed, or unsigned token.
@@ -167,6 +257,46 @@ module Login = {
167
257
  | _ => None
168
258
  }
169
259
  }
260
+
261
+ /**
262
+ Re-mint an existing session as one of the caller's roles.
263
+
264
+ A switch is not a fresh login: the client holds a token, not a password, and
265
+ asking for the password again to change role would make an ordinary
266
+ navigation a re-authentication. Possession of a token this server signed is
267
+ already proof of the credentials that produced it.
268
+
269
+ 🚨 **Membership is re-read from the store, never from the presented token.**
270
+ A narrowed token carries `availableRolesClaim`, and widening back by trusting
271
+ it would make the record of what a caller gave up into the authority for
272
+ getting it back. The signature makes that claim authentic, not correct: the
273
+ store is where membership actually lives, and re-reading it means a role
274
+ revoked since the token was issued cannot be switched into.
275
+
276
+ `activeRole` unset widens back to full membership — which is not a privilege
277
+ escalation, because the subset being widened *to* is the one the store says
278
+ the caller has.
279
+ */
280
+ let reissue = (~token: string, ~activeRole: option<string>): result<string, string> =>
281
+ switch verifyAndDecode(token) {
282
+ | None => Error("Invalid token")
283
+ | Some(presented) =>
284
+ switch store.contents->Dict.get(presented.username) {
285
+ | None => Error("Unknown user")
286
+ | Some({identity, _}) =>
287
+ switch switch activeRole {
288
+ | None => Ok(identity)
289
+ | Some(role) => identity->narrow(~activeRole=role)
290
+ } {
291
+ | Error(_) as e => e
292
+ | Ok(minted) =>
293
+ let json = minted->S.reverseConvertToJsonOrThrow(Identity.schema)->JSON.stringify
294
+ let payload = _b64urlEncode(json)
295
+ Ok(`${payload}.${_sign(payload)}`)
296
+ }
297
+ }
298
+ }
299
+
170
300
  }
171
301
 
172
302
  // ── Provider implementation ───────────────────────────────────────────────
@@ -118,7 +118,29 @@ function _b64urlDecode(s) {
118
118
  }
119
119
  }
120
120
 
121
- async function issue(username, password) {
121
+ let activeRoleClaim = "activeRole";
122
+
123
+ let availableRolesClaim = "availableRoles";
124
+
125
+ function narrow(identity, activeRole) {
126
+ if (!identity.groups.includes(activeRole)) {
127
+ return {
128
+ TAG: "Error",
129
+ _0: `Cannot act as "` + activeRole + `": not a group this user holds`
130
+ };
131
+ }
132
+ let existing = identity.claims;
133
+ let claims = existing !== undefined ? Object.fromEntries(Object.entries(existing)) : ({});
134
+ claims[activeRoleClaim] = activeRole;
135
+ claims[availableRolesClaim] = identity.groups.join(",");
136
+ let newrecord = {...identity};
137
+ return {
138
+ TAG: "Ok",
139
+ _0: (newrecord.claims = claims, newrecord.groups = [activeRole], newrecord)
140
+ };
141
+ }
142
+
143
+ async function issue(username, password, activeRole) {
122
144
  let match = store.contents[username];
123
145
  if (match === undefined) {
124
146
  return {
@@ -132,7 +154,15 @@ async function issue(username, password) {
132
154
  _0: "Invalid credentials"
133
155
  };
134
156
  }
135
- let json = JSON.stringify(S.reverseConvertToJsonOrThrow(match.identity, Identity$Reventless.schema));
157
+ let identity = match.identity;
158
+ let e = activeRole !== undefined ? narrow(identity, activeRole) : ({
159
+ TAG: "Ok",
160
+ _0: identity
161
+ });
162
+ if (e.TAG !== "Ok") {
163
+ return e;
164
+ }
165
+ let json = JSON.stringify(S.reverseConvertToJsonOrThrow(e._0, Identity$Reventless.schema));
136
166
  let payload = _b64urlEncode(json);
137
167
  let sig = _sign(payload);
138
168
  return {
@@ -141,6 +171,19 @@ async function issue(username, password) {
141
171
  };
142
172
  }
143
173
 
174
+ function mintedIdentity(username, activeRole) {
175
+ return Stdlib_Option.flatMap(store.contents[username], param => {
176
+ let identity = param.identity;
177
+ if (activeRole === undefined) {
178
+ return identity;
179
+ }
180
+ let narrowed = narrow(identity, activeRole);
181
+ if (narrowed.TAG === "Ok") {
182
+ return narrowed._0;
183
+ }
184
+ });
185
+ }
186
+
144
187
  function verifyAndDecode(token) {
145
188
  let parts = token.split(".");
146
189
  let match = parts[0];
@@ -156,6 +199,37 @@ function verifyAndDecode(token) {
156
199
  }
157
200
  }
158
201
 
202
+ function reissue(token, activeRole) {
203
+ let presented = verifyAndDecode(token);
204
+ if (presented === undefined) {
205
+ return {
206
+ TAG: "Error",
207
+ _0: "Invalid token"
208
+ };
209
+ }
210
+ let match = store.contents[presented.username];
211
+ if (match === undefined) {
212
+ return {
213
+ TAG: "Error",
214
+ _0: "Unknown user"
215
+ };
216
+ }
217
+ let identity = match.identity;
218
+ let e = activeRole !== undefined ? narrow(identity, activeRole) : ({
219
+ TAG: "Ok",
220
+ _0: identity
221
+ });
222
+ if (e.TAG !== "Ok") {
223
+ return e;
224
+ }
225
+ let json = JSON.stringify(S.reverseConvertToJsonOrThrow(e._0, Identity$Reventless.schema));
226
+ let payload = _b64urlEncode(json);
227
+ return {
228
+ TAG: "Ok",
229
+ _0: payload + `.` + _sign(payload)
230
+ };
231
+ }
232
+
159
233
  let Login = {
160
234
  store: store,
161
235
  setCredentials: setCredentials,
@@ -166,8 +240,13 @@ let Login = {
166
240
  _sign: _sign,
167
241
  _b64urlEncode: _b64urlEncode,
168
242
  _b64urlDecode: _b64urlDecode,
243
+ activeRoleClaim: activeRoleClaim,
244
+ availableRolesClaim: availableRolesClaim,
245
+ narrow: narrow,
169
246
  issue: issue,
170
- verifyAndDecode: verifyAndDecode
247
+ mintedIdentity: mintedIdentity,
248
+ verifyAndDecode: verifyAndDecode,
249
+ reissue: reissue
171
250
  };
172
251
 
173
252
  function _bearerToken(header) {
@@ -79,7 +79,9 @@ let _loginRejected = (res: nodeResponse, ~error: string): unit =>
79
79
  JSON.Encode.object(Dict.fromArray([("error", JSON.Encode.string(error))])),
80
80
  )
81
81
 
82
- type _loginBody = {username: string, password: string}
82
+ // `activeRole` narrows the minted token to one of the caller's own groups. Its
83
+ // absence is the ordinary login and mints what it always did.
84
+ type _loginBody = {username: string, password: string, activeRole?: string}
83
85
 
84
86
  let handleLogin = (req: nodeRequest, res: nodeResponse): unit =>
85
87
  readBody(req, body => {
@@ -90,14 +92,20 @@ let handleLogin = (req: nodeRequest, res: nodeResponse): unit =>
90
92
  }
91
93
  switch parsed {
92
94
  | None => _loginRejected(res, ~error="Invalid JSON body")
93
- | Some({username, password}) =>
94
- let _ = LocalAuth.Login.issue(~username, ~password)->Promise.then(result => {
95
+ | Some({username, password, ?activeRole}) =>
96
+ let _ = LocalAuth.Login.issue(~username, ~password, ~activeRole?)->Promise.then(result => {
95
97
  switch result {
96
98
  | Error(msg) => _loginRejected(res, ~error=msg)
97
99
  | Ok(token) =>
98
100
  // Echo back the identity the client will see in subsequent
99
101
  // ctx.identity values, so the SPA doesn't need a second round-trip.
100
- let identity = switch LocalAuth.lookupUser(username) {
102
+ //
103
+ // Read from the mint rather than the store: the two differ exactly
104
+ // when the token is narrowed, and a response that described the wider
105
+ // stored identity would put the client a step behind the server from
106
+ // its first request — showing a menu for groups its own token no
107
+ // longer carries.
108
+ let identity = switch LocalAuth.Login.mintedIdentity(~username, ~activeRole) {
101
109
  | Some(i) => i
102
110
  | None => Reventless.Identity.anonymous
103
111
  }
@@ -119,6 +127,59 @@ let handleLogin = (req: nodeRequest, res: nodeResponse): unit =>
119
127
  }
120
128
  })
121
129
 
130
+ // `activeRole` absent widens back to the caller's full membership — see
131
+ // `LocalAuth.Login.reissue` for why that is not an escalation.
132
+ type _switchBody = {activeRole?: string}
133
+
134
+ // Re-mints the presented session as one of the caller's roles.
135
+ //
136
+ // Separate from login because a switch is not a re-authentication: the client
137
+ // holds a token, not a password. The bearer is already verified by the dispatch
138
+ // rule above — every path but login rejects an unverifiable one — so reaching
139
+ // here means the token is this server's own.
140
+ let handleSwitchRole = (req: nodeRequest, res: nodeResponse): unit =>
141
+ readBody(req, body => {
142
+ let requested = switch body->JSON.parseOrThrow {
143
+ | json => (json->Obj.magic: _switchBody).activeRole
144
+ | exception _ => None
145
+ }
146
+ let presented =
147
+ req.headers
148
+ ->Dict.get("authorization")
149
+ ->Option.flatMap(h =>
150
+ String.startsWith(h, "Bearer ")
151
+ ? Some(String.slice(h, ~start=7, ~end=String.length(h))->String.trim)
152
+ : None
153
+ )
154
+ switch presented {
155
+ | None => _loginRejected(res, ~error="Missing bearer token")
156
+ | Some(token) =>
157
+ switch LocalAuth.Login.reissue(~token, ~activeRole=requested) {
158
+ | Error(msg) => _loginRejected(res, ~error=msg)
159
+ | Ok(newToken) =>
160
+ // Same shape as login, so the client stores the result the same way and
161
+ // a switch is indistinguishable from a fresh session downstream.
162
+ let identity = switch LocalAuth.Login.verifyAndDecode(newToken) {
163
+ | Some(i) => i
164
+ | None => Reventless.Identity.anonymous
165
+ }
166
+ _writeJson(
167
+ res,
168
+ ~status=200,
169
+ JSON.Encode.object(
170
+ Dict.fromArray([
171
+ ("token", JSON.Encode.string(newToken)),
172
+ (
173
+ "identity",
174
+ identity->S.reverseConvertToJsonOrThrow(Reventless.Identity.schema),
175
+ ),
176
+ ]),
177
+ ),
178
+ )
179
+ }
180
+ }
181
+ })
182
+
122
183
  let handleLogout = (_req: nodeRequest, res: nodeResponse): unit => {
123
184
  res->writeHead(204, {"Access-Control-Allow-Origin": "*"})
124
185
  res->endEmpty
@@ -205,6 +266,8 @@ let _dispatch = (req: nodeRequest, res: nodeResponse, yoga: YG.yoga, getSdl: uni
205
266
  res->end_(getSdl())
206
267
  } else if path == "/__inmemory/login" && req.method == "POST" {
207
268
  handleLogin(req, res)
269
+ } else if path == "/__inmemory/switch-role" && req.method == "POST" {
270
+ handleSwitchRole(req, res)
208
271
  } else if path == "/__inmemory/logout" && req.method == "POST" {
209
272
  handleLogout(req, res)
210
273
  } else if path == "/events" && req.method == "POST" {