@reventlessdev/reventless-aws 3.0.0-alpha.293 → 3.0.0-alpha.295

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,23 @@
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.295 (2026-08-14)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** enforce Cognito group gates that AppSync was silently ignoring ([1392638](https://github.com/ReventlessDev/reventless-core/commit/13926388c097500606f75c387630a19ee1a3aa71))
11
+
12
+
13
+ # 3.0.0-alpha.294 (2026-08-14)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** provision the active-role write door outside the host-UI branch ([2ffc121](https://github.com/ReventlessDev/reventless-core/commit/2ffc12198dfedd151c298add42fbb9bb0dc95ca6))
18
+ ### Features
19
+
20
+ * **aws:** prove the pre-token-generation trigger before attaching it to a pool ([6e981a4](https://github.com/ReventlessDev/reventless-core/commit/6e981a4f6b4089a140cd7dda7df00177b98d4a65))
21
+
22
+
6
23
  # 3.0.0-alpha.293 (2026-08-13)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.293",
3
+ "version": "3.0.0-alpha.295",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.9",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.10",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-node": "2.0.0-alpha.5",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.74",
18
+ "@reventlessdev/rescript-node": "2.0.0-alpha.6",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.75",
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.232",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.139",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.233",
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.97",
24
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.96",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.112"
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.113",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.140"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -2107,31 +2107,6 @@ module MakeWithConfig = (
2107
2107
  | _ => ()
2108
2108
  }
2109
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
-
2135
2110
  // The place index itself, exported so the unattended slice path can reach the
2136
2111
  // capability: a slice's Lambda signs an SDK call directly and needs the index
2137
2112
  // name, not an endpoint (no proxy hop, no dependence on a public URL). The
@@ -2283,6 +2258,44 @@ module MakeWithConfig = (
2283
2258
  Pulumi.Pulumi.export("hostShellUrl", distributionUrl)
2284
2259
  }
2285
2260
 
2261
+ // Where a caller records the role they want to act as
2262
+ // (docs/plans/active-role-narrows-the-token.md §6). The mutation only writes
2263
+ // a preference; the pre-token-generation trigger reads the row and re-checks
2264
+ // it against real membership before narrowing anything, so this door grants
2265
+ // nobody anything on its own.
2266
+ //
2267
+ // The table and the trigger were provisioned with the pool — they have to be,
2268
+ // since the pool is declared carrying the trigger's ARN. Only the write door
2269
+ // waits for the API, which is why it is here and not there.
2270
+ //
2271
+ // 🚨 **Outside `switch hostUiBundle`, and that is the whole point.** It was
2272
+ // written inside it, where the pool happens to be resolved — and a platform
2273
+ // whose shell ships from its own stack passes no bundle, so the door silently
2274
+ // did not exist there. The table and trigger still appeared, because
2275
+ // `resolveCognitoUserPool` is process-cached and `Auth_Cognito.make` reaches
2276
+ // it first: the feature deployed looking complete, with the trigger attached
2277
+ // to the pool and nothing able to write a row for it to read. Same shape as
2278
+ // the object-store endpoints at the top of this file, which sat in this branch
2279
+ // for the same reason and were moved out for the same one. Nothing that is not
2280
+ // *about* the host UI belongs inside this switch.
2281
+ //
2282
+ // Split-API only, like the upload and geocode resolvers: `Platform_SetActiveRole`
2283
+ // lives on the domain base document, which a unified-mode deployment's single
2284
+ // API does not carry. The table and trigger are provisioned either way — see
2285
+ // `Platform_Stack` for why that is not gated on this flag.
2286
+ if Config.splitApi {
2287
+ // Resolved here rather than reused from the branch above, which may not have
2288
+ // run. The call is process-cached, so this is the same pool either way.
2289
+ let pool = Platform_Stack.resolveCognitoUserPool()
2290
+ Auth_ActiveRoleStore.makeWriteDoor(
2291
+ ~api=domainApi,
2292
+ ~table=pool.activeRoleTable,
2293
+ ~cognitoUserPoolId=pool.poolId->Pulumi.Output.asInput,
2294
+ ~cognitoUserPoolArn=pool.poolArn->Pulumi.Output.asInput,
2295
+ ~opts={},
2296
+ )
2297
+ }
2298
+
2286
2299
  Pulumi.Pulumi.getOutputs()
2287
2300
  }
2288
2301
 
@@ -1131,9 +1131,6 @@ function MakeWithConfig(Config) {
1131
1131
  if (index !== undefined && Config.splitApi) {
1132
1132
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
1133
1133
  }
1134
- if (Config.splitApi) {
1135
- Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
1136
- }
1137
1134
  let index$1 = hostUiBundle.geocoderPlaceIndex;
1138
1135
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
1139
1136
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -1234,6 +1231,10 @@ function MakeWithConfig(Config) {
1234
1231
  }
1235
1232
  Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
1236
1233
  }
1234
+ if (Config.splitApi) {
1235
+ let pool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
1236
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, pool.activeRoleTable, pool.poolId, pool.poolArn, undefined, {});
1237
+ }
1237
1238
  return Pulumi$Pulumi.getOutputs();
1238
1239
  };
1239
1240
  let startServers = () => {};
@@ -2410,7 +2411,6 @@ function Make($star) {
2410
2411
  if (index !== undefined) {
2411
2412
  Geocoder_AwsLocation_Resolver$ReventlessAws.make(domainApi, index.indexName, undefined, {});
2412
2413
  }
2413
- Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, cognitoPool.activeRoleTable, cognitoPool.poolId, cognitoPool.poolArn, undefined, {});
2414
2414
  let index$1 = hostUiBundle.geocoderPlaceIndex;
2415
2415
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
2416
2416
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
@@ -2511,6 +2511,8 @@ function Make($star) {
2511
2511
  }
2512
2512
  Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
2513
2513
  }
