@reventlessdev/reventless-aws 3.0.0-alpha.291 โ†’ 3.0.0-alpha.293

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +9 -9
  3. package/src/Platform.res +85 -5
  4. package/src/Platform.res.mjs +62 -6
  5. package/src/Platform_Stack.res +78 -0
  6. package/src/Platform_Stack.res.mjs +17 -2
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +10 -0
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +8 -1
  9. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +89 -12
  10. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +55 -4
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +309 -0
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +230 -0
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +280 -0
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +163 -0
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +148 -0
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +128 -0
  17. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +180 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +105 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +201 -0
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +126 -0
  21. package/src/plugin/runtime/PluginRuntime_Builder.res +16 -0
  22. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +6 -0
  23. package/tests/Auth_ActiveRolePoolAttachmentTest.res +211 -0
  24. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +232 -0
  25. package/tests/Auth_ActiveRoleStoreTest.res +42 -0
  26. package/tests/Auth_ActiveRoleStoreTest.res.mjs +41 -0
  27. package/tests/Auth_ActiveRoleTrigger_OpsTest.res +169 -0
  28. package/tests/Auth_ActiveRoleTrigger_OpsTest.res.mjs +189 -0
  29. package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +110 -0
  30. package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +87 -0
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.293 (2026-08-13)
7
+
8
+ ### Features
9
+
10
+ * **aws:** narrow the token to the role a caller chose to act as ([194332f](https://github.com/ReventlessDev/reventless-core/commit/194332fa0af8e13417f72d57904e2ed469747dde))
11
+
12
+
13
+ # 3.0.0-alpha.292 (2026-08-13)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** re-detect once the Lambda that answers the handshake is up ([597e174](https://github.com/ReventlessDev/reventless-core/commit/597e174059e25461d66db35118c933d2ffce7e1f))
18
+ * **aws:** retain offloaded plugin structures so refs cannot dangle ([120a1a7](https://github.com/ReventlessDev/reventless-core/commit/120a1a7228087dd79f8bcfdd2fddc7b6dbdaf296))
19
+
20
+
6
21
  # 3.0.0-alpha.291 (2026-08-13)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.291",
3
+ "version": "3.0.0-alpha.293",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,17 +12,17 @@
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.8",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.9",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
- "@reventlessdev/rescript-node": "2.0.0-alpha.5",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.72",
19
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
18
+ "@reventlessdev/rescript-node": "2.0.0-alpha.5",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.74",
20
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.138",
23
- "@reventlessdev/reventless-core": "3.0.0-alpha.230",
24
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.94",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.232",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
24
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
26
26
  "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
27
27
  },
28
28
  "devDependencies": {
package/src/Platform.res CHANGED
@@ -245,11 +245,12 @@ module MakeWithConfig = (
245
245
  // unified mode carries the admin base here instead; it declares no stores in practice,
246
246
  // so the upload resolvers below are never created there.)
247
247
  let domainBaseFragment = ReventlessCore.GraphQL_Stitcher.encode({
248
- types: Array.concat(
249
- ReventlessCore.Platform_AdminApi.uploadTypes,
250
- ReventlessCore.Platform_AdminApi.geocodeTypes,
248
+ types: ReventlessCore.Platform_AdminApi.uploadTypes
249
+ ->Array.concat(ReventlessCore.Platform_AdminApi.geocodeTypes)
250
+ ->Array.concat(ReventlessCore.Platform_AdminApi.activeRoleTypes),
251
+ mutations: ReventlessCore.Platform_AdminApi.uploadMutationFields->Array.concat(
252
+ ReventlessCore.Platform_AdminApi.activeRoleMutationFields,
251
253
  ),
252
- mutations: ReventlessCore.Platform_AdminApi.uploadMutationFields,
253
254
  queries: Array.concat(
254
255
  [" Platform_ping: String"],
255
256
  ReventlessCore.Platform_AdminApi.geocodeQueryFields,
@@ -2106,6 +2107,31 @@ module MakeWithConfig = (
2106
2107
  | _ => ()
2107
2108
  }
2108
2109
 
2110
+ // Where a caller records the role they want to act as
2111
+ // (docs/plans/active-role-narrows-the-token.md ยง6). The mutation only writes
2112
+ // a preference; the pre-token-generation trigger reads the row and re-checks
2113
+ // it against real membership before narrowing anything, so this door grants
2114
+ // nobody anything on its own.
2115
+ //
2116
+ // The table and the trigger were provisioned with the pool โ€” they have to
2117
+ // be, since the pool is declared carrying the trigger's ARN. Only the write
2118
+ // door waits for the API.
2119
+ //
2120
+ // Split-API only, like the upload and geocode resolvers above:
2121
+ // `Platform_SetActiveRole` lives on the domain base document, which a
2122
+ // unified-mode deployment's single API does not carry. The table and
2123
+ // trigger are provisioned either way โ€” see `Platform_Stack` for why that
2124
+ // is not gated on this flag.
2125
+ if Config.splitApi {
2126
+ Auth_ActiveRoleStore.makeWriteDoor(
2127
+ ~api=domainApi,
2128
+ ~table=cognitoPool.activeRoleTable,
2129
+ ~cognitoUserPoolId=cognitoPool.poolId->Pulumi.Output.asInput,
2130
+ ~cognitoUserPoolArn=cognitoPool.poolArn->Pulumi.Output.asInput,
2131
+ ~opts={},
2132
+ )
2133
+ }
2134
+
2109
2135
  // The place index itself, exported so the unattended slice path can reach the
2110
2136
  // capability: a slice's Lambda signs an SDK call directly and needs the index
2111
2137
  // name, not an endpoint (no proxy hop, no dependence on a public URL). The
@@ -2296,11 +2322,34 @@ module MakeWithConfig = (
2296
2322
  ->Pulumi.Output.apply(o => o->Option.getOr("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY"))
2297
2323
  | None => Pulumi.Output.make("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")
2298
2324
  }
2325
+ // The key this deploy wrote for the plugin's structure, exported below. The
2326
+ // Plugin read model is what the manifest bake reads and it is updated
2327
+ // asynchronously, so the bake needs to know which structure "current" means;
2328
+ // an equality check against the key the stack just wrote says that without
2329
+ // timestamps, and passes immediately for a plugin whose structure did not
2330
+ // change.
2331
+ let structureOffloadKey = ref(None)
2332
+
2299
2333
  ReventlessCore.Plugin_Helpers.registerOffload((~store, ~bytes) => {
2300
2334
  let hash = NodeCrypto.sha256Hex(bytes)
2301
2335
  let key = "sha256/" ++ hash
2336
+ if store == "pluginStructures" {
2337
+ structureOffloadKey := Some(key)
2338
+ }
2302
2339
  // Content-addressed: the name and key are the hash, so re-deploying an
2303
2340
  // unchanged field writes the same object (idempotent, deduplicating).
2341
+ //
2342
+ // `retainOnDelete`: the only readers of these objects are the persisted
2343
+ // refs on the Plugin read model, and that row is updated asynchronously โ€”
2344
+ // the deploy publishes a re-detect, the Plugin aggregate handles it, the
2345
+ // projection lands, minutes later. A changed field means a new hash, so
2346
+ // without this Pulumi DELETES the previous object as part of the same
2347
+ // update, and every reference still on the read model dangles until
2348
+ // registration catches up. S3 answers a GET for a deleted key with
2349
+ // AccessDenied (it masks the 404 unless the caller may ListBucket), so the
2350
+ // window shows up as an authorization failure in the manifest bake and in
2351
+ // the AutoUI definitions query. Content-addressed objects are immutable
2352
+ // and cost kilobytes; keeping them closes the window outright.
2304
2353
  let _ = PulumiAws.S3.BucketObject.make(
2305
2354
  ~name="offload-" ++ hash,
2306
2355
  ~args={
@@ -2309,6 +2358,7 @@ module MakeWithConfig = (
2309
2358
  content: Pulumi.Input.make(bytes),
2310
2359
  contentType: Pulumi.Input.make("application/json"),
2311
2360
  },
2361
+ ~opts={retainOnDelete: true},
2312
2362
  )
2313
2363
  {Reventless.Offload.store, key, hash, bytes: bytes->String.length}
2314
2364
  })
@@ -2361,6 +2411,23 @@ module MakeWithConfig = (
2361
2411
  let pluginOutputs = pluginComponent->ReventlessCore.Component.outputs
2362
2412
  ReventlessCore.Plugin_Helpers.exportPluginOutputs(pluginOutputs)
2363
2413
 
2414
+ // What the manifest bake compares the read model against. Keyed by the bare
2415
+ // plugin name because that is how the Plugin read model keys its rows โ€” the
2416
+ // stack's own id carries the version, which the bake has no opinion about.
2417
+ switch structureOffloadKey.contents {
2418
+ | Some(key) =>
2419
+ Pulumi.Pulumi.export(
2420
+ "pluginStructureRef",
2421
+ pluginOutputs.id->Pulumi.Output.apply(id =>
2422
+ Dict.fromArray([
2423
+ ("plugin", JSON.Encode.string(ReventlessCore.Plugin.name(id))),
2424
+ ("key", JSON.Encode.string(key)),
2425
+ ])->JSON.Encode.object
2426
+ ),
2427
+ )
2428
+ | None => ()
2429
+ }
2430
+
2364
2431
  // โ”€โ”€ Merged-API association (merged-api plan, Phase 4) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2365
2432
  // Associate this plugin's source API with the platform's merged API โ€”
2366
2433
  // this replaces the SigV4 RegisterApiFragment handshake + reactive push +
@@ -2539,7 +2606,20 @@ module MakeWithConfig = (
2539
2606
  // synchronously always saw the initial empty config, so the publish was skipped
2540
2607
  // on every deploy. `pluginOutputs.heartbeat` derives from that same callback's
2541
2608
  // output, so applying to it is the earliest point the config is populated.
2542
- let _ = pluginOutputs.heartbeat->Pulumi.Output.apply(_ => {
2609
+ //
2610
+ // The EventCollector Lambda is the other half of the gate because it is what
2611
+ // ANSWERS the handshake: the re-detect only asks the platform to re-run it,
2612
+ // and the definition that comes back is the one compiled into that Lambda
2613
+ // (PluginConnectExtension_Mapping's UnknownPluginDetected branch). Published
2614
+ // before it carries the new code, the plugin answers with the PREVIOUS
2615
+ // deploy's definition, `Connect` sees a definition it already holds and emits
2616
+ // nothing, and the row keeps the old structure โ€” silently, and with no second
2617
+ // re-detect coming to correct it. Waiting on the heartbeat alone gated on a
2618
+ // Lambda that has no part in the answer.
2619
+ let redetectReady =
2620
+ (pluginOutputs.heartbeat, PluginRuntime_Builder.eventCollectorReadyRef.contents)
2621
+ ->Pulumi.Output.all2
2622
+ let _ = redetectReady->Pulumi.Output.apply(_ => {
2543
2623
  let hbConfig = PluginRuntime_Builder.heartbeatConfigRef.contents
2544
2624
  switch (Pulumi.Pulumi.isDryRun(), hbConfig.epQueueUrl) {
2545
2625
  | (true, _) => () // `pulumi preview` โ€” no deploy-time side effects
@@ -16,6 +16,7 @@ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
16
16
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
17
17
  import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
18
18
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
19
+ import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin.res.mjs";
19
20
  import * as AWS_Tags$ReventlessAws from "./adapter/AWS_Tags.res.mjs";
20
21
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
21
22
  import * as Scheduler$ReventlessAws from "./components/Scheduler.res.mjs";
@@ -59,6 +60,7 @@ import * as StateTopic_AppSync$ReventlessAws from "./adapter/StateTopic/StateTop
59
60
  import * as AppSync_SdlDecorate$ReventlessAws from "./components/Api/AppSync_SdlDecorate.res.mjs";
60
61
  import * as UiFragmentRegistry$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
61
62
  import * as Util_ResourceNaming$ReventlessAws from "./util/Util_ResourceNaming.res.mjs";
63
+ import * as Auth_ActiveRoleStore$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleStore.res.mjs";
62
64
  import * as ClonerRunner_Fargate$ReventlessAws from "./plugin/cloner/ClonerRunner_Fargate.res.mjs";
63
65
  import * as QueryEngine_DynamoDb$ReventlessAws from "./adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs";
64
66
  import * as PluginRuntime_Builder$ReventlessAws from "./plugin/runtime/PluginRuntime_Builder.res.mjs";
@@ -191,8 +193,8 @@ function MakeWithConfig(Config) {
191
193
  }
192
194
  };
193
195
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
194
- types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
195
- mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
196
+ types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes).concat(Platform_AdminApi$ReventlessCore.activeRoleTypes),
197
+ mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields.concat(Platform_AdminApi$ReventlessCore.activeRoleMutationFields),
196
198
  queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
197
199
  subscriptions: [],
198
200
  subscriptionSources: []
@@ -1129,6 +1131,9 @@ function MakeWithConfig(Config) {
1129
1131
  if (index !== undefined && Config.splitApi) {
1130
1132
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
1131
1133
  }
1134
+ if (Config.splitApi) {
1135
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
1136
+ }
1132
1137
  let index$1 = hostUiBundle.geocoderPlaceIndex;
1133
1138
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
1134
1139
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -1248,14 +1253,22 @@ function MakeWithConfig(Config) {
1248
1253
  hooksApiRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApi);
1249
1254
  hooksApiRoleRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApiRole);
1250
1255
  let offloadBucketName = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("offloadBucket").apply(o => Stdlib_Option.getOr(o, "OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")) : Pulumi.output("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY");
1256
+ let structureOffloadKey = {
1257
+ contents: undefined
1258
+ };
1251
1259
  Plugin_Helpers$ReventlessCore.registerOffload((store, bytes) => {
1252
1260
  let hash = NodeCrypto.sha256Hex(bytes);
1253
1261
  let key = "sha256/" + hash;
1262
+ if (store === "pluginStructures") {
1263
+ structureOffloadKey.contents = key;
1264
+ }
1254
1265
  new (Aws.s3.BucketObject)("offload-" + hash, {
1255
1266
  bucket: offloadBucketName,
1256
1267
  key: key,
1257
1268
  content: bytes,
1258
1269
  contentType: "application/json"
1270
+ }, {
1271
+ retainOnDelete: true
1259
1272
  });
1260
1273
  return {
1261
1274
  store: store,
@@ -1273,6 +1286,19 @@ function MakeWithConfig(Config) {
1273
1286
  Pulumi$Pulumi.$$export("_interopMeta", Plugin_Helpers$ReventlessCore.getInteropMeta());
1274
1287
  let pluginOutputs = Component$ReventlessCore.outputs(pluginComponent);
1275
1288
  Plugin_Helpers$ReventlessCore.exportPluginOutputs(pluginOutputs);
1289
+ let key = structureOffloadKey.contents;
1290
+ if (key !== undefined) {
1291
+ Pulumi$Pulumi.$$export("pluginStructureRef", pluginOutputs.id.apply(id => Object.fromEntries([
1292
+ [
1293
+ "plugin",
1294
+ Plugin$ReventlessCore.name(id)
1295
+ ],
1296
+ [
1297
+ "key",
1298
+ key
1299
+ ]
1300
+ ])));
1301
+ }
1276
1302
  if (platformStackRef !== undefined) {
1277
1303
  let stackRef = Primitive_option.valFromOption(platformStackRef);
1278
1304
  let defaultOutput = stackRef.getOutput("default");
@@ -1335,7 +1361,11 @@ function MakeWithConfig(Config) {
1335
1361
  if (sel !== undefined) {
1336
1362
  PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
1337
1363
  }
1338
- pluginOutputs.heartbeat.apply(param => {
1364
+ let redetectReady = Pulumi.all([
1365
+ pluginOutputs.heartbeat,
1366
+ PluginRuntime_Builder$ReventlessAws.eventCollectorReadyRef.contents
1367
+ ]);
1368
+ redetectReady.apply(param => {
1339
1369
  let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
1340
1370
  let match = IndexJs.isDryRun();
1341
1371
  let match$1 = hbConfig.epQueueUrl;
@@ -1465,8 +1495,8 @@ function Make($star) {
1465
1495
  }
1466
1496
  };
1467
1497
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
1468
- types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
1469
- mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields,
1498
+ types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes).concat(Platform_AdminApi$ReventlessCore.activeRoleTypes),
1499
+ mutations: Platform_AdminApi$ReventlessCore.uploadMutationFields.concat(Platform_AdminApi$ReventlessCore.activeRoleMutationFields),
1470
1500
  queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
1471
1501
  subscriptions: [],
1472
1502
  subscriptionSources: []
@@ -2380,6 +2410,7 @@ function Make($star) {
2380
2410
  if (index !== undefined) {
2381
2411
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
2382
2412
  }
2413
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
2383
2414
  let index$1 = hostUiBundle.geocoderPlaceIndex;
2384
2415
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
2385
2416
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -2499,14 +2530,22 @@ function Make($star) {
2499
2530
  hooksApiRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApi);
2500
2531
  hooksApiRoleRef.contents = Platform_Casts$ReventlessAws.wrapHookedValue(targetApiRole);
2501
2532
  let offloadBucketName = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("offloadBucket").apply(o => Stdlib_Option.getOr(o, "OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY")) : Pulumi.output("OFFLOAD_BUCKET_PENDING_PLATFORM_DEPLOY");
2533
+ let structureOffloadKey = {
2534
+ contents: undefined
2535
+ };
2502
2536
  Plugin_Helpers$ReventlessCore.registerOffload((store, bytes) => {
2503
2537
  let hash = NodeCrypto.sha256Hex(bytes);
2504
2538
  let key = "sha256/" + hash;
2539
+ if (store === "pluginStructures") {
2540
+ structureOffloadKey.contents = key;
2541
+ }
2505
2542
  new (Aws.s3.BucketObject)("offload-" + hash, {
2506
2543
  bucket: offloadBucketName,
2507
2544
  key: key,
2508
2545
  content: bytes,
2509
2546
  contentType: "application/json"
2547
+ }, {
2548
+ retainOnDelete: true
2510
2549
  });
2511
2550
  return {
2512
2551
  store: store,
@@ -2524,6 +2563,19 @@ function Make($star) {
2524
2563
  Pulumi$Pulumi.$$export("_interopMeta", Plugin_Helpers$ReventlessCore.getInteropMeta());
2525
2564
  let pluginOutputs = Component$ReventlessCore.outputs(pluginComponent);
2526
2565
  Plugin_Helpers$ReventlessCore.exportPluginOutputs(pluginOutputs);
2566
+ let key = structureOffloadKey.contents;
2567
+ if (key !== undefined) {
2568
+ Pulumi$Pulumi.$$export("pluginStructureRef", pluginOutputs.id.apply(id => Object.fromEntries([
2569
+ [
2570
+ "plugin",
2571
+ Plugin$ReventlessCore.name(id)
2572
+ ],
2573
+ [
2574
+ "key",
2575
+ key
2576
+ ]
2577
+ ])));
2578
+ }
2527
2579
  if (platformStackRef !== undefined) {
2528
2580
  let stackRef = Primitive_option.valFromOption(platformStackRef);
2529
2581
  let defaultOutput = stackRef.getOutput("default");
@@ -2586,7 +2638,11 @@ function Make($star) {
2586
2638
  if (sel !== undefined) {
2587
2639
  PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
2588
2640
  }
2589
- pluginOutputs.heartbeat.apply(param => {
2641
+ let redetectReady = Pulumi.all([
2642
+ pluginOutputs.heartbeat,
2643
+ PluginRuntime_Builder$ReventlessAws.eventCollectorReadyRef.contents
2644
+ ]);
2645
+ redetectReady.apply(param => {
2590
2646
  let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
2591
2647
  let match = IndexJs.isDryRun();
2592
2648
  let match$1 = hbConfig.epQueueUrl;
@@ -6,6 +6,12 @@ type cognitoUserPool = {
6
6
  clientId: Pulumi.Output.t<string>,
7
7
  poolArn: Pulumi.Output.t<string>,
8
8
  managed: bool,
9
+ /** Rows holding the role each caller chose to act as, read by the pool's
10
+ pre-token-generation trigger. Carried on the resolved pool because the table
11
+ has to be provisioned *before* the pool (the trigger reads it, and the pool
12
+ is declared with the trigger's ARN), while the mutation that writes it is
13
+ provisioned after the API โ€” see [Auth_ActiveRoleStore.res]. */
14
+ activeRoleTable: Auth_ActiveRoleStore.storeTable,
9
15
  }
10
16
 
11
17
  /**
@@ -32,6 +38,11 @@ type cognitoUserPool = {
32
38
  * `cognitoUserPoolArn`, `cognitoRegion`, and `cognitoUserPoolManaged` as
33
39
  * stack outputs so downstream stacks (and Stage D AppSync wiring) can read
34
40
  * them via `StackReference`.
41
+ *
42
+ * Also provisions the role-state table and the pre-token-generation trigger that
43
+ * narrows `cognito:groups` to a caller's chosen role. They belong here rather
44
+ * than beside the mutation that writes the table because the pool must be
45
+ * declared carrying the trigger's ARN โ€” see the ordering note below.
35
46
  */
36
47
  let _resolveUncached = (): cognitoUserPool => {
37
48
  let cfg = Pulumi.Config.make(Some("platform"))
@@ -40,6 +51,33 @@ let _resolveUncached = (): cognitoUserPool => {
40
51
  | None => cfg->Pulumi.Config.get("cognitoUserPoolId")
41
52
  }
42
53
 
54
+ // Provisioned ahead of the pool in both pool modes: the pre-token-generation
55
+ // trigger reads these rows, and in auto mode the pool is declared carrying the
56
+ // trigger's ARN โ€” so table โ†’ trigger โ†’ pool is a forced order, not a choice.
57
+ // See [docs/plans/active-role-narrows-the-token.md] ยง6.
58
+ //
59
+ // Unconditional, including for a unified-API platform that mounts no
60
+ // `Platform_SetActiveRole` and so can never write a row.
61
+ //
62
+ // This was briefly gated on whether a write door would exist, to save a
63
+ // DynamoDB read per sign-in in that mode. The gate was a parameter on this
64
+ // function โ€” and because the result is process-cached, it made the feature's
65
+ // existence depend on *which caller resolved the pool first*. `Auth_Cognito.make`
66
+ // reaches this through an adapter record and gets there before the platform
67
+ // does, so the whole feature silently vanished from the deploy: no error, no
68
+ // resource, nothing to notice. A local `pulumi preview` was the only thing that
69
+ // caught it.
70
+ //
71
+ // Saving one GetItem in a legacy mode is not worth a switch whose failure mode
72
+ // is a feature that quietly is not there. If the cost ever matters, the fix is
73
+ // a design that does not hinge on call order โ€” not this parameter again.
74
+ let activeRoleTable = Auth_ActiveRoleStore.makeTable(~opts={})
75
+ let activeRoleTrigger = Auth_ActiveRoleTrigger.make(
76
+ ~activeRoleTableName=activeRoleTable.name->Pulumi.Output.asInput,
77
+ ~activeRoleTableArn=activeRoleTable.arn->Pulumi.Output.asInput,
78
+ ~opts={},
79
+ )
80
+
43
81
  let result = switch existingPoolId {
44
82
  | Some(poolId) =>
45
83
  let tokenUnits: PulumiAws.Cognito.UserPoolClient.tokenValidityUnits = {
@@ -66,11 +104,25 @@ let _resolveUncached = (): cognitoUserPool => {
66
104
 
67
105
  let lookup = PulumiAws.Cognito.UserPool.getUserPoolOutput(~args={userPoolId: poolId})
68
106
 
107
+ // BYO: we hold no handle to set `lambdaConfig` on, and Cognito offers no
108
+ // separate attachment resource โ€” so the trigger is attached by a declared
109
+ // resource that describes the pool, merges into what it finds, and sends the
110
+ // result back whole. Never a hand-run command: that would leave the
111
+ // deployment's behaviour depending on an act no source describes.
112
+ let _attachment = Auth_ActiveRolePoolAttachment.make(
113
+ ~props={
114
+ userPoolId: Pulumi.Input.make(poolId),
115
+ preTokenGenerationArn: activeRoleTrigger.functionArn->Pulumi.Output.asInput,
116
+ },
117
+ ~opts={},
118
+ )
119
+
69
120
  {
70
121
  poolId: Pulumi.Output.make(poolId),
71
122
  clientId: client.id,
72
123
  poolArn: lookup->Pulumi.Output.apply(r => r.arn),
73
124
  managed: false,
125
+ activeRoleTable,
74
126
  }
75
127
 
76
128
  | None =>
@@ -83,6 +135,11 @@ let _resolveUncached = (): cognitoUserPool => {
83
135
  requireUppercase: Pulumi.Input.make(true),
84
136
  requireNumbers: Pulumi.Input.make(true),
85
137
  }
138
+ // Auto: the pool is ours, so the trigger is an ordinary declared property and
139
+ // no out-of-band `UpdateUserPool` is involved. Declaring it here rather than
140
+ // attaching it afterwards also keeps Pulumi's own state the authority โ€” an
141
+ // attachment made behind the resource's back would read as drift on the next
142
+ // refresh and be reverted.
86
143
  let pool = PulumiAws.Cognito.UserPool.make(
87
144
  ~name="HostUiPool",
88
145
  ~args={
@@ -90,6 +147,9 @@ let _resolveUncached = (): cognitoUserPool => {
90
147
  usernameAttributes: Pulumi.Input.make([Pulumi.Input.make("email")]),
91
148
  passwordPolicy: Pulumi.Input.make(pwdPolicy),
92
149
  mfaConfiguration: Pulumi.Input.make("OFF"),
150
+ lambdaConfig: Pulumi.Input.make({
151
+ PulumiAws.Cognito.UserPool.preTokenGeneration: activeRoleTrigger.functionArn->Pulumi.Output.asInput,
152
+ }),
93
153
  tags: AWS.Tags.make(
94
154
  ~name="HostUiPool",
95
155
  ~kind=ReventlessCore.ComponentType.Platform,
@@ -126,9 +186,18 @@ let _resolveUncached = (): cognitoUserPool => {
126
186
  clientId: client.id,
127
187
  poolArn: pool.arn,
128
188
  managed: true,
189
+ activeRoleTable,
129
190
  }
130
191
  }
131
192
 
193
+ // After the branch, because the permission is scoped to the pool's ARN and the
194
+ // pool is what the branch produces.
195
+ Auth_ActiveRoleTrigger.grantInvoke(
196
+ ~trigger=activeRoleTrigger,
197
+ ~cognitoUserPoolArn=result.poolArn->Pulumi.Output.asInput,
198
+ ~opts={},
199
+ )
200
+
132
201
  let regionStr =
133
202
  Pulumi.Config.make(Some("aws"))->Pulumi.Config.get("region")->Option.getOr("unknown")
134
203
 
@@ -149,6 +218,15 @@ let _resolveUncached = (): cognitoUserPool => {
149
218
  // stack-export calls happen exactly once per Pulumi run.
150
219
  let _cached: ref<option<cognitoUserPool>> = ref(None)
151
220
 
221
+ /**
222
+ ๐Ÿšจ **Takes no arguments, and must not grow any.** Everything here is provisioned
223
+ on the *first* call and every later caller gets that same result โ€” so a
224
+ parameter would not configure the pool, it would configure whichever caller
225
+ happened to arrive first. `Auth_Cognito.make` reaches this through an adapter
226
+ record and gets here before the platform's own call does, which is not obvious
227
+ from any call site. Anything that needs to vary belongs in [_resolveUncached],
228
+ read from config or environment where every caller sees the same answer.
229
+ */
152
230
  let resolveCognitoUserPool = (): cognitoUserPool =>
153
231
  switch _cached.contents {
154
232
  | Some(p) => p
@@ -6,11 +6,16 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as AWS_Tags$ReventlessAws from "./adapter/AWS_Tags.res.mjs";
8
8
  import * as Util_LocalConfig$ReventlessAws from "./util/Util_LocalConfig.res.mjs";
9
+ import * as Auth_ActiveRoleStore$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleStore.res.mjs";
10
+ import * as Auth_ActiveRoleTrigger$ReventlessAws from "./adapter/Auth/Auth_ActiveRoleTrigger.res.mjs";
11
+ import * as Auth_ActiveRolePoolAttachment$ReventlessAws from "./adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs";
9
12
 
10
13
  function _resolveUncached() {
11
14
  let cfg = new Pulumi.Config("platform");
12
15
  let v = Util_LocalConfig$ReventlessAws.get("cognitoUserPoolId");
13
16
  let existingPoolId = v !== undefined ? v : cfg.get("cognitoUserPoolId");
17
+ let activeRoleTable = Auth_ActiveRoleStore$ReventlessAws.makeTable(undefined, {});
18
+ let activeRoleTrigger = Auth_ActiveRoleTrigger$ReventlessAws.make(activeRoleTable.name, activeRoleTable.arn, undefined, {});
14
19
  let result;
15
20
  if (existingPoolId !== undefined) {
16
21
  let tokenUnits_accessToken = "minutes";
@@ -37,11 +42,16 @@ function _resolveUncached() {
37
42
  let lookup = Aws.cognito.getUserPoolOutput({
38
43
  userPoolId: existingPoolId
39
44
  });
45
+ Auth_ActiveRolePoolAttachment$ReventlessAws.make(undefined, {
46
+ userPoolId: existingPoolId,
47
+ preTokenGenerationArn: activeRoleTrigger.functionArn
48
+ }, {});
40
49
  result = {
41
50
  poolId: Pulumi.output(existingPoolId),
42
51
  clientId: client.id,
43
52
  poolArn: lookup.apply(r => r.arn),
44
- managed: false
53
+ managed: false,
54
+ activeRoleTable: activeRoleTable
45
55
  };
46
56
  } else {
47
57
  let adminConfig = {
@@ -58,6 +68,9 @@ function _resolveUncached() {
58
68
  requireUppercase: pwdPolicy_requireUppercase
59
69
  };
60
70
  let pool = new (Aws.cognito.UserPool)("HostUiPool", {
71
+ lambdaConfig: {
72
+ preTokenGeneration: activeRoleTrigger.functionArn
73
+ },
61
74
  adminCreateUserConfig: adminConfig,
62
75
  usernameAttributes: ["email"],
63
76
  passwordPolicy: pwdPolicy,
@@ -89,9 +102,11 @@ function _resolveUncached() {
89
102
  poolId: pool.id,
90
103
  clientId: client$1.id,
91
104
  poolArn: pool.arn,
92
- managed: true
105
+ managed: true,
106
+ activeRoleTable: activeRoleTable
93
107
  };
94
108
  }
109
+ Auth_ActiveRoleTrigger$ReventlessAws.grantInvoke(activeRoleTrigger, result.poolArn, undefined, {});
95
110
  let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
96
111
  Pulumi$Pulumi.$$export("cognitoUserPoolId", result.poolId);
97
112
  Pulumi$Pulumi.$$export("cognitoUserPoolClientId", result.clientId);
@@ -117,6 +117,16 @@ let make = (
117
117
  actions: Actions(["s3:GetObject"]),
118
118
  resources: Resource("arn:aws:s3:::" ++ offloadBucket ++ "/*"),
119
119
  },
120
+ // Read-only on the bucket itself, purely so a key that is not there
121
+ // says so: S3 answers GET for a missing key with AccessDenied unless
122
+ // the caller may list the bucket, which reports a ref the handler
123
+ // cannot resolve as an IAM problem it does not have.
124
+ {
125
+ sid: "AllowOffloadList",
126
+ effect: Allow,
127
+ actions: Actions(["s3:ListBucket"]),
128
+ resources: Resource("arn:aws:s3:::" ++ offloadBucket),
129
+ },
120
130
  ],
121
131
  )
122
132
  ->PolicyDocument.toJsonString
@@ -46,6 +46,7 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
46
46
  pluginReadModelTableName,
47
47
  offloadBucketName
48
48
  ]).apply(param => {
49
+ let offloadBucket = param[1];
49
50
  new (Aws.iam.RolePolicy)(name + "LambdaPolicy", {
50
51
  policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "LambdaPolicy", [
51
52
  {
@@ -64,7 +65,13 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
64
65
  Sid: "AllowOffloadGet",
65
66
  Effect: "Allow",
66
67
  Action: ["s3:GetObject"],
67
- Resource: "arn:aws:s3:::" + param[1] + "/*"
68
+ Resource: "arn:aws:s3:::" + offloadBucket + "/*"
69
+ },
70
+ {
71
+ Sid: "AllowOffloadList",
72
+ Effect: "Allow",
73
+ Action: ["s3:ListBucket"],
74
+ Resource: "arn:aws:s3:::" + offloadBucket
68
75
  }
69
76
  ])),
70
77
  role: lambdaRole.id