@reventlessdev/reventless-aws 3.0.0-alpha.297 → 3.0.0-alpha.299

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,21 @@
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.299 (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **aws:** refuse to push an AppSync schema that cannot be gated ([18e9279](https://github.com/ReventlessDev/reventless-core/commit/18e92793b1b42b4b94dc61e9e285918c67e62e8a))
11
+
12
+
13
+ # 3.0.0-alpha.298 (2026-08-14)
14
+
15
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
16
+
17
+
18
+
19
+
20
+
6
21
  # 3.0.0-alpha.297 (2026-08-14)
7
22
 
8
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.297",
3
+ "version": "3.0.0-alpha.299",
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
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
17
17
  "@reventlessdev/rescript-jest": "1.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
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.233",
22
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.233",
23
23
  "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
24
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
24
25
  "@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"
26
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.113"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -470,6 +470,13 @@ let stitchStandaloneWithAwsDirectives = (
470
470
  // Last: every remaining undirectived type takes the group-less Cognito arm.
471
471
  // After stampSharedIamTypes so the shared traversal types keep `@aws_iam`.
472
472
  ->AppSync_SdlDecorate.stampAllTypesCognito
473
+ // Fields the stitcher injects during assembly (`_noop`) are invisible to the
474
+ // fragment sweep above, so sweep the assembled document too.
475
+ ->AppSync_SdlDecorate.stampUndirectivedRootFields
476
+ // Then refuse to hand back a schema that cannot be gated. `defaultAction: DENY`
477
+ // is unavailable on a multi-auth API, so this deploy-time check is the only
478
+ // fail-closed step there is — everything downstream trusts what it returns.
479
+ ->AppSync_SdlDecorate.assertGateable
473
480
  }
474
481
 
475
482
  // ── Provider implementation ────────────────────────────────────────────────
@@ -533,6 +540,22 @@ let _makeApiResourceWith = (
533
540
  // Auth_Cognito (cached inside Platform_Stack, so calling from each API call
534
541
  // site — DomainApi, PlatformApi — is safe). A single Output yielding the
535
542
  // {userPoolId, awsRegion, defaultAction} record AppSync expects.
543
+ //
544
+ // `ALLOW` is forced, not chosen. `DENY` would make a missing directive fail
545
+ // closed, which is what we want — but AWS refuses the combination outright:
546
+ //
547
+ // BadRequestException: Additional authentication providers cannot be
548
+ // specified when setting DENY for top level user pool authentication type.
549
+ //
550
+ // Every API here configures AWS_IAM as an additional provider (the
551
+ // server-to-server lambdas need it), so DENY is unavailable for as long as
552
+ // that is true. Tried on a deploy 2026-08-14; all four APIs rejected it.
553
+ //
554
+ // The protection therefore rests entirely on every field and type carrying an
555
+ // explicit directive — `stampUndirectivedFields` + `stampAllTypesCognito` at
556
+ // the assembly choke point — with no backstop underneath. That makes
557
+ // `scripts/check-appsync-directive-coverage.mjs` load-bearing rather than
558
+ // advisory: run it against a DEPLOYED api after any change to the decoration.
536
559
  let userPoolConfigOut = switch userPoolConfig {
537
560
  | Some(config) => config
538
561
  | None =>
@@ -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) {
@@ -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,