@reventlessdev/reventless-aws 3.0.0-alpha.292 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
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
+
6
13
  # 3.0.0-alpha.292 (2026-08-13)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.292",
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",
16
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.73",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.9",
18
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
19
18
  "@reventlessdev/rescript-node": "2.0.0-alpha.5",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.74",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.231",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.232",
23
23
  "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
24
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.95",
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
@@ -60,6 +60,7 @@ import * as StateTopic_AppSync$ReventlessAws from "./adapter/StateTopic/StateTop
60
60
  import * as AppSync_SdlDecorate$ReventlessAws from "./components/Api/AppSync_SdlDecorate.res.mjs";
61
61
  import * as UiFragmentRegistry$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
62
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";
63
64
  import * as ClonerRunner_Fargate$ReventlessAws from "./plugin/cloner/ClonerRunner_Fargate.res.mjs";
64
65
  import * as QueryEngine_DynamoDb$ReventlessAws from "./adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs";
65
66
  import * as PluginRuntime_Builder$ReventlessAws from "./plugin/runtime/PluginRuntime_Builder.res.mjs";
@@ -192,8 +193,8 @@ function MakeWithConfig(Config) {
192
193
  }
193
194
  };
194
195
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
195
- types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
196
- 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),
197
198
  queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
198
199
  subscriptions: [],
199
200
  subscriptionSources: []
@@ -1130,6 +1131,9 @@ function MakeWithConfig(Config) {
1130
1131
  if (index !== undefined && Config.splitApi) {
1131
1132
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
1132
1133
  }
1134
+ if (Config.splitApi) {
1135
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
1136
+ }
1133
1137
  let index$1 = hostUiBundle.geocoderPlaceIndex;
1134
1138
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
1135
1139
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -1491,8 +1495,8 @@ function Make($star) {
1491
1495
  }
1492
1496
  };
