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

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,13 @@
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.241 (2026-07-29)
7
+
8
+ ### Features
9
+
10
+ * **platform:** serve and publish declared object stores without a host UI ([9cc4700](https://github.com/ReventlessDev/reventless-core/commit/9cc4700dc96f05e3d35fbc912a4c7be7a5531337))
11
+
12
+
6
13
  # 3.0.0-alpha.240 (2026-07-28)
7
14
 
8
15
  ### 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.241",
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",
17
16
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
18
17
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
18
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
19
19
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
20
20
  "@reventlessdev/reventless-core": "3.0.0-alpha.190",
21
21
  "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
22
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
22
23
  "@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"
24
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.83"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -44,6 +44,40 @@ 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
+ bucketName: string,
65
+ uploadUrl: Pulumi.Output.t<string>,
66
+ // Public base URL the store is served from, when the platform serves it
67
+ // itself. `None` when a host-UI bundle is deployed — there the shell's own
68
+ // origin serves the store same-origin and a relative `/{prefix}/…` resolves
69
+ // without a base.
70
+ baseUrl: option<Pulumi.Output.t<string>>,
71
+ }
72
+ let objectStoreEndpointsRef: ref<array<objectStoreEndpoint>> = ref([])
73
+
74
+ /** Returns the object stores this platform provisioned from field declarations.
75
+
76
+ Empty when nothing is declared — a legitimate answer rather than a
77
+ call-order mistake, so this returns `[]` instead of throwing the way
78
+ `getApiConfig` does. */
79
+ let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
80
+
47
81
  module MakeWithConfig = (
48
82
  Config: {
49
83
  let splitApi: bool
@@ -1570,6 +1604,91 @@ module MakeWithConfig = (
1570
1604
  })
1571
1605
  )
1572
1606
 
1607
+ // ── Serving the declared stores ───────────────────────────────────────
1608
+ //
1609
+ // A store's bucket blocks public policy and takes its read grant only from
1610
+ // a distribution's BucketPolicy, so "provisioned" and "readable" are two
1611
+ // different things. When a host UI bundle is deployed, its distribution
1612
+ // fronts the stores same-origin (below) and a relative `/{prefix}/…`
1613
+ // resolves with no base URL. When it is not, the platform fronts them
1614
+ // itself — otherwise every declared store is write-only.
1615
+ //
1616
+ // Never both: two distributions fronting one bucket would each write the
1617
+ // bucket's single allowed policy and silently unpick the other's grant.
1618
+ // `Util_StoreLayout.servingFor` is where that exclusion is decided, so it is
1619
+ // one testable choice rather than a condition spelled out here.
1620
+ let storeServingBaseUrl: option<Pulumi.Output.t<string>> = switch Util_StoreLayout.servingFor(
1621
+ ~hasHostUiBundle=hostUiBundle->Option.isSome,
1622
+ ~declaredBucketCount=declaredServedBuckets->Array.length,
1623
+ ) {
1624
+ | NoStores | HostShell => None
1625
+ | PlatformOwned =>
1626
+ Some(
1627
+ Plugin_Stack.makeServedBucketDistribution(
1628
+ ~name="object-stores",
1629
+ ~servedBuckets=declaredServedBuckets,
1630
+ ),
1631
+ )
1632
+ }
1633
+
1634
+ let declaredStoreEndpoints = declaredStoreServices->Array.map(((
1635
+ store,
1636
+ keyPrefix,
1637
+ bucketName,
1638
+ uploadUrl,
1639
+ )) => {store, keyPrefix, bucketName, uploadUrl, baseUrl: storeServingBaseUrl})
1640
+ objectStoreEndpointsRef := declaredStoreEndpoints
1641
+
1642
+ // Exported unconditionally — the provisioning above already is. Their only
1643
+ // consumers used to sit inside `switch hostUiBundle`, which left a platform
1644
+ // whose UI ships from another stack holding stores it could not name.
1645
+ //
1646
+ // Omitted entirely when nothing is declared, so a deployment with no
1647
+ // declared store keeps a byte-identical output set.
1648
+ if declaredStoreEndpoints->Array.length > 0 {
1649
+ Pulumi.Pulumi.export(
1650
+ "uploadEndpoints",
1651
+ declaredStoreEndpoints
1652
+ ->Array.map(e => e.uploadUrl->Pulumi.Output.apply(u => (e.store, JSON.Encode.string(u))))
1653
+ ->Pulumi.Output.all
1654
+ ->Pulumi.Output.apply(pairs => pairs->Dict.fromArray->JSON.Encode.object),
1655
+ )
1656
+ Pulumi.Pulumi.export(
1657
+ "objectStores",
1658
+ declaredStoreEndpoints
1659
+ ->Array.map(e =>
1660
+ switch e.baseUrl {
1661
+ | Some(u) => u->Pulumi.Output.apply(b => (e, Some(b)))
1662
+ | None => Pulumi.Output.make((e, None))
1663
+ }
1664
+ )
1665
+ ->Pulumi.Output.all
1666
+ ->Pulumi.Output.apply(pairs =>
1667
+ pairs
1668
+ ->Array.map(((e, baseUrl)) => (
1669
+ e.store,
1670
+ [
1671
+ ("bucketName", JSON.Encode.string(e.bucketName)),
1672
+ ("keyPrefix", JSON.Encode.string(e.keyPrefix)),
1673
+ ]
1674
+ // Absent when the host shell serves the store: there the object is
1675
+ // addressable same-origin and a base URL would be a second, and
1676
+ // wrong, way to reach it.
1677
+ ->Array.concat(
1678
+ switch baseUrl {
1679
+ | Some(b) => [("baseUrl", JSON.Encode.string(b))]
1680
+ | None => []
1681
+ },
1682
+ )
1683
+ ->Dict.fromArray
1684
+ ->JSON.Encode.object,
1685
+ ))
1686
+ ->Dict.fromArray
1687
+ ->JSON.Encode.object
1688
+ ),
1689
+ )
1690
+ }
1691
+
1573
1692
  // Host UI shell deployment — opt-in via ~hostUiBundle. The shell SPA is
1574
1693
  // hosted on its own CloudFront distribution; `config.json` is generated
1575
1694
  // at deploy time with the resolved API endpoints, region, and Cognito
@@ -116,6 +116,14 @@ function getSplitApiOutputs() {
116
116
  return splitApiOutputsRef.contents;
117
117
  }
118
118
 
119
+ let objectStoreEndpointsRef = {
120
+ contents: []
121
+ };
122
+
123
+ function getObjectStoreEndpoints() {
124
+ return objectStoreEndpointsRef.contents;
125
+ }
126
+
119
127
  function MakeWithConfig(Config) {
120
128
  Stdlib_Option.forEach(Config.commandHandlerConfig.aggregates, param => {
121
129
  Stdlib_Option.forEach(param.sync, AggregateRuntime_Builder_Single$ReventlessAws.setConfig);
@@ -941,18 +949,76 @@ function MakeWithConfig(Config) {
941
949
  bucketArn: b.bucketArn,
942
950
  bucketRegionalDomainName: b.bucketRegionalDomainName
943
951
  })));
952
+ let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
953
+ let storeServingBaseUrl;
954
+ switch (match$1) {
955
+ case "NoStores" :
956
+ case "HostShell" :
957
+ storeServingBaseUrl = undefined;
958
+ break;
959
+ case "PlatformOwned" :
960
+ storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
961
+ break;
962
+ }
963
+ let declaredStoreEndpoints = declaredStoreServices.map(param => ({
964
+ store: param[0],
965
+ keyPrefix: param[1],
966
+ bucketName: param[2],
967
+ uploadUrl: param[3],
968
+ baseUrl: storeServingBaseUrl
969
+ }));
970
+ objectStoreEndpointsRef.contents = declaredStoreEndpoints;
971
+ if (declaredStoreEndpoints.length !== 0) {
972
+ Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
973
+ e.store,
974
+ u
975
+ ]))).apply(pairs => Object.fromEntries(pairs)));
976
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => {
977
+ let u = e.baseUrl;
978
+ if (u !== undefined) {
979
+ return u.apply(b => [
980
+ e,
981
+ b
982
+ ]);
983
+ } else {
984
+ return Pulumi.output([
985
+ e,
986
+ undefined
987
+ ]);
988
+ }
989
+ })).apply(pairs => Object.fromEntries(pairs.map(param => {
990
+ let baseUrl = param[1];
991
+ let e = param[0];
992
+ return [
993
+ e.store,
994
+ Object.fromEntries([
995
+ [
996
+ "bucketName",
997
+ e.bucketName
998
+ ],
999
+ [
1000
+ "keyPrefix",
1001
+ e.keyPrefix
1002
+ ]
1003
+ ].concat(baseUrl !== undefined ? [[
1004
+ "baseUrl",
1005
+ baseUrl
1006
+ ]] : []))
1007
+ ];
1008
+ }))));
1009
+ }
944
1010
  if (hostUiBundle !== undefined) {
945
- let match$1 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
946
- let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
1011
+ let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
1012
+ let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
947
1013
  let customDomain;
948
- if (match$1 !== undefined && match$2 !== undefined) {
1014
+ if (match$2 !== undefined && match$3 !== undefined) {
949
1015
  let stack = Pulumi.getStack();
950
1016
  let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
951
1017
  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);
1018
+ let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
953
1019
  customDomain = {
954
1020
  fqdn: fqdn,
955
- hostedZoneId: match$2
1021
+ hostedZoneId: match$3
956
1022
  };
957
1023
  } else {
958
1024
  customDomain = undefined;
@@ -967,11 +1033,11 @@ function MakeWithConfig(Config) {
967
1033
  bucketRegionalDomainName: store.bucketRegionalDomainName
968
1034
  }] : []
969
1035
  ).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, [
1036
+ 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
1037
  "config.json",
972
1038
  "ui-hints.json"
973
1039
  ], customDomain, servedBuckets);