2514
+ let pool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
2515
+ Auth_ActiveRoleStore$ReventlessAws.makeWriteDoor(domainApi, pool.activeRoleTable, pool.poolId, pool.poolArn, undefined, {});
2514
2516
  return Pulumi$Pulumi.getOutputs();
2515
2517
  };
2516
2518
  let startServers = () => {};
@@ -109,10 +109,14 @@ let _resolveUncached = (): cognitoUserPool => {
109
109
  // resource that describes the pool, merges into what it finds, and sends the
110
110
  // result back whole. Never a hand-run command: that would leave the
111
111
  // deployment's behaviour depending on an act no source describes.
112
+ // The code hash is an input so a new build of the trigger reaches this
113
+ // resource: the function ARN is stable, so it alone would let a broken
114
+ // version onto the pool unchecked after the first deploy.
112
115
  let _attachment = Auth_ActiveRolePoolAttachment.make(
113
116
  ~props={
114
117
  userPoolId: Pulumi.Input.make(poolId),
115
118
  preTokenGenerationArn: activeRoleTrigger.functionArn->Pulumi.Output.asInput,
119
+ codeHash: Pulumi.Input.make(activeRoleTrigger.sourceCodeHash),
116
120
  },
117
121
  ~opts={},
118
122
  )
@@ -44,7 +44,8 @@ function _resolveUncached() {
44
44
  });
45
45
  Auth_ActiveRolePoolAttachment$ReventlessAws.make(undefined, {
46
46
  userPoolId: existingPoolId,
47
- preTokenGenerationArn: activeRoleTrigger.functionArn
47
+ preTokenGenerationArn: activeRoleTrigger.functionArn,
48
+ codeHash: activeRoleTrigger.sourceCodeHash
48
49
  }, {});
49
50
  result = {
50
51
  poolId: Pulumi.output(existingPoolId),
@@ -159,6 +159,54 @@ let getClient = async (): cognitoClient =>
159
159
  c
160
160
  }
161
161
 
