@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
@@ -1,14 +1,18 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
3
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Effect from "effect/Effect";
5
6
  import * as Pulumi from "@pulumi/pulumi";
6
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
8
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
8
9
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
9
11
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
10
12
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
11
13
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
14
+ import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
15
+ import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
12
16
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
13
17
 
14
18
  function extractIdentity(ctx) {
@@ -140,6 +144,18 @@ function deriveSdlField(fieldName, variantSchema) {
140
144
 
141
145
  let handlerRefs = {};
142
146
 
147
+ async function runCommand(generateCommand, payload) {
148
+ try {
149
+ return await Effect.runPromise(generateCommand(payload));
150
+ } catch (raw_e) {
151
+ let e = Primitive_exceptions.internalToException(raw_e);
152
+ if (Plugin_ResolverError$ReventlessCore.isCallerFault(e)) {
153
+ throw GraphQL_CallerError$ReventlessLocal.badUserInput(Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "invalid command"));
154
+ }
155
+ throw e;
156
+ }
157
+ }
158
+
143
159
  function register(fields, commandSchema, commandAuthorization, server) {
144
160
  server.registerTypes([commandResultSdl]);
145
161
  let sdlFields = fields.map(field => {
@@ -187,7 +203,7 @@ function register(fields, commandSchema, commandAuthorization, server) {
187
203
  meta: payload_meta,
188
204
  identity: identity
189
205
  };
190
- return CommandTopic$ReventlessCore.commandOutcomeToJson(await Effect.runPromise(generateCommand(payload)));
206
+ return CommandTopic$ReventlessCore.commandOutcomeToJson(await runCommand(generateCommand, payload));
191
207
  };
192
208
  resolvers[field] = resolver;
193
209
  });
@@ -231,7 +247,7 @@ function registerDcb(fieldName, commandSchema, commandAuthorization, server) {
231
247
  meta: payload_meta,
232
248
  identity: identity
233
249
  };
234
- return CommandTopic$ReventlessCore.commandOutcomeToJson(await Effect.runPromise(generateCommand(payload)));
250
+ return CommandTopic$ReventlessCore.commandOutcomeToJson(await runCommand(generateCommand, payload));
235
251
  };
236
252
  let resolvers = {};
237
253
  resolvers[fieldName] = resolver;