974
- let bucketName = match$3.bucketName;
1040
+ let bucketName = match$4.bucketName;
975
1041
  let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
976
1042
  let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
977
1043
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
@@ -1083,7 +1149,7 @@ function MakeWithConfig(Config) {
1083
1149
  contentType: "application/json"
1084
1150
  });
1085
1151
  }
1086
- Pulumi$Pulumi.$$export("hostShellUrl", match$3.distributionUrl);
1152
+ Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
1087
1153
  }
1088
1154
  return Pulumi$Pulumi.getOutputs();
1089
1155
  };
@@ -2027,18 +2093,76 @@ function Make($star) {
2027
2093
  bucketArn: b.bucketArn,
2028
2094
  bucketRegionalDomainName: b.bucketRegionalDomainName
2029
2095
  })));
2096
+ let match$1 = Util_StoreLayout$ReventlessAws.servingFor(Stdlib_Option.isSome(hostUiBundle), declaredServedBuckets.length);
2097
+ let storeServingBaseUrl;
2098
+ switch (match$1) {
2099
+ case "NoStores" :
2100
+ case "HostShell" :
2101
+ storeServingBaseUrl = undefined;
2102
+ break;
2103
+ case "PlatformOwned" :
2104
+ storeServingBaseUrl = Plugin_Stack$ReventlessAws.makeServedBucketDistribution("object-stores", declaredServedBuckets, undefined);
2105
+ break;
2106
+ }
2107
+ let declaredStoreEndpoints = declaredStoreServices.map(param => ({
2108
+ store: param[0],
2109
+ keyPrefix: param[1],
2110
+ bucketName: param[2],
2111
+ uploadUrl: param[3],
2112
+ baseUrl: storeServingBaseUrl
2113
+ }));
2114
+ objectStoreEndpointsRef.contents = declaredStoreEndpoints;
2115
+ if (declaredStoreEndpoints.length !== 0) {
2116
+ Pulumi$Pulumi.$$export("uploadEndpoints", Pulumi.all(declaredStoreEndpoints.map(e => e.uploadUrl.apply(u => [
2117
+ e.store,
2118
+ u
2119
+ ]))).apply(pairs => Object.fromEntries(pairs)));
2120
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => {
2121
+ let u = e.baseUrl;
2122
+ if (u !== undefined) {
2123
+ return u.apply(b => [
2124
+ e,
2125
+ b
2126
+ ]);
2127
+ } else {
2128
+ return Pulumi.output([
2129
+ e,
2130
+ undefined
2131
+ ]);
2132
+ }
2133
+ })).apply(pairs => Object.fromEntries(pairs.map(param => {
2134
+ let baseUrl = param[1];
2135
+ let e = param[0];
2136
+ return [
2137
+ e.store,
2138
+ Object.fromEntries([
2139
+ [
2140
+ "bucketName",
2141
+ e.bucketName
2142
+ ],
2143
+ [
2144
+ "keyPrefix",
2145
+ e.keyPrefix
2146
+ ]
2147
+ ].concat(baseUrl !== undefined ? [[
2148
+ "baseUrl",
2149
+ baseUrl
2150
+ ]] : []))
2151
+ ];
2152
+ }))));
2153
+ }
2030
2154
  if (hostUiBundle !== undefined) {
2031
- let match$1 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
2032
- let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
2155
+ let match$2 = Util_LocalConfig$ReventlessAws.get("hostUiBaseDomain");
2156
+ let match$3 = Util_LocalConfig$ReventlessAws.get("hostUiHostedZoneId");
2033
2157
  let customDomain;
2034
- if (match$1 !== undefined && match$2 !== undefined) {
2158
+ if (match$2 !== undefined && match$3 !== undefined) {
2035
2159
  let stack = Pulumi.getStack();
2036
2160
  let baseName = Stdlib_Option.getOr(Util_LocalConfig$ReventlessAws.get("hostUiBaseName"), Pulumi.getProject());
2037
2161
  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);
2162
+ let fqdn = Util_HostUiDomain$ReventlessAws.deriveFqdn(baseName, stack, match$2, prodStacks);
2039
2163
  customDomain = {
2040
2164
  fqdn: fqdn,
2041
- hostedZoneId: match$2
2165
+ hostedZoneId: match$3
2042
2166
  };
2043
2167
  } else {
2044
2168
  customDomain = undefined;
@@ -2053,11 +2177,11 @@ function Make($star) {
2053
2177
  bucketRegionalDomainName: store.bucketRegionalDomainName
2054
2178
  }] : []
2055
2179
  ).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, [
2180
+ 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
2181
  "config.json",
2058
2182
  "ui-hints.json"
2059
2183
  ], customDomain, servedBuckets);
