@reventlessdev/reventless-core 3.0.0-alpha.231 → 3.0.0-alpha.232

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,13 @@
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.232 (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
+
6
13
  # 3.0.0-alpha.231 (2026-08-13)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.231",
3
+ "version": "3.0.0-alpha.232",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,17 +28,17 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
31
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.5",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.5",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.5",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
39
  "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
40
- "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.112",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -0,0 +1,118 @@
1
+ // The cross-provider contract for "a caller holding several roles acting as one
2
+ // of them" — see [docs/plans/active-role-narrows-the-token.md].
3
+ //
4
+ // The narrowing itself is minted in two places that cannot share code: the local
5
+ // platform signs its own tokens (`LocalAuth.Login`), and on AWS the minting point
6
+ // is Cognito's pre-token-generation trigger, running in Cognito's runtime against
7
+ // Cognito's event shape. What the two must agree on is exactly what lives here:
8
+ // the claim names a client reads, and the table of cases the subset rule has to
9
+ // satisfy on both paths.
10
+
11
+ /**
12
+ Claim naming the role the caller chose to act as. Present only on a narrowed
13
+ token, so its presence *is* the answer to "am I acting as one of my roles?".
14
+
15
+ Kept apart from the group claim even though a narrowed token's groups are exactly
16
+ this one role, because the two say different things: groups are what every
17
+ enforcement point evaluates, and this is what the caller asked for.
18
+ */
19
+ let activeRoleClaim = "activeRole"
20
+
21
+ /**
22
+ Claim naming the roles the caller gave up by narrowing — their full membership,
23
+ comma-joined.
24
+
25
+ 🚨 **Never read this for authorization.** It exists so a client can offer the
26
+ switch back, and it is by definition wider than what the caller is currently
27
+ permitted. Every enforcement point reads the group claim; this is the one piece
28
+ of an identity that deliberately describes privilege the caller does *not*
29
+ currently have.
30
+ */
31
+ let availableRolesClaim = "availableRoles"
32
+
33
+ /**
34
+ Claim naming a stored role that was *not* applied because the caller no longer
35
+ holds it.
36
+
37
+ Only the Cognito path can produce this. A stored preference outlives the
38
+ membership that justified it, and the trigger meets that on an ordinary refresh
39
+ with no client asking for anything — so it cannot refuse the way the local path
40
+ refuses a request. It mints the full set instead, and says so here: without this
41
+ claim a caller would be silently un-narrowed with nothing to explain why the role
42
+ they picked stopped taking effect.
43
+ */
44
+ let staleRoleClaim = "activeRoleStale"
45
+
46
+ /**
47
+ The conformance table §6 of the plan requires: the same (membership, requested,
48
+ expected) cases run against both minting paths, so the two implementations cannot
49
+ drift on the question that carries the security.
50
+
51
+ It lives in product code rather than in either package's tests because ReScript
52
+ test directories are `type: dev` and therefore invisible across a package
53
+ boundary — and a table copied into two test suites is exactly the drift it exists
54
+ to prevent.
55
+
56
+ `expected` is the group set the minted token must carry. `None` means the request
57
+ must be **refused** rather than honoured, ignored, or reduced to the empty set —
58
+ the distinction §3 turns on. A path with no client to refuse (the trigger, reading
59
+ stored state) resolves `None` by minting `membership` and marking
60
+ [staleRoleClaim]; a path answering a live request refuses it.
61
+ */
62
+ type conformanceCase = {
63
+ label: string,
64
+ membership: array<string>,
65
+ requested: option<string>,
66
+ expected: option<array<string>>,
67
+ }
68
+
69
+ let conformanceCases: array<conformanceCase> = [
70
+ {
71
+ label: "no role requested mints exactly what it always minted",
72
+ membership: ["Fulfilment", "Shopper"],
73
+ requested: None,
74
+ expected: Some(["Fulfilment", "Shopper"]),
75
+ },
76
+ {
77
+ label: "a held role narrows to that role alone",
78
+ membership: ["Fulfilment", "Shopper"],
79
+ requested: Some("Shopper"),
80
+ expected: Some(["Shopper"]),
81
+ },
82
+ {
83
+ label: "the other held role narrows just as well",
84
+ membership: ["Fulfilment", "Shopper"],
85
+ requested: Some("Fulfilment"),
86
+ expected: Some(["Fulfilment"]),
87
+ },
88
+ {
89
+ label: "a role the caller does not hold is refused, never widened into",
90
+ membership: ["Fulfilment", "Shopper"],
91
+ requested: Some("Admin"),
92
+ expected: None,
93
+ },
94
+ {
95
+ label: "a sole role narrows to itself rather than being a special case",
96
+ membership: ["Shopper"],
97
+ requested: Some("Shopper"),
98
+ expected: Some(["Shopper"]),
99
+ },
100
+ {
101
+ label: "a caller holding nothing can request nothing",
102
+ membership: [],
103
+ requested: Some("Shopper"),
104
+ expected: None,
105
+ },
106
+ {
107
+ label: "the empty string is a request, and one no caller can hold",
108
+ membership: ["Shopper"],
109
+ requested: Some(""),
110
+ expected: None,
111
+ },
112
+ {
113
+ label: "group names are matched exactly, not case-insensitively",
114
+ membership: ["Shopper"],
115
+ requested: Some("shopper"),
116
+ expected: None,
117
+ },
118
+ ]
@@ -0,0 +1,82 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let conformanceCases = [
5
+ {
6
+ label: "no role requested mints exactly what it always minted",
7
+ membership: [
8
+ "Fulfilment",
9
+ "Shopper"
10
+ ],
11
+ requested: undefined,
12
+ expected: [
13
+ "Fulfilment",
14
+ "Shopper"
15
+ ]
16
+ },
17
+ {
18
+ label: "a held role narrows to that role alone",
19
+ membership: [
20
+ "Fulfilment",
21
+ "Shopper"
22
+ ],
23
+ requested: "Shopper",
24
+ expected: ["Shopper"]
25
+ },
26
+ {
27
+ label: "the other held role narrows just as well",
28
+ membership: [
29
+ "Fulfilment",
30
+ "Shopper"
31
+ ],
32
+ requested: "Fulfilment",
33
+ expected: ["Fulfilment"]
34
+ },
35
+ {
36
+ label: "a role the caller does not hold is refused, never widened into",
37
+ membership: [
38
+ "Fulfilment",
39
+ "Shopper"
40
+ ],
41
+ requested: "Admin",
42
+ expected: undefined
43
+ },
44
+ {
45
+ label: "a sole role narrows to itself rather than being a special case",
46
+ membership: ["Shopper"],
47
+ requested: "Shopper",
48
+ expected: ["Shopper"]
49
+ },
50
+ {
51
+ label: "a caller holding nothing can request nothing",
52
+ membership: [],
53
+ requested: "Shopper",
54
+ expected: undefined
55
+ },
56
+ {
57
+ label: "the empty string is a request, and one no caller can hold",
58
+ membership: ["Shopper"],
59
+ requested: "",
60
+ expected: undefined
61
+ },
62
+ {
63
+ label: "group names are matched exactly, not case-insensitively",
64
+ membership: ["Shopper"],
65
+ requested: "shopper",
66
+ expected: undefined
67
+ }
68
+ ];
69
+
70
+ let activeRoleClaim = "activeRole";
71
+
72
+ let availableRolesClaim = "availableRoles";
73
+
74
+ let staleRoleClaim = "activeRoleStale";
75
+
76
+ export {
77
+ activeRoleClaim,
78
+ availableRolesClaim,
79
+ staleRoleClaim,
80
+ conformanceCases,
81
+ }
82
+ /* No side effect */
@@ -119,6 +119,33 @@ let geocodeTypes = [
119
119
 
120
120
  let geocodeQueryFields = [` geocode(text: String!): [GeocodeCandidate!]`]
121
121
 
122
+ // Acting as one of the roles you hold (docs/plans/active-role-narrows-the-token.md).
123
+ //
124
+ // The write door for the caller's chosen role. Like uploads and geocoding this is
125
+ // an ordinary authenticated-user operation, so it belongs on the **domain** base
126
+ // (default `AllowAuthenticated`), never the Admin-gated admin base — the whole
127
+ // point is that a non-admin can drop into a narrower role.
128
+ //
129
+ // There is deliberately no `sub` argument: the subject is the authorizer-verified
130
+ // `ctx.identity.sub`, so the mutation can only ever address the caller's own row.
131
+ // `activeRole: null` clears the choice and widens back to full membership.
132
+ //
133
+ // 🚨 **Mounted by the AWS platform only.** This is the Cognito path's door —
134
+ // Cognito mints the token, so the choice has to be stored for the pre-token-
135
+ // generation trigger to read on the next refresh. The local platform mints its own
136
+ // tokens and therefore re-mints immediately over `POST /__inmemory/switch-role`
137
+ // (`LocalAuth.Login.reissue`); a mutation there would be a second way to do
138
+ // something already done, with a token the caller would still have to go and
139
+ // fetch. The constants live here with the other platform SDL rather than in the
140
+ // AWS package so every platform-owned field stays in one file.
141
+ let activeRoleTypes = [
142
+ `type Platform_ActiveRole {\n activeRole: String\n availableRoles: [String!]!\n}`,
143
+ ]
144
+
145
+ let activeRoleMutationFields = [
146
+ ` Platform_SetActiveRole(activeRole: String): Platform_ActiveRole`,
147
+ ]
148
+
122
149
  let baseFragment = (~cloner: bool) => {
123
150
  let base = GraphQL_FragmentGenerator.generate(
124
151
  ~mutationEntries=mutationEntries(~cloner),
@@ -86,6 +86,10 @@ let geocodeTypes = [`type GeocodeCandidate {\n label: String!\n lat: Float!\n
86
86
 
87
87
  let geocodeQueryFields = [` geocode(text: String!): [GeocodeCandidate!]`];
88
88
 
89
+ let activeRoleTypes = [`type Platform_ActiveRole {\n activeRole: String\n availableRoles: [String!]!\n}`];
90
+
91
+ let activeRoleMutationFields = [` Platform_SetActiveRole(activeRole: String): Platform_ActiveRole`];
92
+
89
93
  function baseFragment(cloner) {
90
94
  let base = GraphQL_FragmentGenerator$ReventlessCore.generate(mutationEntries(cloner), PluginBaseFragment$ReventlessCore.queryEntries);
91
95
  let parts = GraphQL_Stitcher$ReventlessCore.decode(base);
@@ -128,6 +132,8 @@ export {
128
132
  uploadMutationFields,
129
133
  geocodeTypes,
130
134
  geocodeQueryFields,
135
+ activeRoleTypes,
136
+ activeRoleMutationFields,
131
137
  baseFragment,
132
138
  }
133
139
  /* cloneArgsSchema Not a pure module */