@reventlessdev/reventless-aws 3.0.0-alpha.294 → 3.0.0-alpha.296

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,20 @@
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.296 (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **aws:** give every field and type an explicit Cognito directive ([37c1063](https://github.com/ReventlessDev/reventless-core/commit/37c1063560aaa6ee200e74d58b9de4d6881441fd))
11
+
12
+
13
+ # 3.0.0-alpha.295 (2026-08-14)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** enforce Cognito group gates that AppSync was silently ignoring ([1392638](https://github.com/ReventlessDev/reventless-core/commit/13926388c097500606f75c387630a19ee1a3aa71))
18
+
19
+
6
20
  # 3.0.0-alpha.294 (2026-08-14)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.294",
3
+ "version": "3.0.0-alpha.296",
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.9",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
16
16
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
17
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
18
- "@reventlessdev/rescript-node": "2.0.0-alpha.5",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.74",
18
+ "@reventlessdev/rescript-node": "2.0.0-alpha.6",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.75",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.232",
23
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
24
- "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
25
- "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
23
+ "@reventlessdev/reventless-core": "3.0.0-alpha.233",
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.97",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.113",
26
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -6,13 +6,20 @@
6
6
  // ([Auth_ActiveRoleTrigger.res] bundles it and attaches it to the user pool).
7
7
  //
8
8
  // The narrowing has to happen here rather than in our own enforcement code
9
- // because `@authorize(AllowGroups([...]))` compiles to `@aws_auth`, which AppSync
9
+ // because `@authorize(AllowGroups([...]))` compiles to a field directive AppSync
10
10
  // evaluates against `cognito:groups` **before any of our code executes**. A
11
11
  // request header we invent could scope reads correctly and still leave every
12
12
  // group-gated mutation callable — right about the data, wrong about the writes.
13
13
  // Overriding the group claim is the one place every enforcement point already
14
14
  // looks.
15
15
  //
16
+ // That premise was false for as long as the directive was emitted as the
17
+ // single-mode `@aws_auth(...)`, which AppSync ignores on a multi-auth API — the
18
+ // gate was open and this trigger narrowed a claim nothing read for authorization.
19
+ // The directive is now `@aws_cognito_user_pools(...)`, which the service does
20
+ // enforce, so the reasoning above holds again. See
21
+ // `AppSync_SdlDecorate.formatCognitoGroupsDirective`.
22
+ //
16
23
  // **Version 1 of the trigger, deliberately.** Group override and added ID-token
17
24
  // claims are `V1_0` capabilities; `V2_0`/`V3_0` buy access-token customisation
18
25
  // this does not need and are gated behind the Essentials and Plus feature plans.
@@ -171,9 +171,9 @@ let rec waitForMergeSuccess = async (
171
171
  }
172
172
  }
173
173
 
174
- // ── @aws_auth directive injection ─────────────────────────────────────────
175
- // Injects @aws_auth(cognito_groups: [...]) directives into SDL field strings
176
- // based on authorization metadata from schema entries.
174
+ // ── Cognito group directive injection ─────────────────────────────────────
175
+ // Injects @aws_cognito_user_pools(cognito_groups: [...]) directives into SDL
176
+ // field strings based on authorization metadata from schema entries.
177
177
  //
178
178
  // Two sources are merged:
179
179
  // 1. The legacy `authorization?: {tableName, group}` field — single group
@@ -184,19 +184,22 @@ let rec waitForMergeSuccess = async (
184
184
  //
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
- // `@aws_auth(cognito_groups: ["g1", "g2", ...])`. `AllowAuthenticated` /
188
- // `AllowAnonymous` emit no directive (with Cognito as primary auth, any
189
- // reaching request is already authenticated). `AllowGroups([])` and
190
- // `DenyAll` emit a sentinel `__deny_all__` group that no Cognito user can
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
191
  // belong to — effectively blocking the field at the API layer.
192
192
  //
193
+ // NOT `@aws_auth(...)`: that is the single-mode form, which AppSync ignores on a
194
+ // multi-auth API — which every API this adapter provisions is. See
195
+ // `AppSync_SdlDecorate.formatCognitoGroupsDirective` for the full rationale and
196
+ // the observation that established it.
197
+ //
193
198
  // ── Dual-auth (Cognito + IAM) for deploy-time system callers ────────────────
194
199
  // A field flagged `systemCallable` (via `mutationSchemaEntry`/`querySchemaEntry`)
195
200
  // must be reachable by BOTH the console UI (Cognito) and a deploy-time system
196
201
  // caller signing SigV4 with ambient AWS credentials (`Util_AppSync_Caller`,
197
- // the AWS_IAM additional auth provider). `@aws_auth(...)` is the single-mode
198
- // directive form and does not admit IAM on a multi-auth API. For these fields
199
- // we emit the multi-auth form instead:
202
+ // the AWS_IAM additional auth provider). For these fields we append the IAM arm:
200
203
  // @aws_cognito_user_pools(cognito_groups: [...]) @aws_iam
201
204
  // The Cognito arm preserves the field's existing group gating (or, when the
202
205
  // field carries no Cognito group restriction, a bare `@aws_cognito_user_pools`
@@ -215,24 +218,17 @@ let _permissionToCognitoGroups = (
215
218
  | AllowAuthenticated | AllowAnonymous => None
216
219
  }
217
220
 
218
- let _formatGroupsDirective = (groups: array<string>): string => {
219
- let quoted = groups->Array.map(g => `"${g}"`)->Array.join(", ")
220
- `@aws_auth(cognito_groups: [${quoted}])`
221
- }
221
+ // Both directive forms are defined once in the runtime-pure AppSync_SdlDecorate
222
+ // so this deploy path and the bundled AdminEventCollector Lambda's reactive push
223
+ // cannot drift. The Cognito-only arm carries the rationale for why it is
224
+ // `@aws_cognito_user_pools(...)` and never `@aws_auth(...)`.
225
+ let _formatGroupsDirective = AppSync_SdlDecorate.formatCognitoGroupsDirective
222
226
 
223
227
  // Multi-auth directive for a field that must accept BOTH Cognito and IAM.
224
228
  // `groups=Some([...])` preserves Cognito group gating; `groups=None` keeps the
225
229
  // field open to any authenticated Cognito user. `@aws_iam` admits the
226
230
  // deploy-time SigV4 system caller. See the dual-auth note above.
227
- let _formatDualAuthDirective = (groups: option<array<string>>): string => {
228
- let cognito = switch groups {
229
- | Some(g) =>
230
- let quoted = g->Array.map(x => `"${x}"`)->Array.join(", ")
231
- `@aws_cognito_user_pools(cognito_groups: [${quoted}])`
232
- | None => `@aws_cognito_user_pools`
233
- }
234
- `${cognito} @aws_iam`
235
- }
231
+ let _formatDualAuthDirective = AppSync_SdlDecorate.formatDualAuthDirective
236
232
 
237
233
  // ── Type-level dual-auth ─────────────────────────────────────────────────────
238
234
  // On a multi-auth API, object TYPES without auth directives are accessible only
@@ -359,6 +355,12 @@ let injectAwsAuth = (
359
355
  }
360
356
  })
361
357
 
358
+ // A field with no group restriction gets the group-less Cognito directive
359
+ // rather than no directive at all. Same reachability under `defaultAction:
360
+ // ALLOW`; the difference only shows once the default is DENY, where an
361
+ // unstamped field is refused. See AppSync_SdlDecorate.cognitoOpenDirective.
362
+ let openDirective = AppSync_SdlDecorate.cognitoOpenDirective
363
+
362
364
  let augmentedMutations = parts.mutations->Array.map(field => {
363
365
  let fieldName = ReventlessCore.GraphQL_Stitcher.extractLeadingName(field)
364
366
  let groups = mutationAuthMap->Dict.get(fieldName)
@@ -367,7 +369,7 @@ let injectAwsAuth = (
367
369
  } else {
368
370
  switch groups {
369
371
  | Some(groups) => `${field}\n ${_formatGroupsDirective(groups)}`
370
- | None => field
372
+ | None => `${field}\n ${openDirective}`
371
373
  }
372
374
  }
373
375
  })
@@ -383,11 +385,23 @@ let injectAwsAuth = (
383
385
  } else {
384
386
  switch groups {
385
387
  | Some(groups) => `${field} ${_formatGroupsDirective(groups)}`
386
- | None => field
388
+ | None => `${field} ${openDirective}`
387
389
  }
388
390
  }
389
391
  })
