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

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.300 (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
11
+
12
+
13
+ # 3.0.0-alpha.299 (2026-08-14)
14
+
15
+ ### Features
16
+
17
+ * **aws:** refuse to push an AppSync schema that cannot be gated ([18e9279](https://github.com/ReventlessDev/reventless-core/commit/18e92793b1b42b4b94dc61e9e285918c67e62e8a))
18
+
19
+
6
20
  # 3.0.0-alpha.298 (2026-08-14)
7
21
 
8
22
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.298",
3
+ "version": "3.0.0-alpha.300",
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.10",
16
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
15
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
16
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
18
18
  "@reventlessdev/rescript-node": "2.0.0-alpha.6",
19
19
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.75",
20
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.233",
22
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
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"
21
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.141",
23
+ "@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"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -335,7 +335,7 @@ module MakeWithConfig = (
335
335
  {
336
336
  userPoolId,
337
337
  ?awsRegion,
338
- defaultAction: PulumiAws.AppSync.GraphQLApi.DENY,
338
+ defaultAction: PulumiAws.AppSync.GraphQLApi.ALLOW,
339
339
  }: PulumiAws.AppSync.GraphQLApi.userPoolConfig
340
340
  )
341
341
  })
@@ -220,7 +220,7 @@ function MakeWithConfig(Config) {
220
220
  let awsRegion = Stdlib_Option.orElse(param[1], getFromDefault("cognitoRegion"));
221
221
  return {
222
222
  userPoolId: userPoolId,
223
- defaultAction: "DENY",
223
+ defaultAction: "ALLOW",
224
224
  awsRegion: awsRegion
225
225
  };
226
226
  });
@@ -1523,7 +1523,7 @@ function Make($star) {
1523
1523
  let awsRegion = Stdlib_Option.orElse(param[1], getFromDefault("cognitoRegion"));
1524
1524
  return {
1525
1525
  userPoolId: userPoolId,
1526
- defaultAction: "DENY",
1526
+ defaultAction: "ALLOW",
1527
1527
  awsRegion: awsRegion
1528
1528
  };
1529
1529
  });
@@ -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
@@ -470,6 +473,13 @@ let stitchStandaloneWithAwsDirectives = (
470
473
  // Last: every remaining undirectived type takes the group-less Cognito arm.
471
474
  // After stampSharedIamTypes so the shared traversal types keep `@aws_iam`.
472
475
  ->AppSync_SdlDecorate.stampAllTypesCognito
476
+ // Fields the stitcher injects during assembly (`_noop`) are invisible to the
477
+ // fragment sweep above, so sweep the assembled document too.
478
+ ->AppSync_SdlDecorate.stampUndirectivedRootFields
479
+ // Then refuse to hand back a schema that cannot be gated. `defaultAction: DENY`
480
+ // is unavailable on a multi-auth API, so this deploy-time check is the only
481
+ // fail-closed step there is — everything downstream trusts what it returns.
482
+ ->AppSync_SdlDecorate.assertGateable
473
483
  }
474
484
 
475
485
  // ── Provider implementation ────────────────────────────────────────────────
