@reventlessdev/reventless-aws 3.0.0-alpha.300 → 3.0.0-alpha.301

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,16 @@
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.301 (2026-08-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** ask Cognito about a caller by the name it knows them by ([265f436](https://github.com/ReventlessDev/reventless-core/commit/265f4361f9695a12d1dcc055b256ec72174d428e))
11
+ ### Features
12
+
13
+ * **core:** curate the pages a shell builds across a plugin's views ([589835a](https://github.com/ReventlessDev/reventless-core/commit/589835a1ad428c5e2dea8bf6e4c64e49d2f67e0d))
14
+
15
+
6
16
  # 3.0.0-alpha.300 (2026-08-14)
7
17
 
8
18
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.300",
3
+ "version": "3.0.0-alpha.301",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
18
- "@reventlessdev/rescript-node": "2.0.0-alpha.6",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.75",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
17
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
18
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
21
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.141",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.235",
23
22
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
24
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.98",
25
- "@reventlessdev/reventless-core": "3.0.0-alpha.234",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.113"
23
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.99",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.114",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.142"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -153,6 +153,7 @@ let make = (
153
153
  )
154
154
  strings("views", sel.views)
155
155
  strings("commands", sel.commands)
156
+ strings("derived", sel.derived)
156
157
  entry->JSON.Encode.object
157
158
  })
158
159
  ->JSON.Encode.array
@@ -87,9 +87,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
87
87
  });
88
88
  strings("views", sel.views);
89
89
  strings("commands", sel.commands);
90
+ strings("derived", sel.derived);
90
91
  return entry;
91
92
  })) : "";
92
- let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, Platform_Admin_Structure$ReventlessCore.structure));
93
+ let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, undefined, Platform_Admin_Structure$ReventlessCore.structure));
93
94
  let packageDirs = Object.fromEntries([[
94
95
  "@reventlessdev/reventless-aws",
95
96
  Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
@@ -304,6 +304,7 @@ let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest
304
304
  ReventlessCore.Platform_BakedManifest.plugin,
305
305
  views: strings(o, "views"),
306
306
  commands: strings(o, "commands"),
307
+ derived: strings(o, "derived"),
307
308
  })
308
309
  )
309
310
  }
@@ -263,7 +263,8 @@ function bakeSelection(json) {
263
263
  return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => Stdlib_Option.map(Stdlib_Option.flatMap(o["plugin"], Stdlib_JSON.Decode.string), plugin => ({
264
264
  plugin: plugin,
265
265
  views: strings(o, "views"),
266
- commands: strings(o, "commands")
266
+ commands: strings(o, "commands"),
267
+ derived: strings(o, "derived")
267
268
  })));
268
269
  }
269
270
 
