@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.
- package/CHANGELOG.md +19 -0
- package/package.json +10 -10
- package/src/BakedManifest.res +63 -51
- package/src/BakedManifest.res.mjs +44 -28
- package/src/HostShellDist.res +29 -0
- package/src/HostShellDist.res.mjs +20 -0
- package/src/Platform.res +30 -11
- package/src/Platform.res.mjs +6 -0
- package/src/ShellConfig.res +178 -0
- package/src/ShellConfig.res.mjs +114 -0
- package/src/UiHints.res +107 -0
- package/src/UiHints.res.mjs +61 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +4 -6
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +5 -0
- package/src/adapter/Auth/LocalAuth.res +136 -6
- package/src/adapter/Auth/LocalAuth.res.mjs +82 -3
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +23 -2
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +19 -2
- package/src/adapter/DomainGraphQL_Server.res +67 -4
- package/src/adapter/DomainGraphQL_Server.res.mjs +49 -2
- package/src/adapter/GraphQL_CallerError.res +19 -0
- package/src/adapter/GraphQL_CallerError.res.mjs +16 -0
- package/src/adapter/GraphQL_Server.res.mjs +3 -0
- package/tests/BakedManifestFilesTest.res +68 -0
- package/tests/BakedManifestFilesTest.res.mjs +115 -0
- package/tests/ShellConfigTest.res +221 -0
- package/tests/ShellConfigTest.res.mjs +203 -0
- package/tests/UiHintsTest.res +94 -0
- package/tests/UiHintsTest.res.mjs +99 -0
- package/tests/adapter/CommandAuthorizationTest.res +86 -0
- package/tests/adapter/CommandAuthorizationTest.res.mjs +62 -0
- package/tests/adapter/LocalAuthLoginTest.res +214 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +191 -8
- 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 */
|
package/src/UiHints.res
ADDED
|
@@ -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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
/**
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
297
|
+
let outcome = await runCommand(generateCommand, payload)
|
|
277
298
|
outcome->commandOutcomeToJson
|
|
278
299
|
}
|
|
279
300
|
}
|