1493
1497
  let domainBaseFragment = GraphQL_Stitcher$ReventlessCore.encode({
1494
- types: Platform_AdminApi$ReventlessCore.uploadTypes.concat(Platform_AdminApi$ReventlessCore.geocodeTypes),
1495
- 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),
1496
1500
  queries: [" Platform_ping: String"].concat(Platform_AdminApi$ReventlessCore.geocodeQueryFields),
1497
1501
  subscriptions: [],
1498
1502
  subscriptionSources: []
@@ -2406,6 +2410,7 @@ function Make($star) {
2406
2410
  if (index !== undefined) {
2407
2411
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
2408
2412
  }
2413
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
2409
2414
  let index$1 = hostUiBundle.geocoderPlaceIndex;
2410
2415
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
2411
2416
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -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);
@@ -0,0 +1,309 @@
1
+ /** Attaches the pre-token-generation trigger to a Cognito user pool the stack
2
+ does **not** own — the BYO case of `Platform_Stack.resolveCognitoUserPool`,
3
+ where `platform:cognitoUserPoolId` names an existing pool that is looked up
4
+ for its ARN and nothing more.
5
+
6
+ BYO cannot be the unsupported mode. It is the mode a customer with an
7
+ existing identity estate is in, and requiring the framework to own their user
8
+ pool before an operator can act as a role is a far larger ask than the
9
+ feature is worth. In **auto** mode the pool is ours and
10
+ `lambdaConfig.preTokenGeneration` is an ordinary property on a resource we
11
+ already declare — this resource is not used there.
12
+
13
+ The trigger is a property of the pool and Cognito offers no separate
14
+ attachment resource, so something has to call `UpdateUserPool` against a pool
15
+ no stack owns. That belongs in a declared resource executed at deploy time,
16
+ never a hand-run command against a live pool, which would leave the
17
+ deployment's behaviour depending on an act no source describes.
18
+
19
+ 🚨 **`UpdateUserPool` resets by omission.** The API requires "a value for all
20
+ parameters that you don't want set to a default value". An attach that sends
21
+ only `LambdaConfig` silently returns every other setting on that pool to its
22
+ default — on a pool the framework did not create and whose configuration it
23
+ never described. So every call here describes first and sends the pool's own
24
+ configuration back whole, with one field changed. See [mergedUpdateInput].
25
+
26
+ Mirrors the dynImport + hand-rolled shape of
27
+ [AppSync_SourceApiAssociation_Retrying.res]: Pulumi serialises a dynamic
28
+ provider's whole captured closure into stack state, so the SDK is imported
29
+ lazily and nothing here captures a Pulumi Output. */
30
+
31
+ let log = ReventlessCore.Logger.fromEnv()
32
+
33
+ // ── The merge ────────────────────────────────────────────────────────────────
34
+
35
+ /** Keys `DescribeUserPool` returns that `UpdateUserPool` does not accept.
36
+
37
+ A **denylist**, deliberately, and the choice matters. An allowlist that
38
+ missed a field would omit it from the update and silently reset it — exactly
39
+ the failure this whole resource exists to prevent, and one that succeeds
40
+ quietly. A denylist that misses a read-only field instead makes AWS reject
41
+ the call, which is loud, immediate, and fixable. When the two error shapes
42
+ are "a customer's pool quietly loses a setting" and "the deploy fails with a
43
+ parameter error", the second is the one to design for.
44
+
45
+ `Name` is absent from this list because it is not dropped but *renamed* —
46
+ `DescribeUserPool` returns `Name`, `UpdateUserPool` takes `PoolName`. */
47
+ let readOnlyKeys = [
48
+ "Id",
49
+ "Name",
50
+ "Arn",
51
+ "Status",
52
+ "CreationDate",
53
+ "LastModifiedDate",
54
+ "SchemaAttributes",
55
+ "AliasAttributes",
56
+ "UsernameAttributes",
57
+ "UsernameConfiguration",
58
+ "EstimatedNumberOfUsers",
59
+ "Domain",
60
+ "CustomDomain",
61
+ "SmsConfigurationFailure",
62
+ "EmailConfigurationFailure",
63
+ ]
64
+
65
+ /** The pool's own configuration, sent back whole with `PreTokenGeneration` set
66
+ (or, when `preTokenGenerationArn` is `None`, removed).
67
+
68
+ Pure and total so the property that matters — a pool carrying non-default
69
+ settings still carries them afterwards — is checkable without a pool. */
70
+ let mergedUpdateInput = (
71
+ ~described: dict<JSON.t>,
72
+ ~userPoolId: string,
73
+ ~preTokenGenerationArn: option<string>,
74
+ ): dict<JSON.t> => {
75
+ let out = Dict.make()
76
+ described->Dict.forEachWithKey((value, key) =>
77
+ if !(readOnlyKeys->Array.includes(key)) {
78
+ out->Dict.set(key, value)
79
+ }
80
+ )
81
+ described->Dict.get("Name")->Option.forEach(name => out->Dict.set("PoolName", name))
82
+ out->Dict.set("UserPoolId", JSON.Encode.string(userPoolId))
83
+
84
+ // The pool's other triggers are carried through untouched. Replacing the whole
85
+ // `LambdaConfig` with a single-key object would silently detach every trigger
86
+ // the customer already had — the reset-by-omission hazard one level down.
87
+ let lambdaConfig =
88
+ described
89
+ ->Dict.get("LambdaConfig")
90
+ ->Option.flatMap(JSON.Decode.object)
91
+ ->Option.mapOr(Dict.make(), existing => Dict.fromArray(existing->Dict.toArray))
92
+ switch preTokenGenerationArn {
93
+ | Some(arn) => lambdaConfig->Dict.set("PreTokenGeneration", JSON.Encode.string(arn))
94
+ | None => lambdaConfig->Dict.delete("PreTokenGeneration")
95
+ }
96
+ out->Dict.set("LambdaConfig", lambdaConfig->JSON.Encode.object)
97
+ out
98
+ }
99
+
100
+ /** The trigger currently attached to a described pool, if any. */
101
+ let attachedTrigger = (~described: dict<JSON.t>): option<string> =>
102
+ described
103
+ ->Dict.get("LambdaConfig")
104
+ ->Option.flatMap(JSON.Decode.object)
105
+ ->Option.flatMap(c => c->Dict.get("PreTokenGeneration"))
106
+ ->Option.flatMap(JSON.Decode.string)
107
+
108
+ // ── AWS SDK bindings (lazily imported — see AppSync_SourceApiAssociation_Retrying) ──
109
+
110
+ type cognitoClient
111
+
112
+ type describeInput = {@as("UserPoolId") userPoolId: string}
113
+ type describeResult = {@as("UserPool") userPool?: JSON.t}
114
+
115
+ type describeCmd
116
+ type updateCmd
117
+
118
+ type sdkModule = {
119
+ @as("CognitoIdentityProviderClient") clientCtor: unit => cognitoClient,
120
+ @as("DescribeUserPoolCommand") describeCtor: describeInput => describeCmd,
121
+ @as("UpdateUserPoolCommand") updateCtor: dict<JSON.t> => updateCmd,
122
+ }
123
+
124
+ // Calls a constructor with `new` — the SDK exports plain classes.
125
+ //
126
+ // `%raw` here rather than a companion `.mjs` bound through `@module`, matching
127
+ // [AppSync_SourceApiAssociation_Retrying.res]: Pulumi serialises this provider's
128
+ // whole closure into stack state, and a helper reached through a module import
129
+ // is a dependency that serialisation cannot carry. Inline source can be.
130
+ let newOf1: ('ctor, 'arg) => 'r = %raw(`(C, x) => new C(x)`)
131
+ let newOf0: 'ctor => 'r = %raw(`(C) => new C()`)
132
+
133
+ @send external sendDescribe: (cognitoClient, describeCmd) => promise<describeResult> = "send"
134
+ @send external sendUpdate: (cognitoClient, updateCmd) => promise<unit> = "send"
135
+
136
+ // Emitted as a literal `import("...")` so the Pulumi serialiser does not
137
+ // statically capture the module in the provider closure.
138
+ @val external dynImport: string => promise<sdkModule> = "import"
139
+
140
+ let _sdk: ref<option<sdkModule>> = ref(None)
141
+ let _client: ref<option<cognitoClient>> = ref(None)
142
+
143
+ let getSdk = async (): sdkModule =>
144
+ switch _sdk.contents {
145
+ | Some(m) => m
146
+ | None =>
147
+ let m = await dynImport("@aws-sdk/client-cognito-identity-provider")
148
+ _sdk.contents = Some(m)
149
+ m
150
+ }
151
+
152
+ let getClient = async (): cognitoClient =>
153
+ switch _client.contents {
154
+ | Some(c) => c
155
+ | None =>
156
+ let sdk = await getSdk()
157
+ let c = newOf0(sdk.clientCtor)
158
+ _client.contents = Some(c)
159
+ c
160
+ }
161
+
162
+ @get @return(nullable) external exnName: JsExn.t => option<string> = "name"
163
+
164
+ let isPoolGoneError = (jsErr: JsExn.t): bool =>
165
+ switch (jsErr->exnName, JsExn.message(jsErr)) {
166
+ | (Some("ResourceNotFoundException"), _) => true
167
+ | (_, Some(msg)) => msg->String.includes("ResourceNotFoundException")
168
+ | _ => false
169
+ }
170
+
171
+ // ── Describe → merge → update ────────────────────────────────────────────────
172
+
173
+ let describePool = async (~userPoolId: string): option<dict<JSON.t>> => {
174
+ let sdk = await getSdk()
175
+ let client = await getClient()
176
+ let result = await client->sendDescribe(newOf1(sdk.describeCtor, {userPoolId: userPoolId}))
177
+ result.userPool->Option.flatMap(JSON.Decode.object)
178
+ }
179
+
180
+ /** Set (or clear) the pool's pre-token-generation trigger, preserving every
181
+ other setting the pool carries. */
182
+ let applyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: option<string>): unit => {
183
+ let sdk = await getSdk()
184
+ let client = await getClient()
185
+ switch await describePool(~userPoolId) {
186
+ | None =>
187
+ // Describe succeeded but returned no pool body. Sending an update built from
188
+ // nothing is precisely the reset this resource exists to avoid.
189
+ JsError.throwWithMessage(
190
+ `DescribeUserPool returned no pool for "${userPoolId}"; refusing to send an UpdateUserPool that would reset it`,
191
+ )
192
+ | Some(described) =>
193
+ let input = mergedUpdateInput(~described, ~userPoolId, ~preTokenGenerationArn)
194
+ await client->sendUpdate(newOf1(sdk.updateCtor, input))
195
+ }
196
+ }
197
+
198
+ // ── Dynamic provider ─────────────────────────────────────────────────────────
199
+
200
+ type providerInputs = {
201
+ userPoolId: string,
202
+ preTokenGenerationArn: string,
203
+ }
204
+
205
+ type createOuts = {
206
+ userPoolId: string,
207
+ preTokenGenerationArn: string,
208
+ }
209
+
210
+ type createResult = {id: string, outs: createOuts}
211
+ type updateResult = {outs: createOuts}
212
+ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace: bool}
213
+ type readResult = {id?: string, props?: createOuts}
214
+
215
+ let create = async (inputs: providerInputs): createResult => {
216
+ await applyTrigger(
217
+ ~userPoolId=inputs.userPoolId,
218
+ ~preTokenGenerationArn=Some(inputs.preTokenGenerationArn),
219
+ )
220
+ {
221
+ id: inputs.userPoolId,
222
+ outs: {userPoolId: inputs.userPoolId, preTokenGenerationArn: inputs.preTokenGenerationArn},
223
+ }
224
+ }
225
+
226
+ let update = async (_id: string, _olds: createOuts, news: providerInputs): updateResult => {
227
+ await applyTrigger(
228
+ ~userPoolId=news.userPoolId,
229
+ ~preTokenGenerationArn=Some(news.preTokenGenerationArn),
230
+ )
231
+ {outs: {userPoolId: news.userPoolId, preTokenGenerationArn: news.preTokenGenerationArn}}
232
+ }
233
+
234
+ /** Detaching on destroy is not optional. The Lambda is torn down with the rest
235
+ of the stack; a pool left pointing at a deleted function fails **every**
236
+ sign-in, and it is a pool the framework does not own — so nothing else in
237
+ this deployment would ever put it right. A pool that has already gone is
238
+ nothing to detach from. */
239
+ let delete_ = async (_id: string, props: createOuts): unit =>
240
+ try {
241
+ await applyTrigger(~userPoolId=props.userPoolId, ~preTokenGenerationArn=None)
242
+ } catch {
243
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) =>
244
+ log.info(
245
+ ~comp="Auth_ActiveRolePoolAttachment",
246
+ `user pool ${props.userPoolId} is gone; nothing to detach`,
247
+ )
248
+ }
249
+
250
+ /** A different pool is a different attachment: the old pool must be detached
251
+ before the new one is attached, so this replaces rather than updates in
252
+ place. Changing only the function ARN is an ordinary update. */
253
+ let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
254
+ let poolChanged = olds.userPoolId != news.userPoolId
255
+ {
256
+ changes: poolChanged || olds.preTokenGenerationArn != news.preTokenGenerationArn,
257
+ replaces: poolChanged ? ["userPoolId"] : [],
258
+ deleteBeforeReplace: true,
259
+ }
260
+ }
261
+
262
+ /** Live state for `pulumi refresh`: report what the pool actually carries, so a
263
+ trigger detached out of band shows as drift and the next `up` re-attaches
264
+ it. A missing pool drops the resource from state. */
265
+ let read_ = async (id: string, props: createOuts): readResult =>
266
+ try {
267
+ switch await describePool(~userPoolId=props.userPoolId) {
268
+ | None => ({}: readResult)
269
+ | Some(described) => {
270
+ id,
271
+ props: {
272
+ userPoolId: props.userPoolId,
273
+ preTokenGenerationArn: attachedTrigger(~described)->Option.getOr(""),
274
+ },
275
+ }
276
+ }
277
+ } catch {
278
+ | exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) => ({}: readResult)
279
+ }
280
+
281
+ // Provider as a plain JS object (no Pulumi Output captures — all state flows
282
+ // through inputs / olds / news).
283
+ let provider = {
284
+ "create": create,
285
+ "update": update,
286
+ "delete": delete_,
287
+ "diff": diff_,
288
+ "read": read_,
289
+ }
290
+
291
+ // ── Pulumi dynamic resource binding ──────────────────────────────────────────
292
+
293
+ type t = {id: Pulumi.Output.t<string>}
294
+
295
+ type constructorProps = {
296
+ userPoolId: Pulumi.Input.t<string>,
297
+ preTokenGenerationArn: Pulumi.Input.t<string>,
298
+ }
299
+
300
+ // Explicit /index.js path because @pulumi/pulumi/dynamic is a directory import
301
+ // not resolvable in ESM mode.
302
+ @module("@pulumi/pulumi/dynamic/index.js") @new
303
+ external _newResource: ('provider, string, 'props, Pulumi.CustomResourceOptions.t) => t = "Resource"
304
+
305
+ let make = (
306
+ ~name: string="ActiveRolePoolAttachment",
307
+ ~props: constructorProps,
308
+ ~opts: Pulumi.CustomResourceOptions.t,
309
+ ): t => _newResource(provider, name, props, opts)