@reventlessdev/reventless-aws 3.0.0-alpha.330 → 3.0.0-alpha.331

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,18 @@
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.331 (2026-09-01)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **capabilities:** a plugin's geocoding need was declared nowhere and failed silently ([67917dd](https://github.com/ReventlessDev/reventless-core/commit/67917dd504b43fa78b7c6a51644c9eae656b7f6b))
11
+ ### Features
12
+
13
+ * **capabilities:** a plugin can send a message without naming a provider ([b001a1e](https://github.com/ReventlessDev/reventless-core/commit/b001a1e9361a0f4d0affe228cb4c08c38a5a995e))
14
+ * **spec:** a command declares its lifecycle edge as a value ([40eee9f](https://github.com/ReventlessDev/reventless-core/commit/40eee9f7723dc05e418be680528f01967d074da4))
15
+ * **spec:** a graft leaves a trace the deployed plugin can read ([c08ff6c](https://github.com/ReventlessDev/reventless-core/commit/c08ff6c0f6177d58603e7ae1e5cec392d9bac16a))
16
+
17
+
6
18
  # 3.0.0-alpha.330 (2026-08-27)
7
19
 
8
20
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.330",
3
+ "version": "3.0.0-alpha.331",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -17,16 +17,16 @@
17
17
  "uuid": "^13.0.0",
18
18
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.14",
19
19
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
20
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
20
21
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
21
22
  "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.7",
22
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
23
23
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
24
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
25
- "@reventlessdev/reventless-core": "3.0.0-alpha.251",
26
- "@reventlessdev/reventless-infra": "3.0.0-alpha.152",
25
+ "@reventlessdev/reventless-core": "3.0.0-alpha.252",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.153",
27
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.125",
27
28
  "@reventlessdev/reventless-interop": "3.0.0-alpha.34",
28
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.115",
29
- "@reventlessdev/reventless-spec": "3.0.0-alpha.124"
29
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.116"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -99,6 +99,10 @@ let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
99
99
  the same sentinel, as `PluginRuntime_Builder.inboundSliceReg.auditTableName`. */
100
100
  let geocoderPlaceIndexRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
101
101
 
102
+ /** The verified sender address, for the same single-program case and with the
103
+ same `""` sentinel and the same reason. */
104
+ let messagingEmailSenderRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
105
+
102
106
  module MakeWithConfig = (
103
107
  Config: {
104
108
  let splitApi: bool
@@ -1174,6 +1178,12 @@ module MakeWithConfig = (
1174
1178
  // on the domain API (`Geocoder_AwsLocation_Resolver`, split mode) — and exports
1175
1179
  // the index name for the unattended slice path. Unset ⇒ no resolver, no export.
1176
1180
  geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1181
+ // Optional messaging sender, as returned by `Capability_Messaging_Ses.make`.
1182
+ // When set, the deploy exports the verified `From:` address for the
1183
+ // unattended slice path and grants those Lambdas `ses:SendEmail`. No client
1184
+ // door: unlike geocoding, a browser has no business sending mail from the
1185
+ // deployment's identity. Unset ⇒ no export, no grant.
1186
+ messagingSender?: ReventlessInfra.Platform.messagingSender,
1177
1187
  // Optional object store for direct-to-S3 uploads, as returned by
1178
1188
  // `Capability_ObjectStore_S3.make`. When set, the deploy registers it with
1179
1189
  // the platform's one Upload service (`Upload_Presign_S3`) under the `uploads`
@@ -1726,7 +1736,8 @@ module MakeWithConfig = (
1726
1736
  ->Array.filterMap(c =>
1727
1737
  switch c {
1728
1738
  | ObjectStore({plugin, store}) => Some((plugin, store))
1729
- | Geocoding => None
1739
+ | Geocoding
1740
+ | Messaging => None
1730
1741
  }
1731
1742
  )
1732
1743
  ->Array.reduce([], (acc, (plugin, store)) =>
@@ -2141,6 +2152,47 @@ module MakeWithConfig = (
2141
2152
  Pulumi.Pulumi.export("geocoderPlaceIndex", geocoderPlaceIndexFlat)
2142
2153
  geocoderPlaceIndexRef := geocoderPlaceIndexFlat
2143
2154
 
2155
+ // The generated capability list says a plugin of this deployment declared it
2156
+ // needs geocoding. Provisioning it is a hand-written line in this root —
2157
+ // `Capability_Geocoding_AwsLocation.make`, passed as `~geocoderPlaceIndex` —
2158
+ // so the list and the handle can disagree, and the way they disagree is
2159
+ // silent: the slice retries, exhausts, and writes a permanent verdict.
2160
+ // Caught here rather than only in the plugin stack because this is the stack
2161
+ // that can fix it, and it deploys first.
2162
+ if capabilities->Array.includes(Geocoding) && cfg.geocoderPlaceIndex->Option.isNone {
2163
+ JsError.throwWithMessage(
2164
+ `A plugin of this deployment declares the Geocoding capability, but this platform ` ++
2165
+ `provisions no place index.\n` ++
2166
+ ` Add \`let placeIndex = ReventlessAws.Capability_Geocoding_AwsLocation.make(~name=…)\` ` ++
2167
+ `and pass \`~geocoderPlaceIndex=placeIndex\` in \`~hostUiBundle\`.\n` ++
2168
+ ` The declaration is generated from the plugins' capabilities.json — regenerate with ` ++
2169
+ `\`pnpm run generate:platform\` if it is stale.`,
2170
+ )
2171
+ }
2172
+
2173
+ // The verified sender, exported for the same reason and read the same way.
2174
+ let messagingEmailSenderFlat = switch cfg.messagingSender {
2175
+ | Some({emailSender: ?Some(sender)}) => sender->Pulumi.Output.fromInput
2176
+ | _ => Pulumi.Output.make("")
2177
+ }
2178
+ Pulumi.Pulumi.export("messagingEmailSender", messagingEmailSenderFlat)
2179
+ messagingEmailSenderRef := messagingEmailSenderFlat
2180
+
2181
+ // Same gate as geocoding's, and the failure it prevents is worse: an
2182
+ // unprovisioned mailer answers `Unavailable` on every send, so the messages
2183
+ // queue and retry and are eventually abandoned — a notification nobody
2184
+ // receives and no error names.
2185
+ if capabilities->Array.includes(Messaging) && cfg.messagingSender->Option.isNone {
2186
+ JsError.throwWithMessage(
2187
+ `A plugin of this deployment declares the Messaging capability, but this platform ` ++
2188
+ `provisions no sender.\n` ++
2189
+ ` Add \`let sender = ReventlessAws.Capability_Messaging_Ses.make(~name=…, ~email=…)\` ` ++
2190
+ `and pass \`~messagingSender=sender\` in \`~hostUiBundle\`.\n` ++
2191
+ ` The declaration is generated from the plugins' capabilities.json — regenerate with ` ++
2192
+ `\`pnpm run generate:platform\` if it is stale.`,
2193
+ )
2194
+ }
2195
+
2144
2196
  // Presign endpoints are no longer written to config.json: under route B the
2145
2197
  // client calls the platform API's `Upload_Presign` mutation (reachable from
2146
2198
  // `platformApiEndpoint`, already present) with the store it declares, so there
@@ -2449,6 +2501,21 @@ module MakeWithConfig = (
2449
2501
  PluginRuntime_Builder.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex)
2450
2502
  PluginRuntime_Builder.registerGeocoderPlaceIndex(geocoderPlaceIndex)
2451
2503
 
2504
+ // The verified `From:` address, read across the same two halves and carrying
2505
+ // the same `""`-means-unprovisioned convention. Registered twice for the same
2506
+ // reason the place index is: the Lambda reads the address, and its role has to
2507
+ // be allowed to send from the identity that address names.
2508
+ let messagingEmailSender: Pulumi.Output.t<string> = switch platformStackRef {
2509
+ | Some(stackRef) =>
2510
+ (
2511
+ stackRef->Pulumi.StackReference.getOutput("messagingEmailSender"):
2512
+ Pulumi.Output.t<option<string>>
2513
+ )->Pulumi.Output.apply(o => o->Option.getOr(""))
2514
+ | None => messagingEmailSenderRef.contents
2515
+ }
2516
+ PluginRuntime_Builder.registerCapabilityEnv("MESSAGING_EMAIL_SENDER", messagingEmailSender)
2517
+ PluginRuntime_Builder.registerMessagingSender(messagingEmailSender)
2518
+
2452
2519
  module P = unpack(plugin)
2453
2520
  let pluginComponent = P.make()
2454
2521
  ReventlessCore.Plugin_Helpers.clearOffload()
@@ -2580,9 +2647,69 @@ module MakeWithConfig = (
2580
2647
  // is: a dangling `apply` is not guaranteed to be evaluated, and a check
2581
2648
  // that might not run is not a check.
2582
2649
  let capabilityGate: Pulumi.Output.t<unit> =
2583
- (pluginOutputs.pluginStructure, stackRef->Pulumi.StackReference.getOutput("objectStores"))
2584
- ->Pulumi.Output.all2
2585
- ->Pulumi.Output.apply((((structure, objectStores): (_, option<JSON.t>))) => {
2650
+ (
2651
+ pluginOutputs.pluginStructure,
2652
+ stackRef->Pulumi.StackReference.getOutput("objectStores"),
2653
+ geocoderPlaceIndex,
2654
+ messagingEmailSender,
2655
+ )
2656
+ ->Pulumi.Output.all4
2657
+ ->Pulumi.Output.apply((((structure, objectStores, placeIndex, emailSender): (
2658
+ _,
2659
+ option<JSON.t>,
2660
+ string,
2661
+ string,
2662
+ ))) => {
2663
+ // ── Declared capabilities ──────────────────────────────────────────
2664
+ //
2665
+ // A slice that names `Geocoding` in `capabilityNeeds` says its
2666
+ // `translate` will reach `capabilities.geocode`. A platform that
2667
+ // provisioned no place index hands it an empty endpoint, and the client
2668
+ // reports `Unavailable` — retried three times, then `onExhausted` fires
2669
+ // and every address the slice ever sees is recorded as permanently
2670
+ // unresolvable. A silent data outcome with no error anywhere, which is
2671
+ // why a *declared* need fails the deploy.
2672
+ //
2673
+ // Only a declared need. A plugin that says nothing still degrades
2674
+ // exactly as before: the empty endpoint is a modelled outcome for a
2675
+ // deployment that simply has no geocoder, and failing those would break
2676
+ // deployments that work.
2677
+ switch Reventless.CapabilityNeed.unmet(
2678
+ ~declared=structure
2679
+ ->Option.flatMap(s => s.requiredCapabilities)
2680
+ ->Option.getOr([])
2681
+ ->Array.map(d => (d.capability, d.component)),
2682
+ // The values the slice Lambdas are actually handed, so this reads
2683
+ // what decides the outcome rather than a second record of the same
2684
+ // intent. `""` is what an unprovisioned platform exports.
2685
+ ~provisioned=Array.concat(
2686
+ placeIndex == "" ? [] : [Reventless.CapabilityNeed.Geocoding],
2687
+ emailSender == "" ? [] : [Reventless.CapabilityNeed.Messaging],
2688
+ ),
2689
+ ) {
2690
+ | [] => ()
2691
+ | missing =>
2692
+ JsError.throwWithMessage(
2693
+ Reventless.CapabilityNeed.unmetMessage(missing) ++
2694
+ missing
2695
+ ->Array.map(u =>
2696
+ switch u.need {
2697
+ | Geocoding =>
2698
+ `\n Geocoding is \`Capability_Geocoding_AwsLocation.make\`, passed to the ` ++
2699
+ `platform as \`~geocoderPlaceIndex\`.`
2700
+ | Messaging =>
2701
+ `\n Messaging is \`Capability_Messaging_Ses.make\`, passed to the platform ` ++
2702
+ `as \`~messagingSender\`.`
2703
+ }
2704
+ )
2705
+ // One line per capability, not per declaring component: two slices
2706
+ // missing the same provider need telling once.
2707
+ ->Belt.Set.String.fromArray
2708
+ ->Belt.Set.String.toArray
2709
+ ->Array.join(""),
2710
+ )
2711
+ }
2712
+
2586
2713
  let required =
2587
2714
  structure->Option.flatMap(s => s.requiredStores)->Option.getOr([])
2588
2715
  let provisioned =
@@ -11,6 +11,7 @@ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output
11
11
  import * as Pulumi$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Pulumi.res.mjs";
12
12
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
13
13
  import * as Pulumi from "@pulumi/pulumi";
14
+ import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
14
15
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
15
16
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
16
17
  import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
@@ -25,6 +26,7 @@ import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/co
25
26
  import * as DcbBackend$ReventlessAws from "./adapter/DcbEventLog/DcbBackend.res.mjs";
26
27
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
27
28
  import * as ReadModel$ReventlessCore from "@reventlessdev/reventless-core/src/components/ReadModel/ReadModel.res.mjs";
29
+ import * as CapabilityNeed$Reventless from "@reventlessdev/reventless-spec/src/semantic/CapabilityNeed.res.mjs";
28
30
  import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
29
31
  import * as Util_Bundle$ReventlessAws from "./util/Util_Bundle.res.mjs";
30
32
  import * as Plugin_Stack$ReventlessAws from "./plugin/stack/Plugin_Stack.res.mjs";
@@ -138,6 +140,10 @@ let geocoderPlaceIndexRef = {
138
140
  contents: Pulumi.output("")
139
141
  };
140
142
 
143
+ let messagingEmailSenderRef = {
144
+ contents: Pulumi.output("")
145
+ };
146
+
141
147
  function MakeWithConfig(Config) {
142
148
  Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
143
149
  Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
@@ -451,6 +457,8 @@ function MakeWithConfig(Config) {
451
457
  eventSchema: UiFragmentRegistry$ReventlessCore.eventSchema,
452
458
  commandSchema: UiFragmentRegistry$ReventlessCore.commandSchema,
453
459
  commandAuthorization: UiFragmentRegistry$ReventlessCore.commandAuthorization,
460
+ commandTransition: UiFragmentRegistry$ReventlessCore.commandTransition,
461
+ traits: UiFragmentRegistry$ReventlessCore.traits,
454
462
  readConsistency: UiFragmentRegistry$ReventlessCore.readConsistency
455
463
  };
456
464
  let UiFragmentRegistrySlice = StateChangeSlice_Builder$ReventlessAws.Make(Spec)({
@@ -638,7 +646,9 @@ function MakeWithConfig(Config) {
638
646
  errorSchema: PluginSpec$ReventlessCore.errorSchema,
639
647
  commandSchema: PluginSpec$ReventlessCore.commandSchema,
640
648
  moduleUrl: PluginSpec$ReventlessCore.moduleUrl,
641
- commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization
649
+ commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization,
650
+ commandTransition: PluginSpec$ReventlessCore.commandTransition,
651
+ traits: PluginSpec$ReventlessCore.traits
642
652
  })({
643
653
  initialState: PluginBehavior$ReventlessCore.initialState,
644
654
  evolve: PluginBehavior$ReventlessCore.evolve,
@@ -1145,6 +1155,22 @@ function MakeWithConfig(Config) {
1145
1155
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
1146
1156
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
1147
1157
  geocoderPlaceIndexRef.contents = geocoderPlaceIndexFlat;
1158
+ if (capabilities.includes("Geocoding") && Stdlib_Option.isNone(hostUiBundle.geocoderPlaceIndex)) {
1159
+ Stdlib_JsError.throwWithMessage(`A plugin of this deployment declares the Geocoding capability, but this platform provisions no place index.\n Add \`let placeIndex = ReventlessAws.Capability_Geocoding_AwsLocation.make(~name=…)\` and pass \`~geocoderPlaceIndex=placeIndex\` in \`~hostUiBundle\`.\n The declaration is generated from the plugins' capabilities.json — regenerate with \`pnpm run generate:platform\` if it is stale.`);
1160
+ }
1161
+ let match$5 = hostUiBundle.messagingSender;
1162
+ let messagingEmailSenderFlat;
1163
+ if (match$5 !== undefined) {
1164
+ let sender = match$5.emailSender;
1165
+ messagingEmailSenderFlat = sender !== undefined ? sender : Pulumi.output("");
1166
+ } else {
1167
+ messagingEmailSenderFlat = Pulumi.output("");
1168
+ }
1169
+ Pulumi$Pulumi.$$export("messagingEmailSender", messagingEmailSenderFlat);
1170
+ messagingEmailSenderRef.contents = messagingEmailSenderFlat;
1171
+ if (capabilities.includes("Messaging") && Stdlib_Option.isNone(hostUiBundle.messagingSender)) {
1172
+ Stdlib_JsError.throwWithMessage(`A plugin of this deployment declares the Messaging capability, but this platform provisions no sender.\n Add \`let sender = ReventlessAws.Capability_Messaging_Ses.make(~name=…, ~email=…)\` and pass \`~messagingSender=sender\` in \`~hostUiBundle\`.\n The declaration is generated from the plugins' capabilities.json — regenerate with \`pnpm run generate:platform\` if it is stale.`);
1173
+ }
1148
1174
  let configJsonContent = Pulumi.all([
1149
1175
  Pulumi.all([
1150
1176
  resolvedDomainApiEndpoint,
@@ -1214,9 +1240,9 @@ function MakeWithConfig(Config) {
1214
1240
  contentType: "application/json"
1215
1241
  });
1216
1242
  }
1217
- let match$5 = hostUiBundle.bakedManifest;
1218
- if (componentDefinitions !== undefined && match$5 !== undefined) {
1219
- let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
1243
+ let match$6 = hostUiBundle.bakedManifest;
1244
+ if (componentDefinitions !== undefined && match$6 !== undefined) {
1245
+ let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$6).map(param => param[0]);
1220
1246
  let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
1221
1247
  Pulumi.all([
1222
1248
  componentDefinitions.roleId,
@@ -1297,6 +1323,9 @@ function MakeWithConfig(Config) {
1297
1323
  let geocoderPlaceIndex = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderPlaceIndex").apply(o => Stdlib_Option.getOr(o, "")) : geocoderPlaceIndexRef.contents;
1298
1324
  PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex);
1299
1325
  PluginRuntime_Builder$ReventlessAws.registerGeocoderPlaceIndex(geocoderPlaceIndex);
1326
+ let messagingEmailSender = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("messagingEmailSender").apply(o => Stdlib_Option.getOr(o, "")) : messagingEmailSenderRef.contents;
1327
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("MESSAGING_EMAIL_SENDER", messagingEmailSender);
1328
+ PluginRuntime_Builder$ReventlessAws.registerMessagingSender(messagingEmailSender);
1300
1329
  let pluginComponent = plugin.make();
1301
1330
  Plugin_Helpers$ReventlessCore.clearOffload();
1302
1331
  currentDeployTarget.contents = "Domain";
@@ -1352,17 +1381,36 @@ function MakeWithConfig(Config) {
1352
1381
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
1353
1382
  let capabilityGate = Pulumi.all([
1354
1383
  pluginOutputs.pluginStructure,
1355
- stackRef.getOutput("objectStores")
1384
+ stackRef.getOutput("objectStores"),
1385
+ geocoderPlaceIndex,
1386
+ messagingEmailSender
1356
1387
  ]).apply(param => {
1357
- let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
1388
+ let structure = param[0];
1389
+ let missing = CapabilityNeed$Reventless.unmet(Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredCapabilities), []).map(d => [
1390
+ d.capability,
1391
+ d.component
1392
+ ]), (
1393
+ param[2] === "" ? [] : ["Geocoding"]
1394
+ ).concat(param[3] === "" ? [] : ["Messaging"]));
1395
+ if (missing.length !== 0) {
1396
+ Stdlib_JsError.throwWithMessage(CapabilityNeed$Reventless.unmetMessage(missing) + Belt_SetString.toArray(Belt_SetString.fromArray(missing.map(u => {
1397
+ let match = u.need;
1398
+ if (match === "Geocoding") {
1399
+ return `\n Geocoding is \`Capability_Geocoding_AwsLocation.make\`, passed to the platform as \`~geocoderPlaceIndex\`.`;
1400
+ } else {
1401
+ return `\n Messaging is \`Capability_Messaging_Ses.make\`, passed to the platform as \`~messagingSender\`.`;
1402
+ }
1403
+ }))).join(""));
1404
+ }
1405
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredStores), []);
1358
1406
  let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
1359
- let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
1360
- if (typeof missing !== "object") {
1407
+ let missing$1 = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
1408
+ if (typeof missing$1 !== "object") {
1361
1409
  return;
1362
- } else if (missing.TAG === "NotAdopted") {
1363
- return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
1410
+ } else if (missing$1.TAG === "NotAdopted") {
1411
+ return log.warn("Platform:deployPlugin", undefined, `declares ` + missing$1._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
1364
1412
  } else {
1365
- return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
1413
+ return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing$1.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing$1.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
1366
1414
  }
1367
1415
  });
1368
1416
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
@@ -1770,6 +1818,8 @@ function Make($star) {
1770
1818
  eventSchema: UiFragmentRegistry$ReventlessCore.eventSchema,
1771
1819
  commandSchema: UiFragmentRegistry$ReventlessCore.commandSchema,
1772
1820
  commandAuthorization: UiFragmentRegistry$ReventlessCore.commandAuthorization,
1821
+ commandTransition: UiFragmentRegistry$ReventlessCore.commandTransition,
1822
+ traits: UiFragmentRegistry$ReventlessCore.traits,
1773
1823
  readConsistency: UiFragmentRegistry$ReventlessCore.readConsistency
1774
1824
  };
1775
1825
  let UiFragmentRegistrySlice = StateChangeSlice_Builder$ReventlessAws.Make(Spec)({
@@ -1957,7 +2007,9 @@ function Make($star) {
1957
2007
  errorSchema: PluginSpec$ReventlessCore.errorSchema,
1958
2008
  commandSchema: PluginSpec$ReventlessCore.commandSchema,
1959
2009
  moduleUrl: PluginSpec$ReventlessCore.moduleUrl,
1960
- commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization
2010
+ commandAuthorization: PluginSpec$ReventlessCore.commandAuthorization,
2011
+ commandTransition: PluginSpec$ReventlessCore.commandTransition,
2012
+ traits: PluginSpec$ReventlessCore.traits
1961
2013
  })({
1962
2014
  initialState: PluginBehavior$ReventlessCore.initialState,
1963
2015
  evolve: PluginBehavior$ReventlessCore.evolve,
@@ -2441,6 +2493,22 @@ function Make($star) {
2441
2493
  let geocoderPlaceIndexFlat = index$1 !== undefined ? index$1.indexName : Pulumi.output("");
2442
2494
  Pulumi$Pulumi.$$export("geocoderPlaceIndex", geocoderPlaceIndexFlat);
2443
2495
  geocoderPlaceIndexRef.contents = geocoderPlaceIndexFlat;
2496
+ if (capabilities.includes("Geocoding") && Stdlib_Option.isNone(hostUiBundle.geocoderPlaceIndex)) {
2497
+ Stdlib_JsError.throwWithMessage(`A plugin of this deployment declares the Geocoding capability, but this platform provisions no place index.\n Add \`let placeIndex = ReventlessAws.Capability_Geocoding_AwsLocation.make(~name=…)\` and pass \`~geocoderPlaceIndex=placeIndex\` in \`~hostUiBundle\`.\n The declaration is generated from the plugins' capabilities.json — regenerate with \`pnpm run generate:platform\` if it is stale.`);
2498
+ }
2499
+ let match$5 = hostUiBundle.messagingSender;
2500
+ let messagingEmailSenderFlat;
2501
+ if (match$5 !== undefined) {
2502
+ let sender = match$5.emailSender;
2503
+ messagingEmailSenderFlat = sender !== undefined ? sender : Pulumi.output("");
2504
+ } else {
2505
+ messagingEmailSenderFlat = Pulumi.output("");
2506
+ }
2507
+ Pulumi$Pulumi.$$export("messagingEmailSender", messagingEmailSenderFlat);
2508
+ messagingEmailSenderRef.contents = messagingEmailSenderFlat;
2509
+ if (capabilities.includes("Messaging") && Stdlib_Option.isNone(hostUiBundle.messagingSender)) {
2510
+ Stdlib_JsError.throwWithMessage(`A plugin of this deployment declares the Messaging capability, but this platform provisions no sender.\n Add \`let sender = ReventlessAws.Capability_Messaging_Ses.make(~name=…, ~email=…)\` and pass \`~messagingSender=sender\` in \`~hostUiBundle\`.\n The declaration is generated from the plugins' capabilities.json — regenerate with \`pnpm run generate:platform\` if it is stale.`);
2511
+ }
2444
2512
  let configJsonContent = Pulumi.all([
2445
2513
  Pulumi.all([
2446
2514
  resolvedDomainApiEndpoint,
@@ -2510,9 +2578,9 @@ function Make($star) {
2510
2578
  contentType: "application/json"
2511
2579
  });
2512
2580
  }
2513
- let match$5 = hostUiBundle.bakedManifest;
2514
- if (componentDefinitions !== undefined && match$5 !== undefined) {
2515
- let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
2581
+ let match$6 = hostUiBundle.bakedManifest;
2582
+ if (componentDefinitions !== undefined && match$6 !== undefined) {
2583
+ let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$6).map(param => param[0]);
2516
2584
  let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
2517
2585
  Pulumi.all([
2518
2586
  componentDefinitions.roleId,
@@ -2591,6 +2659,9 @@ function Make($star) {
2591
2659
  let geocoderPlaceIndex = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("geocoderPlaceIndex").apply(o => Stdlib_Option.getOr(o, "")) : geocoderPlaceIndexRef.contents;
2592
2660
  PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("PLACE_INDEX_NAME", geocoderPlaceIndex);
2593
2661
  PluginRuntime_Builder$ReventlessAws.registerGeocoderPlaceIndex(geocoderPlaceIndex);
2662
+ let messagingEmailSender = platformStackRef !== undefined ? Primitive_option.valFromOption(platformStackRef).getOutput("messagingEmailSender").apply(o => Stdlib_Option.getOr(o, "")) : messagingEmailSenderRef.contents;
2663
+ PluginRuntime_Builder$ReventlessAws.registerCapabilityEnv("MESSAGING_EMAIL_SENDER", messagingEmailSender);
2664
+ PluginRuntime_Builder$ReventlessAws.registerMessagingSender(messagingEmailSender);
2594
2665
  let pluginComponent = plugin.make();
2595
2666
  Plugin_Helpers$ReventlessCore.clearOffload();
2596
2667
  currentDeployTarget.contents = "Domain";
@@ -2646,17 +2717,36 @@ function Make($star) {
2646
2717
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
2647
2718
  let capabilityGate = Pulumi.all([
2648
2719
  pluginOutputs.pluginStructure,
2649
- stackRef.getOutput("objectStores")
2720
+ stackRef.getOutput("objectStores"),
2721
+ geocoderPlaceIndex,
2722
+ messagingEmailSender
2650
2723
  ]).apply(param => {
2651
- let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
2724
+ let structure = param[0];
2725
+ let missing = CapabilityNeed$Reventless.unmet(Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredCapabilities), []).map(d => [
2726
+ d.capability,
2727
+ d.component
2728
+ ]), (
2729
+ param[2] === "" ? [] : ["Geocoding"]
2730
+ ).concat(param[3] === "" ? [] : ["Messaging"]));
2731
+ if (missing.length !== 0) {
2732
+ Stdlib_JsError.throwWithMessage(CapabilityNeed$Reventless.unmetMessage(missing) + Belt_SetString.toArray(Belt_SetString.fromArray(missing.map(u => {
2733
+ let match = u.need;
2734
+ if (match === "Geocoding") {
2735
+ return `\n Geocoding is \`Capability_Geocoding_AwsLocation.make\`, passed to the platform as \`~geocoderPlaceIndex\`.`;
2736
+ } else {
2737
+ return `\n Messaging is \`Capability_Messaging_Ses.make\`, passed to the platform as \`~messagingSender\`.`;
2738
+ }
2739
+ }))).join(""));
2740
+ }
2741
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(structure, s => s.requiredStores), []);
2652
2742
  let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
2653
- let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
2654
- if (typeof missing !== "object") {
2743
+ let missing$1 = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
2744
+ if (typeof missing$1 !== "object") {
2655
2745
  return;
2656
- } else if (missing.TAG === "NotAdopted") {
2657
- return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
2746
+ } else if (missing$1.TAG === "NotAdopted") {
2747
+ return log.warn("Platform:deployPlugin", undefined, `declares ` + missing$1._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
2658
2748
  } else {
2659
- return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
2749
+ return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing$1.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing$1.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
2660
2750
  }
2661
2751
  });
2662
2752
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
@@ -2764,6 +2854,7 @@ export {
2764
2854
  objectStoreEndpointsRef,
2765
2855
  getObjectStoreEndpoints,
2766
2856
  geocoderPlaceIndexRef,
2857
+ messagingEmailSenderRef,
2767
2858
  MakeWithConfig,
2768
2859
  Make,
2769
2860
  }
@@ -0,0 +1,84 @@
1
+ // Amazon SES as a messaging transport for unattended callers.
2
+ //
3
+ // Policy — who a message can be addressed to, what a send can answer, and which
4
+ // failures are worth retrying — lives in `Reventless.Messaging`, shared with
5
+ // every other transport. This module only knows how to ask SES and how to turn
6
+ // its answer into that vocabulary.
7
+ //
8
+ // Runtime-pure: no Pulumi import, so it can be bundled into a Lambda entry point
9
+ // without dragging deploy-time modules into the cold-start graph.
10
+
11
+ module Send = AwsSdk.SES.SendEmailCommand
12
+
13
+ /**
14
+ The channels this transport can attempt, given what the deployment provisioned.
15
+
16
+ Derived from the sender address rather than declared beside it: SES sends from a
17
+ verified identity, so no address means no email channel, and a list that claimed
18
+ one anyway would be a promise the first send breaks. SMS and push are not wired
19
+ here — `Messaging.UnsupportedChannel` is what a caller gets, which is settled
20
+ rather than retried, so a preference for a channel this deployment does not run
21
+ costs one refusal and not a retry budget.
22
+ */
23
+ let channels = (~sender: string): array<Reventless.Messaging.channel> =>
24
+ sender == "" ? [] : [Email]
25
+
26
+ /**
27
+ Send one message.
28
+
29
+ `Refused` rather than a blank subject line for a message that carries none: SES
30
+ requires one, retrying will not produce one, and a delivered email with an empty
31
+ header is the outcome nobody wanted. The other two arms follow the port's retry
32
+ rule — an unset sender is the deployment's gap (`Unavailable`, retried and swept)
33
+ and anything SES itself refuses is settled.
34
+ */
35
+ let send = async (
36
+ ~sender: string,
37
+ ~recipient: Reventless.Messaging.recipient,
38
+ ~message: Reventless.Messaging.message,
39
+ ): result<Reventless.Messaging.receipt, Reventless.Messaging.failure> =>
40
+ switch recipient {
41
+ | ToSms(_) => Error(UnsupportedChannel(Sms))
42
+ | ToPush(_) => Error(UnsupportedChannel(Push))
43
+ | ToEmail(address) =>
44
+ if sender == "" {
45
+ Error(Unavailable("no messaging sender configured (MESSAGING_EMAIL_SENDER unset)"))
46
+ } else {
47
+ switch message.subject {
48
+ | None | Some("") => Error(Refused("an email needs a subject, and this message carries none"))
49
+ | Some(subject) =>
50
+ switch await Send.send(
51
+ Send.make({
52
+ fromEmailAddress: sender,
53
+ destination: {toAddresses: [address->Reventless.Email.toString]},
54
+ content: {
55
+ simple: {
56
+ subject: {data: subject},
57
+ body: {text: {data: message.body}},
58
+ },
59
+ },
60
+ }),
61
+ ) {
62
+ // SES answers 200 with no MessageId only if the shape changed under us;
63
+ // an accepted message without an id is still accepted, and inventing one
64
+ // would put a value in a receipt that no support conversation resolves.
65
+ | resp => Ok({ref: resp.messageId->Option.getOr("")})
66
+ | exception exn =>
67
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
68
+ // Every SES error is treated as transient. The permanent ones it does
69
+ // raise — an unverified identity, an address on the suppression list —
70
+ // are deployment or recipient state that a sweep should keep surfacing,
71
+ // and reading them apart means matching on message text, which is a
72
+ // provider's prose and not a contract.
73
+ Error(Unavailable(msg))
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ /** The port, closed over the deployment's sender. What a Lambda entry point puts
80
+ on `Capabilities.messaging`. */
81
+ let provider = (~sender: string): Reventless.Messaging.provider => {
82
+ channels: channels(~sender),
83
+ send: (~recipient, ~message) => send(~sender, ~recipient, ~message),
84
+ }
@@ -0,0 +1,120 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as SES$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/SES.res.mjs";
4
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
+ import * as ClientSesv2 from "@aws-sdk/client-sesv2";
8
+
9
+ function channels(sender) {
10
+ if (sender === "") {
11
+ return [];
12
+ } else {
13
+ return ["Email"];
14
+ }
15
+ }
16
+
17
+ async function send(sender, recipient, message) {
18
+ switch (recipient.TAG) {
19
+ case "ToEmail" :
20
+ if (sender === "") {
21
+ return {
22
+ TAG: "Error",
23
+ _0: {
24
+ TAG: "Unavailable",
25
+ _0: "no messaging sender configured (MESSAGING_EMAIL_SENDER unset)"
26
+ }
27
+ };
28
+ }
29
+ let subject = message.subject;
30
+ if (subject === undefined) {
31
+ return {
32
+ TAG: "Error",
33
+ _0: {
34
+ TAG: "Refused",
35
+ _0: "an email needs a subject, and this message carries none"
36
+ }
37
+ };
38
+ }
39
+ if (subject === "") {
40
+ return {
41
+ TAG: "Error",
42
+ _0: {
43
+ TAG: "Refused",
44
+ _0: "an email needs a subject, and this message carries none"
45
+ }
46
+ };
47
+ }
48
+ let resp;
49
+ try {
50
+ resp = await SES$AwsSdk.SendEmailCommand.send(new ClientSesv2.SendEmailCommand({
51
+ Content: {
52
+ Simple: {
53
+ Body: {
54
+ Text: {
55
+ Data: message.body
56
+ }
57
+ },
58
+ Subject: {
59
+ Data: subject
60
+ }
61
+ }
62
+ },
63
+ Destination: {
64
+ ToAddresses: [recipient._0]
65
+ },
66
+ FromEmailAddress: sender
67
+ }));
68
+ } catch (raw_exn) {
69
+ let exn = Primitive_exceptions.internalToException(raw_exn);
70
+ let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
71
+ return {
72
+ TAG: "Error",
73
+ _0: {
74
+ TAG: "Unavailable",
75
+ _0: msg
76
+ }
77
+ };
78
+ }
79
+ return {
80
+ TAG: "Ok",
81
+ _0: {
82
+ ref: Stdlib_Option.getOr(resp.MessageId, "")
83
+ }
84
+ };
85
+ break;
86
+ case "ToSms" :
87
+ return {
88
+ TAG: "Error",
89
+ _0: {
90
+ TAG: "UnsupportedChannel",
91
+ _0: "Sms"
92
+ }
93
+ };
94
+ case "ToPush" :
95
+ return {
96
+ TAG: "Error",
97
+ _0: {
98
+ TAG: "UnsupportedChannel",
99
+ _0: "Push"
100
+ }
101
+ };
102
+ }
103
+ }
104
+
105
+ function provider(sender) {
106
+ return {
107
+ channels: channels(sender),
108
+ send: (recipient, message) => send(sender, recipient, message)
109
+ };
110
+ }
111
+
112
+ let Send;
113
+
114
+ export {
115
+ Send,
116
+ channels,
117
+ send,
118
+ provider,
119
+ }
120
+ /* SES-AwsSdk Not a pure module */
@@ -226,7 +226,9 @@ public unauthenticated endpoint for an unattended path.
226
226
  Resolved per call rather than captured once, so a Lambda whose configuration is
227
227
  updated does not need a cold start to see it. An unset `PLACE_INDEX_NAME` reaches
228
228
  `Geocoder_AwsLocation_Backend` as `""`, which answers `Unavailable` — a retryable
229
- outcome, not a verdict on the address.
229
+ outcome, not a verdict on the address. `MESSAGING_EMAIL_SENDER` is read the same
230
+ way, and empty yields a provider with no channels rather than one that claims a
231
+ channel it cannot send on.
230
232
  */
231
233
  let capabilities = (): Reventless.Capabilities.t => {
232
234
  geocode: (~text) =>
@@ -234,6 +236,9 @@ let capabilities = (): Reventless.Capabilities.t => {
234
236
  ~indexName=NodeProcess.env->Dict.get("PLACE_INDEX_NAME")->Option.getOr(""),
235
237
  ~text,
236
238
  ),
239
+ messaging: Messaging_Ses_Backend.provider(
240
+ ~sender=NodeProcess.env->Dict.get("MESSAGING_EMAIL_SENDER")->Option.getOr(""),
241
+ ),
237
242
  }
238
243
 
239
244
  // ── Phase-1/phase-2 pipelines ───────────────────────────────────────────────
@@ -14,6 +14,7 @@ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/U
14
14
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
15
15
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
16
16
  import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.mjs";
17
+ import * as Messaging_Ses_Backend$ReventlessAws from "../Messaging/Messaging_Ses_Backend.res.mjs";
17
18
  import * as Util_DynamoDb_Runtime$ReventlessAws from "../../util/Util_DynamoDb_Runtime.res.mjs";
18
19
  import * as AutomationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs";
19
20
  import * as StreamRoutedEntryPoint_Ops$ReventlessAws from "./StreamRoutedEntryPoint_Ops.res.mjs";
@@ -151,7 +152,8 @@ function makeLoadTodoItems(queryDbTableName, todoItems, rowSchema, comp) {
151
152
 
152
153
  function capabilities() {
153
154
  return {
154
- geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
155
+ geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined),
156
+ messaging: Messaging_Ses_Backend$ReventlessAws.provider(Stdlib_Option.getOr(process.env["MESSAGING_EMAIL_SENDER"], ""))
155
157
  };
156
158
  }
157
159
 
@@ -185,9 +187,7 @@ function makeOutboundJsonEventsHandler(consumedEventSchema, callback, publishJso
185
187
  await loadTodoItems();
186
188
  callback.phase1(events);
187
189
  await syncTodoItems();
188
- await callback.phase2(publishJsons, {
189
- geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
190
- });
190
+ await callback.phase2(publishJsons, capabilities());
191
191
  return await syncTodoItems();
192
192
  }));
193
193
  }
@@ -223,9 +223,7 @@ function makeOutboundRegisteredHandler(entry, sliceName, consumedEventSchema, ca
223
223
  },
224
224
  sweep: async () => {
225
225
  await loadTodoItems();
226
- await callback.phase2(publishJsons, {
227
- geocode: text => Geocoder_AwsLocation_Backend$ReventlessAws.search(Stdlib_Option.getOr(process.env["PLACE_INDEX_NAME"], ""), text, undefined)
228
- });
226
+ await callback.phase2(publishJsons, capabilities());
229
227
  return await syncTodoItems();
230
228
  },
231
229
  comp: comp
@@ -296,6 +296,43 @@ let makeGeocoderGrant = (~runtime: ReventlessCore.Runtime.environment<runtimePar
296
296
  )
297
297
  }
298
298
 
299
+ // Least-privilege `ses:SendEmail` on the platform's verified sender identity,
300
+ // for the slices that send through the injected messaging capability. Built the
301
+ // same way as the geocoder grant above, and for the same three reasons: the
302
+ // provisioned bit is a plain bool, the address is an Output, and the RolePolicy
303
+ // is created at top level rather than inside an `apply`.
304
+ let makeMessagingGrant = (~runtime: ReventlessCore.Runtime.environment<runtimeParts>, ~opts) =>
305
+ if PluginRuntime_Builder.messagingProvisioned() {
306
+ let customOpts =
307
+ opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
308
+ let policyJson =
309
+ PluginRuntime_Builder.messagingSender()->Pulumi.Output.apply(sender => {
310
+ open PulumiAws.PolicyDocument
311
+ PulumiAws.PolicyDocument.make(
312
+ ~id="AllAutomationSlicesSendEmail",
313
+ ~statements=[
314
+ {
315
+ sid: "AllowSendEmail",
316
+ effect: Allow,
317
+ // SESv2's SendEmail authorizes against the v1 action name, and the
318
+ // resource is the identity the message is sent FROM — not the
319
+ // recipient, which is why one grant covers every destination.
320
+ actions: Action("ses:SendEmail"),
321
+ resources: Resource(`arn:aws:ses:*:*:identity/${sender}`),
322
+ },
323
+ ],
324
+ )->PulumiAws.PolicyDocument.toJsonString
325
+ })
326
+ let _ = PulumiAws.IAM.RolePolicy.make(
327
+ ~name="AllAutomationSlicesSendEmail",
328
+ ~args={
329
+ policy: policyJson->Pulumi.Output.asInput,
330
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
331
+ },
332
+ ~opts=customOpts,
333
+ )
334
+ }
335
+
299
336
  let finished = ref(false)
300
337
 
301
338
  // Legacy finalizer, kept only to satisfy `EventCollectorRuntime_Builder.T`.
@@ -619,6 +656,7 @@ let finishWithDcbEventLog = (dcbEventLog: ReventlessCore.DcbEventLog.component)
619
656
 
620
657
  makeSweepSchedule(~runtime, ~opts)
621
658
  makeGeocoderGrant(~runtime, ~opts)
659
+ makeMessagingGrant(~runtime, ~opts)
622
660
  | None =>
623
661
  log.warn(
624
662
  ~comp="AutomationSliceRuntime_Builder_Single",
@@ -150,6 +150,23 @@ function makeGeocoderGrant(runtime, opts) {
150
150
  }, customOpts);
151
151
  }
152
152
 
153
+ function makeMessagingGrant(runtime, opts) {
154
+ if (!PluginRuntime_Builder$ReventlessAws.messagingProvisioned()) {
155
+ return;
156
+ }
157
+ let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
158
+ let policyJson = PluginRuntime_Builder$ReventlessAws.messagingSender().apply(sender => PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "AllAutomationSlicesSendEmail", [{
159
+ Sid: "AllowSendEmail",
160
+ Effect: "Allow",
161
+ Action: "ses:SendEmail",
162
+ Resource: `arn:aws:ses:*:*:identity/` + sender
163
+ }])));
164
+ new (Aws.iam.RolePolicy)("AllAutomationSlicesSendEmail", {
165
+ policy: policyJson,
166
+ role: runtime.parts.lambdaRole.id
167
+ }, customOpts);
168
+ }
169
+
153
170
  let finished = {
154
171
  contents: false
155
172
  };
@@ -327,6 +344,7 @@ function finishWithDcbEventLog(dcbEventLog) {
327
344
  }], runtime, opts);
328
345
  makeSweepSchedule(runtime, opts);
329
346
  makeGeocoderGrant(runtime, opts);
347
+ makeMessagingGrant(runtime, opts);
330
348
  } else {
331
349
  log.warn("AutomationSliceRuntime_Builder_Single", undefined, "finishWithDcbEventLog: DCB EventLog has no parent");
332
350
  }
@@ -357,6 +375,7 @@ export {
357
375
  sweepIntervalMinutes,
358
376
  makeSweepSchedule,
359
377
  makeGeocoderGrant,
378
+ makeMessagingGrant,
360
379
  finished,
361
380
  finish,
362
381
  finishWithDcbEventLog,
@@ -0,0 +1,31 @@
1
+ // A verified SES sender identity, provisioned with the framework's house
2
+ // conventions applied — the backing for the messaging capability's email channel.
3
+ //
4
+ // The identity is the whole of the provisioning: SES sends from a verified
5
+ // address, and everything else a send needs travels on the message. So this
6
+ // helper exists to make the sender a deploy-time decision with one owner rather
7
+ // than an environment variable each stack spells for itself.
8
+ //
9
+ // **Verification is not instant and not automatic.** Creating the identity asks
10
+ // AWS to send a confirmation mail to the address; until somebody follows it, SES
11
+ // refuses every send. That is a deployment step this code cannot perform, and a
12
+ // deploy that "succeeded" is not evidence mail flows — which is why the runtime
13
+ // treats an SES refusal as retryable, so the first sends queue rather than being
14
+ // written off while the address is still pending.
15
+ //
16
+ // A deployment in the SES sandbox may also only send *to* verified addresses.
17
+ // Leaving the sandbox is an account-level request, not a resource.
18
+
19
+ open PulumiAws
20
+
21
+ /** Declare the sender address and hand back the platform's messaging handle.
22
+ `~email` is the `From:` every message this deployment sends carries. */
23
+ let make = (
24
+ ~name: string,
25
+ ~email: string,
26
+ ~opts: option<Pulumi.CustomResourceOptions.t>=?,
27
+ ): ReventlessInfra.Platform.messagingSender => {
28
+ let identity = SES.EmailIdentity.make(~name, ~args={email: email}, ~opts?)
29
+
30
+ {emailSender: identity.email->Pulumi.Output.asInput}
31
+ }
@@ -0,0 +1,18 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
5
+
6
+ function make(name, email, opts) {
7
+ let identity = new (Aws.ses.EmailIdentity)(name, {
8
+ email: email
9
+ }, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
10
+ return {
11
+ emailSender: identity.email
12
+ };
13
+ }
14
+
15
+ export {
16
+ make,
17
+ }
18
+ /* @pulumi/aws Not a pure module */
@@ -77,6 +77,20 @@ let registerGeocoderPlaceIndex = (indexName: Pulumi.Output.t<string>) => {
77
77
  let geocoderPlaceIndex = () => geocoderPlaceIndexRef.contents
78
78
  let geocoderProvisioned = () => geocoderProvisionedRef.contents
79
79
 
80
+ /** The messaging sender, carried for the same reason and in the same shape: a
81
+ slice Lambda sending mail needs `ses:SendEmail` on the identity, and the
82
+ identity is what the address names. */
83
+ let messagingSenderRef: ref<Pulumi.Output.t<string>> = ref(Pulumi.Output.make(""))
84
+ let messagingProvisionedRef = ref(false)
85
+
86
+ let registerMessagingSender = (sender: Pulumi.Output.t<string>) => {
87
+ messagingSenderRef := sender
88
+ messagingProvisionedRef := true
89
+ }
90
+
91
+ let messagingSender = () => messagingSenderRef.contents
92
+ let messagingProvisioned = () => messagingProvisionedRef.contents
93
+
80
94
  type sliceModulePaths = {
81
95
  specPath: string,
82
96
  behaviorPath: string,
@@ -68,6 +68,27 @@ function geocoderProvisioned() {
68
68
  return geocoderProvisionedRef.contents;
69
69
  }
70
70
 
71
+ let messagingSenderRef = {
72
+ contents: Pulumi.output("")
73
+ };
74
+
75
+ let messagingProvisionedRef = {
76
+ contents: false
77
+ };
78
+
79
+ function registerMessagingSender(sender) {
80
+ messagingSenderRef.contents = sender;
81
+ messagingProvisionedRef.contents = true;
82
+ }
83
+
84
+ function messagingSender() {
85
+ return messagingSenderRef.contents;
86
+ }
87
+
88
+ function messagingProvisioned() {
89
+ return messagingProvisionedRef.contents;
90
+ }
91
+
71
92
  let dcbConfigRef = {
72
93
  contents: {
73
94
  pluginName: "",
@@ -544,6 +565,11 @@ export {
544
565
  registerGeocoderPlaceIndex,
545
566
  geocoderPlaceIndex,
546
567
  geocoderProvisioned,
568
+ messagingSenderRef,
569
+ messagingProvisionedRef,
570
+ registerMessagingSender,
571
+ messagingSender,
572
+ messagingProvisioned,
547
573
  dcbConfigRef,
548
574
  registeredSliceModulePaths,
549
575
  registerStateChangeSliceSpec,
@@ -50,6 +50,8 @@ let emptyStructure: Reventless.Plugin.pluginStructure = {
50
50
  extensionPoints: None,
51
51
  requiredStores: None,
52
52
  requiredStoreDeclarations: None,
53
+ requiredCapabilities: None,
54
+ traitDeclarations: None,
53
55
  }
54
56
 
55
57
  let definition = (~structure): Reventless.Plugin.pluginDefinition => {
@@ -63,7 +63,9 @@ let emptyStructure = {
63
63
  extensions: emptyStructure_extensions,
64
64
  extensionPoints: undefined,
65
65
  requiredStores: undefined,
66
- requiredStoreDeclarations: undefined
66
+ requiredStoreDeclarations: undefined,
67
+ requiredCapabilities: undefined,
68
+ traitDeclarations: undefined
67
69
  };
68
70
 
69
71
  function definition(structure) {
@@ -134,7 +136,9 @@ globalThis.describe("the bake converges when a plugin's structure changes", () =
134
136
  extensions: emptyStructure_extensions,
135
137
  extensionPoints: undefined,
136
138
  requiredStores: ["Ordering.receipts"],
137
- requiredStoreDeclarations: undefined
139
+ requiredStoreDeclarations: undefined,
140
+ requiredCapabilities: undefined,
141
+ traitDeclarations: undefined
138
142
  });
139
143
  let keyB = match$1[0];
140
144
  let defA = definition(match[1]);
@@ -19,3 +19,5 @@ type error = AlreadyExists
19
19
  let name = "AggTestAggregate"
20
20
  let moduleUrl = "agg-test://AggTestAggregate"
21
21
  let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
22
+ type lifecycleState = unit
23
+ let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> => Unrestricted
@@ -18,6 +18,10 @@ function commandAuthorization(param) {
18
18
  return "AllowAnonymous";
19
19
  }
20
20
 
21
+ function commandTransition(param) {
22
+ return "Unrestricted";
23
+ }
24
+
21
25
  let name = "AggTestAggregate";
22
26
 
23
27
  let moduleUrl = "agg-test://AggTestAggregate";
@@ -29,5 +33,6 @@ export {
29
33
  name,
30
34
  moduleUrl,
31
35
  commandAuthorization,
36
+ commandTransition,
32
37
  }
33
38
  /* commandSchema Not a pure module */
@@ -56,6 +56,8 @@ type event =
56
56
  let name = "EpCompositeSlice"
57
57
  let moduleUrl = "ep-test://EpCompositeSlice"
58
58
  let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
59
+ type lifecycleState = unit
60
+ let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> => Unrestricted
59
61
  let readConsistency = Reventless.ReadConsistency.EscalateOnRetry
60
62
 
61
63
  // `module Id = Reventless.Id.String` — patched in by `patchSpecId` at runtime.
@@ -51,6 +51,10 @@ function commandAuthorization(param) {
51
51
  return "AllowAnonymous";
52
52
  }
53
53
 
54
+ function commandTransition(param) {
55
+ return "Unrestricted";
56
+ }
57
+
54
58
  let name = "EpCompositeSlice";
55
59
 
56
60
  let moduleUrl = "ep-test://EpCompositeSlice";
@@ -65,6 +69,7 @@ export {
65
69
  name,
66
70
  moduleUrl,
67
71
  commandAuthorization,
72
+ commandTransition,
68
73
  readConsistency,
69
74
  }
70
75
  /* consumedEventSchema Not a pure module */
@@ -22,6 +22,8 @@ type event = WidgetAdded({widgetId: @s.matches(Reventless.DcbTag.partition) stri
22
22
  let name = "EpTestSlice"
23
23
  let moduleUrl = "ep-test://EpTestSlice"
24
24
  let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
25
+ type lifecycleState = unit
26
+ let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> => Unrestricted
25
27
  let readConsistency = Reventless.ReadConsistency.EscalateOnRetry
26
28
 
27
29
  // `module Id = Reventless.Id.String` — patched in by `patchSpecId` at runtime,
@@ -24,6 +24,10 @@ function commandAuthorization(param) {
24
24
  return "AllowAnonymous";
25
25
  }
26
26
 
27
+ function commandTransition(param) {
28
+ return "Unrestricted";
29
+ }
30
+
27
31
  let name = "EpTestSlice";
28
32
 
29
33
  let moduleUrl = "ep-test://EpTestSlice";
@@ -38,6 +42,7 @@ export {
38
42
  name,
39
43
  moduleUrl,
40
44
  commandAuthorization,
45
+ commandTransition,
41
46
  readConsistency,
42
47
  }
43
48
  /* consumedEventSchema Not a pure module */