@reventlessdev/reventless-aws 3.0.0-alpha.299 → 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,23 @@
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
+
16
+ # 3.0.0-alpha.300 (2026-08-14)
17
+
18
+ ### Features
19
+
20
+ * **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
21
+
22
+
6
23
  # 3.0.0-alpha.299 (2026-08-14)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.299",
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-aws-sdk": "3.0.0-alpha.11",
15
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
17
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
17
18
  "@reventlessdev/rescript-jest": "1.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",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
21
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.233",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.235",
24
22
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.97",
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
  }
@@ -68,7 +68,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
68
68
  ~subIdField,
69
69
  ~idResolverConfigs: array<idResolverConfig>,
70
70
  ~idsResolverConfigs: array<idsResolverConfig>,
71
- // AWS path enforces authorization via `@aws_auth(cognito_groups: …)` on the
71
+ // AWS path enforces authorization via `@aws_cognito_user_pools(cognito_groups: …)` on the
72
72
  // SDL fields (see Stage E in docs/plans/host-ui-login-core.md). Accepted but
73
73
  // ignored here so the in-memory and AWS resolver maker signatures stay aligned.
74
74
  ~authorization as _: Reventless.Authorization.permission,
@@ -185,10 +185,13 @@ let rec waitForMergeSuccess = async (
185
185
  // When both are present on the same field, the spec-level permission wins
186
186
  // (it is more specific). `AllowGroups([g1, g2, ...])` emits
187
187
  // `@aws_cognito_user_pools(cognito_groups: ["g1", "g2", ...])`.
188
- // `AllowAuthenticated` / `AllowAnonymous` emit no directive (with Cognito as
189
- // primary auth, any reaching request is already authenticated). `AllowGroups([])`
190
- // and `DenyAll` emit a sentinel `__deny_all__` group that no Cognito user can
191
- // belong to effectively blocking the field at the API layer.
188
+ // `AllowAuthenticated` / `AllowAnonymous` emit the group-less
189
+ // `@aws_cognito_user_pools` same reachability (with Cognito as primary auth
190
+ // any reaching request is already authenticated), but stated rather than left
191
+ // implicit, so `assertGateable` can tell "deliberately open" from "nobody
192
+ // stamped this". `AllowGroups([])` and `DenyAll` emit a sentinel `__deny_all__`
193
+ // group that no Cognito user can belong to — effectively blocking the field at
194
+ // the API layer.
192
195
  //
193
196
  // NOT `@aws_auth(...)`: that is the single-mode form, which AppSync ignores on a
194
197
  // multi-auth API — which every API this adapter provisions is. See
@@ -422,14 +425,14 @@ let injectAwsAuth = (
422
425
  })
423
426
  }
424
427
 
425
- // Injects @aws_auth with the given group on ALL mutation, query, and subscription
428
+ // Injects the Cognito group directive on ALL mutation, query, and subscription
426
429
  // fields in a fragment. Used for the base fragment where all fields share the
427
430
  // same authorization group.
428
431
  //
429
432
  // `~iamFieldNames` opts the named mutation/query fields into deploy-time IAM
430
433
  // (dual-auth): those fields emit `@aws_cognito_user_pools(cognito_groups:
431
- // ["<group>"]) @aws_iam` instead of the single-mode `@aws_auth(...)`, keeping
432
- // the same Cognito group gating while also admitting the SigV4 system caller.
434
+ // ["<group>"]) @aws_iam`, keeping the same Cognito group gating while also
435
+ // admitting the SigV4 system caller.
433
436
  // Subscriptions are never IAM-marked (the deploy caller does not subscribe).
434
437
  // Canonical definition lives in the runtime-pure AppSync_SdlDecorate so the
435
438
  // bundled AdminEventCollector Lambda's reactive push decorates the admin base
@@ -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;