@reventlessdev/reventless-aws 3.0.0-alpha.240 → 3.0.0-alpha.242

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.242 (2026-07-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **platform:** export the bucket's physical name, not the layout's ([43b8ba4](https://github.com/ReventlessDev/reventless-core/commit/43b8ba4f04499c918c7c16921962ad640ea20198))
11
+ ### Features
12
+
13
+ * **platform:** fail a plugin deploy that needs a store the platform lacks ([8f2b6e6](https://github.com/ReventlessDev/reventless-core/commit/8f2b6e6ca8ea472c70bee40e57c959a64f2a7b4e))
14
+
15
+
16
+ # 3.0.0-alpha.241 (2026-07-29)
17
+
18
+ ### Features
19
+
20
+ * **platform:** serve and publish declared object stores without a host UI ([9cc4700](https://github.com/ReventlessDev/reventless-core/commit/9cc4700dc96f05e3d35fbc912a4c7be7a5531337))
21
+
22
+
6
23
  # 3.0.0-alpha.240 (2026-07-28)
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.240",
3
+ "version": "3.0.0-alpha.242",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,16 +12,16 @@
12
12
  "sury": "11.0.0-alpha.4",
13
13
  "uuid": "^13.0.0",
14
14
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.25",
15
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
16
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
16
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
17
17
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
18
18
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
19
19
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
20
- "@reventlessdev/reventless-core": "3.0.0-alpha.190",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
22
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.54",
23
- "@reventlessdev/reventless-spec": "3.0.0-alpha.83",
24
- "@reventlessdev/reventless-interop": "3.0.0-alpha.29"
20
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
21
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.84",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.109",
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.55",
24
+ "@reventlessdev/reventless-core": "3.0.0-alpha.191"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -44,6 +44,45 @@ let splitApiOutputsRef: ref<option<splitApiOutputs>> = ref(None)
44
44
  Call after `makePlatform` — returns `None` in unified mode. */
45
45
  let getSplitApiOutputs = () => splitApiOutputsRef.contents
46
46
 
47
+ // Object stores provisioned from field declarations — populated by
48
+ // deployPlatform. Access via getObjectStoreEndpoints() after it has been called.
49
+ //
50
+ // A platform whose UI ships from its own stack needs each store's presign
51
+ // endpoint, and `deployPlatform` is the only place that knows it. Without this
52
+ // the store is provisioned and unnameable: the stores are built unconditionally,
53
+ // but every consumer of them used to sit inside `switch hostUiBundle`. Same
54
+ // reason `getSplitApiOutputs` exists — a root cannot re-export what it cannot
55
+ // reach.
56
+ type objectStoreEndpoint = {
57
+ // The store's qualified `{plugin}.{store}` name. The same string
58
+ // `pluginStructure.requiredStores` carries, so a UI's binding key and the
59
+ // declaration's identity are one value rather than two that can drift.
60
+ store: string,
61
+ // The path this store's keys are rooted at, and therefore the path it must be
62
+ // served under for a minted `/{key}` ref to resolve.
63
+ keyPrefix: string,
64
+ // The bucket's **physical** name, which is not the name the layout computes:
65
+ // Pulumi auto-names the resource, so a `SharedBucket` stack asking for
66
+ // `alpha-stores` gets `alpha-stores-507202d`. An Output because that suffix is
67
+ // only known after the bucket resolves — and a plain string here is exactly
68
+ // how this shipped exporting a bucket name that no ARN matches.
69
+ bucketName: Pulumi.Output.t<string>,
70
+ uploadUrl: Pulumi.Output.t<string>,
71
+ // Public base URL the store is served from, when the platform serves it
72
+ // itself. `None` when a host-UI bundle is deployed — there the shell's own
73
+ // origin serves the store same-origin and a relative `/{prefix}/…` resolves
74
+ // without a base.
75
+ baseUrl: option<Pulumi.Output.t<string>>,
76
+ }
77
+ let objectStoreEndpointsRef: ref<array<objectStoreEndpoint>> = ref([])
78
+
79
+ /** Returns the object stores this platform provisioned from field declarations.
80
+
81
+ Empty when nothing is declared — a legitimate answer rather than a
82
+ call-order mistake, so this returns `[]` instead of throwing the way
83
+ `getApiConfig` does. */
84
+ let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
85
+
47
86
  module MakeWithConfig = (
48
87
  Config: {
49
88
  let splitApi: bool
@@ -1552,7 +1591,11 @@ module MakeWithConfig = (
1552
1591
  ~bucketName=storeHandle.bucketName,
1553
1592
  ~servedPrefix=storeHandle.keyPrefix,
1554
1593
  )
1555
- (`${plugin}.${store}`, keyPrefix, bucketName, presign.url)
1594
+ // `bucketName` is the layout's *logical* name and groups the served view
1595
+ // below; `storeHandle.bucketName` is the physical one Pulumi resolves, and
1596
+ // is what a consumer needs to build an ARN. Carrying both is the point —
1597
+ // they are different strings and only one of them exists in S3.
1598
+ (`${plugin}.${store}`, keyPrefix, bucketName, storeHandle.bucketName, presign.url)
1556
1599
  })
1557
1600
 
1558
1601
  // Group the served view by bucket: one origin and one bucket policy per
@@ -1563,13 +1606,105 @@ module MakeWithConfig = (
1563
1606
  ->Option.map(b => {
1564
1607
  ReventlessInfra.Platform.id: bucketName,
1565
1608
  prefixes: declaredStoreServices
1566
- ->Array.filterMap(((_, prefix, bn, _)) => bn == bucketName ? Some(prefix) : None),
1609
+ ->Array.filterMap(((_, prefix, bn, _, _)) => bn == bucketName ? Some(prefix) : None),
1567
1610
  bucketId: b.bucketId,
1568
1611
  bucketArn: b.bucketArn,
1569
1612
  bucketRegionalDomainName: b.bucketRegionalDomainName,
1570
1613
  })
1571
1614
  )
1572
1615
 
1616
+ // ── Serving the declared stores ───────────────────────────────────────
1617
+ //
1618
+ // A store's bucket blocks public policy and takes its read grant only from
1619
+ // a distribution's BucketPolicy, so "provisioned" and "readable" are two
1620
+ // different things. When a host UI bundle is deployed, its distribution
1621
+ // fronts the stores same-origin (below) and a relative `/{prefix}/…`
1622
+ // resolves with no base URL. When it is not, the platform fronts them
1623
+ // itself — otherwise every declared store is write-only.
1624
+ //
1625
+ // Never both: two distributions fronting one bucket would each write the
1626
+ // bucket's single allowed policy and silently unpick the other's grant.
1627
+ // `Util_StoreLayout.servingFor` is where that exclusion is decided, so it is
1628
+ // one testable choice rather than a condition spelled out here.
1629
+ let storeServingBaseUrl: option<Pulumi.Output.t<string>> = switch Util_StoreLayout.servingFor(
1630
+ ~hasHostUiBundle=hostUiBundle->Option.isSome,
1631
+ ~declaredBucketCount=declaredServedBuckets->Array.length,
1632
+ ) {
1633
+ | NoStores | HostShell => None
1634
+ | PlatformOwned =>
1635
+ Some(
1636
+ Plugin_Stack.makeServedBucketDistribution(
1637
+ ~name="object-stores",
1638
+ ~servedBuckets=declaredServedBuckets,
1639
+ ),
1640
+ )
1641
+ }
1642
+
1643
+ let declaredStoreEndpoints = declaredStoreServices->Array.map(((
1644
+ store,
1645
+ keyPrefix,
1646
+ _logicalBucketName,
1647
+ physicalBucketName,
1648
+ uploadUrl,
1649
+ )) => {
1650
+ store,
1651
+ keyPrefix,
1652
+ bucketName: physicalBucketName->Pulumi.Output.fromInput,
1653
+ uploadUrl,
1654
+ baseUrl: storeServingBaseUrl,
1655
+ })
1656
+ objectStoreEndpointsRef := declaredStoreEndpoints
1657
+
1658
+ // Exported unconditionally — the provisioning above already is. Their only
1659
+ // consumers used to sit inside `switch hostUiBundle`, which left a platform
1660
+ // whose UI ships from another stack holding stores it could not name.
1661
+ //
1662
+ // Omitted entirely when nothing is declared, so a deployment with no
1663
+ // declared store keeps a byte-identical output set.
1664
+ if declaredStoreEndpoints->Array.length > 0 {
1665
+ Pulumi.Pulumi.export(
1666
+ "uploadEndpoints",
1667
+ declaredStoreEndpoints
1668
+ ->Array.map(e => e.uploadUrl->Pulumi.Output.apply(u => (e.store, JSON.Encode.string(u))))
1669
+ ->Pulumi.Output.all
1670
+ ->Pulumi.Output.apply(pairs => pairs->Dict.fromArray->JSON.Encode.object),
1671
+ )
1672
+ Pulumi.Pulumi.export(
1673
+ "objectStores",
1674
+ declaredStoreEndpoints
1675
+ ->Array.map(e =>
1676
+ Pulumi.Output.all2((e.bucketName, e.baseUrl->Pulumi.Output.allOpt))->Pulumi.Output.apply(((
1677
+ bucketName,
1678
+ baseUrl,
1679
+ )) => (e, bucketName, baseUrl))
1680
+ )
1681
+ ->Pulumi.Output.all
1682
+ ->Pulumi.Output.apply(resolved =>
1683
+ resolved
1684
+ ->Array.map(((e, bucketName, baseUrl)) => (
1685
+ e.store,
1686
+ [
1687
+ ("bucketName", JSON.Encode.string(bucketName)),
1688
+ ("keyPrefix", JSON.Encode.string(e.keyPrefix)),
1689
+ ]
1690
+ // Absent when the host shell serves the store: there the object is
1691
+ // addressable same-origin and a base URL would be a second, and
1692
+ // wrong, way to reach it.
1693
+ ->Array.concat(
1694
+ switch baseUrl {
1695
+ | Some(b) => [("baseUrl", JSON.Encode.string(b))]
1696
+ | None => []
1697
+ },
1698
+ )
1699
+ ->Dict.fromArray
1700
+ ->JSON.Encode.object,
1701
+ ))
1702
+ ->Dict.fromArray
1703
+ ->JSON.Encode.object
1704
+ ),
1705
+ )
1706
+ }
1707
+
1573
1708
  // Host UI shell deployment — opt-in via ~hostUiBundle. The shell SPA is
1574
1709
  // hosted on its own CloudFront distribution; `config.json` is generated
1575
1710
  // at deploy time with the resolved API endpoints, region, and Cognito
@@ -1706,7 +1841,7 @@ module MakeWithConfig = (
1706
1841
  // built before per-store binding reads it and is unaffected.
1707
1842
  let storeUploadEndpointsOutput: Pulumi.Output.t<array<(string, string)>> =
1708
1843
  declaredStoreServices
1709
- ->Array.map(((qualified, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
1844
+ ->Array.map(((qualified, _, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
1710
1845
  ->Pulumi.Output.all
1711
1846
 
1712
1847
  let configJsonContent =
@@ -1934,11 +2069,66 @@ module MakeWithConfig = (
1934
2069
  ~mergedApiIdentifier=checkedMergedApiArn,
1935
2070
  ~association,
1936
2071
  )
2072
+
2073
+ // ── Capability coverage ────────────────────────────────────────────────
2074
+ //
2075
+ // `requiredStores` is what this plugin's fields *declare*; the platform's
2076
+ // `objectStores` output is what it actually provisioned. A store in the
2077
+ // first and not the second is the split-stack ordering hazard: the
2078
+ // platform deploys before the plugin and cannot see the plugin's schemas,
2079
+ // so the capability list is hand-written and can simply be wrong.
2080
+ //
2081
+ // It is worth failing on because every symptom is silent. The upload
2082
+ // input finds no per-store endpoint, falls back to the legacy single
2083
+ // service, and writes to whatever bucket that serves — a 2xx, a plausible
2084
+ // ref, and the wrong destination. A case slip in the hand-written name
2085
+ // produces exactly that, and so does forgetting the entry entirely.
2086
+ //
2087
+ // Two outcomes rather than one, because "you got it wrong" and "you have
2088
+ // not started" deserve different answers. A platform exporting no stores
2089
+ // at all has not adopted capability provisioning; failing it would break
2090
+ // deployments that are working. A platform exporting *some* stores but
2091
+ // not this one has adopted it and is missing or misspelling an entry.
2092
+ //
2093
+ // Folded into the association export for the same reason the merge gate
2094
+ // is: a dangling `apply` is not guaranteed to be evaluated, and a check
2095
+ // that might not run is not a check.
2096
+ let capabilityGate: Pulumi.Output.t<unit> =
2097
+ (pluginOutputs.pluginStructure, stackRef->Pulumi.StackReference.getOutput("objectStores"))
2098
+ ->Pulumi.Output.all2
2099
+ ->Pulumi.Output.apply((((structure, objectStores): (_, option<JSON.t>))) => {
2100
+ let required =
2101
+ structure->Option.flatMap(s => s.requiredStores)->Option.getOr([])
2102
+ let provisioned =
2103
+ objectStores
2104
+ ->Option.flatMap(JSON.Decode.object)
2105
+ ->Option.map(Dict.keysToArray)
2106
+ ->Option.getOr([])
2107
+ switch Util_StoreLayout.coverageFor(~required, ~provisioned) {
2108
+ | Covered => ()
2109
+ | NotAdopted(missing) =>
2110
+ log.warn(
2111
+ ~comp="Platform:deployPlugin",
2112
+ `declares ${missing->Array.join(", ")} but the platform stack provisions no object stores — ` ++
2113
+ `add them to the platform's ~capabilities and redeploy the platform first, ` ++
2114
+ `or uploads will fall back to the legacy service and write to the wrong bucket`,
2115
+ )
2116
+ | Missing({missing, provisioned}) =>
2117
+ JsError.throwWithMessage(
2118
+ `Plugin requires object store(s) the platform does not provision: ${missing->Array.join(", ")}.\n` ++
2119
+ ` The platform stack provisions: ${provisioned->Array.join(", ")}.\n` ++
2120
+ ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — ` ++
2121
+ `check the capability's spelling and case against it.\n` ++
2122
+ ` Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`,
2123
+ )
2124
+ }
2125
+ })
2126
+
1937
2127
  Pulumi.Pulumi.export(
1938
2128
  "sourceApiAssociationId",
1939
- (association.associationId, mergeGate)
1940
- ->Pulumi.Output.all2
1941
- ->Pulumi.Output.apply(((id, _)) => id),
2129
+ (association.associationId, mergeGate, capabilityGate)
2130
+ ->Pulumi.Output.all3
2131
+ ->Pulumi.Output.apply(((id, _, _)) => id),
1942
2132
  )
1943
2133
  Pulumi.Pulumi.export(
1944
2134
  "pluginSourceApiId",
@@ -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 Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
14
15
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
15
16
  import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
16
17
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
@@ -116,6 +117,14 @@ function getSplitApiOutputs() {
116
117
  return splitApiOutputsRef.contents;
117
118
  }
118
119
 
120
+ let objectStoreEndpointsRef = {
121
+ contents: []
122
+ };
123
+
124
+ function getObjectStoreEndpoints() {
125
+ return objectStoreEndpointsRef.contents;
126
+ }
127
+
119
128
  function MakeWithConfig(Config) {
120
129
  Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
121
130
  Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
@@ -927,6 +936,7 @@ function MakeWithConfig(Config) {
927
936
  plugin + `.` + store,
928
937
  keyPrefix,
929
938
  bucketName,
939
+ storeHandle.bucketName,
930
940
  presign.url
931
941
  ];
932
942
  });
@@ -941,18 +951,70 @@ function MakeWithConfig(Config) {
941
951
  bucketArn: b.bucketArn,
942
952
  bucketRegionalDomainName: b.bucketRegionalDomainName
943
953
  })));
954
+ let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
955
+ let storeServingBaseUrl;
956
+ switch (match$1) {
957
+ case "NoStores" :
958
+ case "HostShell" :
959
+ storeServingBaseUrl = undefined;
960
+ break;
961
+ case "PlatformOwned" :
962
+ storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
963
+ break;
964
+ }
965
+ let declaredStoreEndpoints = declaredStoreServices.map(param => ({
966
+ store: param[0],
967
+ keyPrefix: param[1],
968
+ bucketName: param[3],
969
+ uploadUrl: param[4],
970
+ baseUrl: storeServingBaseUrl
971
+ }));
972
+ objectStoreEndpointsRef.contents = declaredStoreEndpoints;
973
+ if (declaredStoreEndpoints.length !== 0) {
974
+ Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
975
+ e.store,
976
+ u
977
+ ]))).apply(pairs => Object.fromEntries(pairs)));
978
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
979
+ e.bucketName,
980
+ Output$Pulumi.allOpt(e.baseUrl)
981
+ ]).apply(param => [
982
+ e,
983
+ param[0],
984
+ param[1]
985
+ ]))).apply(resolved => Object.fromEntries(resolved.map(param => {
986
+ let baseUrl = param[2];
987
+ let e = param[0];
988
+ return [
989
+ e.store,
990
+ Object.fromEntries([
991
+ [
992
+ "bucketName",
993
+ param[1]
994
+ ],
995
+ [
996
+ "keyPrefix",
997
+ e.keyPrefix
998
+ ]
999
+ ].concat(baseUrl !== undefined ? [[
1000
+ "baseUrl",
1001
+ baseUrl
1002
+ ]] : []))
1003
+ ];
1004
+ }))));
1005
+ }
944
1006
  if (hostUiBundle !== undefined) {
945
- let match$1 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
946
- let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
1007
+ let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
1008
+ let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
947
1009
  let customDomain;
948
- if (match$1 !== undefined && match$2 !== undefined) {
1010
+ if (match$2 !== undefined && match$3 !== undefined) {
949
1011
  let stack = Pulumi.getStack();
950
1012
  let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
951
1013
  let prodStacks = Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), Util_HostUiDomain$ReventlessAws.parseProdStacks), Util_HostUiDomain$ReventlessAws.defaultProdStacks);
952
- let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$1, prodStacks);
1014
+ let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
953
1015
  customDomain = {
954
1016
  fqdn: fqdn,
955
- hostedZoneId: match$2
1017
+ hostedZoneId: match$3
956
1018
  };