162
+ // ── Lambda bindings, for proving the trigger before attaching it ─────────────
163
+
164
+ type lambdaClient
165
+ type invokeCmd
166
+
167
+ type lambdaSdkModule = {
168
+ @as("LambdaClient") clientCtor: unit => lambdaClient,
169
+ @as("InvokeCommand") invokeCtor: dict<JSON.t> => invokeCmd,
170
+ }
171
+
172
+ type invokeResult = {
173
+ @as("FunctionError") functionError?: string,
174
+ @as("Payload") payload?: JSON.t,
175
+ }
176
+
177
+ @send external sendInvoke: (lambdaClient, invokeCmd) => promise<invokeResult> = "send"
178
+ @val external dynImportLambda: string => promise<lambdaSdkModule> = "import"
179
+
180
+ let _lambdaSdk: ref<option<lambdaSdkModule>> = ref(None)
181
+ let _lambdaClient: ref<option<lambdaClient>> = ref(None)
182
+
183
+ let getLambdaSdk = async (): lambdaSdkModule =>
184
+ switch _lambdaSdk.contents {
185
+ | Some(m) => m
186
+ | None =>
187
+ let m = await dynImportLambda("@aws-sdk/client-lambda")
188
+ _lambdaSdk.contents = Some(m)
189
+ m
190
+ }
191
+
192
+ let getLambdaClient = async (): lambdaClient =>
193
+ switch _lambdaClient.contents {
194
+ | Some(c) => c
195
+ | None =>
196
+ let sdk = await getLambdaSdk()
197
+ let c = newOf0(sdk.clientCtor)
198
+ _lambdaClient.contents = Some(c)
199
+ c
200
+ }
201
+
202
+ /** Decodes the SDK's `Payload`, which arrives as a byte array rather than a
203
+ string. */
204
+ let decodePayload: JSON.t => string = %raw(`(p) => {
205
+ if (p == null) return "";
206
+ if (typeof p === "string") return p;
207
+ try { return new TextDecoder().decode(p); } catch { return String(p); }
208
+ }`)
209
+
162
210
  @get @return(nullable) external exnName: JsExn.t => option<string> = "name"
163
211
 
164
212
  let isPoolGoneError = (jsErr: JsExn.t): bool =>
@@ -168,6 +216,129 @@ let isPoolGoneError = (jsErr: JsExn.t): bool =>
168
216
  | _ => false
169
217
  }
170
218
 