390
392
 
393
+ // Subscriptions carried no directive at all on this path — they are reads and
394
+ // need one for the same reason queries do. A subscription is never IAM-marked
395
+ // (the deploy caller does not subscribe), so it takes the Cognito arm only:
396
+ // the mutation's groups where the subscribed field has them, otherwise open.
397
+ let augmentedSubscriptions = parts.subscriptions->Array.map(field => {
398
+ let fieldName = ReventlessCore.GraphQL_Stitcher.extractLeadingName(field)
399
+ switch mutationAuthMap->Dict.get(fieldName) {
400
+ | Some(groups) => `${field}\n ${_formatGroupsDirective(groups)}`
401
+ | None => `${field}\n ${openDirective}`
402
+ }
403
+ })
404
+
391
405
  // Type-level dual-auth for every type the callable entries' responses reach —
392
406
  // the node type, its Connection/Edge wrappers, and nested state types all
393
407
  // share the entry's returnTypeName prefix.
@@ -404,6 +418,7 @@ let injectAwsAuth = (
404
418
  types: augmentedTypes,
405
419
  mutations: augmentedMutations,
406
420
  queries: augmentedQueries,
421
+ subscriptions: augmentedSubscriptions,
407
422
  })
408
423
  }
409
424
 
@@ -448,6 +463,9 @@ let stitchStandaloneWithAwsDirectives = (
448
463
  ReventlessCore.GraphQL_Stitcher.stitchStandalone(~fragment)
449
464
  ->AppSync_SdlDecorate.injectAwsSubscribe(~sources)
450
465
  ->stampSharedIamTypes
466
+ // Last: every remaining undirectived type takes the group-less Cognito arm.
467
+ // After stampSharedIamTypes so the shared traversal types keep `@aws_iam`.
468
+ ->AppSync_SdlDecorate.stampAllTypesCognito
451
469
  }
452
470
 
453
471
  // ── Provider implementation ────────────────────────────────────────────────
@@ -126,22 +126,6 @@ function _permissionToCognitoGroups(permission) {
126
126
  }
127
127
  }
128
128
 
