@reventlessdev/reventless-aws 3.0.0-alpha.319 → 3.0.0-alpha.320

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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +9 -5
  3. package/rescript.json +4 -0
  4. package/run-provision-identity.mjs +3 -0
  5. package/scripts/ProvisionIdentity.res +303 -0
  6. package/scripts/ProvisionIdentity.res.mjs +376 -0
  7. package/src/Platform.res +54 -30
  8. package/src/Platform.res.mjs +34 -32
  9. package/src/Platform_Stack.res +102 -19
  10. package/src/Platform_Stack.res.mjs +43 -7
  11. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res +267 -32
  12. package/src/adapter/Auth/Auth_ActiveRolePoolAttachment.res.mjs +124 -14
  13. package/src/adapter/Auth/Auth_ActiveRoleStore.res +93 -5
  14. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +53 -4
  15. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res +50 -4
  16. package/src/adapter/Auth/Auth_ActiveRoleStore_Ops.res.mjs +24 -3
  17. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res +92 -0
  18. package/src/adapter/Auth/Auth_ActiveRoleStore_Schema.res.mjs +49 -0
  19. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +21 -7
  20. package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +15 -7
  21. package/src/util/Util_AwsError.res +56 -0
  22. package/src/util/Util_AwsError.res.mjs +59 -0
  23. package/src/util/Util_ShellConfig.res +28 -0
  24. package/src/util/Util_ShellConfig.res.mjs +22 -0
  25. package/tests/Auth_ActiveRolePoolAttachmentTest.res +175 -0
  26. package/tests/Auth_ActiveRolePoolAttachmentTest.res.mjs +136 -0
  27. package/tests/Auth_ActiveRoleStoreTest.res +65 -0
  28. package/tests/Auth_ActiveRoleStoreTest.res.mjs +48 -0
  29. package/tests/Auth_ActiveRoleStore_SchemaTest.res +98 -0
  30. package/tests/Auth_ActiveRoleStore_SchemaTest.res.mjs +97 -0
  31. package/tests/AwsErrorFixtures.mjs +16 -0
  32. package/tests/ProvisionIdentityTest.res +109 -0
  33. package/tests/ProvisionIdentityTest.res.mjs +132 -0
  34. package/tests/Util_AwsErrorTest.res +127 -0
  35. package/tests/Util_AwsErrorTest.res.mjs +76 -0
  36. package/tests/Util_ShellConfigTest.res +51 -0
  37. package/tests/Util_ShellConfigTest.res.mjs +42 -0
@@ -16,6 +16,16 @@
16
16
  never a hand-run command against a live pool, which would leave the
17
17
  deployment's behaviour depending on an act no source describes.
18
18
 
19
+ 🚨 **The slot is the pool's, and this resource takes it only when it is
20
+ free or already ours.** Cognito allows a pool exactly one
21
+ `PreTokenGeneration` trigger, so an unconditional attach is last-writer-wins:
22
+ it silently replaces a BYO customer's own claims-enrichment trigger, and it
23
+ lets two platform stacks sharing a pool each read a store the other never
24
+ writes — every role switch reporting success and doing nothing. So the
25
+ describe this already performs is also read for *what is attached*, and a
26
+ slot held by anything else fails the deploy naming it. See [classifySlot] and
27
+ [docs/plans/active-role-store-scoped-to-the-pool.md].
28
+
19
29
  🚨 **`UpdateUserPool` resets by omission.** The API requires "a value for all
20
30
  parameters that you don't want set to a default value". An attach that sends
21
31
  only `LambdaConfig` silently returns every other setting on that pool to its
@@ -135,10 +145,33 @@ let mergedUpdateInput = (
135
145
  out
136
146
  }
137
147
 