957
1019
  } else {
958
1020
  customDomain = undefined;
@@ -967,11 +1029,11 @@ function MakeWithConfig(Config) {
967
1029
  bucketRegionalDomainName: store.bucketRegionalDomainName
968
1030
  }] : []
969
1031
  ).concat(declaredServedBuckets);
970
- let match$3 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
1032
+ let match$4 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
971
1033
  "config.json",
972
1034
  "ui-hints.json"
973
1035
  ], customDomain, servedBuckets);
974
- let bucketName = match$3.bucketName;
1036
+ let bucketName = match$4.bucketName;
975
1037
  let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
976
1038
  let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
977
1039
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
@@ -981,7 +1043,7 @@ function MakeWithConfig(Config) {
981
1043
  let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
982
1044
  let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
983
1045
  let qualified = param[0];
984
- return param[3].apply(u => [
1046
+ return param[4].apply(u => [
985
1047
  qualified,
986
1048
  u
987
1049
  ]);
@@ -1083,7 +1145,7 @@ function MakeWithConfig(Config) {
1083
1145
  contentType: "application/json"
1084
1146
  });
1085
1147
  }
1086
- Pulumi$Pulumi.$$export("hostShellUrl", match$3.distributionUrl);
1148
+ Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
1087
1149
  }
1088
1150
  return Pulumi$Pulumi.getOutputs();