219
+ // ── Prove the trigger before attaching it ────────────────────────────────────
220
+
221
+ /** The subject the probe presents. It is not a real user and must never match
222
+ one: the handler looks the id up in the role table, and a probe that
223
+ collided with a real row would exercise a different branch than the one it
224
+ is here to check. */
225
+ let probeSubject = "reventless-attachment-probe"
226
+
227
+ /** A minimal `V1_0` pre-token-generation event.
228
+
229
+ Empty membership on purpose — with no groups and no stored role there is
230
+ nothing to narrow, so a healthy handler returns the event unchanged. The
231
+ probe is checking that the function *runs*, not what it decides; what it
232
+ decides has unit tests, and they do not need a deployed pool. */
233
+ let probeEvent = (~userPoolId: string): JSON.t => {
234
+ let groupConfiguration = Dict.make()
235
+ groupConfiguration->Dict.set("groupsToOverride", JSON.Encode.array([]))
236
+ groupConfiguration->Dict.set("iamRolesToOverride", JSON.Encode.array([]))
237
+
238
+ let userAttributes = Dict.make()
239
+ userAttributes->Dict.set("sub", JSON.Encode.string(probeSubject))
240
+
241
+ let request = Dict.make()
242
+ request->Dict.set("userAttributes", JSON.Encode.object(userAttributes))
243
+ request->Dict.set("groupConfiguration", JSON.Encode.object(groupConfiguration))
244
+
245
+ let event = Dict.make()
246
+ event->Dict.set("version", JSON.Encode.string("1"))
247
+ event->Dict.set("triggerSource", JSON.Encode.string("TokenGeneration_Authentication"))
248
+ event->Dict.set("userPoolId", JSON.Encode.string(userPoolId))
249
+ event->Dict.set("userName", JSON.Encode.string(probeSubject))
250
+ event->Dict.set("request", JSON.Encode.object(request))
251
+ event->Dict.set("response", JSON.Encode.object(Dict.make()))
252
+ JSON.Encode.object(event)
253
+ }
254
+
255
+ /** What one probe invocation proved.
256
+
257
+ A variant rather than a bool so the two failures stay distinguishable: they
258
+ have different causes and want different sentences. */
259
+ type probeVerdict =
260
+ | Healthy
261
+ | Crashed(string)
262
+ | NotAnEvent
263
+
264
+ /** The verdict for a given invoke result.
265
+
266
+ Pure and total, so every branch is checkable without a deployed function —
267
+ the same reason [mergedUpdateInput] is. A payload that is not JSON at all
268
+ counts as not an event rather than throwing: the point here is to produce a
269
+ verdict, and a parse error escaping would fail the deploy with a message
270
+ about JSON instead of about the trigger. */
271
+ let probeVerdict = (~functionError: option<string>, ~payload: string): probeVerdict =>
272
+ switch functionError {
273
+ | Some(kind) => Crashed(kind)
274
+ | None =>
275
+ // A trigger must hand the event back for Cognito to mint anything from. A
276
+ // function that returns 200 with something else shaped is as fatal as one
277
+ // that throws, and rather harder to notice.
278
+ let hasRequest = try {
279
+ payload
280
+ ->JSON.parseOrThrow
281
+ ->JSON.Decode.object
282
+ ->Option.flatMap(o => o->Dict.get("request"))
283
+ ->Option.isSome
284
+ } catch {
285
+ | _ => false
286
+ }
287
+ hasRequest ? Healthy : NotAnEvent
288
+ }
289
+
290
+ /**
291
+ Invoke the trigger once and refuse to attach it if it cannot answer.
292
+
293
+ 🚨 **This is the check that keeps a broken trigger off a live pool.** Cognito
294
+ runs this function on every token it mints, and a function that throws fails the
295
+ sign-in — so a trigger that cannot start does not degrade the feature, it takes
296
+ authentication away from every user of the pool. Nothing downstream notices: the
297
+ deploy reports success, and the first report is a person unable to log in.
298
+
299
+ A trigger cannot make itself fail open. It dies at module load, before any
300
+ handler code runs, so no `try` inside the handler can catch it. The only place
301
+ that can refuse a broken function is the thing about to point a pool at it, and
302
+ that is here.
303
+
304
+ So: broken function → this throws → the deploy fails with the function's own
305
+ error → **the pool is never touched**. That is the same trade [readOnlyKeys]
306
+ already makes deliberately, where a loud failure beats a quiet reset.
307
+
308
+ Needs `lambda:InvokeFunction` on the deploying principal.
309
+ */
310
+ let verifyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: string): unit => {
311
+ let sdk = await getLambdaSdk()
312
+ let client = await getLambdaClient()
313
+
314
+ let input = Dict.make()
315
+ input->Dict.set("FunctionName", JSON.Encode.string(preTokenGenerationArn))
316
+ input->Dict.set("InvocationType", JSON.Encode.string("RequestResponse"))
317
+ input->Dict.set("Payload", JSON.Encode.string(probeEvent(~userPoolId)->JSON.stringify))
318
+
319
+ let result = await client->sendInvoke(newOf1(sdk.invokeCtor, input))
320
+ let payload = result.payload->Option.mapOr("", decodePayload)
321
+
322
+ // The payload carries the runtime's own error — module-resolution failures, a
323
+ // missing environment variable, an unhandled throw. Surfaced verbatim because
324
+ // it is the most useful sentence anyone debugging this will read.
325
+ switch probeVerdict(~functionError=result.functionError, ~payload) {
326
+ | Crashed(kind) =>
327
+ JsError.throwWithMessage(
328
+ `active-role trigger ${preTokenGenerationArn} failed its pre-attach check (${kind}) and was NOT attached to user pool ${userPoolId}. Attaching it would have failed every sign-in on that pool. The function reported: ${payload}`,
329
+ )
330
+ | NotAnEvent =>
331
+ JsError.throwWithMessage(
332
+ `active-role trigger ${preTokenGenerationArn} answered its pre-attach check with something that is not a pre-token-generation event, so it was NOT attached to user pool ${userPoolId}. Cognito would have failed every sign-in. It returned: ${payload}`,
333
+ )
334
+ | Healthy =>
335
+ log.info(
336
+ ~comp="Auth_ActiveRolePoolAttachment",
337
+ `trigger ${preTokenGenerationArn} answered the pre-attach check; attaching to ${userPoolId}`,
338
+ )
339
+ }
340
+ }
341
+
171
342
  // ── Describe → merge → update ────────────────────────────────────────────────
172
343
 
