@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
@@ -216,7 +216,18 @@ let resolveStructure = (
216
216
  ->Option.flatMap(JSON.Decode.string) {
217
217
  | None => Promise.resolve(item)
218
218
  | Some(key) =>
219
- fetch(key)->Promise.then(bytes => {
219
+ fetch(key)
220
+ // The bucket answers for the whole platform, so a failure here says which
221
+ // plugin's ref could not be read — otherwise the S3 error names the key
222
+ // and the bucket, and finding the row it came from is a table scan by hand.
223
+ ->Promise.catch(e => {
224
+ let plugin = item->str("name")->Option.getOr("<unnamed>")
225
+ JsError.throwWithMessage(
226
+ `offloaded structure for plugin ${plugin} is unreadable at ${key}: ` ++
227
+ e->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error"),
228
+ )
229
+ })
230
+ ->Promise.then(bytes => {
220
231
  let resolved = Dict.fromArray(item->Dict.toArray)
221
232
  resolved->Dict.set("structure", JSON.parseOrThrow(bytes))
222
233
  Promise.resolve(resolved)
@@ -337,6 +348,58 @@ let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
337
348
  }
338
349
  )
339
350
 
351
+ // ── Registration freshness ───────────────────────────────────────────────────
352
+ // The read model this bake scans is updated asynchronously: the plugin stack
353
+ // publishes a re-detect, the plugin answers with its definition, the projection
354
+ // lands. Invoked seconds after the last stack finished, the scan can still
355
+ // describe the deploy before it — and a manifest baked from that is wrong in the
356
+ // one way nothing downstream can detect, because it is a perfectly well-formed
357
+ // description of the wrong deployment.
358
+ //
359
+ // So the invocation carries the structure key each plugin stack just wrote. That
360
+ // is an equality check rather than an inference from timestamps, and it costs
361
+ // nothing for a plugin that was not redeployed: its key already matches. A caller
362
+ // that supplies no expectations bakes whatever is current — the query paths never
363
+ // send any, and a hand-run bake should not need to.
364
+ let bakeExpectations = (event: JSON.t): dict<string> =>
365
+ event
366
+ ->JSON.Decode.object
367
+ ->Option.flatMap(o => o->Dict.get("expect"))
368
+ ->Option.flatMap(JSON.Decode.object)
369
+ ->Option.mapOr(Dict.make(), o =>
370
+ o
371
+ ->Dict.toArray
372
+ ->Array.filterMap(((plugin, key)) => key->JSON.Decode.string->Option.map(k => (plugin, k)))
373
+ ->Dict.fromArray
374
+ )
375
+
376
+ // The offload key a scanned row carries. None for a structure held inline, which
377
+ // on a deployed platform means the row predates offloading — it cannot match an
378
+ // expectation, and saying so beats baking it.
379
+ let structureRefKey = (item: dict<JSON.t>): option<string> =>
380
+ item
381
+ ->Dict.get("structure")
382
+ ->Option.flatMap(JSON.Decode.object)
383
+ ->Option.flatMap(o => o->Dict.get(Reventless.Offload.sentinelKey))
384
+ ->Option.flatMap(JSON.Decode.object)
385
+ ->Option.flatMap(r => r->Dict.get("key"))
386
+ ->Option.flatMap(JSON.Decode.string)
387
+
388
+ // Compared against the collapsed latest version per plugin, the same view the
389
+ // bake itself takes — an older version's row lingering on the table is not the
390
+ // registration anyone is waiting for.
391
+ let pendingRegistrations = (items: array<dict<JSON.t>>, ~expect: dict<string>): array<string> => {
392
+ let current =
393
+ Platform_AdminScan_Ops.latestByName(
394
+ items,
395
+ ~nameVersionOf=item => item->str("name"),
396
+ ~toEntry=(item, ~name) => item->structureRefKey->Option.map(key => (name, key)),
397
+ )->Dict.fromArray
398
+ expect
399
+ ->Dict.toArray
400
+ ->Array.filterMap(((plugin, key)) => current->Dict.get(plugin) == Some(key) ? None : Some(plugin))
401
+ }
402
+
340
403
  // Every failure mode here is the deployment's own mistake — a name matching no
341
404
  // component, a structure too old to read, a bucket the function may not write —
342
405
  // and every one of them produces the same symptom if swallowed: a shop that
@@ -404,22 +467,36 @@ let handler = async (event: JSON.t): array<JSON.t> => {
404
467
  let fetch = Reventless.Offload.cachedFetch(key =>
405
468
  AwsSdk.S3.GetObjectCommand.getString(~bucket, ~key)
406
469
  )
407
- let items = await Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
470
+ let resolveAll = () => Promise.all(rawItems->Array.map(item => resolveStructure(fetch, item)))
408
471
  switch bakeTarget {
409
472
  | Some(target) =>
410
- // The built-in admin entry is deliberately absent: it never enters the
411
- // Plugin read model, and the in-memory bake curates the composed plugins
412
- // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
413
- let structures = Platform_AdminScan_Ops.latestByName(
414
- items,
415
- ~nameVersionOf=item => item->str("name"),
416
- ~toEntry=structureOf,
417
- )
418
- await runBake(~target, ~structures)
473
+ // Checked on the raw rows: the refs are what the deploy can predict, and a
474
+ // row that is behind should not have its structure fetched at all.
475
+ switch pendingRegistrations(rawItems, ~expect=bakeExpectations(event)) {
476
+ | [] =>
477
+ // The built-in admin entry is deliberately absent: it never enters the
478
+ // Plugin read model, and the in-memory bake curates the composed plugins
479
+ // only. A deployment naming it gets `UnknownPlugin`, on both platforms.
480
+ let structures = Platform_AdminScan_Ops.latestByName(
481
+ await resolveAll(),
482
+ ~nameVersionOf=item => item->str("name"),
483
+ ~toEntry=structureOf,
484
+ )
485
+ await runBake(~target, ~structures)
486
+ | pending =>
487
+ // Not an error — the deploy just has not finished arriving. Reported so the
488
+ // caller can invoke again rather than bake the previous deployment.
489
+ [
490
+ Dict.fromArray([
491
+ ("baked", JSON.Encode.bool(false)),
492
+ ("pending", pending->Array.map(JSON.Encode.string)->JSON.Encode.array),
493
+ ])->JSON.Encode.object,
494
+ ]
495
+ }
419
496
  | None =>
420
497
  let userEntries =
421
498
  Platform_AdminScan_Ops.latestByName(
422
- items,
499
+ await resolveAll(),
423
500
  ~nameVersionOf=item => item->str("name"),
424
501
  ~toEntry,
425
502
  )
@@ -6,8 +6,11 @@ import * as S3$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/S3.res.mjs";
6
6
  import * as Nodepath from "node:path";
7
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
8
8
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
9
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
10
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
11
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
12
+ import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
13
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
11
14
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
12
15
  import * as ClientS3 from "@aws-sdk/client-s3";
13
16
  import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
@@ -216,7 +219,10 @@ function resolveStructure(fetch, item) {
216
219
  }
217
220
  let key = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(refJson), r => r["key"]), Stdlib_JSON.Decode.string);
218
221
  if (key !== undefined) {
219
- return fetch(key).then(bytes => {
222
+ return Stdlib_Promise.$$catch(fetch(key), e => {
223
+ let plugin = Stdlib_Option.getOr(str(item, "name"), "<unnamed>");
224
+ return Stdlib_JsError.throwWithMessage(`offloaded structure for plugin ` + plugin + ` is unreadable at ` + key + `: ` + Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(e), Stdlib_JsExn.message), "unknown error"));
225
+ }).then(bytes => {
220
226
  let resolved = Object.fromEntries(Object.entries(item));
221
227
  resolved["structure"] = JSON.parse(bytes);
222
228
  return Promise.resolve(resolved);
@@ -295,6 +301,35 @@ function bakeTargetOf(event) {
295
301
  });
296
302
  }
297
303
 
304
+ function bakeExpectations(event) {
305
+ return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(event), o => o["expect"]), Stdlib_JSON.Decode.object), {}, o => Object.fromEntries(Stdlib_Array.filterMap(Object.entries(o), param => {
306
+ let plugin = param[0];
307
+ return Stdlib_Option.map(Stdlib_JSON.Decode.string(param[1]), k => [
308
+ plugin,
309
+ k
310
+ ]);
311
+ })));
312
+ }
313
+
314
+ function structureRefKey(item) {
315
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), o => o[Offload$Reventless.sentinelKey]), Stdlib_JSON.Decode.object), r => r["key"]), Stdlib_JSON.Decode.string);
316
+ }
317
+
318
+ function pendingRegistrations(items, expect) {
319
+ let current = Object.fromEntries(Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), (item, name) => Stdlib_Option.map(structureRefKey(item), key => [
320
+ name,
321
+ key
322
+ ])));
323
+ return Stdlib_Array.filterMap(Object.entries(expect), param => {
324
+ let plugin = param[0];
325
+ if (Primitive_object.equal(current[plugin], param[1])) {
326
+ return;
327
+ } else {
328
+ return plugin;
329
+ }
330
+ });
331
+ }
332
+
298
333
  async function runBake(target, structures) {
299
334
  let selections = bakeSelections();
300
335
  if (selections.length === 0) {
@@ -351,12 +386,25 @@ async function handler(event) {
351
386
  ]]));