1089
1151
  };
@@ -1142,9 +1204,25 @@ function MakeWithConfig(Config) {
1142
1204
  ]).apply(param => param[0]);
1143
1205
  let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
1144
1206
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
1207
+ let capabilityGate = Pulumi.all([
1208
+ pluginOutputs.pluginStructure,
1209
+ stackRef.getOutput("objectStores")
1210
+ ]).apply(param => {
1211
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
1212
+ let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
1213
+ let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
1214
+ if (typeof missing !== "object") {
1215
+ return;
1216
+ } else if (missing.TAG === "NotAdopted") {
1217
+ 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`);
1218
+ } else {
1219
+ 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.`);
1220
+ }
1221
+ });
1145
1222
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
1146
1223
  association.associationId,
1147
- mergeGate
1224
+ mergeGate,
1225
+ capabilityGate
1148
1226
  ]).apply(param => param[0]));
1149
1227
  Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
1150
1228
  Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
@@ -2013,6 +2091,7 @@ function Make($star) {
2013
2091
  plugin + `.` + store,
2014
2092
  keyPrefix,
2015
2093
  bucketName,
2094
+ storeHandle.bucketName,
2016
2095
  presign.url
2017
2096
  ];
2018
2097
  });
@@ -2027,18 +2106,70 @@ function Make($star) {
2027
2106
  bucketArn: b.bucketArn,
2028
2107
  bucketRegionalDomainName: b.bucketRegionalDomainName
2029
2108
  })));