138
- /** The trigger currently attached to a described pool, if any.
148
+ /** Whatever function currently holds the pool's pre-token-generation slot,
149
+ regardless of the contract version it is held at.
150
+
151
+ Prefers `PreTokenGenerationConfig`, since that is the field AWS populates.
152
+
153
+ Version-blind on purpose, and that is what separates it from
154
+ [attachedTrigger]: this answers "is the slot occupied, and by whom" — the
155
+ question the takeover check asks. A foreign trigger pinned at `V2_0` is very
156
+ much attached, and a reader that reported it as absent would have this
157
+ resource quietly replace exactly the trigger it is meant to refuse. */
158
+ let attachedTriggerArn = (~described: dict<JSON.t>): option<string> => {
159
+ let lambdaConfig = described->Dict.get("LambdaConfig")->Option.flatMap(JSON.Decode.object)
160
+ switch lambdaConfig
161
+ ->Option.flatMap(c => c->Dict.get("PreTokenGenerationConfig"))
162
+ ->Option.flatMap(JSON.Decode.object)
163
+ ->Option.flatMap(config => config->Dict.get("LambdaArn"))
164
+ ->Option.flatMap(JSON.Decode.string) {
165
+ | Some(_) as arn => arn
166
+ | None =>
167
+ lambdaConfig
168
+ ->Option.flatMap(c => c->Dict.get("PreTokenGeneration"))
169
+ ->Option.flatMap(JSON.Decode.string)
170
+ }
171
+ }
139
172
 
140
- Prefers `PreTokenGenerationConfig`, since that is the field AWS populates and
141
- the one that carries the version.
173
+ /** The trigger currently attached to a described pool **at a version this
174
+ handler implements**, if any.
142
175
 
143
176
  A pool whose `LambdaVersion` names a contract [triggerVersion] does not
144
177
  implement reports `None` — *not attached* — even when it points at our own
@@ -163,6 +196,83 @@ let attachedTrigger = (~described: dict<JSON.t>): option<string> => {
163
196
  }
164
197
  }
165
198
 
199
+ // ── Whose slot is it ─────────────────────────────────────────────────────────
200
+
201
+ /** What this deployment found in the pool's one pre-token-generation slot. */
202
+ type slot =
203
+ /** Nothing attached. */
204
+ | Vacant
205
+ /** This deployment's own trigger. */
206
+ | Ours
207
+ /** Another deployment's active-role trigger, reading the **same** store.
208
+
209
+ Not a conflict. Two platform stacks sharing a pool run the same code over the
210
+ same rows, so whichever holds the slot serves both — which is the whole point
211
+ of naming one store with `platform:activeRoleStore`. */
212
+ | SharedWith(string)
213
+ /** Another deployment's active-role trigger, reading a **different** store.
214
+
215
+ The defect itself, caught at the only moment anything can see both halves:
216
+ this stack's write door would write rows that trigger never reads. */
217
+ | DifferentStore({arn: string, theirStore: string})
218
+ /** Something that is not one of ours at all. */
219
+ | Foreign(string)
220
+
221
+ /**
222
+ Which of the five a described pool presents.
223
+
224
+ Pure and total, like [mergedUpdateInput] and [probeVerdict], so every branch is
225
+ checkable without a pool. `attachedStore` is the `ACTIVE_ROLE_TABLE` the attached
226
+ function is configured with — `None` when it carries none, or when it could not
227
+ be read at all.
228
+
229
+ Reading the *store* rather than matching a function name is what makes this a
230
+ check on the invariant rather than on a convention: two triggers agree exactly
231
+ when the rows they read are the rows the other's resolver writes, and that is a
232
+ fact about their configuration, not about what they are called.
233
+ */
234
+ let classifySlot = (
235
+ ~attachedArn: option<string>,
236
+ ~ourArn: string,
237
+ ~ourStore: string,
238
+ ~attachedStore: option<string>,
239
+ ): slot =>
240
+ switch attachedArn {
241
+ | None | Some("") => Vacant
242
+ | Some(arn) if arn == ourArn => Ours
243
+ | Some(arn) =>
244
+ switch attachedStore {
245
+ | Some(store) if store == ourStore => SharedWith(arn)
246
+ | Some(store) => DifferentStore({arn, theirStore: store})
247
+ | None => Foreign(arn)
248
+ }
249
+ }
250
+
251
+ /**
252
+ The sentence to fail the deploy with, or `None` to proceed.
253
+
254
+ 🚨 **The same trade [readOnlyKeys] makes.** Today this resource replaces whatever
255
+ it finds, so a BYO customer's own claims-enrichment trigger is silently detached
256
+ by a deploy — on a pool the framework went to considerable lengths not to
257
+ otherwise disturb. Given "a customer's pool quietly loses a trigger" and "the
258
+ deploy fails naming what is in the way", the second is the one to design for.
259
+
260
+ Both refusals name what is attached, because the operator cannot act on either
261
+ without knowing which function holds the slot.
262
+ */
263
+ let refusalFor = (~slot: slot, ~userPoolId: string, ~ourStore: string): option<string> =>
264
+ switch slot {
265
+ | Vacant | Ours | SharedWith(_) => None
266
+ | DifferentStore({arn, theirStore}) =>
267
+ Some(
268
+ `user pool ${userPoolId} already carries the active-role trigger ${arn}, which reads "${theirStore}" — but this deployment's Platform_SetActiveRole writes "${ourStore}". A pool holds one pre-token-generation trigger, so attaching would leave one of the two stacks writing rows nothing reads and every role switch silently doing nothing. Point both stacks at one store with platform:activeRoleStore, or give them separate user pools.`,
269
+ )
270
+ | Foreign(arn) =>
271
+ Some(
272
+ `user pool ${userPoolId} already carries the pre-token-generation trigger ${arn}, which is not an active-role trigger of this framework — attaching would silently replace it, and Cognito allows a pool only one. Detach it deliberately if it is obsolete, or give this deployment its own user pool. (If it *is* a Reventless trigger, this deployment could not read its configuration: the deploying principal needs lambda:GetFunctionConfiguration on it.)`,
273
+ )
274
+ }
275
+
166
276
  // ── AWS SDK bindings (lazily imported — see AppSync_SourceApiAssociation_Retrying) ──
