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

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 CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.220 (2026-08-13)
7
+
8
+ ### Features
9
+
10
+ * **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
11
+
12
+
13
+ # 3.0.0-alpha.219 (2026-08-13)
14
+
15
+ **Note:** Version bump only for package @reventlessdev/reventless-local
16
+
17
+
18
+
19
+
20
+
6
21
  # 3.0.0-alpha.218 (2026-08-13)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.218",
3
+ "version": "3.0.0-alpha.220",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -36,17 +36,17 @@
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
39
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
40
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
41
41
  "@reventlessdev/rescript-node": "2.0.0-alpha.5",
42
- "@reventlessdev/reventless-core": "3.0.0-alpha.230",
43
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.78",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.177",
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.232",
43
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.80",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.179",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
46
48
  "@reventlessdev/reventless-seed": "1.0.0-alpha.12",
47
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.94",
48
- "@reventlessdev/reventless-spec": "3.0.0-alpha.112",
49
- "@reventlessdev/reventless-infra": "3.0.0-alpha.138"
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -139,33 +139,18 @@ module Login = {
139
139
  }
140
140
 
141
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.
142
+ The claim names a narrowed token carries, shared with the Cognito minting
143
+ path a client must not have to know which platform signed its token to read
144
+ its own active role. See `ReventlessCore.Auth_ActiveRole` for what each means
145
+ and why `availableRoles` is never an authorization input.
146
+
147
+ Both are present only on a narrowed token, which is what keeps an ordinary
148
+ login byte-identical to what it minted before any of this existed: an
149
+ unnarrowed token's `groups` already *are* the full membership, so there is
150
+ nothing to remember.
150
151
  */
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"
152
+ let activeRoleClaim = ReventlessCore.Auth_ActiveRole.activeRoleClaim
153
+ let availableRolesClaim = ReventlessCore.Auth_ActiveRole.availableRolesClaim
169
154
 