2109
+ let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
2110
+ let storeServingBaseUrl;
2111
+ switch (match$1) {
2112
+ case "NoStores" :
2113
+ case "HostShell" :
2114
+ storeServingBaseUrl = undefined;
2115
+ break;
2116
+ case "PlatformOwned" :
2117
+ storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
2118
+ break;
2119
+ }
2120
+ let declaredStoreEndpoints = declaredStoreServices.map(param => ({
2121
+ store: param[0],
2122
+ keyPrefix: param[1],
2123
+ bucketName: param[3],
2124
+ uploadUrl: param[4],
2125
+ baseUrl: storeServingBaseUrl
2126
+ }));
2127
+ objectStoreEndpointsRef.contents = declaredStoreEndpoints;
2128
+ if (declaredStoreEndpoints.length !== 0) {
2129
+ Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
2130
+ e.store,
2131
+ u
2132
+ ]))).apply(pairs => Object.fromEntries(pairs)));
2133
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
2134
+ e.bucketName,
2135
+ Output$Pulumi.allOpt(e.baseUrl)
2136
+ ]).apply(param => [
2137
+ e,
2138
+ param[0],
2139
+ param[1]
2140
+ ]))).apply(resolved => Object.fromEntries(resolved.map(param => {
2141
+ let baseUrl = param[2];
2142
+ let e = param[0];
2143
+ return [
2144
+ e.store,
2145
+ Object.fromEntries([
2146
+ [
2147
+ "bucketName",
2148
+ param[1]
2149
+ ],
2150
+ [
2151
+ "keyPrefix",
2152
+ e.keyPrefix
2153
+ ]
2154
+ ].concat(baseUrl !== undefined ? [[
2155
+ "baseUrl",
2156
+ baseUrl
2157
+ ]] : []))
2158
+ ];
2159
+ }))));
2160
+ }
2030
2161
  if (hostUiBundle !== undefined) {
2031
- let match$1 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
2032
- let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
2162
+ let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
2163
+ let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
2033
2164
  let customDomain;
2034
- if (match$1 !== undefined && match$2 !== undefined) {
2165
+ if (match$2 !== undefined && match$3 !== undefined) {
2035
2166
  let stack = Pulumi.getStack();
2036
2167
  let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
2037
2168
  let prodStacks = Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), Util_HostUiDomain$ReventlessAws.parseProdStacks), Util_HostUiDomain$ReventlessAws.defaultProdStacks);
2038
- let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$1, prodStacks);
2169
+ let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
2039
2170
  customDomain = {
2040
2171
  fqdn: fqdn,
2041
- hostedZoneId: match$2
2172
+ hostedZoneId: match$3
2042
2173
  };
2043
2174
  } else {
2044
2175
  customDomain = undefined;
@@ -2053,11 +2184,11 @@ function Make($star) {
2053
2184
  bucketRegionalDomainName: store.bucketRegionalDomainName
2054
2185
  }] : []
2055
2186
  ).concat(declaredServedBuckets);
2056
- let match$3 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
2187
+ let match$4 = Plugin_Stack$ReventlessAws.makeUiBundleDistribution("host-ui", Stdlib_Option.getOr(hostUiBundle.bundleVersion, version), Stdlib_Option.getOr(hostUiBundle.assetsDir, Util_Bundle$ReventlessAws.resolvePackageRoot("@reventlessdev/reventless-host-shell") + "/dist"), true, undefined, true, [
2057
2188
  "config.json",
2058
2189
  "ui-hints.json"
2059
2190
  ], customDomain, servedBuckets);
2060
- let bucketName = match$3.bucketName;
2191
+ let bucketName = match$4.bucketName;
2061
2192
  let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
2062
2193
  let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
2063
2194
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
@@ -2067,7 +2198,7 @@ function Make($star) {
2067
2198
  let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
2068
2199
  let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
2069
2200
  let qualified = param[0];
2070
- return param[3].apply(u => [
2201
+ return param[4].apply(u => [
2071
2202
  qualified,
2072
2203
  u
2073
2204
  ]);
@@ -2169,7 +2300,7 @@ function Make($star) {
2169
2300
  contentType: "application/json"
2170
2301
  });
2171
2302
  }
2172
- Pulumi$Pulumi.$$export("hostShellUrl", match$3.distributionUrl);
2303
+ Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
2173
2304
  }
2174
2305
  return Pulumi$Pulumi.getOutputs();
2175
2306
  };
@@ -2228,9 +2359,25 @@ function Make($star) {
2228
2359
  ]).apply(param => param[0]);
2229
2360
  let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
2230
2361
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
2362
+ let capabilityGate = Pulumi.all([
2363
+ pluginOutputs.pluginStructure,
2364
+ stackRef.getOutput("objectStores")
2365
+ ]).apply(param => {
2366
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
2367
+ let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
2368
+ let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
2369
+ if (typeof missing !== "object") {
2370
+ return;
2371
+ } else if (missing.TAG === "NotAdopted") {
2372
+ 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`);
2373
+ } else {
2374
+ 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.`);
2375
+ }
2376
+ });
2231
2377
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
2232
2378
  association.associationId,
2233
- mergeGate
2379
+ mergeGate,
2380
+ capabilityGate
2234
2381
  ]).apply(param => param[0]));
2235
2382
  Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
2236
2383
  Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
@@ -2321,6 +2468,8 @@ export {
2321
2468
  getApiConfig,
2322
2469
  splitApiOutputsRef,
2323
2470
  getSplitApiOutputs,
2471
+ objectStoreEndpointsRef,
2472
+ getObjectStoreEndpoints,
2324
2473
  MakeWithConfig,
2325
2474
  Make,
2326
2475
  }
@@ -538,3 +538,193 @@ let makeUiBundleDistribution = (
538
538
  bucketName: bucket.bucket,
539
539
  }
540
540
  }