167
277
 
168
278
  type cognitoClient
@@ -222,9 +332,12 @@ let getClient = async (): cognitoClient =>
222
332
  type lambdaClient
223
333
  type invokeCmd
224
334
 
335
+ type configCmd
336
+
225
337
  type lambdaSdkModule = {
226
338
  @as("LambdaClient") clientCtor: unit => lambdaClient,
227
339
  @as("InvokeCommand") invokeCtor: dict<JSON.t> => invokeCmd,
340
+ @as("GetFunctionConfigurationCommand") configCtor: dict<JSON.t> => configCmd,
228
341
  }
229
342
 
230
343
  type invokeResult = {
@@ -232,7 +345,10 @@ type invokeResult = {
232
345
  @as("Payload") payload?: JSON.t,
233
346
  }
234
347
 
348
+ type configResult = {@as("Environment") environment?: JSON.t}
349
+
235
350
  @send external sendInvoke: (lambdaClient, invokeCmd) => promise<invokeResult> = "send"
351
+ @send external sendGetConfig: (lambdaClient, configCmd) => promise<configResult> = "send"
236
352
  @val external dynImportLambda: string => promise<lambdaSdkModule> = "import"
237
353
 
238
354
  let _lambdaSdk: ref<option<lambdaSdkModule>> = ref(None)
@@ -257,6 +373,33 @@ let getLambdaClient = async (): lambdaClient =>
257
373
  c
258
374
  }
259
375
 
376
+ /** The store an already-attached function reads, from its own configuration.
377
+
378
+ `None` covers three cases on purpose — no `Environment`, no
379
+ `ACTIVE_ROLE_TABLE` in it, and the call failing outright (a deleted function,
380
+ a cross-account ARN, a deploying principal without
381
+ `lambda:GetFunctionConfiguration`). All three mean the same thing here: this
382
+ deployment cannot establish that the attached trigger reads what its own
383
+ resolver writes, and [refusalFor]'s `Foreign` sentence names every one of
384
+ them. Failing closed is the point — the alternative is taking the slot on the
385
+ strength of a lookup that did not happen. */
386
+ let activeRoleStoreOf = async (~functionArn: string): option<string> =>
387
+ try {
388
+ let sdk = await getLambdaSdk()
389
+ let client = await getLambdaClient()
390
+ let input = Dict.make()
391
+ input->Dict.set("FunctionName", JSON.Encode.string(functionArn))
392
+ let result = await client->sendGetConfig(newOf1(sdk.configCtor, input))
393
+ result.environment
394
+ ->Option.flatMap(JSON.Decode.object)
395
+ ->Option.flatMap(env => env->Dict.get("Variables"))
396
+ ->Option.flatMap(JSON.Decode.object)
397
+ ->Option.flatMap(vars => vars->Dict.get("ACTIVE_ROLE_TABLE"))
398
+ ->Option.flatMap(JSON.Decode.string)
399
+ } catch {
400
+ | _ => None
401
+ }
402
+
260
403
  /** Decodes the SDK's `Payload`, which arrives as a byte array rather than a
261
404
  string. */