2060
- let bucketName = match$3.bucketName;
2184
+ let bucketName = match$4.bucketName;
2061
2185
  let regionStr = Stdlib_Option.getOr(new Pulumi.Config("aws").get("region"), "unknown");
2062
2186
  let cognitoPool = Platform_Stack$ReventlessAws.resolveCognitoUserPool();
2063
2187
  let domainEventsEndpointOutput = domainEventsApiOpt !== undefined ? AppSync_EventsApi$ReventlessAws.httpEndpoint(domainEventsApiOpt).apply(ep => ep + "/event") : Pulumi.output(undefined);
@@ -2169,7 +2293,7 @@ function Make($star) {
2169
2293
  contentType: "application/json"
2170
2294
  });
2171
2295
  }
2172
- Pulumi$Pulumi.$$export("hostShellUrl", match$3.distributionUrl);
2296
+ Pulumi$Pulumi.$$export("hostShellUrl", match$4.distributionUrl);
2173
2297
  }
2174
2298
  return Pulumi$Pulumi.getOutputs();
2175
2299
  };
@@ -2321,6 +2445,8 @@ export {
2321
2445
  getApiConfig,
2322
2446
  splitApiOutputsRef,
2323
2447
  getSplitApiOutputs,
2448
+ objectStoreEndpointsRef,
2449
+ getObjectStoreEndpoints,
2324
2450
  MakeWithConfig,
2325
2451
  Make,
2326
2452
  }