170
155
  /**
171
156
  Narrow an identity to one of its own roles.
@@ -5,6 +5,7 @@ import * as Nodecrypto from "node:crypto";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
8
+ import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
8
9
 
9
10
  let defaultUser_groups = ["User"];
10
11
 
@@ -118,10 +119,6 @@ function _b64urlDecode(s) {
118
119
  }
119
120
  }
120
121
 
121
- let activeRoleClaim = "activeRole";
122
-
123
- let availableRolesClaim = "availableRoles";
124
-
125
122
  function narrow(identity, activeRole) {
126
123
  if (!identity.groups.includes(activeRole)) {
127
124
  return {
@@ -131,8 +128,8 @@ function narrow(identity, activeRole) {
131
128
  }
132
129
  let existing = identity.claims;
133
130
  let claims = existing !== undefined ? Object.fromEntries(Object.entries(existing)) : ({});
134
- claims[activeRoleClaim] = activeRole;
135
- claims[availableRolesClaim] = identity.groups.join(",");
131
+ claims[Auth_ActiveRole$ReventlessCore.activeRoleClaim] = activeRole;
132
+ claims[Auth_ActiveRole$ReventlessCore.availableRolesClaim] = identity.groups.join(",");
136
133
  let newrecord = {...identity};
137
134
  return {
138
135
  TAG: "Ok",
@@ -240,8 +237,8 @@ let Login = {
240
237
  _sign: _sign,
241
238
  _b64urlEncode: _b64urlEncode,
242
239
  _b64urlDecode: _b64urlDecode,
243
- activeRoleClaim: activeRoleClaim,
244
- availableRolesClaim: availableRolesClaim,
240
+ activeRoleClaim: Auth_ActiveRole$ReventlessCore.activeRoleClaim,
241
+ availableRolesClaim: Auth_ActiveRole$ReventlessCore.availableRolesClaim,
245
242
  narrow: narrow,
246
243
  issue: issue,
247
244
  mintedIdentity: mintedIdentity,
@@ -392,3 +392,47 @@ testPromise("switching refuses a token this server did not sign", async () => {
392
392
  | Error(msg) => expect(msg)->toEqual("Invalid token")
393
393
  }
394
394
  })
395
+
396
+ // ── The shared conformance table ──────────────────────────────────────────
397
+ //
398
+ // The cases above were written against this path. `ReventlessCore.Auth_ActiveRole`
399
+ // holds them in a form the Cognito path runs too — the two minting
400
+ // implementations cannot share code across a process boundary, so a table both
401
+ // drive is what keeps them from drifting. A case added there fails here until
402
+ // this path satisfies it, which is the point.
403
+
404
+ describe("the conformance table, minted locally", () => {
405
+ ReventlessCore.Auth_ActiveRole.conformanceCases->Array.forEach(({
406
+ label,
407
+ membership,
408
+ requested,
409
+ expected,
410
+ }) =>
411
+ testPromise(label, async () => {
412
+ resetAll()
413
+ LocalAuth.Login.setCredentials(
414
+ ~username="conformance",
415
+ ~password="pw",
416
+ ~identity={
417
+ userId: "u-conformance",
418
+ username: "conformance",
419
+ groups: membership,
420
+ provider: InMemory,
421
+ },
422
+ )
423
+ let minted = await LocalAuth.Login.issue(
424
+ ~username="conformance",
425
+ ~password="pw",
426
+ ~activeRole=?requested,
427
+ )
428
+ switch (minted, expected) {
429
+ | (Ok(token), Some(groups)) => expect(decodeOrThrow(token).groups)->toEqual(groups)
430
+ // `None` means refused — not honoured, not ignored, and not reduced to the
431
+ // empty set. A token minted here at all would be the vulnerability.
432
+ | (Ok(_), None) => JsError.throwWithMessage("expected the request to be refused")
433
+ | (Error(msg), Some(_)) => JsError.throwWithMessage("expected a token, got: " ++ msg)
434
+ | (Error(_), None) => expect(true)->toEqual(true)
435
+ }
436
+ })
437
+ )
438
+ })
@@ -4,6 +4,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
5
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
6
6
  import * as LocalAuth$ReventlessLocal from "../../src/adapter/Auth/LocalAuth.res.mjs";
7
+ import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
7
8
 
8
9
  let alice_groups = ["Editor"];
9
10
 
@@ -378,6 +379,37 @@ globalThis.test("switching refuses a token this server did not sign", async () =
378
379
  globalThis.expect(msg._0).toEqual("Invalid token");
379
380
  });
380
381
 
382
+ globalThis.describe("the conformance table, minted locally", () => {
383
+ Auth_ActiveRole$ReventlessCore.conformanceCases.forEach(param => {
384
+ let expected = param.expected;
385
+ let requested = param.requested;
386
+ let membership = param.membership;
387
+ globalThis.test(param.label, async () => {
388
+ resetAll();
389
+ LocalAuth$ReventlessLocal.Login.setCredentials("conformance", "pw", {
390
+ userId: "u-conformance",
391
+ username: "conformance",
392
+ groups: membership,
393
+ provider: "InMemory"
394
+ });
395
+ let minted = await LocalAuth$ReventlessLocal.Login.issue("conformance", "pw", requested);
396
+ if (minted.TAG === "Ok") {
397
+ if (expected !== undefined) {
398
+ globalThis.expect(decodeOrThrow(minted._0).groups).toEqual(expected);
399
+ return;
400
+ } else {
401
+ return Stdlib_JsError.throwWithMessage("expected the request to be refused");
402
+ }
403
+ } else if (expected !== undefined) {
404
+ return Stdlib_JsError.throwWithMessage("expected a token, got: " + minted._0);
405
+ } else {
406
+ globalThis.expect(true).toEqual(true);
407
+ return;
408
+ }
409
+ });
410
+ });
411
+ });
412
+
381
413
  export {
382
414
  alice,
383
415
  bob,