262
405
  let decodePayload: JSON.t => string = %raw(`(p) => {
@@ -282,6 +425,11 @@ let isPoolGoneError = (jsErr: JsExn.t): bool =>
282
425
  is here to check. */
283
426
  let probeSubject = "reventless-attachment-probe"
284
427
 
428
+ /** The app client the probe presents, and like [probeSubject] it must match no
429
+ real one — together they form the row key, and a collision would exercise a
430
+ stored preference rather than the empty path this is checking. */
431
+ let probeClientId = "reventless-attachment-probe-client"
432
+
285
433
  /** A minimal `V1_0` pre-token-generation event.
286
434
 
287
435
  Empty membership on purpose — with no groups and no stored role there is
@@ -300,11 +448,19 @@ let probeEvent = (~userPoolId: string): JSON.t => {
300
448
  request->Dict.set("userAttributes", JSON.Encode.object(userAttributes))
301
449
  request->Dict.set("groupConfiguration", JSON.Encode.object(groupConfiguration))
302
450
 
451
+ // Carried because Cognito carries it, and the handler keys its row on it. A
452
+ // probe without one takes the "no client id, nothing to look up" branch and
453
+ // would report a function healthy without ever reaching the store — proving
454
+ // less than it appears to.
455
+ let callerContext = Dict.make()
456
+ callerContext->Dict.set("clientId", JSON.Encode.string(probeClientId))
457
+
303
458
  let event = Dict.make()
304
459
  event->Dict.set("version", JSON.Encode.string("1"))
305
460
  event->Dict.set("triggerSource", JSON.Encode.string("TokenGeneration_Authentication"))
306
461
  event->Dict.set("userPoolId", JSON.Encode.string(userPoolId))
307
462
  event->Dict.set("userName", JSON.Encode.string(probeSubject))
463
+ event->Dict.set("callerContext", JSON.Encode.object(callerContext))
308
464
  event->Dict.set("request", JSON.Encode.object(request))
309
465
  event->Dict.set("response", JSON.Encode.object(Dict.make()))
310
466
  JSON.Encode.object(event)
@@ -406,23 +562,72 @@ let describePool = async (~userPoolId: string): option<dict<JSON.t>> => {
406
562
  result.userPool->Option.flatMap(JSON.Decode.object)
407
563
  }
408
564
 
409
- /** Set (or clear) the pool's pre-token-generation trigger, preserving every
410
- other setting the pool carries. */
411
- let applyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: option<string>): unit => {
565
+ /** Send the merge for an already-described pool. */
566
+ let sendMerged = async (
567
+ ~described: dict<JSON.t>,
568
+ ~userPoolId: string,
569
+ ~preTokenGenerationArn: option<string>,
570
+ ): unit => {
412
571
  let sdk = await getSdk()
413
572
  let client = await getClient()
573
+ let input = mergedUpdateInput(~described, ~userPoolId, ~preTokenGenerationArn)
574
+ await client->sendUpdate(newOf1(sdk.updateCtor, input))
575
+ }
576
+
577
+ /** Describe succeeded but returned no pool body. Sending an update built from
578
+ nothing is precisely the reset this resource exists to avoid. */
579
+ let throwOnEmptyDescribe = (~userPoolId: string) =>
580
+ JsError.throwWithMessage(
581
+ `DescribeUserPool returned no pool for "${userPoolId}"; refusing to send an UpdateUserPool that would reset it`,
582
+ )
583
+
584
+ /**
585
+ Attach this deployment's trigger — after establishing that the slot is this
586
+ deployment's to take.
587
+
588
+ The order is the whole design. Describe once, then in turn: refuse a slot held by
589
+ a trigger that is not ours or reads a different store; prove our own function can
590
+ run; only then write. Every refusal happens before any `UpdateUserPool`, so a
591
+ pool that fails this is left exactly as it was found.
592
+ */
593
+ let attachTrigger = async (
594
+ ~userPoolId: string,
595
+ ~preTokenGenerationArn: string,
596
+ ~activeRoleStore: string,
597
+ ): unit =>
414
598
  switch await describePool(~userPoolId) {
415
- | None =>
416
- // Describe succeeded but returned no pool body. Sending an update built from
417
- // nothing is precisely the reset this resource exists to avoid.
418
- JsError.throwWithMessage(
419
- `DescribeUserPool returned no pool for "${userPoolId}"; refusing to send an UpdateUserPool that would reset it`,
420
- )
599
+ | None => throwOnEmptyDescribe(~userPoolId)
421
600
  | Some(described) =>
422
- let input = mergedUpdateInput(~described, ~userPoolId, ~preTokenGenerationArn)
423
- await client->sendUpdate(newOf1(sdk.updateCtor, input))
601
+ let attachedArn = attachedTriggerArn(~described)
602
+ // Only for a slot held by something other than us — our own function's store
603
+ // is the one we were handed, so asking AWS about it would prove nothing and
604
+ // cost a call on the common path.
605
+ let attachedStore = switch attachedArn {
606
+ | Some(arn) if arn != preTokenGenerationArn && arn != "" =>
607
+ await activeRoleStoreOf(~functionArn=arn)
608
+ | _ => None
609
+ }
610
+ let slot = classifySlot(
611
+ ~attachedArn,
612
+ ~ourArn=preTokenGenerationArn,
613
+ ~ourStore=activeRoleStore,
614
+ ~attachedStore,
615
+ )
616
+ switch refusalFor(~slot, ~userPoolId, ~ourStore=activeRoleStore) {
617
+ | Some(message) => JsError.throwWithMessage(message)
618
+ | None => ()
619
+ }
620
+ switch slot {
621
+ | SharedWith(arn) =>
622
+ log.info(
623
+ ~comp="Auth_ActiveRolePoolAttachment",
624
+ `user pool ${userPoolId} carries active-role trigger ${arn} from another deployment, reading the same store "${activeRoleStore}" — taking the slot serves both`,
625
+ )
626
+ | _ => ()
627
+ }
628
+ await verifyTrigger(~userPoolId, ~preTokenGenerationArn)
629
+ await sendMerged(~described, ~userPoolId, ~preTokenGenerationArn=Some(preTokenGenerationArn))
424
630
  }
425
- }
426
631
 
427
632
  // ── Dynamic provider ─────────────────────────────────────────────────────────
428
633
 
@@ -437,12 +642,19 @@ type providerInputs = {
437
642
  function once, on the deploy that created it, and never for any version
438
643
  shipped afterwards. A check that only ever runs once is not a check. */
439
644
  codeHash: string,
645
+ /** The store this deployment's trigger reads and its write door writes.
646
+
647
+ Carried so the takeover check can compare it against the store of whatever
648
+ already holds the pool's slot — the comparison that decides whether two
649
+ deployments sharing this pool agree or silently disagree. */
650
+ activeRoleStore: string,
440
651
  }
441
652
 
442
653
  type createOuts = {
443
654
  userPoolId: string,
444
655
  preTokenGenerationArn: string,
445
656
  codeHash: string,
657
+ activeRoleStore: string,
446
658
  }
447
659
 
448
660
  type createResult = {id: string, outs: createOuts}
@@ -451,13 +663,10 @@ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace:
451
663
  type readResult = {id?: string, props?: createOuts}
452
664
 
453
665
  let create = async (inputs: providerInputs): createResult => {
454
- await verifyTrigger(
666
+ await attachTrigger(
455
667
  ~userPoolId=inputs.userPoolId,
456
668
  ~preTokenGenerationArn=inputs.preTokenGenerationArn,
457
- )
458
- await applyTrigger(
459
- ~userPoolId=inputs.userPoolId,
460
- ~preTokenGenerationArn=Some(inputs.preTokenGenerationArn),
669
+ ~activeRoleStore=inputs.activeRoleStore,
461
670
  )
462
671
  {
463
672
  id: inputs.userPoolId,
@@ -465,27 +674,28 @@ let create = async (inputs: providerInputs): createResult => {
465
674
  userPoolId: inputs.userPoolId,
466
675
  preTokenGenerationArn: inputs.preTokenGenerationArn,
467
676
  codeHash: inputs.codeHash,
677
+ activeRoleStore: inputs.activeRoleStore,
468
678
  },
469
679
  }
470
680
  }
471
681
 
472
682
  let update = async (_id: string, _olds: createOuts, news: providerInputs): updateResult => {
473
- // Re-proved on every update, not only on create: the usual reason this runs is
474
- // that the function's code changed, which is exactly when it might have stopped
475
- // working. The pool keeps the trigger it already has until this succeeds.
476
- await verifyTrigger(
683
+ // Re-checked and re-proved on every update, not only on create: the usual
684
+ // reason this runs is that the function's code changed, which is exactly when
685
+ // it might have stopped working and a slot this deployment held once can
686
+ // have been taken since. The pool keeps the trigger it already has until this
687
+ // succeeds.
688
+ await attachTrigger(
477
689
  ~userPoolId=news.userPoolId,
478
690
  ~preTokenGenerationArn=news.preTokenGenerationArn,
479
- )
480
- await applyTrigger(
481
- ~userPoolId=news.userPoolId,
482
- ~preTokenGenerationArn=Some(news.preTokenGenerationArn),
691
+ ~activeRoleStore=news.activeRoleStore,
483
692
  )
484
693
  {
485
694
  outs: {
486
695
  userPoolId: news.userPoolId,
487
696
  preTokenGenerationArn: news.preTokenGenerationArn,
488
697
  codeHash: news.codeHash,
698
+ activeRoleStore: news.activeRoleStore,
489
699
  },
490
700
  }
491
701
  }
@@ -494,10 +704,29 @@ let update = async (_id: string, _olds: createOuts, news: providerInputs): updat
494
704
  of the stack; a pool left pointing at a deleted function fails **every**
495
705
  sign-in, and it is a pool the framework does not own — so nothing else in
496
706
  this deployment would ever put it right. A pool that has already gone is
497
- nothing to detach from. */
707
+ nothing to detach from.
708
+
709
+ 🚨 **Only when the slot still holds *our* function.** Where two stacks share
710
+ a pool the later one takes the slot, and a blind detach here would then tear
711
+ out a trigger this deployment does not own while destroying — reintroducing
712
+ the outage this exists to prevent, from the opposite direction. What is left
713
+ behind in that case is a healthy trigger reading the store both stacks share. */
498
714
  let delete_ = async (_id: string, props: createOuts): unit =>
499
715
  try {
500
- await applyTrigger(~userPoolId=props.userPoolId, ~preTokenGenerationArn=None)
716
+ switch await describePool(~userPoolId=props.userPoolId) {
717
+ | None => throwOnEmptyDescribe(~userPoolId=props.userPoolId)
718
+ | Some(described) =>
719
+ switch attachedTriggerArn(~described) {
720
+ | Some(arn) if arn == props.preTokenGenerationArn =>
721
+ await sendMerged(~described, ~userPoolId=props.userPoolId, ~preTokenGenerationArn=None)
722
+ | Some(arn) =>
723
+ log.info(
724
+ ~comp="Auth_ActiveRolePoolAttachment",
725
+ `user pool ${props.userPoolId} carries ${arn}, not this deployment's trigger; leaving it attached`,
726
+ )
727
+ | None => ()
728
+ }
729
+ }
501
730
  } catch {
502
731
  | exn if exn->JsExn.fromException->Option.mapOr(false, isPoolGoneError) =>
503
732
  log.info(
@@ -508,13 +737,15 @@ let delete_ = async (_id: string, props: createOuts): unit =>
508
737
 
509
738
  /** A different pool is a different attachment: the old pool must be detached
510
739
  before the new one is attached, so this replaces rather than updates in
511
- place. Changing only the function ARN is an ordinary update. */
740
+ place. Changing only the function ARN, its code, or the store it reads is an
741
+ ordinary update — each is a reason to run the takeover check again. */
512
742
  let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
513
743
  let poolChanged = olds.userPoolId != news.userPoolId
514
744
  {
515
745
  changes: poolChanged ||
516
746
  olds.preTokenGenerationArn != news.preTokenGenerationArn ||
517
- olds.codeHash != news.codeHash,
747
+ olds.codeHash != news.codeHash ||
748
+ olds.activeRoleStore != news.activeRoleStore,
518
749
  replaces: poolChanged ? ["userPoolId"] : [],
519
750
  deleteBeforeReplace: true,
520
751
  }
@@ -536,6 +767,9 @@ let read_ = async (id: string, props: createOuts): readResult =>
536
767
  // recorded hash is carried through unchanged. Refresh answers "is the
537
768
  // right function attached", which is what the pool actually knows.
538
769
  codeHash: props.codeHash,
770
+ // Likewise not the pool's to report — it is a property of whatever
771
+ // function holds the slot, and refresh is about the pool.
772
+ activeRoleStore: props.activeRoleStore,
539
773
  },
540
774
  }
541
775
  }
@@ -561,6 +795,7 @@ type constructorProps = {
561
795
  userPoolId: Pulumi.Input.t<string>,
562
796
  preTokenGenerationArn: Pulumi.Input.t<string>,
563
797
  codeHash: Pulumi.Input.t<string>,
798
+ activeRoleStore: Pulumi.Input.t<string>,
564
799
  }
565
800
 
566
801
  // Explicit /index.js path because @pulumi/pulumi/dynamic is a directory import