129
- function _formatGroupsDirective(groups) {
130
- let quoted = groups.map(g => `"` + g + `"`).join(", ");
131
- return `@aws_auth(cognito_groups: [` + quoted + `])`;
132
- }
133
-
134
- function _formatDualAuthDirective(groups) {
135
- let cognito;
136
- if (groups !== undefined) {
137
- let quoted = groups.map(x => `"` + x + `"`).join(", ");
138
- cognito = `@aws_cognito_user_pools(cognito_groups: [` + quoted + `])`;
139
- } else {
140
- cognito = `@aws_cognito_user_pools`;
141
- }
142
- return cognito + ` @aws_iam`;
143
- }
144
-
145
129
  function _typeDeclName(decl) {
146
130
  if (!decl.startsWith("type ")) {
147
131
  return;
@@ -228,11 +212,11 @@ function injectAwsAuth(fragment, mutationEntries, queryEntries) {
228
212
  let fieldName = GraphQL_Stitcher$ReventlessCore.extractLeadingName(field);
229
213
  let groups = mutationAuthMap[fieldName];
230
214
  if (Stdlib_Option.getOr(iamFields[fieldName], false)) {
231
- return field + `\n ` + _formatDualAuthDirective(groups);
215
+ return field + `\n ` + AppSync_SdlDecorate$ReventlessAws.formatDualAuthDirective(groups);
232
216
  } else if (groups !== undefined) {
233
- return field + `\n ` + _formatGroupsDirective(groups);
217
+ return field + `\n ` + AppSync_SdlDecorate$ReventlessAws.formatCognitoGroupsDirective(groups);
234
218
  } else {
235
- return field;
219
+ return field + `\n ` + AppSync_SdlDecorate$ReventlessAws.cognitoOpenDirective;
236
220
  }
237
221
  });
238
222
  let augmentedQueries = parts.queries.map(field => {
@@ -240,11 +224,20 @@ function injectAwsAuth(fragment, mutationEntries, queryEntries) {
240
224
  let groups = queryAuthMap[fieldName];
241
225
  let isIam = Stdlib_Option.getOr(iamFields[fieldName], false) || iamQueryFieldPrefixes.some(p => fieldName.startsWith(p));
242
226
  if (isIam) {
243
- return field + ` ` + _formatDualAuthDirective(groups);
227
+ return field + ` ` + AppSync_SdlDecorate$ReventlessAws.formatDualAuthDirective(groups);
244
228
  } else if (groups !== undefined) {
245
- return field + ` ` + _formatGroupsDirective(groups);
229
+ return field + ` ` + AppSync_SdlDecorate$ReventlessAws.formatCognitoGroupsDirective(groups);
230
+ } else {
231
+ return field + ` ` + AppSync_SdlDecorate$ReventlessAws.cognitoOpenDirective;
232
+ }
233
+ });
234
+ let augmentedSubscriptions = parts.subscriptions.map(field => {
235
+ let fieldName = GraphQL_Stitcher$ReventlessCore.extractLeadingName(field);
236
+ let groups = mutationAuthMap[fieldName];
237
+ if (groups !== undefined) {
238
+ return field + `\n ` + AppSync_SdlDecorate$ReventlessAws.formatCognitoGroupsDirective(groups);
246
239
  } else {
247
- return field;
240
+ return field + `\n ` + AppSync_SdlDecorate$ReventlessAws.cognitoOpenDirective;
248
241
  }
249
242
  });
250
243
  let augmentedTypes = parts.types.map(decl => {
@@ -259,7 +252,7 @@ function injectAwsAuth(fragment, mutationEntries, queryEntries) {
259
252
  types: augmentedTypes,
260
253
  mutations: augmentedMutations,
261
254
  queries: augmentedQueries,
262
- subscriptions: parts.subscriptions,
255
+ subscriptions: augmentedSubscriptions,
263
256
  subscriptionSources: parts.subscriptionSources
264
257
  });
265
258
  }
@@ -271,7 +264,7 @@ function injectAwsAuthAll(fragment, group, iamFieldNamesOpt) {
271
264
 
272
265
  function stitchStandaloneWithAwsDirectives(fragment) {
273
266
  let sources = GraphQL_Stitcher$ReventlessCore.collectSubscriptionSources(fragment, []);
274
- return AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes(AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(GraphQL_Stitcher$ReventlessCore.stitchStandalone(fragment), sources));
267
+ return AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito(AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes(AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(GraphQL_Stitcher$ReventlessCore.stitchStandalone(fragment), sources)));
275
268
  }
276
269
 
277
270
  function _makeApiResourceWith(name, schema, userPoolConfig, opts) {
@@ -346,6 +339,10 @@ function generateFragment(mutationEntries, queryEntries) {
346
339
  return injectAwsAuth(fragment, mutationEntries, queryEntries);
347
340
  }
348
341
 
342
+ let _formatGroupsDirective = AppSync_SdlDecorate$ReventlessAws.formatCognitoGroupsDirective;
343
+
344
+ let _formatDualAuthDirective = AppSync_SdlDecorate$ReventlessAws.formatDualAuthDirective;
345
+
349
346
  let stampSharedIamTypes = AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes;
350
347
 
351
348
  let primaryAuthenticationType = "AMAZON_COGNITO_USER_POOLS";
@@ -67,26 +67,55 @@ let injectAwsSubscribe = (
67
67
  // the deploy path without dragging Pulumi into the runtime graph. AppSync_Adapter
68
68
  // delegates its `injectAwsAuthAll` / `stampSharedIamTypes` to these definitions.
69
69
 
70
+ // Cognito group gate. `@aws_cognito_user_pools(cognito_groups: [...])` — NOT the
71
+ // single-mode `@aws_auth(cognito_groups: [...])`.
72
+ //
73
+ // `@aws_auth` is honoured only on an API whose sole authorization mode is
74
+ // AMAZON_COGNITO_USER_POOLS. This adapter always configures AWS_IAM as an
75
+ // additional provider (see AppSync_Adapter's api args — the server-to-server
76
+ // lambdas need it), so every API we provision is multi-auth and the service
77
+ // **silently ignores** `@aws_auth`: the schema deploys, the directive shows up
78
+ // in the deployed SDL, and it gates nothing. What is left is
79
+ // `userPoolConfig.defaultAction: ALLOW`, which admits every authenticated
80
+ // Cognito user — a group gate that fails open.
81
+ //
82
+ // Confirmed by observation against a deployed API: a user in no groups read
83
+ // every `@aws_auth`-gated field and reached a gated mutation's handler. See
84
+ // docs/plans/appsync-group-authorization-unenforced.md.
85
+ let formatCognitoGroupsDirective = (groups: array<string>): string => {
86
+ let quoted = groups->Array.map(g => `"${g}"`)->Array.join(", ")
87
+ `@aws_cognito_user_pools(cognito_groups: [${quoted}])`
88
+ }
89
+
90
+ // The group-less Cognito directive: reachable by any authenticated Cognito
91
+ // caller. Emitted where a field or type is deliberately open — `AllowAuthenticated`,
92
+ // `AllowAnonymous`, a field carrying no permission at all, and every object type.
93
+ //
94
+ // Under `userPoolConfig.defaultAction: ALLOW` this is a no-op: an undirectived
95
+ // field is already reachable by any authenticated caller, so stamping it changes
96
+ // nothing. It exists so the default can be flipped to DENY, where "no directive"
97
+ // stops meaning "open" and starts meaning "refused" — at which point anything
98
+ // left unstamped breaks. Stamping first and flipping second is what keeps that
99
+ // flip from being a guess.
100
+ let cognitoOpenDirective = "@aws_cognito_user_pools"
101
+
70
102
  // Multi-auth directive for a field/type that must accept BOTH Cognito and IAM.
71
103
  // `groups=Some([...])` preserves Cognito group gating; `groups=None` keeps it
72
104
  // open to any authenticated Cognito user. `@aws_iam` admits the deploy-time
73
105
  // SigV4 system caller.
74
106
  let formatDualAuthDirective = (groups: option<array<string>>): string => {
75
107
  let cognito = switch groups {
76
- | Some(g) =>
77
- let quoted = g->Array.map(x => `"${x}"`)->Array.join(", ")
78
- `@aws_cognito_user_pools(cognito_groups: [${quoted}])`
79
- | None => `@aws_cognito_user_pools`
108
+ | Some(g) => formatCognitoGroupsDirective(g)
109
+ | None => cognitoOpenDirective
80
110
  }
81
111
  `${cognito} @aws_iam`
82
112
  }
83
113
 
84
- // Injects @aws_auth with the given group on ALL mutation, query, and subscription
85
- // fields in a fragment. `~iamFieldNames` opts the named mutation/query fields into
86
- // deploy-time IAM (dual-auth): those fields emit
87
- // `@aws_cognito_user_pools(cognito_groups: ["<group>"]) @aws_iam` instead of the
88
- // single-mode `@aws_auth(...)`, keeping the same Cognito gating while also admitting
89
- // the SigV4 system caller. Subscriptions are never IAM-marked.
114
+ // Injects the Cognito group gate on ALL mutation, query, and subscription fields
115
+ // in a fragment. `~iamFieldNames` opts the named mutation/query fields into
116
+ // deploy-time IAM as well, appending the `@aws_iam` arm. Subscriptions are never
117
+ // IAM-marked (the deploy caller does not subscribe) but still carry the Cognito
118
+ // gate a subscription is a read.
90
119
  let injectAwsAuthAll = (
91
120
  fragment: Reventless.Plugin.apiSchemaFragment,
92
121
  ~group: string,
@@ -95,19 +124,18 @@ let injectAwsAuthAll = (
95
124
  let parts = ReventlessCore.GraphQL_Stitcher.decode(fragment)
96
125
  let isIam = (field: string): bool =>
97
126
  iamFieldNames->Array.includes(ReventlessCore.GraphQL_Stitcher.extractLeadingName(field))
127
+ let cognitoOnly = formatCognitoGroupsDirective([group])
98
128
 
99
129
  let augmentedMutations = parts.mutations->Array.map(field =>
100
130
  isIam(field)
101
131
  ? `${field}\n ${formatDualAuthDirective(Some([group]))}`
102
- : `${field}\n @aws_auth(cognito_groups: ["${group}"])`
132
+ : `${field}\n ${cognitoOnly}`
103
133
  )
104
134
  let augmentedQueries = parts.queries->Array.map(field =>
105
- isIam(field)
106
- ? `${field} ${formatDualAuthDirective(Some([group]))}`
107
- : `${field} @aws_auth(cognito_groups: ["${group}"])`
135
+ isIam(field) ? `${field} ${formatDualAuthDirective(Some([group]))}` : `${field} ${cognitoOnly}`
108
136
  )
109
137
  let augmentedSubscriptions = parts.subscriptions->Array.map(field =>
110
- `${field}\n @aws_auth(cognito_groups: ["${group}"])`
138
+ `${field}\n ${cognitoOnly}`
111
139
  )
112
140
 
113
141
  ReventlessCore.GraphQL_Stitcher.encode({
@@ -134,6 +162,38 @@ let stampSharedIamTypes = (sdl: string): string =>
134
162
  acc->String.replace(`type ${name} {`, `type ${name} @aws_cognito_user_pools @aws_iam {`)
135
163
  )
136
164
 
165
+ // Stamp every object `type` declaration that does not already carry an auth
166
+ // directive with the group-less Cognito arm. Run on the ASSEMBLED SDL, AFTER
167
+ // stampSharedIamTypes so the shared traversal types keep their `@aws_iam` arm
168
+ // (this pass skips anything already carrying an `@aws_` directive).
169
+ //
170
+ // Types, not just fields: on a multi-auth API with `defaultAction: DENY`,
171
+ // AppSync refuses an undirectived TYPE even when the caller passed the field's
172
+ // own gate — response shaping walks `…Connection` → `…Edge` → node → nested
173
+ // state types and dies one level in. Entry gating stays on the fields; a type
174
+ // stamp only restores the accessibility an undirectived type has today.
175
+ //
176
+ // Only `type` declarations take auth directives — `input`, `enum`, `union` and
177
+ // `interface` do not, and stamping them is a schema error.
178
+ let stampAllTypesCognito = (sdl: string): string =>
179
+ sdl
180
+ ->String.split("\n")
181
+ ->Array.map(line =>
182
+ if line->String.startsWith("type ") && !(line->String.includes("@aws_")) {
183
+ switch line->String.indexOfOpt("{") {
184
+ | Some(i) =>
185
+ line->String.slice(~start=0, ~end=i) ++
186
+ cognitoOpenDirective ++
187
+ " " ++
188
+ line->String.slice(~start=i, ~end=line->String.length)
189
+ | None => line
190
+ }
191
+ } else {
192
+ line
193
+ }
194
+ )
195
+ ->Array.join("\n")
196
+
137
197
  // ── Merged-API canonical stamping ─────────────────────────────────────────────
138
198
  // Under AppSync Merged APIs the admin source API owns the shared traversal
139
199
  // types; `@canonical` makes its definition win over every plugin source's copy
@@ -37,35 +37,37 @@ function injectAwsSubscribe(sdl, sources) {
37
37
  return before + decorated + after;
38
38
  }
39
39
 
40
+ function formatCognitoGroupsDirective(groups) {
41
+ let quoted = groups.map(g => `"` + g + `"`).join(", ");
42
+ return `@aws_cognito_user_pools(cognito_groups: [` + quoted + `])`;
43
+ }
44
+
45
+ let cognitoOpenDirective = "@aws_cognito_user_pools";
46
+
40
47
  function formatDualAuthDirective(groups) {
41
- let cognito;
42
- if (groups !== undefined) {
43
- let quoted = groups.map(x => `"` + x + `"`).join(", ");
44
- cognito = `@aws_cognito_user_pools(cognito_groups: [` + quoted + `])`;
45
- } else {
46
- cognito = `@aws_cognito_user_pools`;
47
- }
48
+ let cognito = groups !== undefined ? formatCognitoGroupsDirective(groups) : cognitoOpenDirective;
48
49
  return cognito + ` @aws_iam`;
49
50
  }
50
51
 
51
52
  function injectAwsAuthAll(fragment, group, iamFieldNamesOpt) {
52
53
  let iamFieldNames = iamFieldNamesOpt !== undefined ? iamFieldNamesOpt : [];
53
54
  let parts = GraphQL_Stitcher$ReventlessCore.decode(fragment);
55
+ let cognitoOnly = formatCognitoGroupsDirective([group]);
54
56
  let augmentedMutations = parts.mutations.map(field => {
55
57
  if (iamFieldNames.includes(GraphQL_Stitcher$ReventlessCore.extractLeadingName(field))) {
56
58
  return field + `\n ` + formatDualAuthDirective([group]);
57
59
  } else {
58
- return field + `\n @aws_auth(cognito_groups: ["` + group + `"])`;
60
+ return field + `\n ` + cognitoOnly;
59
61
  }
60
62
  });
61
63
  let augmentedQueries = parts.queries.map(field => {
62
64
  if (iamFieldNames.includes(GraphQL_Stitcher$ReventlessCore.extractLeadingName(field))) {
63
65
  return field + ` ` + formatDualAuthDirective([group]);
64
66
  } else {
65
- return field + ` @aws_auth(cognito_groups: ["` + group + `"])`;
67
+ return field + ` ` + cognitoOnly;
66
68
  }
67
69
  });
68
- let augmentedSubscriptions = parts.subscriptions.map(field => field + `\n @aws_auth(cognito_groups: ["` + group + `"])`);
70
+ let augmentedSubscriptions = parts.subscriptions.map(field => field + `\n ` + cognitoOnly);
69
71
  return GraphQL_Stitcher$ReventlessCore.encode({
70
72
  types: parts.types,
71
73
  mutations: augmentedMutations,
@@ -86,6 +88,20 @@ function stampSharedIamTypes(sdl) {
86
88
  return Stdlib_Array.reduce(sharedIamTypeNames, sdl, (acc, name) => acc.replace(`type ` + name + ` {`, `type ` + name + ` @aws_cognito_user_pools @aws_iam {`));
87
89
  }
88
90
 
91
+ function stampAllTypesCognito(sdl) {
92
+ return sdl.split("\n").map(line => {
93
+ if (!(line.startsWith("type ") && !line.includes("@aws_"))) {
94
+ return line;
95
+ }
96
+ let i = Stdlib_String.indexOfOpt(line, "{");
97
+ if (i !== undefined) {
98
+ return line.slice(0, i) + cognitoOpenDirective + " " + line.slice(i, line.length);
99
+ } else {
100
+ return line;
101
+ }
102
+ }).join("\n");
103
+ }
104
+
89
105
  let canonicalTypeNames = [
90
106
  "PageInfo",
91
107
  "CommandAccepted",
@@ -125,10 +141,13 @@ function stampCanonicalTypes(sdl) {
125
141
 
126
142
  export {
127
143
  injectAwsSubscribe,
144
+ formatCognitoGroupsDirective,
145
+ cognitoOpenDirective,
128
146
  formatDualAuthDirective,
129
147
  injectAwsAuthAll,
130
148
  sharedIamTypeNames,
131
149
  stampSharedIamTypes,
150
+ stampAllTypesCognito,
132
151
  canonicalTypeNames,
133
152
  stampCanonicalTypes,
134
153
  }
@@ -25,19 +25,19 @@ let decodeFragment = (fragment: Reventless.Plugin.apiSchemaFragment) =>
25
25
  describe("AppSync_Adapter.injectAwsAuthAll", () => {
26
26
  let baseFragment = ReventlessCore.Platform_AdminApi.baseFragment(~cloner=true)
27
27
 
28
- testSync("adds @aws_auth directive to all mutation fields", () => {
28
+ testSync("adds the Cognito group directive to all mutation fields", () => {
29
29
  let augmented = AppSync_Adapter.injectAwsAuthAll(baseFragment, ~group="Admin")
30
30
  let parts = decodeFragment(augmented)
31
31
  parts.mutations->Array.forEach(field => {
32
- expect(field)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
32
+ expect(field)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
33
33
  })
34
34
  })
35
35
 
36
- testSync("adds @aws_auth directive to all query fields", () => {
36
+ testSync("adds the Cognito group directive to all query fields", () => {
37
37
  let augmented = AppSync_Adapter.injectAwsAuthAll(baseFragment, ~group="Admin")
38
38
  let parts = decodeFragment(augmented)
39
39
  parts.queries->Array.forEach(field => {
40
- expect(field)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
40
+ expect(field)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
41
41
  })
42
42
  })
43
43
 
@@ -61,15 +61,15 @@ describe("AppSync_Adapter.injectAwsAuthAll", () => {
61
61
  // admin base. That caused AppSync to reject the auto-generated resolver for
62
62
  // `Subscription.onPlatform_Plugin_Activate` (and Deactivate) with "Type not
63
63
  // found" at deploy time. The subscription fields must survive the round-trip
64
- // and pick up the same @aws_auth group as mutations/queries.
65
- testSync("preserves subscription fields with @aws_auth directive", () => {
64
+ // and pick up the same Cognito group directive as mutations/queries.
65
+ testSync("preserves subscription fields with the Cognito group directive", () => {
66
66
  let original = decodeFragment(baseFragment)
67
67
  let augmented = AppSync_Adapter.injectAwsAuthAll(baseFragment, ~group="Admin")
68
68
  let parts = decodeFragment(augmented)
69
69
  expect(parts.subscriptions->Array.length)->toBe(original.subscriptions->Array.length)
70
70
  expect(parts.subscriptions->Array.length)->toBeGreaterThan(0)
71
71
  parts.subscriptions->Array.forEach(field => {
72
- expect(field)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
72
+ expect(field)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
73
73
  })
74
74
  })
75
75
 
@@ -80,6 +80,21 @@ describe("AppSync_Adapter.injectAwsAuthAll", () => {
80
80
  expect(joined)->toContain("onPlatform_Plugin_Activate")
81
81
  expect(joined)->toContain("onPlatform_Plugin_Deactivate")
82
82
  })
83
+
84
+ // Regression: the admin base was stamped with the single-mode
85
+ // `@aws_auth(cognito_groups: [...])`, which AppSync IGNORES on a multi-auth API
86
+ // — and every API this adapter provisions is multi-auth. A deployed probe found
87
+ // a Cognito user in no groups reading every admin field. The directive must be
88
+ // `@aws_cognito_user_pools(...)`, the form the service actually enforces.
89
+ // See docs/plans/appsync-group-authorization-unenforced.md.
90
+ testSync("never emits the single-mode @aws_auth form on any field", () => {
91
+ let augmented = AppSync_Adapter.injectAwsAuthAll(baseFragment, ~group="Admin")
92
+ let parts = decodeFragment(augmented)
93
+ let everyField =
94
+ Array.concatMany(parts.mutations, [parts.queries, parts.subscriptions])->Array.join("\n")
95
+ expect(everyField)->not_->toContain("@aws_auth")
96
+ expect(everyField)->toContain("@aws_cognito_user_pools")
97
+ })
83
98
  })
84
99
 
85
100
  describe("AppSync_Adapter.injectAwsAuth", () => {
@@ -97,12 +112,12 @@ describe("AppSync_Adapter.injectAwsAuth", () => {
97
112
  )
98
113
  let parts = decodeFragment(augmented)
99
114
 
100
- // Query entries have authorization → should have @aws_auth
115
+ // Query entries have authorization → should carry the directive
101
116
  let queryWithAuth =
102
117
  queryEntries->Array.some(entry => entry.authorization->Option.isSome)
103
118
  if queryWithAuth {
104
119
  let hasAuthQuery = parts.queries->Array.some(field =>
105
- field->String.includes("@aws_auth")
120
+ field->String.includes("@aws_cognito_user_pools")
106
121
  )
107
122
  expect(hasAuthQuery)->toBe(true)
108
123
  }
@@ -124,11 +139,11 @@ describe("AppSync_Adapter.generateFragment", () => {
124
139
  })
125
140
  })
126
141
 
127
- // ── Stage E2: spec-level permission → @aws_auth directive ───────────────
142
+ // ── Stage E2: spec-level permission → Cognito group directive ───────────────
128
143
  //
129
144
  // Verifies that `injectAwsAuth` lifts the `Authorization.permission` values
130
145
  // (set by Plugin_Builder / Dcb_Builder from `@@reventless.authorize` /
131
- // `@authorize` PPX annotations) into `@aws_auth(cognito_groups: [...])`
146
+ // `@authorize` PPX annotations) into `@aws_cognito_user_pools(cognito_groups: [...])`
132
147
  // directives on the corresponding SDL fields.
133
148
 
134
149
  describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () => {
@@ -182,7 +197,7 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
182
197
  )
183
198
  let parts = decodeFragment(aug)
184
199
  let m = parts.mutations->Array.getUnsafe(0)
185
- expect(m)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
200
+ expect(m)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
186
201
  })
187
202
 
188
203
  testSync("AllowGroups multi-group emits comma-separated groups", () => {
@@ -199,11 +214,11 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
199
214
  )
200
215
  let parts = decodeFragment(aug)
201
216
  expect(parts.mutations->Array.getUnsafe(0))->toContain(
202
- `@aws_auth(cognito_groups: ["Admin", "Editor"])`,
217
+ `@aws_cognito_user_pools(cognito_groups: ["Admin", "Editor"])`,
203
218
  )
204
219
  })
205
220
 
206
- testSync("AllowAuthenticated emits no directive", () => {
221
+ testSync("AllowAuthenticated emits the group-less Cognito directive", () => {
207
222
  let fp = Dict.fromArray([(
208
223
  "p_Add",
209
224
  Reventless.Authorization.AllowAuthenticated,
@@ -216,7 +231,11 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
216
231
  ~queryEntries=[],
217
232
  )
218
233
  let parts = decodeFragment(aug)
219
- expect(parts.mutations->Array.getUnsafe(0))->not_->toContain("@aws_auth")
234
+ // Open, not ungated: the field carries the group-less directive so it stays
235
+ // reachable once defaultAction flips to DENY, but names no group.
236
+ let m = parts.mutations->Array.getUnsafe(0)
237
+ expect(m)->toContain("@aws_cognito_user_pools")
238
+ expect(m)->not_->toContain("cognito_groups")
220
239
  })
221
240
 
222
241
  testSync("DenyAll emits sentinel __deny_all__ group", () => {
@@ -258,7 +277,9 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
258
277
  | None => JsError.throwWithMessage("missing archive field")
259
278
  }
260
279
  switch add {
261
- | Some(f) => expect(f)->not_->toContain("@aws_auth")
280
+ | Some(f) =>
281
+ expect(f)->toContain("@aws_cognito_user_pools")
282
+ expect(f)->not_->toContain("cognito_groups")
262
283
  | None => JsError.throwWithMessage("missing add field")
263
284
  }
264
285
  })
@@ -323,7 +344,7 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
323
344
  expect(m)->not_->toContain(`"Admin"`)
324
345
  })
325
346
 
326
- testSync("AllowAuthenticated on a field overrides the legacy authorization (removes directive)", () => {
347
+ testSync("AllowAuthenticated on a field overrides the legacy authorization (drops the group)", () => {
327
348
  let fp = Dict.fromArray([(
328
349
  "p_X",
329
350
  Reventless.Authorization.AllowAuthenticated,
@@ -344,7 +365,11 @@ describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifting", () =>
344
365
  ~queryEntries=[],
345
366
  )
346
367
  let parts = decodeFragment(aug)
347
- expect(parts.mutations->Array.getUnsafe(0))->not_->toContain("@aws_auth")
368
+ // The spec-level AllowAuthenticated wins over the legacy `authorization.group`:
369
+ // the field keeps a directive, but no longer names a group.
370
+ let m = parts.mutations->Array.getUnsafe(0)
371
+ expect(m)->toContain("@aws_cognito_user_pools")
372
+ expect(m)->not_->toContain("cognito_groups")
348
373
  })
349
374
  })
350
375
 
@@ -397,7 +422,7 @@ describe("AppSync_Adapter.injectAwsAuth — systemCallable dual-auth", () => {
397
422
  expect(m)->not_->toContain("@aws_auth")
398
423
  })
399
424
 
400
- testSync("a non-systemCallable sibling keeps single-mode @aws_auth (no @aws_iam)", () => {
425
+ testSync("a non-systemCallable sibling stays Cognito-only (no @aws_iam)", () => {
401
426
  let entry: ReventlessInfra.Api.mutationSchemaEntry = {
402
427
  fieldNames: ["p_Sync", "p_Other"],
403
428
  commandSchema: S.unknown,
@@ -420,7 +445,7 @@ describe("AppSync_Adapter.injectAwsAuth — systemCallable dual-auth", () => {
420
445
  let parts = decodeFragment(aug)
421
446
  switch parts.mutations->Array.find(f => f->String.includes("p_Other")) {
422
447
  | Some(other) =>
423
- expect(other)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
448
+ expect(other)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
424
449
  expect(other)->not_->toContain("@aws_iam")
425
450
  | None => JsError.throwWithMessage("missing p_Other field")
426
451
  }
@@ -588,13 +613,13 @@ describe("AppSync_Adapter.injectAwsAuthAll — ~iamFieldNames", () => {
588
613
  expect(field)->toContain("@aws_iam")
589
614
  expect(field)->not_->toContain("@aws_auth")
590
615
  } else {
591
- expect(field)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
616
+ expect(field)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
592
617
  expect(field)->not_->toContain("@aws_iam")
593
618
  }
594
619
  })
595
620
  })
596
621
 
597
- testSync("default (no ~iamFieldNames) leaves every field single-mode @aws_auth", () => {
622
+ testSync("default (no ~iamFieldNames) leaves every field Cognito-only", () => {
598
623
  let augmented = AppSync_Adapter.injectAwsAuthAll(baseFragment, ~group="Admin")
599
624
  let parts = decodeFragment(augmented)
600
625
  parts.mutations->Array.forEach(field => expect(field)->not_->toContain("@aws_iam"))
@@ -836,3 +861,56 @@ describe("AppSync_Adapter.waitForMergeSuccess", () => {
836
861
  expect(message.contents)->toContain("MERGE_IN_PROGRESS")
837
862
  })
838
863
  })
864
+
865
+ // ── Subscription stamping ───────────────────────────────────────────────────
866
+ //
867
+ // `injectAwsAuth` re-encoded `subscriptions` untouched, so every plugin
868
+ // subscription reached the deployed SDL with no auth directive at all — 22 of
869
+ // them on the hybrid domain API. Harmless under `defaultAction: ALLOW`, refused
870
+ // under DENY. A subscription is a read and takes the Cognito arm; it is never
871
+ // IAM-marked, because the deploy-time caller does not subscribe.
872
+
873
+ describe("AppSync_Adapter.injectAwsAuth — subscriptions", () => {
874
+ let makeFragment = subscriptionFields =>
875
+ ReventlessCore.GraphQL_Stitcher.encode({
876
+ types: [],
877
+ mutations: ["p_Ship(id: ID!): CommandResult!"],
878
+ queries: [],
879
+ subscriptions: subscriptionFields,
880
+ subscriptionSources: [],
881
+ })
882
+
883
+ let entry = (~fieldNames, ~fieldPermissions): ReventlessInfra.Api.mutationSchemaEntry => {
884
+ fieldNames,
885
+ commandSchema: S.unknown,
886
+ fieldPermissions,
887
+ }
888
+
889
+ testSync("an unannotated subscription gets the group-less Cognito directive", () => {
890
+ let frag = makeFragment(["onSomething: CommandResult"])
891
+ let aug = AppSync_Adapter.injectAwsAuth(frag, ~mutationEntries=[], ~queryEntries=[])
892
+ let s = decodeFragment(aug).subscriptions->Array.getUnsafe(0)
893
+ expect(s)->toContain("@aws_cognito_user_pools")
894
+ expect(s)->not_->toContain("cognito_groups")
895
+ expect(s)->not_->toContain("@aws_iam")
896
+ })
897
+
898
+ testSync("a subscription named like a gated mutation inherits its groups", () => {
899
+ let fp = Dict.fromArray([("p_Ship", Reventless.Authorization.AllowGroups(["Fulfilment"]))])
900
+ let frag = makeFragment(["p_Ship: CommandResult"])
901
+ let aug = AppSync_Adapter.injectAwsAuth(
902
+ frag,
903
+ ~mutationEntries=[entry(~fieldNames=["p_Ship"], ~fieldPermissions=fp)],
904
+ ~queryEntries=[],
905
+ )
906
+ let s = decodeFragment(aug).subscriptions->Array.getUnsafe(0)
907
+ expect(s)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Fulfilment"])`)
908
+ expect(s)->not_->toContain("@aws_iam")
909
+ })
910
+
911
+ testSync("subscription fields survive the round-trip", () => {
912
+ let frag = makeFragment(["onA: CommandResult", "onB: CommandResult"])
913
+ let aug = AppSync_Adapter.injectAwsAuth(frag, ~mutationEntries=[], ~queryEntries=[])
914
+ expect(decodeFragment(aug).subscriptions->Array.length)->toBe(2)
915
+ })
916
+ })
@@ -29,18 +29,18 @@ let decodeFragment = GraphQL_Stitcher$ReventlessCore.decode;
29
29
 
30
30
  globalThis.describe("AppSync_Adapter.injectAwsAuthAll", () => {
31
31
  let baseFragment = Platform_AdminApi$ReventlessCore.baseFragment(true);
32
- globalThis.test("adds @aws_auth directive to all mutation fields", () => {
32
+ globalThis.test("adds the Cognito group directive to all mutation fields", () => {
33
33
  let augmented = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(baseFragment, "Admin", undefined);
34
34
  let parts = GraphQL_Stitcher$ReventlessCore.decode(augmented);
35
35
  parts.mutations.forEach(field => {
36
- globalThis.expect(field).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
36
+ globalThis.expect(field).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
37
37
  });
38
38
  });
39
- globalThis.test("adds @aws_auth directive to all query fields", () => {
39
+ globalThis.test("adds the Cognito group directive to all query fields", () => {
40
40
  let augmented = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(baseFragment, "Admin", undefined);
41
41
  let parts = GraphQL_Stitcher$ReventlessCore.decode(augmented);
42
42
  parts.queries.forEach(field => {
43
- globalThis.expect(field).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
43
+ globalThis.expect(field).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
44
44
  });
45
45
  });
46
46
  globalThis.test("preserves type definitions unchanged", () => {
@@ -56,14 +56,14 @@ globalThis.describe("AppSync_Adapter.injectAwsAuthAll", () => {
56
56
  globalThis.expect(field).toContain(`cognito_groups: ["SuperAdmin"]`);
57
57
  });
58
58
  });
59
- globalThis.test("preserves subscription fields with @aws_auth directive", () => {
59
+ globalThis.test("preserves subscription fields with the Cognito group directive", () => {
60
60
  let original = GraphQL_Stitcher$ReventlessCore.decode(baseFragment);
61
61
  let augmented = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(baseFragment, "Admin", undefined);
62
62
  let parts = GraphQL_Stitcher$ReventlessCore.decode(augmented);
63
63
  globalThis.expect(parts.subscriptions.length).toBe(original.subscriptions.length);
64
64
  globalThis.expect(parts.subscriptions.length).toBeGreaterThan(0);
65
65
  parts.subscriptions.forEach(field => {
66
- globalThis.expect(field).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
66
+ globalThis.expect(field).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
67
67
  });
68
68
  });
69
69
  globalThis.test("admin Plugin aggregate subscription fields survive the round-trip", () => {
@@ -73,6 +73,13 @@ globalThis.describe("AppSync_Adapter.injectAwsAuthAll", () => {
73
73
  globalThis.expect(joined).toContain("onPlatform_Plugin_Activate");
74
74
  globalThis.expect(joined).toContain("onPlatform_Plugin_Deactivate");
75
75
  });
76
+ globalThis.test("never emits the single-mode @aws_auth form on any field", () => {
77
+ let augmented = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(baseFragment, "Admin", undefined);
78
+ let parts = GraphQL_Stitcher$ReventlessCore.decode(augmented);
79
+ let everyField = parts.mutations.concat(parts.queries, parts.subscriptions).join("\n");
80
+ globalThis.expect(everyField).not.toContain("@aws_auth");
81
+ globalThis.expect(everyField).toContain("@aws_cognito_user_pools");
82
+ });
76
83
  });
77
84
 
78
85
  globalThis.describe("AppSync_Adapter.injectAwsAuth", () => {
@@ -85,7 +92,7 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth", () => {
85
92
  if (!queryWithAuth) {
86
93
  return;
87
94
  }
88
- let hasAuthQuery = parts.queries.some(field => field.includes("@aws_auth"));
95
+ let hasAuthQuery = parts.queries.some(field => field.includes("@aws_cognito_user_pools"));
89
96
  globalThis.expect(hasAuthQuery).toBe(true);
90
97
  });
91
98
  });
@@ -135,7 +142,7 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
135
142
  let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
136
143
  let parts = GraphQL_Stitcher$ReventlessCore.decode(aug);
137
144
  let m = parts.mutations[0];
138
- globalThis.expect(m).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
145
+ globalThis.expect(m).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
139
146
  });
140
147
  globalThis.test("AllowGroups multi-group emits comma-separated groups", () => {
141
148
  let fp = Object.fromEntries([[
@@ -152,9 +159,9 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
152
159
  let frag = makeFragment(["p_X(id: ID!): String"], []);
153
160
  let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
154
161
  let parts = GraphQL_Stitcher$ReventlessCore.decode(aug);
155
- globalThis.expect(parts.mutations[0]).toContain(`@aws_auth(cognito_groups: ["Admin", "Editor"])`);
162
+ globalThis.expect(parts.mutations[0]).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin", "Editor"])`);
156
163
  });
157
- globalThis.test("AllowAuthenticated emits no directive", () => {
164
+ globalThis.test("AllowAuthenticated emits the group-less Cognito directive", () => {
158
165
  let fp = Object.fromEntries([[
159
166
  "p_Add",
160
167
  "AllowAuthenticated"
@@ -163,7 +170,9 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
163
170
  let frag = makeFragment(["p_Add(id: ID!): String"], []);
164
171
  let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
165
172
  let parts = GraphQL_Stitcher$ReventlessCore.decode(aug);
166
- globalThis.expect(parts.mutations[0]).not.toContain("@aws_auth");
173
+ let m = parts.mutations[0];
174
+ globalThis.expect(m).toContain("@aws_cognito_user_pools");
175
+ globalThis.expect(m).not.toContain("cognito_groups");
167
176
  });
168
177
  globalThis.test("DenyAll emits sentinel __deny_all__ group", () => {
169
178
  let fp = Object.fromEntries([[
@@ -208,7 +217,8 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
208
217
  Stdlib_JsError.throwWithMessage("missing archive field");
209
218
  }
210
219
  if (add !== undefined) {
211
- globalThis.expect(add).not.toContain("@aws_auth");
220
+ globalThis.expect(add).toContain("@aws_cognito_user_pools");
221
+ globalThis.expect(add).not.toContain("cognito_groups");
212
222
  return;
213
223
  } else {
214
224
  return Stdlib_JsError.throwWithMessage("missing add field");
@@ -262,7 +272,7 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
262
272
  globalThis.expect(m).toContain(`cognito_groups: ["Manager"]`);
263
273
  globalThis.expect(m).not.toContain(`"Admin"`);
264
274
  });
265
- globalThis.test("AllowAuthenticated on a field overrides the legacy authorization (removes directive)", () => {
275
+ globalThis.test("AllowAuthenticated on a field overrides the legacy authorization (drops the group)", () => {
266
276
  let fp = Object.fromEntries([[
267
277
  "p_X",
268
278
  "AllowAuthenticated"
@@ -282,7 +292,9 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — Stage E2 permission lifti
282
292
  let frag = makeFragment(["p_X(id: ID!): String"], []);
283
293
  let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry], []);
284
294
  let parts = GraphQL_Stitcher$ReventlessCore.decode(aug);
285
- globalThis.expect(parts.mutations[0]).not.toContain("@aws_auth");
295
+ let m = parts.mutations[0];
296
+ globalThis.expect(m).toContain("@aws_cognito_user_pools");
297
+ globalThis.expect(m).not.toContain("cognito_groups");
286
298
  });
287
299
  });
288
300
 
@@ -337,7 +349,7 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — systemCallable dual-auth"
337
349
  globalThis.expect(m).not.toContain("cognito_groups");
338
350
  globalThis.expect(m).not.toContain("@aws_auth");
339
351
  });
340
- globalThis.test("a non-systemCallable sibling keeps single-mode @aws_auth (no @aws_iam)", () => {
352
+ globalThis.test("a non-systemCallable sibling stays Cognito-only (no @aws_iam)", () => {
341
353
  let entry_fieldNames = [
342
354
  "p_Sync",
343
355
  "p_Other"
@@ -391,7 +403,7 @@ globalThis.describe("AppSync_Adapter.injectAwsAuth — systemCallable dual-auth"
391
403
  let parts = GraphQL_Stitcher$ReventlessCore.decode(aug);
392
404
  let other = parts.mutations.find(f => f.includes("p_Other"));
393
405
  if (other !== undefined) {
394
- globalThis.expect(other).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
406
+ globalThis.expect(other).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
395
407
  globalThis.expect(other).not.toContain("@aws_iam");
396
408
  return;
397
409
  } else {
@@ -548,12 +560,12 @@ globalThis.describe("AppSync_Adapter.injectAwsAuthAll — ~iamFieldNames", () =>
548
560
  globalThis.expect(field).toContain("@aws_iam");
549
561
  globalThis.expect(field).not.toContain("@aws_auth");
550
562
  } else {
551
- globalThis.expect(field).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
563
+ globalThis.expect(field).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
552
564
  globalThis.expect(field).not.toContain("@aws_iam");
553
565
  }
554
566
  });
555
567
  });
556
- globalThis.test("default (no ~iamFieldNames) leaves every field single-mode @aws_auth", () => {
568
+ globalThis.test("default (no ~iamFieldNames) leaves every field Cognito-only", () => {
557
569
  let augmented = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(baseFragment, "Admin", undefined);
558
570
  let parts = GraphQL_Stitcher$ReventlessCore.decode(augmented);
559
571
  parts.mutations.forEach(field => {
@@ -739,6 +751,51 @@ globalThis.describe("AppSync_Adapter.waitForMergeSuccess", () => {
739
751
  });
740
752
  });
741
753
 
754
+ globalThis.describe("AppSync_Adapter.injectAwsAuth — subscriptions", () => {
755
+ let makeFragment = subscriptionFields => GraphQL_Stitcher$ReventlessCore.encode({
756
+ types: [],
757
+ mutations: ["p_Ship(id: ID!): CommandResult!"],
758
+ queries: [],
759
+ subscriptions: subscriptionFields,
760
+ subscriptionSources: []
761
+ });
762
+ let entry = (fieldNames, fieldPermissions) => ({
763
+ fieldNames: fieldNames,
764
+ commandSchema: S.unknown,
765
+ fieldPermissions: fieldPermissions
766
+ });
767
+ globalThis.test("an unannotated subscription gets the group-less Cognito directive", () => {
768
+ let frag = makeFragment(["onSomething: CommandResult"]);
769
+ let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [], []);
770
+ let s = GraphQL_Stitcher$ReventlessCore.decode(aug).subscriptions[0];
771
+ globalThis.expect(s).toContain("@aws_cognito_user_pools");
772
+ globalThis.expect(s).not.toContain("cognito_groups");
773
+ globalThis.expect(s).not.toContain("@aws_iam");
774
+ });
775
+ globalThis.test("a subscription named like a gated mutation inherits its groups", () => {
776
+ let fp = Object.fromEntries([[
777
+ "p_Ship",
778
+ {
779
+ TAG: "AllowGroups",
780
+ _0: ["Fulfilment"]
781
+ }
782
+ ]]);
783
+ let frag = makeFragment(["p_Ship: CommandResult"]);
784
+ let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [entry(["p_Ship"], fp)], []);
785
+ let s = GraphQL_Stitcher$ReventlessCore.decode(aug).subscriptions[0];
786
+ globalThis.expect(s).toContain(`@aws_cognito_user_pools(cognito_groups: ["Fulfilment"])`);
787
+ globalThis.expect(s).not.toContain("@aws_iam");
788
+ });
789
+ globalThis.test("subscription fields survive the round-trip", () => {
790
+ let frag = makeFragment([
791
+ "onA: CommandResult",
792
+ "onB: CommandResult"
793
+ ]);
794
+ let aug = AppSync_Adapter$ReventlessAws.injectAwsAuth(frag, [], []);
795
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.decode(aug).subscriptions.length).toBe(2);
796
+ });
797
+ });
798
+
742
799
  export {
743
800
  decodeFragment,
744
801
  }
@@ -31,7 +31,7 @@ type Mutation {
31
31
  type Subscription {
32
32
  onCatalog_AddProduct(id: ID): CommandResult
33
33
  onUIFragmentChange: UIFragmentChangeEvent
34
- @aws_auth(cognito_groups: ["Admin"])
34
+ @aws_cognito_user_pools(cognito_groups: ["Admin"])
35
35
  onCatalogEventLog_eventAppended: CatalogEventLogEvent
36
36
  }`
37
37
 
@@ -43,13 +43,13 @@ describe("AppSync_SdlDecorate.injectAwsSubscribe", () => {
43
43
  )
44
44
  })
45
45
 
46
- testSync("appends the many-mutations fan-in on a no-arg field, keeping @aws_auth", () => {
46
+ testSync("appends the many-mutations fan-in on a no-arg field, keeping the group directive", () => {
47
47
  let sdl = AppSync_SdlDecorate.injectAwsSubscribe(stitchedSdl, ~sources)
48
48
  expect(sdl)->toContain(
49
49
  `onUIFragmentChange: UIFragmentChangeEvent\n @aws_subscribe(mutations: ["Platform_UIFragmentRegistered", "Platform_UIFragmentUpdated", "Platform_UIFragmentDeregistered"])`,
50
50
  )
51
51
  // The auth directive line appended by injectAwsAuthAll survives.
52
- expect(sdl)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
52
+ expect(sdl)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
53
53
  })
54
54
 
55
55
  testSync("leaves unmapped fields (Source A) and non-Subscription blocks untouched", () => {
@@ -113,7 +113,7 @@ describe("AppSync_SdlDecorate.injectAwsAuthAll", () => {
113
113
  expect(parts.mutations->Array.getUnsafe(0))->toContain("@aws_iam")
114
114
  // A query NOT in iamFieldNames stays Cognito-only.
115
115
  let other = queryField("Other_Query")
116
- expect(other)->toContain(`@aws_auth(cognito_groups: ["Admin"])`)
116
+ expect(other)->toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`)
117
117
  expect(other->String.includes("@aws_iam"))->toBe(false)
118
118
  })
119
119
  })
@@ -152,3 +152,39 @@ describe("AppSync_SdlDecorate.stampCanonicalTypes", () => {
152
152
  expect(AppSync_SdlDecorate.stampCanonicalTypes(once))->toBe(once)
153
153
  })
154
154
  })
155
+
156
+ // ── Type-level Cognito stamping (defaultAction: DENY groundwork) ─────────────
157
+ //
158
+ // With `defaultAction: ALLOW` an undirectived type is reachable by any
159
+ // authenticated Cognito caller, so this pass changes nothing observable. It
160
+ // exists so the default can be flipped to DENY, where an undirectived type is
161
+ // refused mid-traversal even for a caller who passed the field's own gate.
162
+
163
+ describe("AppSync_SdlDecorate.stampAllTypesCognito", () => {
164
+ testSync("stamps an undirectived object type", () => {
165
+ let sdl = AppSync_SdlDecorate.stampAllTypesCognito("type Product {\n id: ID!\n}")
166
+ expect(sdl)->toContain("type Product @aws_cognito_user_pools {")
167
+ })
168
+
169
+ testSync("leaves an already-directived type alone, keeping its @aws_iam arm", () => {
170
+ let sdl =
171
+ AppSync_SdlDecorate.stampSharedIamTypes(
172
+ "type PageInfo {\n hasNextPage: Boolean!\n}",
173
+ )->AppSync_SdlDecorate.stampAllTypesCognito
174
+ expect(sdl)->toContain("type PageInfo @aws_cognito_user_pools @aws_iam {")
175
+ // Not double-stamped.
176
+ expect(sdl->String.includes("@aws_cognito_user_pools @aws_cognito_user_pools"))->toBe(false)
177
+ })
178
+
179
+ testSync("does not stamp input, enum, union or interface declarations", () => {
180
+ let sdl = AppSync_SdlDecorate.stampAllTypesCognito(
181
+ "input ProductFilter {\n id: ID\n}\n\nenum Status {\n Active\n}\n\nunion CommandResult = CommandAccepted | CommandRejected\n\ninterface Node {\n id: ID!\n}",
182
+ )
183
+ expect(sdl->String.includes("@aws_cognito_user_pools"))->toBe(false)
184
+ })
185
+
186
+ testSync("is idempotent", () => {
187
+ let once = AppSync_SdlDecorate.stampAllTypesCognito("type Product {\n id: ID!\n}")
188
+ expect(AppSync_SdlDecorate.stampAllTypesCognito(once))->toBe(once)
189
+ })
190
+ })
@@ -35,7 +35,7 @@ type Mutation {
35
35
  type Subscription {
36
36
  onCatalog_AddProduct(id: ID): CommandResult
37
37
  onUIFragmentChange: UIFragmentChangeEvent
38
- @aws_auth(cognito_groups: ["Admin"])
38
+ @aws_cognito_user_pools(cognito_groups: ["Admin"])
39
39
  onCatalogEventLog_eventAppended: CatalogEventLogEvent
40
40
  }`;
41
41
 
@@ -44,10 +44,10 @@ globalThis.describe("AppSync_SdlDecorate.injectAwsSubscribe", () => {
44
44
  let sdl = AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(stitchedSdl, sources);
45
45
  globalThis.expect(sdl).toContain(`onCatalog_AddProduct(id: ID): CommandResult\n @aws_subscribe(mutations: ["Catalog_AddProduct"])`);
46
46
  });
47
- globalThis.test("appends the many-mutations fan-in on a no-arg field, keeping @aws_auth", () => {
47
+ globalThis.test("appends the many-mutations fan-in on a no-arg field, keeping the group directive", () => {
48
48
  let sdl = AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(stitchedSdl, sources);
49
49
  globalThis.expect(sdl).toContain(`onUIFragmentChange: UIFragmentChangeEvent\n @aws_subscribe(mutations: ["Platform_UIFragmentRegistered", "Platform_UIFragmentUpdated", "Platform_UIFragmentDeregistered"])`);
50
- globalThis.expect(sdl).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
50
+ globalThis.expect(sdl).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
51
51
  });
52
52
  globalThis.test("leaves unmapped fields (Source A) and non-Subscription blocks untouched", () => {
53
53
  let sdl = AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(stitchedSdl, sources);
@@ -100,7 +100,7 @@ globalThis.describe("AppSync_SdlDecorate.injectAwsAuthAll", () => {
100
100
  globalThis.expect(queryField("Platform_ApiFragments")).toContain("@aws_iam");
101
101
  globalThis.expect(parts.mutations[0]).toContain("@aws_iam");
102
102
  let other = queryField("Other_Query");
103
- globalThis.expect(other).toContain(`@aws_auth(cognito_groups: ["Admin"])`);
103
+ globalThis.expect(other).toContain(`@aws_cognito_user_pools(cognito_groups: ["Admin"])`);
104
104
  globalThis.expect(other.includes("@aws_iam")).toBe(false);
105
105
  });
106
106
  });
@@ -132,6 +132,26 @@ globalThis.describe("AppSync_SdlDecorate.stampCanonicalTypes", () => {
132
132
  });
133
133
  });
134
134
 
135
+ globalThis.describe("AppSync_SdlDecorate.stampAllTypesCognito", () => {
136
+ globalThis.test("stamps an undirectived object type", () => {
137
+ let sdl = AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito("type Product {\n id: ID!\n}");
138
+ globalThis.expect(sdl).toContain("type Product @aws_cognito_user_pools {");
139
+ });
140
+ globalThis.test("leaves an already-directived type alone, keeping its @aws_iam arm", () => {
141
+ let sdl = AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito(AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes("type PageInfo {\n hasNextPage: Boolean!\n}"));
142
+ globalThis.expect(sdl).toContain("type PageInfo @aws_cognito_user_pools @aws_iam {");
143
+ globalThis.expect(sdl.includes("@aws_cognito_user_pools @aws_cognito_user_pools")).toBe(false);
144
+ });
145
+ globalThis.test("does not stamp input, enum, union or interface declarations", () => {
146
+ let sdl = AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito("input ProductFilter {\n id: ID\n}\n\nenum Status {\n Active\n}\n\nunion CommandResult = CommandAccepted | CommandRejected\n\ninterface Node {\n id: ID!\n}");
147
+ globalThis.expect(sdl.includes("@aws_cognito_user_pools")).toBe(false);
148
+ });
149
+ globalThis.test("is idempotent", () => {
150
+ let once = AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito("type Product {\n id: ID!\n}");
151
+ globalThis.expect(AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito(once)).toBe(once);
152
+ });
153
+ });
154
+
135
155
  export {
136
156
  sources,
137
157
  stitchedSdl,