541
+
542
+ /**
543
+ Front a set of served buckets with a CloudFront distribution of their own, with
544
+ no SPA bundle behind it.
545
+
546
+ `makeUiBundleDistribution` can already serve buckets, but only as a side car to
547
+ a UI bundle. A platform that deploys no shell — because its UI ships from its
548
+ own stack — still provisions stores and then has nothing to serve them from:
549
+ the bucket is created with an all-true public-access block and takes its read
550
+ grant solely from a distribution's `BucketPolicy`, so with no distribution the
551
+ objects are unreachable by anything.
552
+
553
+ Serving lives with the stack that owns the buckets, and that is not a style
554
+ preference. **S3 permits exactly one bucket policy per bucket.** If the read
555
+ grant were written by whichever stack happened to build a distribution, two
556
+ distributions fronting one store would silently overwrite each other's grant —
557
+ deploying green and 404ing the loser's objects. Keeping the policy next to the
558
+ bucket makes a second writer impossible rather than merely discouraged.
559
+
560
+ Returns the public base URL. A consumer needs that and the store's prefix to
561
+ address an object; it needs no bucket identity at all, which is both a smaller
562
+ contract and one that cannot collide.
563
+ */
564
+ let makeServedBucketDistribution = (
565
+ ~name: string,
566
+ ~servedBuckets: array<ReventlessInfra.Platform.servedBucket>,
567
+ ~customDomain: option<customDomain>=?,
568
+ ): Pulumi.Output.t<string> => {
569
+ let oac = PulumiAws.CloudFront.OriginAccessControl.make(
570
+ ~name=name ++ "-oac",
571
+ ~args={
572
+ originAccessControlOriginType: Pulumi.Input.make("s3"),
573
+ signingBehavior: Pulumi.Input.make("always"),
574
+ signingProtocol: Pulumi.Input.make("sigv4"),
575
+ },
576
+ )
577
+
578
+ let originIdFor = (id: string): string => "served-" ++ id
579
+
580
+ // CloudFront requires a default behavior, and with no bundle origin there is
581
+ // nothing neutral to point it at. The first served bucket takes it; every
582
+ // real path is matched by its own `{prefix}/*` behavior first, so the default
583
+ // is only reached by a request for a path no store claims — which 404s at S3,
584
+ // the correct answer.
585
+ let defaultOriginId = switch servedBuckets->Array.get(0) {
586
+ | Some(sb) => originIdFor(sb.id)
587
+ | None =>
588
+ JsError.throwWithMessage(
589
+ "Plugin_Stack.makeServedBucketDistribution: no served buckets — call only when at least one store is declared",
590
+ )
591
+ }
592
+
593
+ let viewerCertificate: PulumiAws.CloudFront.Distribution.viewerCertificate = switch customDomain {
594
+ | None => {
595
+ cloudfrontDefaultCertificate: Pulumi.Input.make(true),
596
+ }
597
+ | Some({fqdn, hostedZoneId: _}) =>
598
+ let usEast1 = _getUsEast1Provider()
599
+ let cert = PulumiAws.Acm.Certificate.make(
600
+ ~name=name ++ "-cert",
601
+ ~args={
602
+ domainName: Pulumi.Input.make(fqdn),
603
+ validationMethod: Pulumi.Input.make("DNS"),
604
+ },
605
+ ~opts={provider: usEast1},
606
+ )
607
+ {
608
+ acmCertificateArn: cert.arn->Pulumi.Output.asInput,
609
+ sslSupportMethod: Pulumi.Input.make("sni-only"),
610
+ minimumProtocolVersion: Pulumi.Input.make("TLSv1.2_2021"),
611
+ }
612
+ }
613
+
614
+ let distribution = PulumiAws.CloudFront.Distribution.make(
615
+ ~name=name ++ "-cdn",
616
+ ~args={
617
+ enabled: Pulumi.Input.make(true),
618
+ aliases: ?switch customDomain {
619
+ | None => None
620
+ | Some({fqdn}) => Some(Pulumi.Input.make([fqdn]))
621
+ },
622
+ origins: oac.id->Pulumi.Output.apply(oacId =>
623
+ servedBuckets->Array.map(sb => {
624
+ PulumiAws.CloudFront.Distribution.domainName: sb.bucketRegionalDomainName,
625
+ originId: Pulumi.Input.make(originIdFor(sb.id)),
626
+ originAccessControlId: Pulumi.Input.make(oacId),
627
+ })
628
+ )
629
+ ->Pulumi.Output.asInput,
630
+ defaultCacheBehavior: Pulumi.Input.make(
631
+ (
632
+ {
633
+ targetOriginId: Pulumi.Input.make(defaultOriginId),
634
+ viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
635
+ allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
636
+ cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
637
+ cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
638
+ }: PulumiAws.CloudFront.Distribution.defaultCacheBehavior
639
+ ),
640
+ ),
641
+ // One behavior per served prefix. Store objects have immutable uuid keys,
642
+ // so the long-TTL CachingOptimized policy is safe.
643
+ orderedCacheBehaviors: Pulumi.Input.make(
644
+ servedBuckets->Array.flatMap(sb =>
645
+ sb.prefixes->Array.map(prefix => {
646
+ PulumiAws.CloudFront.Distribution.pathPattern: Pulumi.Input.make(prefix ++ "/*"),
647
+ targetOriginId: Pulumi.Input.make(originIdFor(sb.id)),
648
+ viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
649
+ allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
650
+ cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
651
+ cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
652
+ })
653
+ ),
654
+ ),
655
+ restrictions: Pulumi.Input.make({
656
+ PulumiAws.CloudFront.Distribution.geoRestriction: Pulumi.Input.make({
657
+ PulumiAws.CloudFront.Distribution.restrictionType: Pulumi.Input.make("none"),
658
+ }),
659
+ }),
660
+ viewerCertificate: Pulumi.Input.make(viewerCertificate),
661
+ comment: Pulumi.Input.make(name ++ " object store CDN"),
662
+ tags: AWS.Tags.make(
663
+ ~name=name ++ "-cdn",
664
+ ~kind=ReventlessCore.ComponentType.Platform,
665
+ ~role=Hosting,
666
+ ~scope=Platform,
667
+ ),
668
+ },
669
+ )
670
+
671
+ switch customDomain {
672
+ | None => ()
673
+ | Some({fqdn, hostedZoneId}) =>
674
+ let _ = PulumiAws.Route53.Record.make(
675
+ ~name=name ++ "-domain-alias",
676
+ ~args={
677
+ zoneId: Pulumi.Input.make(hostedZoneId),
678
+ name: Pulumi.Input.make(fqdn),
679
+ type_: Pulumi.Input.make("A"),
680
+ aliases: [
681
+ (
682
+ {
683
+ name: distribution.domainName->Pulumi.Output.asInput,
684
+ zoneId: Pulumi.Input.make(cloudFrontAliasZoneId),
685
+ evaluateTargetHealth: Pulumi.Input.make(false),
686
+ }: PulumiAws.Route53.Record.alias
687
+ ),
688
+ ]->Pulumi.Input.make,
689
+ },
690
+ )
691
+ }
692
+
693
+ // One policy per bucket — see the module doc above for why this is here and
694
+ // not in the consuming stack.
695
+ servedBuckets->Array.forEach(sb => {
696
+ let _ = PulumiAws.S3.BucketPolicy.make(
697
+ ~name=name ++ "-served-" ++ sb.id ++ "-policy",
698
+ ~args={
699
+ bucket: sb.bucketId,
700
+ policy: (sb.bucketArn->Pulumi.Output.fromInput, distribution.arn)
701
+ ->Pulumi.Output.all2
702
+ ->Pulumi.Output.apply(((bucketArn, distributionArn)) =>
703
+ {
704
+ "Version": "2012-10-17",
705
+ "Statement": [
706
+ {
707
+ "Sid": "AllowCloudFrontServicePrincipal",
708
+ "Effect": "Allow",
709
+ "Principal": {"Service": "cloudfront.amazonaws.com"},
710
+ "Action": "s3:GetObject",
711
+ "Resource": bucketArn ++ "/*",
712
+ "Condition": {
713
+ "StringEquals": {"AWS:SourceArn": distributionArn},
714
+ },
715
+ },
716
+ ],
717
+ }
718
+ ->JSON.stringifyAny
719
+ ->Option.getUnsafe
720
+ )
721
+ ->Pulumi.Output.asInput,
722
+ },
723
+ )
724
+ })
725
+
726
+ switch customDomain {
727
+ | Some({fqdn}) => Pulumi.Output.make("https://" ++ fqdn)
728
+ | None => distribution.domainName->Pulumi.Output.apply(d => "https://" ++ d)
729
+ }
730
+ }
@@ -303,6 +303,121 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
303
303
  };