@@ -40,6 +40,15 @@ type storeTable = {
40
40
  // JS resolver code (APPSYNC_JS runtime): forward the caller's arguments and the
41
41
  // authorizer-verified identity. `sub` is what keys the row, and it comes from
42
42
  // here — the mutation has no `sub` argument for a client to supply.
43
+ //
44
+ // `username` travels with it because the two answer different questions. The row
45
+ // is keyed on `sub`, which is stable across a rename and is what the pre-token
46
+ // trigger looks the row up by; but Cognito's admin API addresses a user by
47
+ // *username*, and a pool with neither `UsernameAttributes` nor `AliasAttributes`
48
+ // does not accept a `sub` there — it answers `UserNotFoundException`, which is
49
+ // how the membership read used to fail for every caller on such a pool. Both
50
+ // fields come from the authorizer, so forwarding the second grants nothing the
51
+ // first did not.
43
52
  let invokeCode: Pulumi.Input.t<string> = `import { util } from '@aws-appsync/utils';
44
53
  export function request(ctx) {
45
54
  const id = ctx.identity;
@@ -47,7 +56,7 @@ export function request(ctx) {
47
56
  operation: 'Invoke',
48
57
  payload: {
49
58
  arguments: ctx.args,
50
- identity: id != null && id.sub != null ? { sub: id.sub } : null
59
+ identity: id != null && id.sub != null ? { sub: id.sub, username: id.username ?? null } : null
51
60
  }
52
61
  };
53
62
  }
@@ -22,7 +22,7 @@ export function request(ctx) {
22
22
  operation: 'Invoke',
23
23
  payload: {
24
24
  arguments: ctx.args,
25
- identity: id != null && id.sub != null ? { sub: id.sub } : null
25
+ identity: id != null && id.sub != null ? { sub: id.sub, username: id.username ?? null } : null
26
26
  }
27
27
  };
28
28
  }
@@ -50,16 +50,25 @@ token they hold no longer mentions the role they are asking for. This is the sam
50
50
  trap the local path documents on `LocalAuth.Login.reissue`, where membership is
51
51
  re-read from the user store rather than from the token's own record of it.
52
52
 
53
+ 🚨 **Addressed by username, not by `sub`.** `AdminListGroupsForUser` takes a
54
+ `Username`, and a `sub` is only accepted there when the pool makes it one — a
55
+ pool with `UsernameAttributes` (email or phone sign-in), where the generated
56
+ username *is* the subject. On a plain username pool the same call answers
57
+ `UserNotFoundException: User does not exist.`, which fails the whole mutation
58
+ before it reaches the subset check: not "you may not act as that role" but "you
59
+ do not exist", for a caller who is signed in and holds the role. The row is still
60
+ keyed on `sub` — this parameter names the user to Cognito, nothing else.
61
+
53
62
  Paginated deliberately: `AdminListGroupsForUser` caps a page at 60 groups, and a
54
63
  truncated read would silently refuse a role the caller genuinely holds.
55
64
  */
56
- let membershipOf = async (~sub: string, ~poolId: string): array<string> => {
65
+ let membershipOf = async (~username: string, ~poolId: string): array<string> => {
57
66
  let collected = []
58
67
  let nextToken = ref(None)
59
68
  let more = ref(true)
60
69
  while more.contents {
61
70
  let page = await CognitoIdentityServiceProvider.AdminListGroupsForUserCommand.make({
62
- username: sub,
71
+ username,
63
72
  userPoolId: poolId,
64
73
  nextToken: ?nextToken.contents,
65
74
  })->CognitoIdentityServiceProvider.AdminListGroupsForUserCommand.send
@@ -89,13 +98,33 @@ let mayActAs = (~membership: array<string>, ~requested: string): bool =>
89
98
 
90
99
  // ── AppSync resolver event / result shapes ──────────────────────────────────
91
100
 
92
- type identity = {sub?: string}
101
+ type identity = {sub?: string, username?: Nullable.t<string>}
93
102
  type activeRoleArgs = {activeRole?: Nullable.t<string>}
94
103
  type appSyncEvent = {
95
104
  arguments?: activeRoleArgs,
96
105
  identity?: identity,
97
106
  }
98
107
 
108
+ /**
109
+ The name to address this caller by when asking Cognito what groups they are in.
110
+
111
+ The username when the authorizer supplied one, the subject otherwise. The
112
+ fallback is not a guess at a username: it is the behaviour this handler had
113
+ before the username was forwarded, kept for the pool shapes where it is correct —
114
+ one whose username *is* the subject — rather than turning a working deployment
115
+ into a hard failure on an absent field. Pure so the choice can be tested without
116
+ a pool.
117
+ */
118
+ let cognitoLookupName = (~identity: identity): option<string> =>
119
+ switch identity.username {
120
+ | Some(Value(name)) if name->String.trim != "" => Some(name)
121
+ | _ =>
122
+ switch identity.sub {
123
+ | Some("") | None => None
124
+ | Some(sub) => Some(sub)
125
+ }
126
+ }
127
+
99
128
  let result = (~activeRole: option<string>, ~availableRoles: array<string>): JSON.t =>
100
129
  Dict.fromArray([
101
130
  ("activeRole", activeRole->Option.mapOr(JSON.Null, JSON.Encode.string)),
@@ -111,8 +140,14 @@ let handler = async (event: appSyncEvent): JSON.t => {
111
140
  // unauthenticated path from ever writing a row keyed on the empty subject.
112
141
  JsError.throwWithMessage("unauthenticated")
113
142
  }
143
+ // Two names for one caller, and they are not interchangeable: `sub` keys the
144
+ // row, the lookup name addresses Cognito. See `membershipOf`.
145
+ let lookupName = switch event.identity->Option.flatMap(i => cognitoLookupName(~identity=i)) {
146
+ | Some(name) => name
147
+ | None => JsError.throwWithMessage("unauthenticated")
148
+ }
114
149
  let table = tableName()
115
- let membership = await membershipOf(~sub, ~poolId=userPoolId())
150
+ let membership = await membershipOf(~username=lookupName, ~poolId=userPoolId())
116
151
 
117
152
  // An absent argument and an explicit `null` mean the same thing — clear the
118
153
  // preference and go back to full membership on the next refresh. That is not an
@@ -33,13 +33,13 @@ function userPoolId() {
33
33
  }
34
34
  }
35
35
 
36
- async function membershipOf(sub, poolId) {
36
+ async function membershipOf(username, poolId) {
37
37
  let collected = [];
38
38
  let nextToken;
39
39
  let more = true;
40
40
  while (more) {
41
41
  let page = await CognitoIdentityServiceProvider$AwsSdk.AdminListGroupsForUserCommand.send(new ClientCognitoIdentityProvider.AdminListGroupsForUserCommand({
42
- Username: sub,
42
+ Username: username,
43
43
  UserPoolId: poolId,
44
44
  NextToken: nextToken
45
45
  }));
@@ -60,6 +60,22 @@ function mayActAs(membership, requested) {
60
60
  return membership.includes(requested);
61
61
  }
62
62
 
63
+ function cognitoLookupName(identity) {
64
+ let match = identity.username;
65
+ if (match !== undefined) {
66
+ let name = Primitive_option.valFromOption(match);
67
+ if (name == null) {
68
+ name === null;
69
+ } else if (name.trim() !== "") {
70
+ return name;
71
+ }
72
+ }
73
+ let sub = identity.sub;
74
+ if (sub !== undefined && sub !== "") {
75
+ return sub;
76
+ }
77
+ }
78
+
63
79
  function result(activeRole, availableRoles) {
64
80
  return Object.fromEntries([
65
81
  [
@@ -78,8 +94,10 @@ async function handler(event) {
78
94
  if (sub === "") {
79
95
  Stdlib_JsError.throwWithMessage("unauthenticated");
80
96
  }
97
+ let name = Stdlib_Option.flatMap(event.identity, cognitoLookupName);
98
+ let lookupName = name !== undefined ? name : Stdlib_JsError.throwWithMessage("unauthenticated");
81
99
  let table = tableName();
82
- let membership = await membershipOf(sub, userPoolId());
100
+ let membership = await membershipOf(lookupName, userPoolId());
83
101
  let match = Stdlib_Option.flatMap(event.arguments, a => a.activeRole);
84
102
  let requested;
85
103
  if (match !== undefined) {
@@ -122,6 +140,7 @@ export {
122
140
  userPoolId,
123
141
  membershipOf,
124
142
  mayActAs,
143
+ cognitoLookupName,
125
144
  result,
126
145
  handler,
127
146
  }
@@ -40,3 +40,35 @@ describe("Auth_ActiveRoleStore_Ops.mayActAs — narrowing only", () => {
40
40
  expect(Ops.mayActAs(~membership=[], ~requested="Shopper"))->toBe(false)
41
41
  )
42
42
  })
43
+
44
+ // Which name goes to Cognito. `sub` keys the row and `AdminListGroupsForUser`
45
+ // takes a username, and a pool that makes neither an alias of the other answers
46
+ // `UserNotFoundException` when handed the wrong one — a signed-in caller holding
47
+ // the role they asked for, told they do not exist.
48
+ describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
49
+ testSync("the username is what addresses Cognito when the authorizer sent one", () =>
50
+ expect(
51
+ Ops.cognitoLookupName(~identity={sub: "d265d464-2091-706a-e5f9-3afafe7be29c", username: Value("merch")}),
52
+ )->toEqual(Some("merch"))
53
+ )
54
+
55
+ testSync("an absent username falls back to the subject, as before it was forwarded", () =>
56
+ expect(Ops.cognitoLookupName(~identity={sub: "sub-1"}))->toEqual(Some("sub-1"))
57
+ )
58
+
59
+ // The resolver sends `id.username ?? null`, so null is the shape an authorizer
60
+ // that names no username actually produces — not a missing field.
61
+ testSync("an explicitly null username falls back too", () =>
62
+ expect(Ops.cognitoLookupName(~identity={sub: "sub-1", username: Null}))->toEqual(Some("sub-1"))
63
+ )
64
+
65
+ testSync("a blank username is not a name to look anyone up by", () =>
66
+ expect(Ops.cognitoLookupName(~identity={sub: "sub-1", username: Value(" ")}))->toEqual(
67
+ Some("sub-1"),
68
+ )
69
+ )
70
+
71
+ testSync("neither name means there is nobody to ask about", () =>
72
+ expect(Ops.cognitoLookupName(~identity={}))->toEqual(None)
73
+ )
74
+ })
@@ -30,6 +30,35 @@ globalThis.describe("Auth_ActiveRoleStore_Ops.mayActAs — narrowing only", () =
30
30
  });
31
31
  });
32
32
 
33
+ globalThis.describe("Auth_ActiveRoleStore_Ops.cognitoLookupName", () => {
34
+ globalThis.test("the username is what addresses Cognito when the authorizer sent one", () => {
35
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
36
+ sub: "d265d464-2091-706a-e5f9-3afafe7be29c",
37
+ username: "merch"
38
+ })).toEqual("merch");
39
+ });
40
+ globalThis.test("an absent username falls back to the subject, as before it was forwarded", () => {
41
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
42
+ sub: "sub-1"
43
+ })).toEqual("sub-1");
44
+ });
45
+ globalThis.test("an explicitly null username falls back too", () => {
46
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
47
+ sub: "sub-1",
48
+ username: null
49
+ })).toEqual("sub-1");
50
+ });
51
+ globalThis.test("a blank username is not a name to look anyone up by", () => {
52
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({
53
+ sub: "sub-1",
54
+ username: " "
55
+ })).toEqual("sub-1");
56
+ });
57
+ globalThis.test("neither name means there is nobody to ask about", () => {
58
+ globalThis.expect(Auth_ActiveRoleStore_Ops$ReventlessAws.cognitoLookupName({})).toEqual(undefined);
59
+ });
60
+ });
61
+
33
62
  let Ops;
34
63
 
35
64
  let Contract;