@reventlessdev/reventless-local 3.0.0-alpha.216 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +10 -10
  3. package/src/BakedManifest.res +63 -51
  4. package/src/BakedManifest.res.mjs +44 -28
  5. package/src/HostShellDist.res +29 -0
  6. package/src/HostShellDist.res.mjs +20 -0
  7. package/src/Platform.res +30 -11
  8. package/src/Platform.res.mjs +6 -0
  9. package/src/ShellConfig.res +178 -0
  10. package/src/ShellConfig.res.mjs +114 -0
  11. package/src/UiHints.res +107 -0
  12. package/src/UiHints.res.mjs +61 -0
  13. package/src/adapter/Auth/Auth_GraphqlContext.res +4 -6
  14. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +5 -0
  15. package/src/adapter/Auth/LocalAuth.res +136 -6
  16. package/src/adapter/Auth/LocalAuth.res.mjs +82 -3
  17. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +23 -2
  18. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +19 -2
  19. package/src/adapter/DomainGraphQL_Server.res +67 -4
  20. package/src/adapter/DomainGraphQL_Server.res.mjs +49 -2
  21. package/src/adapter/GraphQL_CallerError.res +19 -0
  22. package/src/adapter/GraphQL_CallerError.res.mjs +16 -0
  23. package/src/adapter/GraphQL_Server.res.mjs +3 -0
  24. package/tests/BakedManifestFilesTest.res +68 -0
  25. package/tests/BakedManifestFilesTest.res.mjs +115 -0
  26. package/tests/ShellConfigTest.res +221 -0
  27. package/tests/ShellConfigTest.res.mjs +203 -0
  28. package/tests/UiHintsTest.res +94 -0
  29. package/tests/UiHintsTest.res.mjs +99 -0
  30. package/tests/adapter/CommandAuthorizationTest.res +86 -0
  31. package/tests/adapter/CommandAuthorizationTest.res.mjs +62 -0
  32. package/tests/adapter/LocalAuthLoginTest.res +214 -0
  33. package/tests/adapter/LocalAuthLoginTest.res.mjs +191 -8
  34. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +2 -2
@@ -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 */
@@ -54,12 +54,10 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t =>
54
54
  | _ => Reventless.Identity.anonymous
55
55
  }
56
56
 
57
- // Throw a `GraphQLError` from the `graphql` package yoga's `maskedErrors`
58
- // option preserves these (only opaque thrown values get masked as
59
- // "Unexpected error / INTERNAL_SERVER_ERROR"). Mirrors the directive-level
60
- // `@aws_auth` rejection that AppSync surfaces in production.
61
- @new @module("graphql")
62
- external makeGraphqlError: (string, {"extensions": {"code": string}}) => exn = "GraphQLError"
57
+ // An unauthorized caller reads the reason: `GraphQL_CallerError` explains why a
58
+ // resolver has to construct the error rather than throw a bare one. Mirrors the
59
+ // directive-level `@aws_auth` rejection that AppSync surfaces in production.
60
+ let makeGraphqlError = GraphQL_CallerError.make
63
61
 
64
62
  let unauthorizedError = (~group: string): exn =>
65
63
  makeGraphqlError(
@@ -44,6 +44,10 @@ function extractIdentity(ctx) {
44
44
  }
45
45
  }
46
46
 
47
+ function makeGraphqlError(prim0, prim1) {
48
+ return new Graphql.GraphQLError(prim0, prim1);
49
+ }
50
+
47
51
  function unauthorizedError(group) {
48
52
  return new Graphql.GraphQLError(`Unauthorized: requires group "` + group + `"`, {
49
53
  extensions: {
@@ -70,6 +74,7 @@ export {
70
74
  identityFromAuthResult,
71
75
  buildAuthContext,
72
76
  extractIdentity,
77
+ makeGraphqlError,
73
78
  unauthorizedError,
74
79
  requireGroup,
75
80
  }
@@ -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) {
@@ -159,6 +159,27 @@ let deriveSdlField = (~fieldName, variantSchema: S.t<unknown>) =>
159
159
 
160
160
  let handlerRefs: dict<ref<option<CommandGenerator.commandGenerator>>> = Dict.make()
161
161
 
162
+ // Run a command through its generator, letting a failure that describes the
163
+ // caller's own request reach them.
164
+ //
165
+ // Without this every such failure arrives as "Unexpected error /
166
+ // INTERNAL_SERVER_ERROR": yoga masks anything that is not a `GraphQLError`, so
167
+ // a payload that does not decode and a database outage read identically from
168
+ // outside. Only errors core marked as the caller's are unwrapped — everything
169
+ // else propagates untouched and stays masked, which is what masking is for.
170
+ let runCommand = async (
171
+ generateCommand: CommandGenerator.commandGenerator,
172
+ payload: CommandGenerator.payload,
173
+ ) =>
174
+ try await generateCommand(payload)->Effect.runPromise catch {
175
+ | e if ReventlessCore.Plugin_ResolverError.isCallerFault(e) =>
176
+ throw(
177
+ GraphQL_CallerError.badUserInput(
178
+ e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("invalid command"),
179
+ ),
180
+ )
181
+ }
182
+
162
183
  // -- register (Phase 1 — synchronous, Aggregates) ----------------------------
163
184
  // Called by Plugin_Builder via aggregateMutationResolverHook before any
164
185
  // Output.apply chains fire. Registers SDL + resolver stubs in GraphQL_Server.
@@ -210,7 +231,7 @@ let register = (
210
231
  meta: {ip: [], user: identity.userId, info: `Mutation.${field}`},
211
232
  identity,
212
233
  }
213
- let outcome = await generateCommand(payload)->Effect.runPromise
234
+ let outcome = await runCommand(generateCommand, payload)
214
235
  outcome->commandOutcomeToJson
215
236
  }
216
237
  }
@@ -273,7 +294,7 @@ let registerDcb = (
273
294
  meta: {ip: [], user: identity.userId, info: `Mutation.${fieldName}`},
274
295
  identity,
275
296
  }
276
- let outcome = await generateCommand(payload)->Effect.runPromise
297
+ let outcome = await runCommand(generateCommand, payload)
277
298
  outcome->commandOutcomeToJson
278
299
  }
279
300
  }