304
304
  }
305
305
 
306
+ function makeServedBucketDistribution(name, servedBuckets, customDomain) {
307
+ let oac = new (Aws.cloudfront.OriginAccessControl)(name + "-oac", {
308
+ originAccessControlOriginType: "s3",
309
+ signingBehavior: "always",
310
+ signingProtocol: "sigv4"
311
+ });
312
+ let sb = servedBuckets[0];
313
+ let defaultOriginId = sb !== undefined ? "served-" + sb.id : Stdlib_JsError.throwWithMessage("Plugin_Stack.makeServedBucketDistribution: no served buckets — call only when at least one store is declared");
314
+ let viewerCertificate;
315
+ if (customDomain !== undefined) {
316
+ let usEast1 = _getUsEast1Provider();
317
+ let cert = new (Aws.acm.Certificate)(name + "-cert", {
318
+ domainName: customDomain.fqdn,
319
+ validationMethod: "DNS"
320
+ }, {
321
+ provider: Primitive_option.some(usEast1)
322
+ });
323
+ viewerCertificate = {
324
+ acmCertificateArn: cert.arn,
325
+ sslSupportMethod: "sni-only",
326
+ minimumProtocolVersion: "TLSv1.2_2021"
327
+ };
328
+ } else {
329
+ viewerCertificate = {
330
+ cloudfrontDefaultCertificate: true
331
+ };
332
+ }
333
+ let distribution = new (Aws.cloudfront.Distribution)(name + "-cdn", {
334
+ enabled: true,
335
+ aliases: customDomain !== undefined ? [customDomain.fqdn] : undefined,
336
+ origins: oac.id.apply(oacId => servedBuckets.map(sb => ({
337
+ domainName: sb.bucketRegionalDomainName,
338
+ originId: "served-" + sb.id,
339
+ originAccessControlId: oacId
340
+ }))),
341
+ defaultCacheBehavior: {
342
+ targetOriginId: defaultOriginId,
343
+ viewerProtocolPolicy: "redirect-to-https",
344
+ allowedMethods: [
345
+ "GET",
346
+ "HEAD"
347
+ ],
348
+ cachedMethods: [
349
+ "GET",
350
+ "HEAD"
351
+ ],
352
+ cachePolicyId: cachingOptimizedPolicyId
353
+ },
354
+ orderedCacheBehaviors: servedBuckets.flatMap(sb => sb.prefixes.map(prefix => ({
355
+ pathPattern: prefix + "/*",
356
+ targetOriginId: "served-" + sb.id,
357
+ viewerProtocolPolicy: "redirect-to-https",
358
+ allowedMethods: [
359
+ "GET",
360
+ "HEAD"
361
+ ],
362
+ cachedMethods: [
363
+ "GET",
364
+ "HEAD"
365
+ ],
366
+ cachePolicyId: cachingOptimizedPolicyId
367
+ }))),
368
+ restrictions: {
369
+ geoRestriction: {
370
+ restrictionType: "none"
371
+ }
372
+ },
373
+ viewerCertificate: viewerCertificate,
374
+ comment: name + " object store CDN",
375
+ tags: AWS_Tags$ReventlessAws.make(name + "-cdn", "Platform", "Hosting", "Platform", undefined, undefined, undefined, undefined)
376
+ });
377
+ if (customDomain !== undefined) {
378
+ new (Aws.route53.Record)(name + "-domain-alias", {
379
+ zoneId: customDomain.hostedZoneId,
380
+ name: customDomain.fqdn,
381
+ type: "A",
382
+ aliases: [{
383
+ name: distribution.domainName,
384
+ zoneId: cloudFrontAliasZoneId,
385
+ evaluateTargetHealth: false
386
+ }]
387
+ });
388
+ }
389
+ servedBuckets.forEach(sb => {
390
+ new (Aws.s3.BucketPolicy)(name + "-served-" + sb.id + "-policy", {
391
+ bucket: sb.bucketId,
392
+ policy: Pulumi.all([
393
+ sb.bucketArn,
394
+ distribution.arn
395
+ ]).apply(param => JSON.stringify({
396
+ Version: "2012-10-17",
397
+ Statement: [{
398
+ Sid: "AllowCloudFrontServicePrincipal",
399
+ Effect: "Allow",
400
+ Principal: {
401
+ Service: "cloudfront.amazonaws.com"
402
+ },
403
+ Action: "s3:GetObject",
404
+ Resource: param[0] + "/*",
405
+ Condition: {
406
+ StringEquals: {
407
+ "AWS:SourceArn": param[1]
408
+ }
409
+ }
410
+ }]
411
+ }))
412
+ });
413
+ });
414
+ if (customDomain !== undefined) {
415
+ return Pulumi.output("https://" + customDomain.fqdn);
416
+ } else {
417
+ return distribution.domainName.apply(d => "https://" + d);
418
+ }
419
+ }
420
+
306
421
  export {
307
422
  log,
308
423
  cachingOptimizedPolicyId,
@@ -313,5 +428,6 @@ export {
313
428
  cacheControlFor,
314
429
  invalidateDistribution,
315
430
  makeUiBundleDistribution,
431
+ makeServedBucketDistribution,
316
432
  }
317
433
  /* log Not a pure module */
@@ -34,6 +34,27 @@ type protection =
34
34
  | Protected
35
35
  | Unprotected
36
36
 
37
+ /** Whether the platform provisions everything a plugin's fields declare. */
38
+ type coverage =
39
+ /** Every declared store is provisioned. */
40
+ | Covered
41
+ /** The platform provisions no stores at all — it has not adopted capability
42
+ provisioning, which is a different situation from getting it wrong. */
43
+ | NotAdopted(array<string>)
44
+ /** The platform provisions stores, but not these. Carries what it does
45
+ provision, because the usual cause is a near-miss worth showing. */
46
+ | Missing({missing: array<string>, provisioned: array<string>})
47
+
48
+ /** Which distribution fronts the declared stores. */
49
+ type serving =
50
+ /** No store is declared, so nothing is served. */
51
+ | NoStores
52
+ /** The host shell's own distribution serves them same-origin, so a minted
53
+ `/{prefix}/…` ref resolves relative and there is no base URL. */
54
+ | HostShell
55
+ /** The platform fronts them itself, because no host shell is deployed. */
56
+ | PlatformOwned
57
+
37
58
  /**
38
59
  Stack-name prefixes whose stacks are disposable.
39
60
 
@@ -102,3 +123,53 @@ The cost is a slightly redundant prefix inside a dedicated bucket
102
123
  (`catalog-productImages/productImages/…`). Take the redundancy.
103
124
  */
104
125
  let keyPrefixFor = (~store: string): string => store
126
+
127
+ /**
128
+ Who serves the declared stores — and the answer is never "both".
129
+
130
+ A store's bucket blocks public policy and takes its read grant solely from a
131
+ distribution's `BucketPolicy`. **S3 permits exactly one bucket policy per
132
+ bucket**, so two distributions fronting one store would each write that single
133
+ policy and silently unpick the other's grant: green deploy, 404s afterwards.
134
+ Making this one function's return a three-way choice is what keeps "both" from
135
+ being expressible.
136
+
137
+ The polarity is the useful part. Provisioning a store and serving it are
138
+ separate; before this, serving happened only as a side car to a host-UI bundle,
139
+ so a platform whose UI shipped from its own stack provisioned stores that
140
+ nothing could read.
141
+ */
142
+ let servingFor = (~hasHostUiBundle: bool, ~declaredBucketCount: int): serving =>
143
+ switch (hasHostUiBundle, declaredBucketCount) {
144
+ | (_, 0) => NoStores
145
+ | (true, _) => HostShell
146
+ | (false, _) => PlatformOwned
147
+ }
148
+
149
+ /**
150
+ Does the platform provision what the plugin's fields declare?
151
+
152
+ The split-stack ordering hazard, stated as a set difference: the platform
153
+ deploys before the plugin and cannot read its schemas, so its capability list is
154
+ written by hand and can simply be wrong. `required` comes from
155
+ `pluginStructure.requiredStores`; `provisioned` from the platform's exported
156
+ `objectStores` keys. Both are qualified `{plugin}.{store}`.
157
+
158
+ **Three outcomes, not two.** A platform provisioning nothing has not adopted
159
+ capability provisioning; failing it would break deployments that work today. A
160
+ platform provisioning *some* stores but not this one has adopted it and has a
161
+ missing or misspelled entry. Collapsing those two into one verdict forces a
162
+ choice between breaking the first group and not helping the second.
163
+
164
+ Worth being strict about because every symptom is silent: the upload input finds
165
+ no per-store endpoint, falls back to the legacy single service, and writes to
166
+ whatever bucket that serves — a 2xx, a plausible ref, and the wrong destination.
167
+ */
168
+ let coverageFor = (~required: array<string>, ~provisioned: array<string>): coverage => {
169
+ let missing = required->Array.filter(r => !(provisioned->Array.includes(r)))
170
+ switch (missing, provisioned) {
171
+ | ([], _) => Covered
172
+ | (missing, []) => NotAdopted(missing)
173
+ | (missing, provisioned) => Missing({missing, provisioned})
174
+ }
175
+ }
@@ -32,11 +32,45 @@ function keyPrefixFor(store) {
32
32
  return store;
33
33
  }
34
34
 
35
+ function servingFor(hasHostUiBundle, declaredBucketCount) {
36
+ if (declaredBucketCount !== 0) {
37
+ if (hasHostUiBundle) {
38
+ return "HostShell";
39
+ } else {
40
+ return "PlatformOwned";
41
+ }
42
+ } else {
43
+ return "NoStores";
44
+ }
45
+ }
46
+
47
+ function coverageFor(required, provisioned) {
48
+ let missing = required.filter(r => !provisioned.includes(r));
49
+ if (missing.length !== 0) {
50
+ if (provisioned.length !== 0) {
51
+ return {
52
+ TAG: "Missing",
53
+ missing: missing,
54
+ provisioned: provisioned
55
+ };
56
+ } else {
57
+ return {
58
+ TAG: "NotAdopted",
59
+ _0: missing
60
+ };
61
+ }
62
+ } else {
63
+ return "Covered";
64
+ }
65
+ }
66
+
35
67
  export {
36
68
  defaultEphemeralPrefixes,
37
69
  layoutFor,
38
70
  protectionFor,
39
71
  bucketNameFor,
40
72
  keyPrefixFor,
73
+ servingFor,
74
+ coverageFor,
41
75
  }
42
76
  /* No side effect */
@@ -40,6 +40,120 @@ describe("Util_StoreLayout.layoutFor", () => {
40
40
  )
41
41
  })