352
387
  let bucket = Stdlib_Option.getOr(process.env["OFFLOAD_BUCKET"], "");
353
388
  let fetch = Offload$Reventless.cachedFetch(key => S3$AwsSdk.GetObjectCommand.getString(bucket, key));
354
- let items = await Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
389
+ let resolveAll = () => Promise.all(rawItems.map(item => resolveStructure(fetch, item)));
355
390
  if (bakeTarget !== undefined) {
356
- let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), structureOf);
391
+ let pending = pendingRegistrations(rawItems, bakeExpectations(event));
392
+ if (pending.length !== 0) {
393
+ return [Object.fromEntries([
394
+ [
395
+ "baked",
396
+ false
397
+ ],
398
+ [
399
+ "pending",
400
+ pending.map(prim => prim)
401
+ ]
402
+ ])];
403
+ }
404
+ let structures = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), structureOf);
357
405
  return await runBake(bakeTarget, structures);
358
406
  }
359
- let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(items, item => str(item, "name"), toEntry);
407
+ let userEntries = Platform_AdminScan_Ops$ReventlessAws.latestByName(await resolveAll(), item => str(item, "name"), toEntry);
360
408
  return admin.concat(userEntries);
361
409
  }
362
410
  console.error("Platform_ComponentDefinitions: PLUGIN_RM_TABLE env var not set");
@@ -386,6 +434,9 @@ export {
386
434
  bakeSelection,
387
435
  bakeSelections,
388
436
  bakeTargetOf,
437
+ bakeExpectations,
438
+ structureRefKey,
439
+ pendingRegistrations,
389
440
  runBake,
390
441
  handler,
391
442
  }
@@ -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)