@@ -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,16 @@ type protection =
34
34
  | Protected
35
35
  | Unprotected
36
36
 
37
+ /** Which distribution fronts the declared stores. */
38
+ type serving =
39
+ /** No store is declared, so nothing is served. */
40
+ | NoStores
41
+ /** The host shell's own distribution serves them same-origin, so a minted
42
+ `/{prefix}/…` ref resolves relative and there is no base URL. */
43
+ | HostShell
44
+ /** The platform fronts them itself, because no host shell is deployed. */
45
+ | PlatformOwned
46
+
37
47
  /**
38
48
  Stack-name prefixes whose stacks are disposable.
39
49
 
@@ -102,3 +112,25 @@ The cost is a slightly redundant prefix inside a dedicated bucket
102
112
  (`catalog-productImages/productImages/…`). Take the redundancy.
103
113
  */
104
114
  let keyPrefixFor = (~store: string): string => store
115
+
116
+ /**
117
+ Who serves the declared stores — and the answer is never "both".
118
+
119
+ A store's bucket blocks public policy and takes its read grant solely from a
120
+ distribution's `BucketPolicy`. **S3 permits exactly one bucket policy per
121
+ bucket**, so two distributions fronting one store would each write that single
122
+ policy and silently unpick the other's grant: green deploy, 404s afterwards.
123
+ Making this one function's return a three-way choice is what keeps "both" from
124
+ being expressible.
125
+
126
+ The polarity is the useful part. Provisioning a store and serving it are
127
+ separate; before this, serving happened only as a side car to a host-UI bundle,
128
+ so a platform whose UI shipped from its own stack provisioned stores that
129
+ nothing could read.
130
+ */
131
+ let servingFor = (~hasHostUiBundle: bool, ~declaredBucketCount: int): serving =>
132
+ switch (hasHostUiBundle, declaredBucketCount) {
133
+ | (_, 0) => NoStores
134
+ | (true, _) => HostShell
135
+ | (false, _) => PlatformOwned
136
+ }
@@ -32,11 +32,24 @@ 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
+
35
47
  export {
36
48
  defaultEphemeralPrefixes,
37
49
  layoutFor,
38
50
  protectionFor,
39
51
  bucketNameFor,
40
52
  keyPrefixFor,
53
+ servingFor,
41
54
  }
42
55
  /* No side effect */
@@ -40,6 +40,46 @@ 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
+
43
83
  describe("Util_StoreLayout.protectionFor", () => {
44
84
  // The pairing a single layout-driven switch would have got wrong: alpha
45
85
  // shares a bucket and is still protected.
@@ -27,6 +27,24 @@ 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
+
30
48
  globalThis.describe("Util_StoreLayout.protectionFor", () => {
31
49
  globalThis.test("alpha shares a bucket and is still protected", () => {
32
50
  globalThis.expect(Util_StoreLayout$ReventlessAws.layoutFor("alpha", Util_HostUiDomain$ReventlessAws.defaultProdStacks)).toEqual("SharedBucket");