42
42
 
43
+ describe("Util_StoreLayout.servingFor", () => {
44
+ testSync("no declared store means nothing to serve", () =>
45
+ expect(Util_StoreLayout.servingFor(~hasHostUiBundle=false, ~declaredBucketCount=0))->toEqual(
46
+ Util_StoreLayout.NoStores,
47
+ )
48
+ )
49
+
50
+ testSync("a host shell serves the stores from its own origin", () =>
51
+ expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=1))->toEqual(
52
+ Util_StoreLayout.HostShell,
53
+ )
54
+ )
55
+
56
+ // The case that was previously unrepresentable and produced a write-only
57
+ // store: stores are provisioned unconditionally, but serving used to happen
58
+ // only as a side car to a host-UI bundle.
59
+ testSync("no host shell means the platform serves them itself", () =>
60
+ expect(Util_StoreLayout.servingFor(~hasHostUiBundle=false, ~declaredBucketCount=1))->toEqual(
61
+ Util_StoreLayout.PlatformOwned,
62
+ )
63
+ )
64
+
65
+ // A bucket carries exactly one policy, so two distributions fronting one
66
+ // store would unpick each other's read grant. The three-way return is what
67
+ // makes "both" unrepresentable — asserted so it stays that way.
68
+ testSync("a host shell wins even with several buckets — never both", () =>
69
+ expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=3))->toEqual(
70
+ Util_StoreLayout.HostShell,
71
+ )
72
+ )
73
+
74
+ // Declaring nothing outranks having a shell: with no store there is no
75
+ // bucket, no policy and no origin, whoever is deployed.
76
+ testSync("no store outranks a host shell", () =>
77
+ expect(Util_StoreLayout.servingFor(~hasHostUiBundle=true, ~declaredBucketCount=0))->toEqual(
78
+ Util_StoreLayout.NoStores,
79
+ )
80
+ )
81
+ })
82
+
83
+ describe("Util_StoreLayout.coverageFor", () => {
84
+ testSync("everything declared is provisioned", () =>
85
+ expect(
86
+ Util_StoreLayout.coverageFor(
87
+ ~required=["Catalog.productImages"],
88
+ ~provisioned=["Catalog.productImages"],
89
+ ),
90
+ )->toEqual(Util_StoreLayout.Covered)
91
+ )
92
+
93
+ testSync("declaring nothing is covered, whatever the platform provisions", () =>
94
+ expect(Util_StoreLayout.coverageFor(~required=[], ~provisioned=["Catalog.productImages"]))->toEqual(
95
+ Util_StoreLayout.Covered,
96
+ )
97
+ )
98
+
99
+ // A platform provisioning nothing has not adopted capability provisioning.
100
+ // Failing it would break deployments that work today, so this is the arm that
101
+ // must NOT be a hard error.
102
+ testSync("a platform provisioning nothing has not adopted, rather than got it wrong", () =>
103
+ expect(Util_StoreLayout.coverageFor(~required=["Catalog.productImages"], ~provisioned=[]))->toEqual(
104
+ Util_StoreLayout.NotAdopted(["Catalog.productImages"]),
105
+ )
106
+ )
107
+
108
+ // The case that shipped: the platform declared the store under a lowercased
109
+ // plugin name, so both sides had a productImages store and neither matched.
110
+ // It carries what IS provisioned because the cause is usually a near-miss.
111
+ testSync("a near-miss reports both sides — this is the case-slip shape", () =>
112
+ expect(
113
+ Util_StoreLayout.coverageFor(
114
+ ~required=["Catalog.productImages"],
115
+ ~provisioned=["catalog.productImages"],
116
+ ),
117
+ )->toEqual(
118
+ Util_StoreLayout.Missing({
119
+ missing: ["Catalog.productImages"],
120
+ provisioned: ["catalog.productImages"],
121
+ }),
122
+ )
123
+ )
124
+
125
+ testSync("only the uncovered stores are reported missing", () =>
126
+ expect(
127
+ Util_StoreLayout.coverageFor(
128
+ ~required=["Catalog.productImages", "Catalog.manuals"],
129
+ ~provisioned=["Catalog.productImages"],
130
+ ),
131
+ )->toEqual(
132
+ Util_StoreLayout.Missing({
133
+ missing: ["Catalog.manuals"],
134
+ provisioned: ["Catalog.productImages"],
135
+ }),
136
+ )
137
+ )
138
+
139
+ // Matching is exact. Suffix or case-insensitive matching would "fix" the
140
+ // case slip above by silently binding to the wrong store, and two plugins may
141
+ // legitimately name a store the same.
142
+ testSync("matching is exact — a suffix match is not a match", () =>
143
+ expect(
144
+ Util_StoreLayout.coverageFor(
145
+ ~required=["Catalog.productImages"],
146
+ ~provisioned=["Ordering.productImages"],
147
+ ),
148
+ )->toEqual(
149
+ Util_StoreLayout.Missing({
150
+ missing: ["Catalog.productImages"],
151
+ provisioned: ["Ordering.productImages"],
152
+ }),
153
+ )
154
+ )
155
+ })
156
+
43
157
  describe("Util_StoreLayout.protectionFor", () => {
44
158
  // The pairing a single layout-driven switch would have got wrong: alpha
45
159
  // shares a bucket and is still protected.
@@ -27,6 +27,63 @@ globalThis.describe("Util_StoreLayout.layoutFor", () => {
27
27
  });
28
28
  });
