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

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.
@@ -42,9 +42,19 @@ let make = (
42
42
  // bucket's CloudFront `{prefix}/*` behavior so the returned `/{key}` ref
43
43
  // resolves.
44
44
  ~servedPrefix: string=defaultServedPrefix,
45
+ // Resource-name stem. Defaults to the single-service name this adapter had
46
+ // when a deployment could only have one store, so the existing service keeps
47
+ // its identity; declared stores each pass their own, because there is one
48
+ // service per store.
49
+ //
50
+ // One service per store rather than one service with wildcard write across
51
+ // stores: a single wide-scoped presigner reintroduces exactly the blast radius
52
+ // the per-store split exists to remove, and it does so invisibly — every
53
+ // upload still works.
54
+ ~name: string="UploadPresignService",
45
55
  ~opts=?,
46
56
  ): serviceOutputs => {
47
- let serviceName = "UploadPresignService"
57
+ let serviceName = name
48
58
  let opts =
49
59
  opts->Option.map(ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions)
50
60
 
@@ -61,12 +71,18 @@ let make = (
61
71
  )
62
72
 
63
73
  // CloudWatch Logs (so failures are observable) plus least-privilege
64
- // `s3:PutObject` on any key under the target bucket.
74
+ // `s3:PutObject` on this store's own keys.
75
+ //
76
+ // Scoped to `{bucket}/{servedPrefix}/*`, not to the whole bucket. Because keys
77
+ // are rooted at the store's prefix in *both* layouts, this one expression is
78
+ // least-privilege in both: a dedicated bucket's service still cannot write
79
+ // outside its prefix, and a shared bucket's services cannot write into each
80
+ // other's stores. The layouts stay one model — even the policy does not fork.
65
81
  let _policy =
66
82
  bucketName
67
83
  ->Pulumi.Output.fromInput
68
84
  ->Pulumi.Output.apply(b => {
69
- let arn = `arn:aws:s3:::${b}/*`
85
+ let arn = `arn:aws:s3:::${b}/${servedPrefix}/*`
70
86
  let _ = IAM.RolePolicy.make(
71
87
  ~name=`${serviceName}Policy`,
72
88
  ~args={
@@ -14,16 +14,16 @@ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/
14
14
 
15
15
  let defaultServedPrefix = "uploads";
16
16
 
17
- function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
17
+ function make(bucketName, corsOriginsOpt, servedPrefixOpt, nameOpt, opts) {
18
18
  let corsOrigins = corsOriginsOpt !== undefined ? corsOriginsOpt : ["*"];
19
19
  let servedPrefix = servedPrefixOpt !== undefined ? servedPrefixOpt : defaultServedPrefix;
20
- let serviceName = "UploadPresignService";
20
+ let name = nameOpt !== undefined ? nameOpt : "UploadPresignService";
21
21
  let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
22
- let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(serviceName, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
22
+ let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Platform", "Identity", "Platform", undefined, undefined, undefined, undefined), opts$1);
23
23
  bucketName.apply(b => {
24
- let arn = `arn:aws:s3:::` + b + `/*`;
25
- new (Aws.iam.RolePolicy)(serviceName + `Policy`, {
26
- policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, serviceName + `Policy`, [
24
+ let arn = `arn:aws:s3:::` + b + `/` + servedPrefix + `/*`;
25
+ new (Aws.iam.RolePolicy)(name + `Policy`, {
26
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + `Policy`, [
27
27
  {
28
28
  Sid: "AllowLambdaLogging",
29
29
  Effect: "Allow",
@@ -50,7 +50,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
50
50
  ]]);
51
51
  let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Upload/Upload_Presign_S3_Ops.res.mjs", packageDirs, undefined);
52
52
  let layers = Stdlib_Option.getOr(Stdlib_Option.map(Lambda$PulumiAws.reventlessLayerArn, arn => [arn]), []);
53
- let lambda = new (Aws.lambda.Function)(serviceName, {
53
+ let lambda = new (Aws.lambda.Function)(name, {
54
54
  handler: "index.handler",
55
55
  runtime: "nodejs22.x",
56
56
  code: match.code,
@@ -58,7 +58,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
58
58
  memorySize: 256,
59
59
  timeout: 30,
60
60
  layers: layers,
61
- tags: AWS_Tags$ReventlessAws.make(serviceName, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
61
+ tags: AWS_Tags$ReventlessAws.make(name, "Platform", "Runtime", "Platform", undefined, undefined, undefined, undefined),
62
62
  environment: {
63
63
  variables: Object.fromEntries([
64
64
  [
@@ -85,7 +85,7 @@ function make(bucketName, corsOriginsOpt, servedPrefixOpt, opts) {
85
85
  },
86
86
  sourceCodeHash: match.sourceCodeHash
87
87
  }, opts$1 !== undefined ? Primitive_option.valFromOption(opts$1) : undefined);
88
- let functionUrl = new (Aws.lambda.FunctionUrl)(serviceName + `Url`, {
88
+ let functionUrl = new (Aws.lambda.FunctionUrl)(name + `Url`, {
89
89
  authorizationType: "NONE",
90
90
  functionName: lambda.name,
91
91
  cors: {
@@ -20,9 +20,20 @@
20
20
  // both cost and delete semantics and belongs to a deployment's judgement rather
21
21
  // than to a framework default.
22
22
  //
23
- // Follow-up, once a store's lifecycle follows a declaration rather than an
24
- // explicit line of app code: `protect` / `retainOnDelete`, so an accidental
25
- // removal of that declaration cannot destroy a bucket holding live objects.
23
+ // A store's lifecycle now follows a *declaration*, which changes the risk. A
24
+ // hand-written bucket could only be destroyed by editing a line of Pulumi; a
25
+ // declared one can be destroyed by renaming a field, because the last
26
+ // `@storageRef("productImages")` disappearing removes the requirement. Hence
27
+ // `~protect`, on by default and turned off only for stacks that are routinely
28
+ // torn down — see `Util_StoreLayout.protectionFor`, which decides that from
29
+ // disposability rather than from the store's layout.
30
+ //
31
+ // Not to be confused with the framework's other buckets: a **store** is
32
+ // declared by a field's type and holds values that events reference, so its
33
+ // objects outlive any single deploy. A **task bucket** (`TaskBucket_S3`) is
34
+ // wired by a slice and holds transient input, and the plugin-bundle and
35
+ // host-ui-bundle buckets are hosting substrate. Neither has a `@storageRef`
36
+ // field pointing at it, and neither is provisioned from a declaration.
26
37
 
27
38
  open PulumiAws
28
39
 
@@ -39,24 +50,62 @@ let defaultCorsRules: S3.Bucket.corsRules = [
39
50
  ]
40
51
 
41
52
  /** Create an object store bucket with framework attribution tags and an
42
- all-true public-access block. */
53
+ all-true public-access block.
54
+
55
+ `~keyPrefix` is the path this store's object keys are rooted at; it travels
56
+ in the returned record so the mint side and the serve side read one value
57
+ instead of restating it.
58
+
59
+ `~plugin` attributes the store to the plugin whose field declared it. The
60
+ bucket is created by the platform deploy — that is where the serving CDN and
61
+ the presign services live, and where a shared bucket has a single owner — so
62
+ ownership is expressed in the tags rather than in which stack ran `make`.
63
+
64
+ `~protect` (default on) blocks `pulumi destroy` and accidental replacement.
65
+ `~forceDestroy` is its counterpart for disposable stacks: a protected bucket
66
+ cannot be torn down, so a PR stack would leak exactly the buckets that
67
+ sharing exists to save. */
43
68
  let make = (
44
69
  ~name: string,
70
+ ~keyPrefix: string=Upload_Presign_S3.defaultServedPrefix,
71
+ ~plugin: option<string>=?,
72
+ ~protect: bool=true,
73
+ ~forceDestroy: bool=false,
45
74
  ~corsRules: S3.Bucket.corsRules=defaultCorsRules,
46
75
  ~opts: option<Pulumi.CustomResourceOptions.t>=?,
47
76
  ): ReventlessInfra.Platform.objectStore => {
77
+ // A declared store is plugin substrate; the legacy hand-written store, which
78
+ // names no plugin, stays platform-scoped so its tags are unchanged.
79
+ let (kind, scope) = switch plugin {
80
+ | Some(_) => (
81
+ ReventlessCore.ComponentType.Plugin,
82
+ ReventlessCore.ResourceAttribution.Scope.Plugin,
83
+ )
84
+ | None => (
85
+ ReventlessCore.ComponentType.Platform,
86
+ ReventlessCore.ResourceAttribution.Scope.Platform,
87
+ )
88
+ }
89
+
90
+ let opts: Pulumi.CustomResourceOptions.t = {
91
+ ...opts->Option.getOr({}),
92
+ protect,
93
+ }
94
+
48
95
  let bucket = S3.Bucket.make(
49
96
  ~name,
50
97
  ~args={
51
98
  corsRules: corsRules->Pulumi.Input.make,
99
+ forceDestroy: forceDestroy->Pulumi.Input.make,
52
100
  tags: AWS.Tags.make(
53
101
  ~name,
54
- ~kind=ReventlessCore.ComponentType.Platform,
102
+ ~kind,
55
103
  ~role=Other("ObjectStore"),
56
- ~scope=Platform,
104
+ ~scope,
105
+ ~plugin?,
57
106
  ),
58
107
  },
59
- ~opts?,
108
+ ~opts,
60
109
  )
61
110
 
62
111
  // A served store is read through the UI's CDN via an origin access control,
@@ -80,5 +129,18 @@ let make = (
80
129
  bucketId: bucket.id->Pulumi.Output.asInput,
81
130
  bucketArn: bucket.arn->Pulumi.Output.asInput,
82
131
  bucketRegionalDomainName: bucket.bucketRegionalDomainName->Pulumi.Output.asInput,
132
+ keyPrefix,
83
133
  }
84
134
  }
135
+
136
+ /** The same bucket, addressed under a different key prefix.
137
+
138
+ A shared-layout stack puts every declared store in one bucket, so the bucket
139
+ is created once and each store is that bucket seen through its own prefix.
140
+ Splitting this out keeps the memoisation of "one bucket per physical name"
141
+ separate from "one store per declaration", which are different cardinalities
142
+ the moment a layout shares. */
143
+ let underPrefix = (
144
+ store: ReventlessInfra.Platform.objectStore,
145
+ ~keyPrefix: string,
146
+ ): ReventlessInfra.Platform.objectStore => {...store, keyPrefix}
@@ -1,8 +1,9 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
- import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as AWS_Tags$ReventlessAws from "../adapter/AWS_Tags.res.mjs";
6
+ import * as Upload_Presign_S3$ReventlessAws from "../adapter/Upload/Upload_Presign_S3.res.mjs";
6
7
 
7
8
  let defaultCorsRules = [{
8
9
  allowedHeaders: ["*"],
@@ -17,15 +18,28 @@ let defaultCorsRules = [{
17
18
  maxAgeSeconds: 3000
18
19
  }];
19
20
 
20
- function make(name, corsRulesOpt, opts) {
21
+ function make(name, keyPrefixOpt, plugin, protectOpt, forceDestroyOpt, corsRulesOpt, opts) {
22
+ let keyPrefix = keyPrefixOpt !== undefined ? keyPrefixOpt : Upload_Presign_S3$ReventlessAws.defaultServedPrefix;
23
+ let protect = protectOpt !== undefined ? protectOpt : true;
24
+ let forceDestroy = forceDestroyOpt !== undefined ? forceDestroyOpt : false;
21
25
  let corsRules = corsRulesOpt !== undefined ? corsRulesOpt : defaultCorsRules;
26
+ let match = plugin !== undefined ? [
27
+ "Plugin",
28
+ "Plugin"
29
+ ] : [
30
+ "Platform",
31
+ "Platform"
32
+ ];
33
+ let newrecord = {...Stdlib_Option.getOr(opts, {})};
34
+ newrecord.protect = protect;
22
35
  let bucket = new (Aws.s3.Bucket)(name, {
23
36
  corsRules: corsRules,
24
- tags: AWS_Tags$ReventlessAws.make(name, "Platform", {
37
+ forceDestroy: forceDestroy,
38
+ tags: AWS_Tags$ReventlessAws.make(name, match[0], {
25
39
  TAG: "Other",
26
40
  _0: "ObjectStore"
27
- }, "Platform", undefined, undefined, undefined, undefined)
28
- }, opts !== undefined ? Primitive_option.valFromOption(opts) : undefined);
41
+ }, match[1], undefined, undefined, plugin, undefined)
42
+ }, newrecord);
29
43
  new (Aws.s3.BucketPublicAccessBlock)(name + "-pab", {
30
44
  bucket: bucket.id,
31
45
  blockPublicAcls: true,
@@ -37,12 +51,24 @@ function make(name, corsRulesOpt, opts) {
37
51
  bucketName: bucket.bucket,
38
52
  bucketId: bucket.id,
39
53
  bucketArn: bucket.arn,
40
- bucketRegionalDomainName: bucket.bucketRegionalDomainName
54
+ bucketRegionalDomainName: bucket.bucketRegionalDomainName,
55
+ keyPrefix: keyPrefix
56
+ };
57
+ }
58
+
59
+ function underPrefix(store, keyPrefix) {
60
+ return {
61
+ bucketName: store.bucketName,
62
+ bucketId: store.bucketId,
63
+ bucketArn: store.bucketArn,
64
+ bucketRegionalDomainName: store.bucketRegionalDomainName,
65
+ keyPrefix: keyPrefix
41
66
  };
42
67
  }
43
68
 
44
69
  export {
45
70
  defaultCorsRules,
46
71
  make,
72
+ underPrefix,
47
73
  }
48
74
  /* @pulumi/aws Not a pure module */
@@ -225,17 +225,26 @@ let makeUiBundleDistribution = (
225
225
  // CachingOptimized policy is safe. Prepended to `orderedCacheBehaviors` so a
226
226
  // served path is matched before the SPA-serving default behavior and never
227
227
  // routed to the bundle bucket.
228
- let servedOriginId = (prefix: string): string => "served-" ++ prefix
229
- let servedCacheBehavior = (prefix: string): PulumiAws.CloudFront.Distribution.orderedCacheBehavior => {
228
+ let servedOriginId = (id: string): string => "served-" ++ id
229
+ let servedCacheBehavior = (
230
+ ~prefix: string,
231
+ ~originId: string,
232
+ ): PulumiAws.CloudFront.Distribution.orderedCacheBehavior => {
230
233
  pathPattern: Pulumi.Input.make(prefix ++ "/*"),
231
- targetOriginId: Pulumi.Input.make(servedOriginId(prefix)),
234
+ targetOriginId: Pulumi.Input.make(originId),
232
235
  viewerProtocolPolicy: Pulumi.Input.make("redirect-to-https"),
233
236
  allowedMethods: Pulumi.Input.make(["GET", "HEAD"]),
234
237
  cachedMethods: Pulumi.Input.make(["GET", "HEAD"]),
235
238
  cachePolicyId: Pulumi.Input.make(cachingOptimizedPolicyId),
236
239
  }
237
240
  let orderedCacheBehaviors = Array.concat(
238
- servedBuckets->Array.map(sb => servedCacheBehavior(sb.prefix)),
241
+ // One behavior per served prefix; several prefixes may share one origin when
242
+ // they live in the same bucket.
243
+ servedBuckets->Array.flatMap(sb =>
244
+ sb.prefixes->Array.map(prefix =>
245
+ servedCacheBehavior(~prefix, ~originId=servedOriginId(sb.id))
246
+ )
247
+ ),
239
248
  Array.concat(
240
249
  [noCacheBehavior("/remoteEntry.js")],
241
250
  spaFallback ? [noCacheBehavior("/" ++ indexDocument), noCacheBehavior("/config.json")] : [],
@@ -336,7 +345,7 @@ let makeUiBundleDistribution = (
336
345
  // per-bucket read grant is the served bucket's own BucketPolicy below).
337
346
  servedBuckets->Array.map(sb => {
338
347
  PulumiAws.CloudFront.Distribution.domainName: sb.bucketRegionalDomainName,
339
- originId: Pulumi.Input.make(servedOriginId(sb.prefix)),
348
+ originId: Pulumi.Input.make(servedOriginId(sb.id)),
340
349
  originAccessControlId: Pulumi.Input.make(oacId),
341
350
  }),
342
351
  )
@@ -432,9 +441,13 @@ let makeUiBundleDistribution = (
432
441
  // object is fetchable at `https://<ui-domain>/{prefix}/<key>` while the bucket
433
442
  // stays private (direct S3 GET is 403). Mirrors the bundle bucket policy above;
434
443
  // the served bucket keeps its own all-true BucketPublicAccessBlock (app-owned).
444
+ //
445
+ // Exactly one policy per bucket — S3 permits no more. Grouping prefixes into
446
+ // one `servedBucket` is what makes that structural rather than a rule to
447
+ // remember.
435
448
  servedBuckets->Array.forEach(sb => {
436
449
  let _ = PulumiAws.S3.BucketPolicy.make(
437
- ~name=name ++ "-served-" ++ sb.prefix ++ "-policy",
450
+ ~name=name ++ "-served-" ++ sb.id ++ "-policy",
438
451
  ~args={
439
452
  bucket: sb.bucketId,
440
453
  policy: (sb.bucketArn->Pulumi.Output.fromInput, distribution.arn)
@@ -119,11 +119,11 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
119
119
  ],
120
120
  cachePolicyId: cachingDisabledPolicyId
121
121
  });
122
- let orderedCacheBehaviors = servedBuckets.map(sb => {
123
- let prefix = sb.prefix;
122
+ let orderedCacheBehaviors = servedBuckets.flatMap(sb => sb.prefixes.map(prefix => {
123
+ let originId = "served-" + sb.id;
124
124
  return {
125
125
  pathPattern: prefix + "/*",
126
- targetOriginId: "served-" + prefix,
126
+ targetOriginId: originId,
127
127
  viewerProtocolPolicy: "redirect-to-https",
128
128
  allowedMethods: [
129
129
  "GET",
@@ -135,7 +135,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
135
135
  ],
136
136
  cachePolicyId: cachingOptimizedPolicyId
137
137
  };
138
- }).concat([noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
138
+ })).concat([noCacheBehavior("/remoteEntry.js")].concat(spaFallback ? [
139
139
  noCacheBehavior("/" + indexDocument),
140
140
  noCacheBehavior("/config.json")
141
141
  ] : []));
@@ -188,7 +188,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
188
188
  originAccessControlId: oacId
189
189
  }].concat(servedBuckets.map(sb => ({
190
190
  domainName: sb.bucketRegionalDomainName,
191
- originId: "served-" + sb.prefix,
191
+ originId: "served-" + sb.id,
192
192
  originAccessControlId: oacId
193
193
  })));
194
194
  }),
@@ -253,7 +253,7 @@ function makeUiBundleDistribution(pluginId, bundleVersion, assetsDir, spaFallbac
253
253
  }))
254
254
  });
255
255
  servedBuckets.forEach(sb => {
256
- new (Aws.s3.BucketPolicy)(name + "-served-" + sb.prefix + "-policy", {
256
+ new (Aws.s3.BucketPolicy)(name + "-served-" + sb.id + "-policy", {
257
257
  bucket: sb.bucketId,
258
258
  policy: Pulumi.all([
259
259
  sb.bucketArn,
@@ -26,3 +26,20 @@ let parseProdStacks = (csv: string): array<string> =>
26
26
  ->String.split(",")
27
27
  ->Array.map(String.trim)
28
28
  ->Array.filter(s => s !== "")
29
+
30
+ /**
31
+ The stacks this deployment considers production, resolved from config.
32
+
33
+ The one notion of "is this stack production", deliberately shared rather than
34
+ answered twice. Domain naming asked it first; object-store layout asks it too,
35
+ and a second key would be two flags that can disagree — the state where a stack
36
+ gets a production domain and non-production storage, or the reverse, with
37
+ nothing to flag the mismatch.
38
+
39
+ Impure by necessity (it reads config); the derivations that consume it stay
40
+ pure and unit-tested.
41
+ */
42
+ let resolveProdStacks = (): array<string> =>
43
+ Util_LocalConfig.get("hostUiProdStacks")
44
+ ->Option.map(parseProdStacks)
45
+ ->Option.getOr(defaultProdStacks)
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Util_LocalConfig$ReventlessAws from "./Util_LocalConfig.res.mjs";
3
5
 
4
6
  let defaultProdStacks = [
5
7
  "prod",
@@ -18,9 +20,14 @@ function parseProdStacks(csv) {
18
20
  return csv.split(",").map(prim => prim.trim()).filter(s => s !== "");
19
21
  }
20
22
 
23
+ function resolveProdStacks() {
24
+ return Stdlib_Option.getOr(Stdlib_Option.map(Util_LocalConfig$ReventlessAws.get("hostUiProdStacks"), parseProdStacks), defaultProdStacks);
25
+ }
26
+
21
27
  export {
22
28
  defaultProdStacks,
23
29
  deriveFqdn,
24
30
  parseProdStacks,
31
+ resolveProdStacks,
25
32
  }
26
- /* No side effect */
33
+ /* Util_LocalConfig-ReventlessAws Not a pure module */
@@ -0,0 +1,104 @@
1
+ // How a stack lays out the object stores its plugins declare, and how hard it
2
+ // protects them. Pure functions so both decisions are decidable and unit-
3
+ // testable without touching Pulumi — the same shape `Util_HostUiDomain` uses
4
+ // for the host-shell FQDN.
5
+ //
6
+ // Two decisions, deliberately independent:
7
+ //
8
+ // layout — one bucket per store, or one bucket per stack with `{store}/…`
9
+ // prefixes inside it
10
+ // protection — whether an accidental removal may destroy the store
11
+ //
12
+ // It is tempting to make one switch decide both, and that is wrong. `alpha`
13
+ // shares a bucket *and* holds hand-entered data worth protecting; a `pr-*`
14
+ // stack shares a bucket and must be destroyable or teardown leaks exactly the
15
+ // buckets sharing exists to save. Layout answers "how many buckets"; protection
16
+ // answers "is this data disposable". Those are different questions about the
17
+ // same stack.
18
+ //
19
+ // Why production alone gets per-store buckets: the growth term is
20
+ // `plugins × stores × stacks`, and stacks is the factor that actually grows —
21
+ // the stack allowlist admits `pr-*`, so per-PR environments multiply every
22
+ // plugin's every store against a 100-bucket account default. Production does
23
+ // not multiply. It is also the only environment where a bucket boundary (rather
24
+ // than a prefix boundary) is worth its cost, since the honest price of sharing
25
+ // is that per-store least-privilege degrades from a bucket ARN to a prefix ARN.
26
+
27
+ /** How many buckets a stack's declared stores get. */
28
+ type t =
29
+ | PerStore
30
+ | SharedBucket
31
+
32
+ /** Whether a store may be destroyed by an accidental removal. */
33
+ type protection =
34
+ | Protected
35
+ | Unprotected
36
+
37
+ /**
38
+ Stack-name prefixes whose stacks are disposable.
39
+
40
+ `pr-*` matches the stack allowlist's per-PR environments. A prefix rather than
41
+ an exact list because the whole point of these stacks is that nobody enumerates
42
+ them in advance.
43
+ */
44
+ let defaultEphemeralPrefixes = ["pr-"]
45
+
46
+ /**
47
+ Production gets a bucket per store; every other stack shares one.
48
+
49
+ `prodStacks` comes from `Util_HostUiDomain.resolveProdStacks` — the same notion
50
+ of "production" that names the host-shell domain, on purpose.
51
+
52
+ Note this polarity **fails open**: a new production stack whose name is not on
53
+ the list (`production`, `live`, `prod-eu`) silently gets the weaker layout and
54
+ nothing errors. That is the accepted cost of keying off a name allowlist, paid
55
+ down by the list being config-overridable and by the deploy logging the layout
56
+ it chose — a silent fail-open is only dangerous while it is silent.
57
+ */
58
+ let layoutFor = (~stack: string, ~prodStacks: array<string>): t =>
59
+ prodStacks->Array.includes(stack) ? PerStore : SharedBucket
60
+
61
+ /**
62
+ Destroy semantics follow disposability, **not** layout.
63
+
64
+ `alpha` shares a bucket and is still protected: it declares
65
+ `reventless:wipeable`, but that authorises the reset tool to empty stores
66
+ *deliberately*, after a scope prompt and a typed confirmation. It does not say a
67
+ field rename may destroy a bucket by accident. Only stacks that are routinely
68
+ torn down are unprotected.
69
+ */
70
+ let protectionFor = (~stack: string, ~ephemeralPrefixes: array<string>=defaultEphemeralPrefixes): protection =>
71
+ ephemeralPrefixes->Array.some(p => stack->String.startsWith(p)) ? Unprotected : Protected
72
+
73
+ /**
74
+ The bucket a store's objects live in.
75
+
76
+ Per-store: `{plugin}-{store}`, so the physical name traces back to the
77
+ declaration that required it — a store rename becomes a visible replace in
78
+ review rather than a silent one. Shared: one `{stack}-stores` bucket for every
79
+ declared store on the stack.
80
+
81
+ Pulumi appends its own suffix for global uniqueness, so neither form has to
82
+ carry an account or region discriminator.
83
+ */
84
+ let bucketNameFor = (~layout: t, ~stack: string, ~plugin: string, ~store: string): string =>
85
+ switch layout {
86
+ | PerStore => `${plugin}-${store}`
87
+ | SharedBucket => `${stack}-stores`
88
+ }
89
+
90
+ /**
91
+ The prefix a store's object keys are rooted at — the store name, in **both**
92
+ layouts.
93
+
94
+ This is the property that keeps the two layouts one model rather than a fork.
95
+ The presign service mints `{store}/{identity}{uuid}/{file}` either way, so the
96
+ stored ref is `/{store}/…` regardless of which bucket sits behind it, and only
97
+ the CDN origin differs. Refs live in an append-only event log: one that encoded
98
+ its bucket layout would be environment-specific and unrewritable, so a prod dump
99
+ restored into a PR stack would carry refs that cannot resolve.
100
+
101
+ The cost is a slightly redundant prefix inside a dedicated bucket
102
+ (`catalog-productImages/productImages/…`). Take the redundancy.
103
+ */
104
+ let keyPrefixFor = (~store: string): string => store
@@ -0,0 +1,42 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let defaultEphemeralPrefixes = ["pr-"];
5
+
6
+ function layoutFor(stack, prodStacks) {
7
+ if (prodStacks.includes(stack)) {
8
+ return "PerStore";
9
+ } else {
10
+ return "SharedBucket";
11
+ }
12
+ }
13
+
14
+ function protectionFor(stack, ephemeralPrefixesOpt) {
15
+ let ephemeralPrefixes = ephemeralPrefixesOpt !== undefined ? ephemeralPrefixesOpt : defaultEphemeralPrefixes;
16
+ if (ephemeralPrefixes.some(p => stack.startsWith(p))) {
17
+ return "Unprotected";
18
+ } else {
19
+ return "Protected";
20
+ }
21
+ }
22
+
23
+ function bucketNameFor(layout, stack, plugin, store) {
24
+ if (layout === "PerStore") {
25
+ return plugin + `-` + store;
26
+ } else {
27
+ return stack + `-stores`;
28
+ }
29
+ }
30
+
31
+ function keyPrefixFor(store) {
32
+ return store;
33
+ }
34
+
35
+ export {
36
+ defaultEphemeralPrefixes,
37
+ layoutFor,
38
+ protectionFor,
39
+ bucketNameFor,
40
+ keyPrefixFor,
41
+ }
42
+ /* No side effect */