@@ -290,6 +306,7 @@ export {
290
306
  variantIndexForField,
291
307
  deriveSdlField,
292
308
  handlerRefs,
309
+ runCommand,
293
310
  register,
294
311
  registerDcb,
295
312
  bindHandler,
@@ -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" {
@@ -69,10 +69,11 @@ function handleLogin(req, res) {
69
69
  if (parsed === undefined) {
70
70
  return _loginRejected(res, "Invalid JSON body");
71
71
  }
72
+ let activeRole = parsed.activeRole;
72
73
  let username = parsed.username;
73
- LocalAuth$ReventlessLocal.Login.issue(username, parsed.password).then(result => {
74
+ LocalAuth$ReventlessLocal.Login.issue(username, parsed.password, activeRole).then(result => {
74
75
  if (result.TAG === "Ok") {
75
- let i = LocalAuth$ReventlessLocal.lookupUser(username);
76
+ let i = LocalAuth$ReventlessLocal.Login.mintedIdentity(username, activeRole);
76
77
  let identity = i !== undefined ? i : Identity$Reventless.anonymous;
77
78
  let identityJson = S.reverseConvertToJsonOrThrow(identity, Identity$Reventless.schema);
78
79
  _writeJson(res, 200, Object.fromEntries([
@@ -93,6 +94,48 @@ function handleLogin(req, res) {
93
94
  });
94
95
  }
95
96
 
97
+ function handleSwitchRole(req, res) {
98
+ readBody(req, body => {
99
+ let requested;
100
+ let exit = 0;
101
+ let json;
102
+ try {
103
+ json = JSON.parse(body);
104
+ exit = 1;
105
+ } catch (exn) {
106
+ requested = undefined;
107
+ }
108
+ if (exit === 1) {
109
+ requested = json.activeRole;
110
+ }
111
+ let presented = Stdlib_Option.flatMap(req.headers["authorization"], h => {
112
+ if (h.startsWith("Bearer ")) {
113
+ return h.slice(7, h.length).trim();
114
+ }
115
+ });
116
+ if (presented === undefined) {
117
+ return _loginRejected(res, "Missing bearer token");
118
+ }
119
+ let msg = LocalAuth$ReventlessLocal.Login.reissue(presented, requested);
120
+ if (msg.TAG !== "Ok") {
121
+ return _loginRejected(res, msg._0);
122
+ }
123
+ let newToken = msg._0;
124
+ let i = LocalAuth$ReventlessLocal.Login.verifyAndDecode(newToken);
125
+ let identity = i !== undefined ? i : Identity$Reventless.anonymous;
126
+ _writeJson(res, 200, Object.fromEntries([
127
+ [
128
+ "token",
129
+ newToken
130
+ ],
131
+ [
132
+ "identity",
133
+ S.reverseConvertToJsonOrThrow(identity, Identity$Reventless.schema)
134
+ ]
135
+ ]));
136
+ });
137
+ }
138
+
96
139
  function handleLogout(_req, res) {
97
140
  res.writeHead(204, {
98
141
  "Access-Control-Allow-Origin": "*"
@@ -163,6 +206,9 @@ function _dispatch(req, res, yoga, getSdl) {
163
206
  if (path === "/__inmemory/login" && req.method === "POST") {
164
207
  return handleLogin(req, res);
165
208
  }
209
+ if (path === "/__inmemory/switch-role" && req.method === "POST") {
210
+ return handleSwitchRole(req, res);
211
+ }
166
212
  if (path === "/__inmemory/logout" && req.method === "POST") {
167
213
  return handleLogout(req, res);
168
214
  }
@@ -729,6 +775,7 @@ export {
729
775
  _writeJson,
730
776
  _loginRejected,
731
777
  handleLogin,
778
+ handleSwitchRole,
732
779
  handleLogout,
733
780
  _corsWriteHeaders,
734
781
  handleObjectPut,
@@ -0,0 +1,19 @@
1
+ // A failure the caller is allowed to read.
2
+ //
3
+ // graphql-yoga's `maskedErrors` replaces any thrown value that is not a
4
+ // `GraphQLError` with "Unexpected error / INTERNAL_SERVER_ERROR". That is the
5
+ // right default — an internal failure's message is not the caller's business —
6
+ // but it also hides the failures that describe the caller's own request, which
7
+ // are the ones they can do something about. Constructing the error through this
8
+ // module is how a resolver says "this one is theirs to read".
9
+ //
10
+ // Mirrors what AppSync surfaces in production, where a resolver's thrown message
11
+ // is returned as the field error.
12
+
13
+ @new @module("graphql")
14
+ external make: (string, {"extensions": {"code": string}}) => exn = "GraphQLError"
15
+
16
+ /** A request the server understood and refused — bad input, in GraphQL's own
17
+ `BAD_USER_INPUT` sense. */
18
+ let badUserInput = (message: string): exn =>
19
+ make(message, {"extensions": {"code": "BAD_USER_INPUT"}})
@@ -0,0 +1,16 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Graphql from "graphql";
4
+
5
+ function badUserInput(message) {
6
+ return new Graphql.GraphQLError(message, {
7
+ extensions: {
8
+ code: "BAD_USER_INPUT"
9
+ }
10
+ });
11
+ }
12
+
13
+ export {
14
+ badUserInput,
15
+ }
16
+ /* graphql Not a pure module */
@@ -16,6 +16,8 @@ let _loginRejected = DomainGraphQL_Server$ReventlessLocal._loginRejected;
16
16
 
17
17
  let handleLogin = DomainGraphQL_Server$ReventlessLocal.handleLogin;
18
18
 
19
+ let handleSwitchRole = DomainGraphQL_Server$ReventlessLocal.handleSwitchRole;
20
+
19
21
  let handleLogout = DomainGraphQL_Server$ReventlessLocal.handleLogout;
20
22
 
21
23
  let _corsWriteHeaders = DomainGraphQL_Server$ReventlessLocal._corsWriteHeaders;
@@ -132,6 +134,7 @@ export {
132
134
  _writeJson,
133
135
  _loginRejected,
134
136
  handleLogin,
137
+ handleSwitchRole,
135
138
  handleLogout,
136
139
  _corsWriteHeaders,
137
140
  handleObjectPut,
@@ -0,0 +1,68 @@
1
+ open JestGlobals
2
+
3
+ // Which files a bake declaration produces, and under which names. The write
4
+ // itself needs a host-shell package on disk; this is the part that decides what
5
+ // gets written, so it is worth pinning without one.
6
+
7
+ let _ = TestRunner.setup()
8
+
9
+ let sel = (plugin): ReventlessInfra.Platform.bakedManifestSelection => {
10
+ plugin,
11
+ views: [],
12
+ commands: [],
13
+ }
14
+
15
+ let keysOf = (config: ReventlessInfra.Platform.bakedManifest) =>
16
+ BakedManifest.files(~config)->Array.map(((key, _)) => key)
17
+
18
+ describe("BakedManifest.files", () => {
19
+ open Expect
20
+
21
+ // The regression line: one declaration, one file, under the name every
22
+ // existing deployment's config.json already points at.
23
+ testSync("a declaration with no journeys produces exactly one file", () => {
24
+ expect(keysOf({components: [sel("Catalog")]}))->toEqual(["component-manifest.json"])
25
+ })
26
+
27
+ testSync("a renamed default is honoured", () => {
28
+ expect(keysOf({components: [sel("Catalog")], key: "shop.json"}))->toEqual(["shop.json"])
29
+ })
30
+
31
+ // The default comes first and stays: it is what a caller matching no declared
32
+ // group gets, which locally includes the no-bearer identity every dev session
33
+ // starts from.
34
+ testSync("journeys are written beside the default, not instead of it", () => {
35
+ expect(
36
+ keysOf({
37
+ components: [sel("Catalog")],
38
+ journeys: [
39
+ {group: "Shopper", components: [sel("Catalog")]},
40
+ {group: "Fulfilment", components: [sel("Ordering")], key: "fulfil.json"},
41
+ ],
42
+ }),
43
+ )->toEqual([
44
+ "component-manifest.json",
45
+ "component-manifest-shopper.json",
46
+ "fulfil.json",
47
+ ])
48
+ })
49
+
50
+ // A group name is a Cognito identifier and a key is part of a URL, so the
51
+ // derivation folds anything that is neither a letter nor a digit.
52
+ testSync("derives a URL-safe key from an awkward group name", () => {
53
+ expect(
54
+ keysOf({components: [sel("Catalog")], journeys: [{group: "Ops Team/EU", components: []}]}),
55
+ )->toEqual(["component-manifest.json", "component-manifest-ops-team-eu.json"])
56
+ })
57
+
58
+ testSync("carries each journey's own selections", () => {
59
+ let files = BakedManifest.files(~config={
60
+ components: [sel("Catalog")],
61
+ journeys: [{group: "Fulfilment", components: [sel("Ordering")]}],
62
+ })
63
+ expect(files->Array.map(((_, sels)) => sels->Array.map(s => s.plugin)))->toEqual([
64
+ ["Catalog"],
65
+ ["Ordering"],
66
+ ])
67
+ })
68
+ })
@@ -0,0 +1,115 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
4
+ import * as BakedManifest$ReventlessLocal from "../src/BakedManifest.res.mjs";
5
+
6
+ TestRunner$ReventlessLocal.setup();
7
+
8
+ function sel(plugin) {
9
+ return {
10
+ plugin: plugin,
11
+ views: [],
12
+ commands: []
13
+ };
14
+ }
15
+
16
+ function keysOf(config) {
17
+ return BakedManifest$ReventlessLocal.files(config).map(param => param[0]);
18
+ }
19
+
20
+ globalThis.describe("BakedManifest.files", () => {
21
+ globalThis.test("a declaration with no journeys produces exactly one file", () => {
22
+ globalThis.expect(keysOf({
23
+ components: [{
24
+ plugin: "Catalog",
25
+ views: [],
26
+ commands: []
27
+ }]
28
+ })).toEqual(["component-manifest.json"]);
29
+ });
30
+ globalThis.test("a renamed default is honoured", () => {
31
+ globalThis.expect(keysOf({
32
+ components: [{
33
+ plugin: "Catalog",
34
+ views: [],
35
+ commands: []
36
+ }],
37
+ key: "shop.json"
38
+ })).toEqual(["shop.json"]);
39
+ });
40
+ globalThis.test("journeys are written beside the default, not instead of it", () => {
41
+ globalThis.expect(keysOf({
42
+ components: [{
43
+ plugin: "Catalog",
44
+ views: [],
45
+ commands: []
46
+ }],
47
+ journeys: [
48
+ {
49
+ group: "Shopper",
50
+ components: [{
51
+ plugin: "Catalog",
52
+ views: [],
53
+ commands: []
54
+ }]
55
+ },
56
+ {
57
+ group: "Fulfilment",
58
+ components: [{
59
+ plugin: "Ordering",
60
+ views: [],
61
+ commands: []
62
+ }],
63
+ key: "fulfil.json"
64
+ }
65
+ ]
66
+ })).toEqual([
67
+ "component-manifest.json",
68
+ "component-manifest-shopper.json",
69
+ "fulfil.json"
70
+ ]);
71
+ });
72
+ globalThis.test("derives a URL-safe key from an awkward group name", () => {
73
+ globalThis.expect(keysOf({
74
+ components: [{
75
+ plugin: "Catalog",
76
+ views: [],
77
+ commands: []
78
+ }],
79
+ journeys: [{
80
+ group: "Ops Team/EU",
81
+ components: []
82
+ }]
83
+ })).toEqual([
84
+ "component-manifest.json",
85
+ "component-manifest-ops-team-eu.json"
86
+ ]);
87
+ });
88
+ globalThis.test("carries each journey's own selections", () => {
89
+ let files = BakedManifest$ReventlessLocal.files({
90
+ components: [{
91
+ plugin: "Catalog",
92
+ views: [],
93
+ commands: []
94
+ }],
95
+ journeys: [{
96
+ group: "Fulfilment",
97
+ components: [{
98
+ plugin: "Ordering",
99
+ views: [],
100
+ commands: []
101
+ }]
102
+ }]
103
+ });
104
+ globalThis.expect(files.map(param => param[1].map(s => s.plugin))).toEqual([
105
+ ["Catalog"],
106
+ ["Ordering"]
107
+ ]);
108
+ });
109
+ });
110
+
111
+ export {
112
+ sel,
113
+ keysOf,
114
+ }
115
+ /* Not a pure module */