29
29
 
30
+ globalThis.describe("Util_StoreLayout.servingFor", () => {
31
+ globalThis.test("no declared store means nothing to serve", () => {
32
+ globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(false, 0)).toEqual("NoStores");
33
+ });
34
+ globalThis.test("a host shell serves the stores from its own origin", () => {
35
+ globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 1)).toEqual("HostShell");
36
+ });
37
+ globalThis.test("no host shell means the platform serves them itself", () => {
38
+ globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(false, 1)).toEqual("PlatformOwned");
39
+ });
40
+ globalThis.test("a host shell wins even with several buckets — never both", () => {
41
+ globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 3)).toEqual("HostShell");
42
+ });
43
+ globalThis.test("no store outranks a host shell", () => {
44
+ globalThis.expect(Util_StoreLayout$ReventlessAws.servingFor(true, 0)).toEqual("NoStores");
45
+ });
46
+ });
47
+
48
+ globalThis.describe("Util_StoreLayout.coverageFor", () => {
49
+ globalThis.test("everything declared is provisioned", () => {
50
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Catalog.productImages"])).toEqual("Covered");
51
+ });
52
+ globalThis.test("declaring nothing is covered, whatever the platform provisions", () => {
53
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([], ["Catalog.productImages"])).toEqual("Covered");
54
+ });
55
+ globalThis.test("a platform provisioning nothing has not adopted, rather than got it wrong", () => {
56
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], [])).toEqual({
57
+ TAG: "NotAdopted",
58
+ _0: ["Catalog.productImages"]
59
+ });
60
+ });
61
+ globalThis.test("a near-miss reports both sides — this is the case-slip shape", () => {
62
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["catalog.productImages"])).toEqual({
63
+ TAG: "Missing",
64
+ missing: ["Catalog.productImages"],
65
+ provisioned: ["catalog.productImages"]
66
+ });
67
+ });
68
+ globalThis.test("only the uncovered stores are reported missing", () => {
69
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([
70
+ "Catalog.productImages",
71
+ "Catalog.manuals"
72
+ ], ["Catalog.productImages"])).toEqual({
73
+ TAG: "Missing",
74
+ missing: ["Catalog.manuals"],
75
+ provisioned: ["Catalog.productImages"]
76
+ });
77
+ });
78
+ globalThis.test("matching is exact — a suffix match is not a match", () => {
79
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Ordering.productImages"])).toEqual({
80
+ TAG: "Missing",
81
+ missing: ["Catalog.productImages"],
82
+ provisioned: ["Ordering.productImages"]
83
+ });
84
+ });
85
+ });
86
+
30
87
  globalThis.describe("Util_StoreLayout.protectionFor", () => {
31
88
  globalThis.test("alpha shares a bucket and is still protected", () => {
32
89
  globalThis.expect(Util_StoreLayout$ReventlessAws.layoutFor("alpha", Util_HostUiDomain$ReventlessAws.defaultProdStacks)).toEqual("SharedBucket");