173
344
  let describePool = async (~userPoolId: string): option<dict<JSON.t>> => {
@@ -200,11 +371,20 @@ let applyTrigger = async (~userPoolId: string, ~preTokenGenerationArn: option<st
200
371
  type providerInputs = {
201
372
  userPoolId: string,
202
373
  preTokenGenerationArn: string,
374
+ /** The deployed function's code hash.
375
+
376
+ Carried purely so a code change reaches this resource. The function ARN is
377
+ stable across deploys, so without this the attachment sees identical inputs
378
+ every time and never runs again — meaning [verifyTrigger] would prove the
379
+ function once, on the deploy that created it, and never for any version
380
+ shipped afterwards. A check that only ever runs once is not a check. */
381
+ codeHash: string,
203
382
  }
204
383
 
205
384
  type createOuts = {
206
385
  userPoolId: string,
207
386
  preTokenGenerationArn: string,
387
+ codeHash: string,
208
388
  }
209
389
 
210
390
  type createResult = {id: string, outs: createOuts}
@@ -213,22 +393,43 @@ type diffResult = {changes: bool, replaces: array<string>, deleteBeforeReplace:
213
393
  type readResult = {id?: string, props?: createOuts}
214
394
 
215
395
  let create = async (inputs: providerInputs): createResult => {
396
+ await verifyTrigger(
397
+ ~userPoolId=inputs.userPoolId,
398
+ ~preTokenGenerationArn=inputs.preTokenGenerationArn,
399
+ )
216
400
  await applyTrigger(
217
401
  ~userPoolId=inputs.userPoolId,
218
402
  ~preTokenGenerationArn=Some(inputs.preTokenGenerationArn),
219
403
  )
220
404
  {
221
405
  id: inputs.userPoolId,
222
- outs: {userPoolId: inputs.userPoolId, preTokenGenerationArn: inputs.preTokenGenerationArn},
406
+ outs: {
407
+ userPoolId: inputs.userPoolId,
408
+ preTokenGenerationArn: inputs.preTokenGenerationArn,
409
+ codeHash: inputs.codeHash,
410
+ },
223
411
  }
224
412
  }
225
413
 
226
414
  let update = async (_id: string, _olds: createOuts, news: providerInputs): updateResult => {
415
+ // Re-proved on every update, not only on create: the usual reason this runs is
416
+ // that the function's code changed, which is exactly when it might have stopped
417
+ // working. The pool keeps the trigger it already has until this succeeds.
418
+ await verifyTrigger(
419
+ ~userPoolId=news.userPoolId,
420
+ ~preTokenGenerationArn=news.preTokenGenerationArn,
421
+ )
227
422
  await applyTrigger(
228
423
  ~userPoolId=news.userPoolId,
229
424
  ~preTokenGenerationArn=Some(news.preTokenGenerationArn),
230
425
  )
231
- {outs: {userPoolId: news.userPoolId, preTokenGenerationArn: news.preTokenGenerationArn}}
426
+ {
427
+ outs: {
428
+ userPoolId: news.userPoolId,
429
+ preTokenGenerationArn: news.preTokenGenerationArn,
430
+ codeHash: news.codeHash,
431
+ },
432
+ }
232
433
  }
233
434
 
234
435
  /** Detaching on destroy is not optional. The Lambda is torn down with the rest
@@ -253,7 +454,9 @@ let delete_ = async (_id: string, props: createOuts): unit =>
253
454
  let diff_ = (_id: string, olds: createOuts, news: providerInputs): diffResult => {
254
455
  let poolChanged = olds.userPoolId != news.userPoolId
255
456
  {
256
- changes: poolChanged || olds.preTokenGenerationArn != news.preTokenGenerationArn,
457
+ changes: poolChanged ||
458
+ olds.preTokenGenerationArn != news.preTokenGenerationArn ||
459
+ olds.codeHash != news.codeHash,
257
460
  replaces: poolChanged ? ["userPoolId"] : [],
258
461
  deleteBeforeReplace: true,
259
462
  }
@@ -271,6 +474,10 @@ let read_ = async (id: string, props: createOuts): readResult =>
271
474
  props: {
272
475
  userPoolId: props.userPoolId,
273
476
  preTokenGenerationArn: attachedTrigger(~described)->Option.getOr(""),
477
+ // The pool cannot report what code the function is running, so the
478
+ // recorded hash is carried through unchanged. Refresh answers "is the
479
+ // right function attached", which is what the pool actually knows.
480
+ codeHash: props.codeHash,
274
481
  },
275
482
  }
276
483
  }
@@ -295,6 +502,7 @@ type t = {id: Pulumi.Output.t<string>}
295
502
  type constructorProps = {
296
503
  userPoolId: Pulumi.Input.t<string>,
297
504
  preTokenGenerationArn: Pulumi.Input.t<string>,
505
+ codeHash: Pulumi.Input.t<string>,
298
506
  }
299
507
 
300
508
  // Explicit /index.js path because @pulumi/pulumi/dynamic is a directory import
@@ -89,6 +89,41 @@ async function getClient() {
89
89
  return c$1;
90
90
  }
91
91
 
92
+ let _lambdaSdk = {
93
+ contents: undefined
94
+ };
95
+
96
+ let _lambdaClient = {
97
+ contents: undefined
98
+ };
99
+
100
+ async function getLambdaSdk() {
101
+ let m = _lambdaSdk.contents;
102
+ if (m !== undefined) {
103
+ return m;
104
+ }
105
+ let m$1 = await import("@aws-sdk/client-lambda");
106
+ _lambdaSdk.contents = m$1;
107
+ return m$1;
108
+ }
109
+
110
+ async function getLambdaClient() {
111
+ let c = _lambdaClient.contents;
112
+ if (c !== undefined) {
113
+ return Primitive_option.valFromOption(c);
114
+ }
115
+ let sdk = await getLambdaSdk();
116
+ let c$1 = newOf0(sdk.LambdaClient);
117
+ _lambdaClient.contents = Primitive_option.some(c$1);
118
+ return c$1;
119
+ }
120
+
121
+ let decodePayload = ((p) => {
122
+ if (p == null) return "";
123
+ if (typeof p === "string") return p;
124
+ try { return new TextDecoder().decode(p); } catch { return String(p); }
125
+ });
126
+
92
127
  function isPoolGoneError(jsErr) {
93
128
  let match = jsErr.name;
94
129
  let match$1 = Stdlib_JsExn.message(jsErr);
@@ -107,6 +142,68 @@ function isPoolGoneError(jsErr) {
107
142
  }
108
143
  }
109
144
 
145
+ let probeSubject = "reventless-attachment-probe";
146
+
147
+ function probeEvent(userPoolId) {
148
+ let groupConfiguration = {};
149
+ groupConfiguration["groupsToOverride"] = [];
150
+ groupConfiguration["iamRolesToOverride"] = [];
151
+ let userAttributes = {};
152
+ userAttributes["sub"] = probeSubject;
153
+ let request = {};
154
+ request["userAttributes"] = userAttributes;
155
+ request["groupConfiguration"] = groupConfiguration;
156
+ let event = {};
157
+ event["version"] = "1";
158
+ event["triggerSource"] = "TokenGeneration_Authentication";
159
+ event["userPoolId"] = userPoolId;
160
+ event["userName"] = probeSubject;
161
+ event["request"] = request;
162
+ event["response"] = {};
163
+ return event;
164
+ }
165
+
166
+ function probeVerdict(functionError, payload) {
167
+ if (functionError !== undefined) {
168
+ return {
169
+ TAG: "Crashed",
170
+ _0: functionError
171
+ };
172
+ }
173
+ let hasRequest;
174
+ try {
175
+ hasRequest = Stdlib_Option.isSome(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(JSON.parse(payload)), o => o["request"]));
176
+ } catch (exn) {
177
+ hasRequest = false;
178
+ }
179
+ if (hasRequest) {
180
+ return "Healthy";
181
+ } else {
182
+ return "NotAnEvent";
183
+ }
184
+ }
185
+
186
+ async function verifyTrigger(userPoolId, preTokenGenerationArn) {
187
+ let sdk = await getLambdaSdk();
188
+ let client = await getLambdaClient();
189
+ let input = {};
190
+ input["FunctionName"] = preTokenGenerationArn;
191
+ input["InvocationType"] = "RequestResponse";
192
+ input["Payload"] = JSON.stringify(probeEvent(userPoolId));
193
+ let result = await client.send(newOf1(sdk.InvokeCommand, input));
194
+ let payload = Stdlib_Option.mapOr(result.Payload, "", decodePayload);
195
+ let kind = probeVerdict(result.FunctionError, payload);
196
+ if (typeof kind !== "object") {
197
+ if (kind === "Healthy") {
198
+ return log.info("Auth_ActiveRolePoolAttachment", undefined, `trigger ` + preTokenGenerationArn + ` answered the pre-attach check; attaching to ` + userPoolId);
199
+ } else {
200
+ return Stdlib_JsError.throwWithMessage(`active-role trigger ` + preTokenGenerationArn + ` answered its pre-attach check with something that is not a pre-token-generation event, so it was NOT attached to user pool ` + userPoolId + `. Cognito would have failed every sign-in. It returned: ` + payload);
201
+ }
202
+ } else {
203
+ return Stdlib_JsError.throwWithMessage(`active-role trigger ` + preTokenGenerationArn + ` failed its pre-attach check (` + kind._0 + `) and was NOT attached to user pool ` + userPoolId + `. Attaching it would have failed every sign-in on that pool. The function reported: ` + payload);
204
+ }
205
+ }
206
+
110
207
  async function describePool(userPoolId) {
111
208
  let sdk = await getSdk();
112
209
  let client = await getClient();
@@ -128,22 +225,26 @@ async function applyTrigger(userPoolId, preTokenGenerationArn) {
128
225
  }
129
226
 
130
227
  async function create(inputs) {
228
+ await verifyTrigger(inputs.userPoolId, inputs.preTokenGenerationArn);
131
229
  await applyTrigger(inputs.userPoolId, inputs.preTokenGenerationArn);
132
230
  return {
133
231
  id: inputs.userPoolId,
134
232
  outs: {
135
233
  userPoolId: inputs.userPoolId,
136
- preTokenGenerationArn: inputs.preTokenGenerationArn
234
+ preTokenGenerationArn: inputs.preTokenGenerationArn,
235
+ codeHash: inputs.codeHash
137
236
  }
138
237
  };
139
238
  }
140
239
 
141
240
  async function update(_id, _olds, news) {
241
+ await verifyTrigger(news.userPoolId, news.preTokenGenerationArn);
142
242
  await applyTrigger(news.userPoolId, news.preTokenGenerationArn);
143
243
  return {
144
244
  outs: {
145
245
  userPoolId: news.userPoolId,
146
- preTokenGenerationArn: news.preTokenGenerationArn
246
+ preTokenGenerationArn: news.preTokenGenerationArn,
247
+ codeHash: news.codeHash
147
248
  }
148
249
  };
149
250
  }
@@ -163,7 +264,7 @@ async function delete_(_id, props) {
163
264
  function diff_(_id, olds, news) {
164
265
  let poolChanged = olds.userPoolId !== news.userPoolId;
165
266
  return {
166
- changes: poolChanged || olds.preTokenGenerationArn !== news.preTokenGenerationArn,
267
+ changes: poolChanged || olds.preTokenGenerationArn !== news.preTokenGenerationArn || olds.codeHash !== news.codeHash,
167
268
  replaces: poolChanged ? ["userPoolId"] : [],
168
269
  deleteBeforeReplace: true
169
270
  };
@@ -177,7 +278,8 @@ async function read_(id, props) {
177
278
  id: id,
178
279
  props: {
179
280
  userPoolId: props.userPoolId,
180
- preTokenGenerationArn: Stdlib_Option.getOr(attachedTrigger(described), "")
281
+ preTokenGenerationArn: Stdlib_Option.getOr(attachedTrigger(described), ""),
282
+ codeHash: props.codeHash
181
283
  }
182
284
  };
183
285
  } else {
@@ -216,7 +318,16 @@ export {
216
318
  _client,
217
319
  getSdk,
218
320
  getClient,
321
+ _lambdaSdk,
322
+ _lambdaClient,
323
+ getLambdaSdk,
324
+ getLambdaClient,
325
+ decodePayload,
219
326
  isPoolGoneError,
327
+ probeSubject,
328
+ probeEvent,
329
+ probeVerdict,
330
+ verifyTrigger,
220
331
  describePool,
221
332
  applyTrigger,
222
333
  create,
@@ -20,6 +20,11 @@ open PulumiAws
20
20
  type triggerOutputs = {
21
21
  functionArn: Pulumi.Output.t<string>,
22
22
  functionName: Pulumi.Output.t<string>,
23
+ /** The archive's hash, exposed so whoever attaches this trigger to a pool can
24
+ tell that the code changed. The function ARN does not move when the code
25
+ does, so without this an attachment sees identical inputs across deploys
26
+ and never re-checks a function it proved once. */
27
+ sourceCodeHash: string,
23
28
  }
24
29
 
25
30
  let make = (
@@ -150,7 +155,7 @@ let make = (
150
155
  ~opts,
151
156
  )
152
157
 
153
- {functionArn: lambda.arn, functionName: lambda.name}
158
+ {functionArn: lambda.arn, functionName: lambda.name, sourceCodeHash}
154
159
  }
155
160
 
156
161
  /** Let Cognito invoke the trigger — scoped to one pool, so a function attached