@@ -534,16 +544,21 @@ let _makeApiResourceWith = (
534
544
  // site — DomainApi, PlatformApi — is safe). A single Output yielding the
535
545
  // {userPoolId, awsRegion, defaultAction} record AppSync expects.
536
546
  //
537
- // `DENY`, not `ALLOW`: an undirectived field or type is refused rather than
538
- // served to any authenticated Cognito caller. This is what makes a missing
539
- // directive fail closed — under `ALLOW` the group gate that was inert for the
540
- // whole of `@aws_auth`'s life produced no symptom at all, because "no
541
- // effective directive" and "open to everyone" were the same thing.
547
+ // `ALLOW` is forced, not chosen. `DENY` would make a missing directive fail
548
+ // closed, which is what we want but AWS refuses the combination outright:
542
549
  //
543
- // Safe only because every field and type now carries an explicit directive
544
- // (`stampUndirectivedFields` + `stampAllTypesCognito` at the assembly choke
545
- // point). Verify with `scripts/check-appsync-directive-coverage.mjs` against a
546
- // DEPLOYED api it must report zero before this value is trusted.
550
+ // BadRequestException: Additional authentication providers cannot be
551
+ // specified when setting DENY for top level user pool authentication type.
552
+ //
553
+ // Every API here configures AWS_IAM as an additional provider (the
554
+ // server-to-server lambdas need it), so DENY is unavailable for as long as
555
+ // that is true. Tried on a deploy 2026-08-14; all four APIs rejected it.
556
+ //
557
+ // The protection therefore rests entirely on every field and type carrying an
558
+ // explicit directive — `stampUndirectivedFields` + `stampAllTypesCognito` at
559
+ // the assembly choke point — with no backstop underneath. That makes
560
+ // `scripts/check-appsync-directive-coverage.mjs` load-bearing rather than
561
+ // advisory: run it against a DEPLOYED api after any change to the decoration.
547
562
  let userPoolConfigOut = switch userPoolConfig {
548
563
  | Some(config) => config
549
564
  | None =>
@@ -552,7 +567,7 @@ let _makeApiResourceWith = (
552
567
  {
553
568
  userPoolId: c.userPoolId,
554
569
  awsRegion: c.region,
555
- defaultAction: AppSync.GraphQLApi.DENY,
570
+ defaultAction: AppSync.GraphQLApi.ALLOW,
556
571
  }: AppSync.GraphQLApi.userPoolConfig
557
572
  )
558
573
  )
@@ -265,7 +265,7 @@ function injectAwsAuthAll(fragment, group, iamFieldNamesOpt) {
265
265
  function stitchStandaloneWithAwsDirectives(fragment) {
266
266
  let sources = GraphQL_Stitcher$ReventlessCore.collectSubscriptionSources(fragment, []);
267
267
  let fragment$1 = AppSync_SdlDecorate$ReventlessAws.stampUndirectivedFields(fragment);
268
- return AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito(AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes(AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(GraphQL_Stitcher$ReventlessCore.stitchStandalone(fragment$1), sources)));
268
+ return AppSync_SdlDecorate$ReventlessAws.assertGateable(AppSync_SdlDecorate$ReventlessAws.stampUndirectivedRootFields(AppSync_SdlDecorate$ReventlessAws.stampAllTypesCognito(AppSync_SdlDecorate$ReventlessAws.stampSharedIamTypes(AppSync_SdlDecorate$ReventlessAws.injectAwsSubscribe(GraphQL_Stitcher$ReventlessCore.stitchStandalone(fragment$1), sources)))));
269
269
  }
270
270
 
271
271
  function _makeApiResourceWith(name, schema, userPoolConfig, opts) {
@@ -288,7 +288,7 @@ function _makeApiResourceWith(name, schema, userPoolConfig, opts) {
288
288
  };
289
289
  let userPoolConfigOut = userPoolConfig !== undefined ? userPoolConfig : Auth_Cognito$ReventlessAws.make(name + `-auth`, undefined).apply(c => ({
290
290
  userPoolId: c.userPoolId,
291
- defaultAction: "DENY",
291
+ defaultAction: "ALLOW",
292
292
  awsRegion: c.region
293
293
  }));
294
294
  let apiArgs_schema = Stdlib_Option.map(schema, prim => prim);
@@ -100,7 +100,7 @@ let make = (~name: string, ~opts: Pulumi.ComponentResource.options): t => {
100
100
  ): AppSync.GraphQLApi.userPoolConfig => {
101
101
  userPoolId: c.userPoolId,
102
102
  awsRegion: c.region,
103
- defaultAction: AppSync.GraphQLApi.DENY,
103
+ defaultAction: AppSync.GraphQLApi.ALLOW,
104
104
  })
105
105
 
106
106
  let mergedApi = AppSync.GraphQLApi.make(
@@ -55,7 +55,7 @@ function make(name, opts) {
55
55
  let authConfigOut = Auth_Cognito$ReventlessAws.make(name + `-auth`, undefined);
56
56
  let userPoolConfigOut = authConfigOut.apply(c => ({
57
57
  userPoolId: c.userPoolId,
58
- defaultAction: "DENY",
58
+ defaultAction: "ALLOW",
59
59
  awsRegion: c.region
60
60
  }));
61
61
  let mergedApi = new (Aws.appsync.GraphQLApi)(name, {
@@ -93,10 +93,10 @@ let formatCognitoGroupsDirective = (groups: array<string>): string => {
93
93
  //
94
94
  // Under `userPoolConfig.defaultAction: ALLOW` this is a no-op: an undirectived
95
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.
96
+ // nothing. It exists so that "carries no directive" becomes a state we can
97
+ // detect and refuse at deploy time (`assertGateable`), rather than one that
98
+ // silently means "open to everyone" which is exactly how the `@aws_auth`
99
+ // outage stayed invisible for as long as it did.
100
100
  let cognitoOpenDirective = "@aws_cognito_user_pools"
101
101
 
102
102
  // Multi-auth directive for a field/type that must accept BOTH Cognito and IAM.
@@ -171,9 +171,12 @@ let stampSharedIamTypes = (sdl: string): string =>
171
171
  // several surfaces never reach it at all: the domain base document
172
172
  // (`Platform_ping`), event-history queries, event-log `…_eventAppended`
173
173
  // subscriptions, the upload presign/release mutations, `Platform_SetActiveRole`,
174
- // and `geocode`. Those deployed with no directive — invisible under
175
- // `defaultAction: ALLOW`, refused under DENY. Sweeping here rather than at each
176
- // emission site means a future injected field cannot silently miss the net.
174
+ // and `geocode`. Those deployed with no directive at all reachable by any
175
+ // authenticated Cognito caller, and invisible, since `defaultAction: ALLOW` makes
176
+ // "no directive" and "open" the same thing. Sweeping here rather than at each
177
+ // emission site means a future injected field cannot silently miss the net;
178
+ // `assertGateable` below turns a miss into a failed deploy rather than an open
179
+ // field.
177
180
  //
178
181
  // Idempotent, and deliberately does not second-guess gating: it only fills in
179
182
  // "open to any authenticated Cognito caller", which is what an undirectived
@@ -198,11 +201,10 @@ let stampUndirectivedFields = (
198
201
  // stampSharedIamTypes so the shared traversal types keep their `@aws_iam` arm
199
202
  // (this pass skips anything already carrying an `@aws_` directive).
200
203
  //
201
- // Types, not just fields: on a multi-auth API with `defaultAction: DENY`,
202
- // AppSync refuses an undirectived TYPE even when the caller passed the field's
203
- // own gate response shaping walks `…Connection` `…Edge` node → nested
204
- // state types and dies one level in. Entry gating stays on the fields; a type
205
- // stamp only restores the accessibility an undirectived type has today.
204
+ // Types, not just fields: response shaping walks `…Connection` `…Edge` → node
205
+ // nested state types, so a type that cannot be traversed fails a request that
206
+ // passed the field's own gate. Entry gating stays on the fields; a type stamp
207
+ // only restores the accessibility an undirectived type has today.
206
208
  //
207
209
  // Only `type` declarations take auth directives — `input`, `enum`, `union` and
208
210
  // `interface` do not, and stamping them is a schema error.
@@ -225,6 +227,202 @@ let stampAllTypesCognito = (sdl: string): string =>
225
227
  )
226
228
  ->Array.join("\n")
227
229
 
230
+ // ── Deploy-time gate invariant ──────────────────────────────────────────────
231
+ //
232
+ // Refuse to push a schema that cannot be gated. This is the fail-closed
233
+ // property `userPoolConfig.defaultAction: DENY` would have provided at REQUEST
234
+ // time — AWS rejects DENY alongside an additional auth provider
235
+ // ("Additional authentication providers cannot be specified when setting DENY
236
+ // for top level user pool authentication type", reproduced on create and
237
+ // update alike), and every API here configures AWS_IAM unconditionally. So the
238
+ // check moves one layer earlier, into our own deploy, where nothing can veto it.
239
+ //
240
+ // Two conditions, and the second is the one that matters:
241
+ //
242
+ // 1. A root field or object type carrying NO directive AppSync honours here
243
+ // is reachable by any authenticated Cognito caller under `ALLOW`.
244
+ // 2. ANY `@aws_auth` at all. It is the single-mode form, silently ignored on
245
+ // a multi-auth API — so its presence means something believes it is gated
246
+ // and is not.
247
+ //
248
+ // Condition 2 is why this is not merely "does it have a directive?". The
249
+ // original outage had `@aws_auth(cognito_groups: ["Admin"])` present on all 18
250
+ // admin fields for its entire life; a presence check would have passed it every
251
+ // single time. Only a check that knows WHICH directive the service honours
252
+ // would have failed that deploy.
253
+ let enforcedDirectives = ["@aws_cognito_user_pools", "@aws_iam"]
254
+
255
+ let hasEnforcedDirective = (s: string): bool =>
256
+ enforcedDirectives->Array.some(d => s->String.includes(d))
257
+
258
+ let rootOperationTypes = ["Query", "Mutation", "Subscription"]
259
+
260
+ let typeDeclNameOf = (line: string): option<string> =>
261
+ if line->String.startsWith("type ") {
262
+ let rest = line->String.slice(~start=5, ~end=line->String.length)
263
+ switch rest->String.search(%re("/[\s{]/")) {
264
+ | -1 => Some(rest)
265
+ | i => Some(rest->String.slice(~start=0, ~end=i))
266
+ }
267
+ } else {
268
+ None
269
+ }
270
+
271
+ // A root-operation field line: exactly two spaces of indent then a name. Deeper
272
+ // indents are directive continuations of the field above.
273
+ let fieldNameOf = (line: string): option<string> =>
274
+ switch line->String.match(%re("/^ {2}(\w+)/")) {
275
+ | Some(groups) => groups->Array.get(1)->Option.flatMap(x => x)
276
+ | None => None
277
+ }
278
+
279
+ // Field sweep on the ASSEMBLED SDL, for fields the fragment sweep cannot see
280
+ // because the stitcher injects them during assembly — `_noop`, the placeholder
281
+ // mutation added to keep a mutation-less schema valid, is the standing example
282
+ // and was live on three deployed APIs.
283
+ //
284
+ // Groups continuation lines into field entries the way `assertGateable` does, so
285
+ // a directive already sitting on the following line counts as present.
286
+ let stampUndirectivedRootFields = (sdl: string): string => {
287
+ let out = []
288
+ let currentRoot = ref(None)
289
+ let buffer = ref([])
290
+
291
+ let flush = () => {
292
+ let entry = buffer.contents
293
+ if entry->Array.length > 0 {
294
+ entry->Array.forEach(l => out->Array.push(l))
295
+ if !hasEnforcedDirective(entry->Array.join("\n")) {
296
+ out->Array.push(` ${cognitoOpenDirective}`)
297
+ }
298
+ buffer.contents = []
299
+ }
300
+ }
301
+
302
+ sdl
303
+ ->String.split("\n")
304
+ ->Array.forEach(line =>
305
+ switch typeDeclNameOf(line) {
306
+ | Some(name) =>
307
+ flush()
308
+ currentRoot.contents = rootOperationTypes->Array.includes(name) ? Some(name) : None
309
+ out->Array.push(line)
310
+ | None =>
311
+ if line->String.startsWith("}") {
312
+ flush()
313
+ currentRoot.contents = None
314
+ out->Array.push(line)
315
+ } else if currentRoot.contents->Option.isSome {
316
+ switch fieldNameOf(line) {
317
+ | Some(_) =>
318
+ flush()
319
+ buffer.contents = [line]
320
+ | None =>
321
+ if buffer.contents->Array.length > 0 {
322
+ buffer.contents = buffer.contents->Array.concat([line])
323
+ } else {
324
+ out->Array.push(line)
325
+ }
326
+ }
327
+ } else {
328
+ out->Array.push(line)
329
+ }
330
+ }
331
+ )
332
+ flush()
333
+ out->Array.join("\n")
334
+ }
335
+
336
+ let assertGateable = (sdl: string): string => {
337
+ let bareFields = []
338
+ let bareTypes = []
339
+ let inertDirectives = []
340
+
341
+ let currentRoot = ref(None)
342
+ // (fieldName, accumulated text incl. continuation lines)
343
+ let pending = ref(None)
344
+
345
+ let flush = () =>
346
+ switch pending.contents {
347
+ | Some((name, text)) =>
348
+ if !hasEnforcedDirective(text) {
349
+ bareFields->Array.push(name)
350
+ }
351
+ pending.contents = None
352
+ | None => ()
353
+ }
354
+
355
+ sdl
356
+ ->String.split("\n")
357
+ ->Array.forEach(line => {
358
+ if line->String.includes("@aws_auth") {
359
+ inertDirectives->Array.push(line->String.trim)
360
+ }
361
+ switch typeDeclNameOf(line) {
362
+ | Some(name) =>
363
+ flush()
364
+ if !hasEnforcedDirective(line) {
365
+ bareTypes->Array.push(name)
366
+ }
367
+ currentRoot.contents = rootOperationTypes->Array.includes(name) ? Some(name) : None
368
+ | None =>
369
+ if line->String.startsWith("}") {
370
+ flush()
371
+ currentRoot.contents = None
372
+ } else if currentRoot.contents->Option.isSome {
373
+ switch fieldNameOf(line) {
374
+ | Some(fieldName) =>
375
+ flush()
376
+ pending.contents = Some((fieldName, line))
377
+ | None =>
378
+ switch pending.contents {
379
+ | Some((n, text)) => pending.contents = Some((n, text ++ "\n" ++ line))
380
+ | None => ()
381
+ }
382
+ }
383
+ }
384
+ }
385
+ })
386
+ flush()
387
+
388
+ let problems = []
389
+ if inertDirectives->Array.length > 0 {
390
+ problems->Array.push(
391
+ ` ${inertDirectives->Array.length->Int.toString} field(s) carry @aws_auth, which AppSync IGNORES on a\n` ++
392
+ ` multi-auth API (this adapter always adds AWS_IAM as an additional provider).\n` ++
393
+ ` They are gated by nothing. Emit @aws_cognito_user_pools(cognito_groups: [...])\n` ++
394
+ ` instead — see AppSync_SdlDecorate.formatCognitoGroupsDirective.\n` ++
395
+ ` ${inertDirectives->Array.slice(~start=0, ~end=8)->Array.join("\n ")}`,
396
+ )
397
+ }
398
+ if bareFields->Array.length > 0 {
399
+ problems->Array.push(
400
+ ` ${bareFields->Array.length->Int.toString} root field(s) carry no enforced auth directive, so they are\n` ++
401
+ ` reachable by ANY authenticated Cognito caller:\n` ++
402
+ ` ${bareFields->Array.join(", ")}`,
403
+ )
404
+ }
405
+ if bareTypes->Array.length > 0 {
406
+ problems->Array.push(
407
+ ` ${bareTypes->Array.length->Int.toString} object type(s) carry no enforced auth directive:\n` ++
408
+ ` ${bareTypes->Array.join(", ")}`,
409
+ )
410
+ }
411
+
412
+ if problems->Array.length > 0 {
413
+ JsError.throwWithMessage(
414
+ `Refusing to push an AppSync schema that cannot be gated.\n\n` ++
415
+ problems->Array.join("\n\n") ++
416
+ `\n\nEvery field and type must carry @aws_cognito_user_pools (optionally with\n` ++
417
+ `cognito_groups) and/or @aws_iam. The sweep in stampUndirectivedFields /\n` ++
418
+ `stampAllTypesCognito should have covered this — a failure here means a schema\n` ++
419
+ `reached assembly by a path that bypasses them.\n` ++
420
+ `See docs/plans/appsync-group-authorization-unenforced.md.`,
421
+ )
422
+ }
423
+ sdl
424
+ }
425
+
228
426
  // ── Merged-API canonical stamping ─────────────────────────────────────────────
229
427
  // Under AppSync Merged APIs the admin source API owns the shared traversal
230
428
  // types; `@canonical` makes its definition win over every plugin source's copy
@@ -1,7 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
7
  import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
6
8
 
7
9
  function injectAwsSubscribe(sdl, sources) {
@@ -120,6 +122,170 @@ function stampAllTypesCognito(sdl) {
120
122
  }).join("\n");
121
123
  }
122
124
 
125
+ let enforcedDirectives = [
126
+ "@aws_cognito_user_pools",
127
+ "@aws_iam"
128
+ ];
129
+
130
+ function hasEnforcedDirective(s) {
131
+ return enforcedDirectives.some(d => s.includes(d));
132
+ }
133
+
134
+ let rootOperationTypes = [
135
+ "Query",
136
+ "Mutation",
137
+ "Subscription"
138
+ ];
139
+
140
+ function typeDeclNameOf(line) {
141
+ if (!line.startsWith("type ")) {
142
+ return;
143
+ }
144
+ let rest = line.slice(5, line.length);
145
+ let i = rest.search(/[\s{]/);
146
+ if (i !== -1) {
147
+ return rest.slice(0, i);
148
+ } else {
149
+ return rest;
150
+ }
151
+ }
152
+
153
+ function fieldNameOf(line) {
154
+ let groups = line.match(/^ {2}(\w+)/);
155
+ if (!(groups == null)) {
156
+ return Stdlib_Option.flatMap(groups[1], x => x);
157
+ }
158
+ }
159
+
160
+ function stampUndirectivedRootFields(sdl) {
161
+ let out = [];
162
+ let currentRoot = {
163
+ contents: undefined
164
+ };
165
+ let buffer = {
166
+ contents: []
167
+ };
168
+ let flush = () => {
169
+ let entry = buffer.contents;
170
+ if (entry.length !== 0) {
171
+ entry.forEach(l => {
172
+ out.push(l);
173
+ });
174
+ if (!hasEnforcedDirective(entry.join("\n"))) {
175
+ out.push(` ` + cognitoOpenDirective);
176
+ }
177
+ buffer.contents = [];
178
+ return;
179
+ }
180
+ };
181
+ sdl.split("\n").forEach(line => {
182
+ let name = typeDeclNameOf(line);
183
+ if (name !== undefined) {
184
+ flush();
185
+ currentRoot.contents = rootOperationTypes.includes(name) ? name : undefined;
186
+ out.push(line);
187
+ return;
188
+ }
189
+ if (line.startsWith("}")) {
190
+ flush();
191
+ currentRoot.contents = undefined;
192
+ out.push(line);
193
+ return;
194
+ }
195
+ if (Stdlib_Option.isSome(currentRoot.contents)) {
196
+ let match = fieldNameOf(line);
197
+ if (match !== undefined) {
198
+ flush();
199
+ buffer.contents = [line];
200
+ } else if (buffer.contents.length !== 0) {
201
+ buffer.contents = buffer.contents.concat([line]);
202
+ } else {
203
+ out.push(line);
204
+ }
205
+ return;
206
+ }
207
+ out.push(line);
208
+ });
209
+ flush();
210
+ return out.join("\n");
211
+ }
212
+
213
+ function assertGateable(sdl) {
214
+ let bareFields = [];
215
+ let bareTypes = [];
216
+ let inertDirectives = [];
217
+ let currentRoot = {
218
+ contents: undefined
219
+ };
220
+ let pending = {
221
+ contents: undefined
222
+ };
223
+ let flush = () => {
224
+ let match = pending.contents;
225
+ if (match !== undefined) {
226
+ if (!hasEnforcedDirective(match[1])) {
227
+ bareFields.push(match[0]);
228
+ }
229
+ pending.contents = undefined;
230
+ return;
231
+ }
232
+ };
233
+ sdl.split("\n").forEach(line => {
234
+ if (line.includes("@aws_auth")) {
235
+ inertDirectives.push(line.trim());
236
+ }
237
+ let name = typeDeclNameOf(line);
238
+ if (name !== undefined) {
239
+ flush();
240
+ if (!hasEnforcedDirective(line)) {
241
+ bareTypes.push(name);
242
+ }
243
+ currentRoot.contents = rootOperationTypes.includes(name) ? name : undefined;
244
+ return;
245
+ }
246
+ if (line.startsWith("}")) {
247
+ flush();
248
+ currentRoot.contents = undefined;
249
+ return;
250
+ }
251
+ if (!Stdlib_Option.isSome(currentRoot.contents)) {
252
+ return;
253
+ }
254
+ let fieldName = fieldNameOf(line);
255
+ if (fieldName !== undefined) {
256
+ flush();
257
+ pending.contents = [
258
+ fieldName,
259
+ line
260
+ ];
261
+ return;
262
+ }
263
+ let match = pending.contents;
264
+ if (match !== undefined) {
265
+ pending.contents = [
266
+ match[0],
267
+ match[1] + "\n" + line
268
+ ];
269
+ return;
270
+ }
271
+ });
272
+ flush();
273
+ let problems = [];
274
+ if (inertDirectives.length !== 0) {
275
+ problems.push(` ` + inertDirectives.length.toString() + ` field(s) carry @aws_auth, which AppSync IGNORES on a\n multi-auth API (this adapter always adds AWS_IAM as an additional provider).\n They are gated by nothing. Emit @aws_cognito_user_pools(cognito_groups: [...])\n instead — see AppSync_SdlDecorate.formatCognitoGroupsDirective.\n ` + inertDirectives.slice(0, 8).join("\n "));
276
+ }
277
+ if (bareFields.length !== 0) {
278
+ problems.push(` ` + bareFields.length.toString() + ` root field(s) carry no enforced auth directive, so they are\n reachable by ANY authenticated Cognito caller:\n ` + bareFields.join(", "));
279
+ }
280
+ if (bareTypes.length !== 0) {
281
+ problems.push(` ` + bareTypes.length.toString() + ` object type(s) carry no enforced auth directive:\n ` + bareTypes.join(", "));
282
+ }
283
+ if (problems.length !== 0) {
284
+ Stdlib_JsError.throwWithMessage(`Refusing to push an AppSync schema that cannot be gated.\n\n` + problems.join("\n\n") + `\n\nEvery field and type must carry @aws_cognito_user_pools (optionally with\ncognito_groups) and/or @aws_iam. The sweep in stampUndirectivedFields /\nstampAllTypesCognito should have covered this — a failure here means a schema\nreached assembly by a path that bypasses them.\nSee docs/plans/appsync-group-authorization-unenforced.md.`);
285
+ }
286
+ return sdl;
287
+ }
288
+
123
289
  let canonicalTypeNames = [
124
290
  "PageInfo",
125
291
  "CommandAccepted",
@@ -167,6 +333,13 @@ export {
167
333
  stampSharedIamTypes,
168
334
  stampUndirectivedFields,
169
335
  stampAllTypesCognito,
336
+ enforcedDirectives,
337
+ hasEnforcedDirective,
338
+ rootOperationTypes,
339
+ typeDeclNameOf,
340
+ fieldNameOf,
341
+ stampUndirectivedRootFields,
342
+ assertGateable,
170
343
  canonicalTypeNames,
171
344
  stampCanonicalTypes,
172
345
  }
@@ -249,3 +249,58 @@ describe("AppSync_SdlDecorate.stampUndirectivedFields", () => {
249
249
  )
250
250
  })
251
251
  })
252
+
253
+ // ── Deploy-time gate invariant ──────────────────────────────────────────────
254
+ //
255
+ // `defaultAction: DENY` — the request-time fail-closed switch — is unavailable:
256
+ // AWS refuses it alongside an additional auth provider, and every API here has
257
+ // AWS_IAM. So this check moves fail-closed into the deploy, where nothing can
258
+ // veto it: a schema that cannot be gated is never pushed.
259
+
260
+ describe("AppSync_SdlDecorate.assertGateable", () => {
261
+ let refuses = (sdl: string): bool =>
262
+ switch AppSync_SdlDecorate.assertGateable(sdl) {
263
+ | _ => false
264
+ | exception _ => true
265
+ }
266
+
267
+ // The original outage, exactly. A "does it have a directive?" check passes
268
+ // this — @aws_auth was present on all 18 admin fields the whole time it was
269
+ // inert. Only knowing WHICH directive the service honours catches it.
270
+ testSync("refuses @aws_auth, the directive AppSync ignores here", () => {
271
+ let sdl = `type Query @aws_cognito_user_pools {\n Admin_Thing: String @aws_auth(cognito_groups: ["Admin"])\n}`
272
+ expect(refuses(sdl))->toBe(true)
273
+ })
274
+
275
+ testSync("refuses a root field carrying no enforced directive", () => {
276
+ expect(refuses("type Query @aws_cognito_user_pools {\n Platform_ping: String\n}"))->toBe(true)
277
+ })
278
+
279
+ testSync("refuses an object type carrying no enforced directive", () => {
280
+ let sdl =
281
+ "type Query @aws_cognito_user_pools {\n a: String @aws_cognito_user_pools\n}\ntype Product {\n id: ID!\n}"
282
+ expect(refuses(sdl))->toBe(true)
283
+ })
284
+
285
+ // Our real emission shape puts a mutation's directive on the FOLLOWING line.
286
+ testSync("accepts a directive on the line after the field", () => {
287
+ let sdl = "type Query @aws_cognito_user_pools {\n Platform_ping: String\n @aws_cognito_user_pools\n}"
288
+ expect(refuses(sdl))->toBe(false)
289
+ })
290
+
291
+ testSync("accepts a group-gated field and an @aws_iam-only field", () => {
292
+ let sdl =
293
+ `type Mutation @aws_cognito_user_pools {\n Gated: String\n @aws_cognito_user_pools(cognito_groups: ["Admin"])\n Sys: String\n @aws_cognito_user_pools @aws_iam\n}`
294
+ expect(refuses(sdl))->toBe(false)
295
+ })
296
+
297
+ // The whole pipeline must satisfy its own invariant on the real admin base.
298
+ testSync("the assembled admin base passes", () => {
299
+ let decorated = AppSync_Adapter.injectAwsAuthAll(
300
+ ReventlessCore.Platform_AdminApi.baseFragment(~cloner=true),
301
+ ~group="Admin",
302
+ )
303
+ let sdl = AppSync_Adapter.stitchStandaloneWithAwsDirectives(~fragment=decorated)
304
+ expect(sdl->String.includes("@aws_cognito_user_pools"))->toBe(true)
305
+ })
306
+ })
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as AppSync_Adapter$ReventlessAws from "../src/components/Api/AppSync_Adapter.res.mjs";
5
5
  import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
6
+ import * as Platform_AdminApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_AdminApi.res.mjs";
6
7
  import * as AppSync_SdlDecorate$ReventlessAws from "../src/components/Api/AppSync_SdlDecorate.res.mjs";
7
8
 
8
9
  let sources = [
@@ -192,6 +193,37 @@ globalThis.describe("AppSync_SdlDecorate.stampUndirectivedFields", () => {
192
193
  });
193
194
  });
194
195
 
196
+ globalThis.describe("AppSync_SdlDecorate.assertGateable", () => {
197
+ let refuses = sdl => {
198
+ try {
199
+ AppSync_SdlDecorate$ReventlessAws.assertGateable(sdl);
200
+ return false;
201
+ } catch (exn) {
202
+ return true;
203
+ }
204
+ };
205
+ globalThis.test("refuses @aws_auth, the directive AppSync ignores here", () => {
206
+ globalThis.expect(refuses(`type Query @aws_cognito_user_pools {\n Admin_Thing: String @aws_auth(cognito_groups: ["Admin"])\n}`)).toBe(true);
207
+ });
208
+ globalThis.test("refuses a root field carrying no enforced directive", () => {
209
+ globalThis.expect(refuses("type Query @aws_cognito_user_pools {\n Platform_ping: String\n}")).toBe(true);
210
+ });
211
+ globalThis.test("refuses an object type carrying no enforced directive", () => {
212
+ globalThis.expect(refuses("type Query @aws_cognito_user_pools {\n a: String @aws_cognito_user_pools\n}\ntype Product {\n id: ID!\n}")).toBe(true);
213
+ });
214
+ globalThis.test("accepts a directive on the line after the field", () => {
215
+ globalThis.expect(refuses("type Query @aws_cognito_user_pools {\n Platform_ping: String\n @aws_cognito_user_pools\n}")).toBe(false);
216
+ });
217
+ globalThis.test("accepts a group-gated field and an @aws_iam-only field", () => {
218
+ globalThis.expect(refuses(`type Mutation @aws_cognito_user_pools {\n Gated: String\n @aws_cognito_user_pools(cognito_groups: ["Admin"])\n Sys: String\n @aws_cognito_user_pools @aws_iam\n}`)).toBe(false);
219
+ });
220
+ globalThis.test("the assembled admin base passes", () => {
221
+ let decorated = AppSync_Adapter$ReventlessAws.injectAwsAuthAll(Platform_AdminApi$ReventlessCore.baseFragment(true), "Admin", undefined);
222
+ let sdl = AppSync_Adapter$ReventlessAws.stitchStandaloneWithAwsDirectives(decorated);
223
+ globalThis.expect(sdl.includes("@aws_cognito_user_pools")).toBe(true);
224
+ });
225
+ });
226
+
195
227
  export {
196
228
  sources,
197
229
  stitchedSdl,