@constructive-io/bucket-provisioner 0.25.0 → 0.26.0

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/README.md CHANGED
@@ -152,7 +152,7 @@ The main class that orchestrates bucket creation and configuration.
152
152
  | `connection.accessKeyId` | `string` | AWS access key ID |
153
153
  | `connection.secretAccessKey` | `string` | AWS secret access key |
154
154
  | `connection.forcePathStyle` | `boolean?` | Force path-style URLs (auto-detected per provider) |
155
- | `allowedOrigins` | `string[]` | Domains allowed for CORS (e.g., `['https://app.example.com']`) |
155
+ | `allowedOrigins` | `string[]?` | Domains allowed for CORS (e.g., `['https://app.example.com']`). Empty or omitted means no browser origin may reach these buckets, and they are provisioned with no CORS rule set at all. |
156
156
 
157
157
  #### `provisioner.provision(options): Promise<ProvisionResult>`
158
158
 
@@ -161,7 +161,7 @@ Creates and configures a bucket. Steps:
161
161
  1. Creates the bucket (or verifies it exists)
162
162
  2. Configures Block Public Access
163
163
  3. Applies bucket policy (public-read or none)
164
- 4. Sets CORS rules for presigned URL uploads
164
+ 4. Sets CORS rules for presigned URL uploads (skipped when no origins are allowed)
165
165
  5. Optionally enables versioning
166
166
  6. Adds lifecycle rules for temp buckets
167
167
 
@@ -172,6 +172,7 @@ Creates and configures a bucket. Steps:
172
172
  | `region` | `string?` | Override region for this bucket |
173
173
  | `versioning` | `boolean?` | Enable S3 versioning (default: `false`) |
174
174
  | `publicUrlPrefix` | `string?` | CDN/public URL for public buckets |
175
+ | `allowedOrigins` | `string[]?` | Per-bucket CORS origins, overriding the constructor's |
175
176
 
176
177
  #### `provisioner.inspect(bucketName, accessType): Promise<ProvisionResult>`
177
178
 
@@ -185,6 +186,21 @@ Returns the underlying `@aws-sdk/client-s3` S3Client for advanced operations.
185
186
 
186
187
  Checks if a bucket exists and is accessible.
187
188
 
189
+ ### Naming
190
+
191
+ #### `physicalBucketName({ scope, databaseId, bucketKey })`
192
+
193
+ The physical name a platform-provisioned bucket gets from its own identity:
194
+ `{scope}-{database label}-{bucketKey}-{digest}`. Takes no prefix — a bucket
195
+ belongs to one database at one scope, and there is no global bucket namespace to
196
+ prefix into. Each readable component has its own budget, so a long scope cannot
197
+ crowd out the database label, and the digest covers the untruncated identity.
198
+
199
+ #### `mintPhysicalBucketName(prefix, databaseId, bucketKey)`
200
+
201
+ The prefixed policy — `{prefix}-{bucketKey}-{digest}` — for a deployment that
202
+ names its own bucket namespace.
203
+
188
204
  ### Policy Builders
189
205
 
190
206
  Standalone functions for generating S3 policy documents.
package/esm/index.d.ts CHANGED
@@ -28,6 +28,8 @@
28
28
  */
29
29
  export type { BucketProvisionerOptions } from './provisioner';
30
30
  export { BucketProvisioner } from './provisioner';
31
+ export type { PhysicalBucketIdentity } from './naming';
32
+ export { mintPhysicalBucketName, physicalBucketName } from './naming';
31
33
  export { createS3Client } from './client';
32
34
  export type { BucketPolicyDocument, BucketPolicyStatement, PublicAccessBlockConfig, } from './policies';
33
35
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
package/esm/index.js CHANGED
@@ -27,6 +27,7 @@
27
27
  * ```
28
28
  */
29
29
  export { BucketProvisioner } from './provisioner';
30
+ export { mintPhysicalBucketName, physicalBucketName } from './naming';
30
31
  // S3 client factory
31
32
  export { createS3Client } from './client';
32
33
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
@@ -0,0 +1,37 @@
1
+ /** What a physical bucket name is derived from: the bucket row's own identity. */
2
+ export interface PhysicalBucketIdentity {
3
+ /** The scope the bucket row lives at (`platform`, `database`, an entity scope). */
4
+ scope: string;
5
+ /** The database that owns the bucket row. */
6
+ databaseId: string;
7
+ /** The bucket's logical key, as declared on the row. */
8
+ bucketKey: string;
9
+ }
10
+ /**
11
+ * The physical name a bucket gets from its own identity:
12
+ * `{scope}-{database label}-{bucketKey}-{digest}`
13
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
14
+ *
15
+ * This is the policy for a platform-provisioned bucket, and it takes no prefix
16
+ * because there is no global bucket namespace to prefix into: a bucket belongs
17
+ * to one database at one scope, and that is what its name should say. Callers
18
+ * derive names from here rather than composing a prefix of their own, so the
19
+ * policy can change in one place — and an already-provisioned bucket never
20
+ * consults it at all, since the row's recorded `physical_name` is authoritative.
21
+ *
22
+ * The scope is part of the digested identity untruncated, so two scopes of one
23
+ * database that declare the same bucket key stay distinct even when their
24
+ * readable labels truncate to the same thing.
25
+ */
26
+ export declare function physicalBucketName(identity: PhysicalBucketIdentity): string;
27
+ /**
28
+ * The prefixed physical-bucket naming policy:
29
+ * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
30
+ *
31
+ * For a deployment that names its own bucket namespace — a single-app
32
+ * installation, or the presigned-upload (lazy) path meeting a bucket an app
33
+ * configured. A platform-provisioned bucket uses {@link physicalBucketName}
34
+ * instead, whose components come from the bucket row rather than from
35
+ * deployment config.
36
+ */
37
+ export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
package/esm/naming.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createHash } from 'crypto';
2
+ /** S3's hard ceiling on a bucket name. */
3
+ const MAX_BUCKET_NAME_LENGTH = 63;
4
+ /** S3's floor, which a degenerate prefix/key could otherwise fall under. */
5
+ const MIN_BUCKET_NAME_LENGTH = 3;
6
+ /** Hex characters of the identity digest kept as the uniqueness tail. */
7
+ const IDENTITY_DIGEST_LENGTH = 12;
8
+ /** Readable budget: how much of the name the prefix and key may each occupy. */
9
+ const PREFIX_BUDGET = 20;
10
+ const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3;
11
+ /**
12
+ * Readable budgets for a name minted from a bucket's own identity.
13
+ *
14
+ * Each component gets its own budget rather than sharing one prefix budget, so
15
+ * a long scope cannot eat the database label (or the label the key) — every
16
+ * component keeps the room it was given, and the digest below covers whatever
17
+ * truncation drops.
18
+ */
19
+ const SCOPE_BUDGET = 12;
20
+ /**
21
+ * How many characters of the owning database's id the readable head carries:
22
+ * the first group of a UUID, enough to recognise a tenant in a bucket listing.
23
+ */
24
+ const DATABASE_LABEL_LENGTH = 8;
25
+ const IDENTITY_KEY_BUDGET = MAX_BUCKET_NAME_LENGTH -
26
+ IDENTITY_DIGEST_LENGTH -
27
+ SCOPE_BUDGET -
28
+ DATABASE_LABEL_LENGTH -
29
+ 3;
30
+ /**
31
+ * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
32
+ * with runs of separators collapsed and no leading or trailing hyphen.
33
+ *
34
+ * Dots are legal in a bucket name but deliberately dropped — a dotted name
35
+ * cannot be used with virtual-hosted-style HTTPS, because the wildcard
36
+ * certificate does not match a further label.
37
+ */
38
+ function sanitizeBucketNameComponent(value) {
39
+ return value
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, '-')
42
+ .replace(/^-+|-+$/g, '');
43
+ }
44
+ /** A readable component: sanitized, truncated to its own budget, hyphen-free at the edges. */
45
+ function readableComponent(value, budget) {
46
+ return sanitizeBucketNameComponent(value).slice(0, budget).replace(/-+$/, '');
47
+ }
48
+ /**
49
+ * Assemble a bounded, S3-legal name from readable components and the untruncated
50
+ * identity they were derived from.
51
+ *
52
+ * The digest is taken over the identity rather than over what survived
53
+ * truncation, so two buckets whose components agree only past their budgets
54
+ * still get distinct names. Nothing here reads the clock or a counter, so a
55
+ * given identity always mints the same name.
56
+ */
57
+ function assembleBucketName(readable, identity, describeIdentity) {
58
+ const digest = createHash('sha256')
59
+ .update(identity)
60
+ .digest('hex')
61
+ .slice(0, IDENTITY_DIGEST_LENGTH);
62
+ const name = [...readable, digest].filter((part) => part.length > 0).join('-');
63
+ // The digest alone already satisfies both bounds, so this is only reachable
64
+ // when every readable component sanitizes away to nothing.
65
+ if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) {
66
+ throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
67
+ }
68
+ return name;
69
+ }
70
+ /**
71
+ * The physical name a bucket gets from its own identity:
72
+ * `{scope}-{database label}-{bucketKey}-{digest}`
73
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
74
+ *
75
+ * This is the policy for a platform-provisioned bucket, and it takes no prefix
76
+ * because there is no global bucket namespace to prefix into: a bucket belongs
77
+ * to one database at one scope, and that is what its name should say. Callers
78
+ * derive names from here rather than composing a prefix of their own, so the
79
+ * policy can change in one place — and an already-provisioned bucket never
80
+ * consults it at all, since the row's recorded `physical_name` is authoritative.
81
+ *
82
+ * The scope is part of the digested identity untruncated, so two scopes of one
83
+ * database that declare the same bucket key stay distinct even when their
84
+ * readable labels truncate to the same thing.
85
+ */
86
+ export function physicalBucketName(identity) {
87
+ const { scope, databaseId, bucketKey } = identity;
88
+ return assembleBucketName([
89
+ readableComponent(scope, SCOPE_BUDGET),
90
+ readableComponent(databaseId, DATABASE_LABEL_LENGTH),
91
+ readableComponent(bucketKey, IDENTITY_KEY_BUDGET),
92
+ ], `${scope}/${databaseId}/${bucketKey}`, `key "${bucketKey}" at scope "${scope}"`);
93
+ }
94
+ /**
95
+ * The prefixed physical-bucket naming policy:
96
+ * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
97
+ *
98
+ * For a deployment that names its own bucket namespace — a single-app
99
+ * installation, or the presigned-upload (lazy) path meeting a bucket an app
100
+ * configured. A platform-provisioned bucket uses {@link physicalBucketName}
101
+ * instead, whose components come from the bucket row rather than from
102
+ * deployment config.
103
+ */
104
+ export function mintPhysicalBucketName(prefix, databaseId, bucketKey) {
105
+ return assembleBucketName([
106
+ readableComponent(prefix, PREFIX_BUDGET),
107
+ readableComponent(bucketKey, BUCKET_KEY_BUDGET),
108
+ ], `${prefix}/${databaseId}/${bucketKey}`, `key "${bucketKey}"`);
109
+ }
@@ -22,8 +22,13 @@ export interface BucketProvisionerOptions {
22
22
  * Default allowed origins for CORS rules.
23
23
  * These are the domains where your app runs (e.g., ["https://app.example.com"]).
24
24
  * Required for browser-based presigned URL uploads.
25
+ *
26
+ * Empty (or omitted) means no browser origin may reach these buckets — a
27
+ * build's log store, say, which only server-side code ever opens. Such a
28
+ * bucket is provisioned with no CORS rule set at all rather than with a
29
+ * fabricated origin.
25
30
  */
26
- allowedOrigins: string[];
31
+ allowedOrigins?: string[];
27
32
  }
28
33
  /**
29
34
  * The BucketProvisioner handles creating and configuring S3-compatible
@@ -45,11 +45,8 @@ export class BucketProvisioner {
45
45
  config;
46
46
  allowedOrigins;
47
47
  constructor(options) {
48
- if (!options.allowedOrigins || options.allowedOrigins.length === 0) {
49
- throw new ProvisionerError('INVALID_CONFIG', 'allowedOrigins must contain at least one origin for CORS configuration');
50
- }
51
48
  this.config = options.connection;
52
- this.allowedOrigins = options.allowedOrigins;
49
+ this.allowedOrigins = options.allowedOrigins ?? [];
53
50
  this.client = createS3Client(options.connection);
54
51
  }
55
52
  /**
@@ -90,12 +87,19 @@ export class BucketProvisioner {
90
87
  // Ensure no leftover public policy on private/temp buckets
91
88
  await this.deleteBucketPolicy(bucketName);
92
89
  }
93
- // 4. Set CORS rules (per-bucket override takes precedence over default)
90
+ // 4. Set CORS rules (per-bucket override takes precedence over default).
91
+ // A bucket no browser origin may reach gets no rule set: cross-origin
92
+ // access is the bucket's own recorded data, and "none recorded" is a
93
+ // legitimate answer, not a missing setting to invent an origin for.
94
94
  const effectiveOrigins = options.allowedOrigins ?? this.allowedOrigins;
95
- const corsRules = accessType === 'private'
96
- ? buildPrivateCorsRules(effectiveOrigins)
97
- : buildUploadCorsRules(effectiveOrigins);
98
- await this.setCors(bucketName, corsRules);
95
+ const corsRules = effectiveOrigins.length === 0
96
+ ? []
97
+ : accessType === 'private'
98
+ ? buildPrivateCorsRules(effectiveOrigins)
99
+ : buildUploadCorsRules(effectiveOrigins);
100
+ if (corsRules.length > 0) {
101
+ await this.setCors(bucketName, corsRules);
102
+ }
99
103
  // 5. Versioning
100
104
  if (versioning) {
101
105
  await this.enableVersioning(bucketName);
package/index.d.ts CHANGED
@@ -28,6 +28,8 @@
28
28
  */
29
29
  export type { BucketProvisionerOptions } from './provisioner';
30
30
  export { BucketProvisioner } from './provisioner';
31
+ export type { PhysicalBucketIdentity } from './naming';
32
+ export { mintPhysicalBucketName, physicalBucketName } from './naming';
31
33
  export { createS3Client } from './client';
32
34
  export type { BucketPolicyDocument, BucketPolicyStatement, PublicAccessBlockConfig, } from './policies';
33
35
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
package/index.js CHANGED
@@ -28,9 +28,12 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.ProvisionerError = exports.buildTempCleanupRule = exports.buildAbortIncompleteMultipartRule = exports.buildUploadCorsRules = exports.buildPrivateCorsRules = exports.getPublicAccessBlock = exports.buildPublicReadPolicy = exports.buildPresignedUrlIamPolicy = exports.buildCloudFrontOacPolicy = exports.createS3Client = exports.BucketProvisioner = void 0;
31
+ exports.ProvisionerError = exports.buildTempCleanupRule = exports.buildAbortIncompleteMultipartRule = exports.buildUploadCorsRules = exports.buildPrivateCorsRules = exports.getPublicAccessBlock = exports.buildPublicReadPolicy = exports.buildPresignedUrlIamPolicy = exports.buildCloudFrontOacPolicy = exports.createS3Client = exports.physicalBucketName = exports.mintPhysicalBucketName = exports.BucketProvisioner = void 0;
32
32
  var provisioner_1 = require("./provisioner");
33
33
  Object.defineProperty(exports, "BucketProvisioner", { enumerable: true, get: function () { return provisioner_1.BucketProvisioner; } });
34
+ var naming_1 = require("./naming");
35
+ Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return naming_1.mintPhysicalBucketName; } });
36
+ Object.defineProperty(exports, "physicalBucketName", { enumerable: true, get: function () { return naming_1.physicalBucketName; } });
34
37
  // S3 client factory
35
38
  var client_1 = require("./client");
36
39
  Object.defineProperty(exports, "createS3Client", { enumerable: true, get: function () { return client_1.createS3Client; } });
package/naming.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /** What a physical bucket name is derived from: the bucket row's own identity. */
2
+ export interface PhysicalBucketIdentity {
3
+ /** The scope the bucket row lives at (`platform`, `database`, an entity scope). */
4
+ scope: string;
5
+ /** The database that owns the bucket row. */
6
+ databaseId: string;
7
+ /** The bucket's logical key, as declared on the row. */
8
+ bucketKey: string;
9
+ }
10
+ /**
11
+ * The physical name a bucket gets from its own identity:
12
+ * `{scope}-{database label}-{bucketKey}-{digest}`
13
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
14
+ *
15
+ * This is the policy for a platform-provisioned bucket, and it takes no prefix
16
+ * because there is no global bucket namespace to prefix into: a bucket belongs
17
+ * to one database at one scope, and that is what its name should say. Callers
18
+ * derive names from here rather than composing a prefix of their own, so the
19
+ * policy can change in one place — and an already-provisioned bucket never
20
+ * consults it at all, since the row's recorded `physical_name` is authoritative.
21
+ *
22
+ * The scope is part of the digested identity untruncated, so two scopes of one
23
+ * database that declare the same bucket key stay distinct even when their
24
+ * readable labels truncate to the same thing.
25
+ */
26
+ export declare function physicalBucketName(identity: PhysicalBucketIdentity): string;
27
+ /**
28
+ * The prefixed physical-bucket naming policy:
29
+ * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
30
+ *
31
+ * For a deployment that names its own bucket namespace — a single-app
32
+ * installation, or the presigned-upload (lazy) path meeting a bucket an app
33
+ * configured. A platform-provisioned bucket uses {@link physicalBucketName}
34
+ * instead, whose components come from the bucket row rather than from
35
+ * deployment config.
36
+ */
37
+ export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
package/naming.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.physicalBucketName = physicalBucketName;
4
+ exports.mintPhysicalBucketName = mintPhysicalBucketName;
5
+ const crypto_1 = require("crypto");
6
+ /** S3's hard ceiling on a bucket name. */
7
+ const MAX_BUCKET_NAME_LENGTH = 63;
8
+ /** S3's floor, which a degenerate prefix/key could otherwise fall under. */
9
+ const MIN_BUCKET_NAME_LENGTH = 3;
10
+ /** Hex characters of the identity digest kept as the uniqueness tail. */
11
+ const IDENTITY_DIGEST_LENGTH = 12;
12
+ /** Readable budget: how much of the name the prefix and key may each occupy. */
13
+ const PREFIX_BUDGET = 20;
14
+ const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3;
15
+ /**
16
+ * Readable budgets for a name minted from a bucket's own identity.
17
+ *
18
+ * Each component gets its own budget rather than sharing one prefix budget, so
19
+ * a long scope cannot eat the database label (or the label the key) — every
20
+ * component keeps the room it was given, and the digest below covers whatever
21
+ * truncation drops.
22
+ */
23
+ const SCOPE_BUDGET = 12;
24
+ /**
25
+ * How many characters of the owning database's id the readable head carries:
26
+ * the first group of a UUID, enough to recognise a tenant in a bucket listing.
27
+ */
28
+ const DATABASE_LABEL_LENGTH = 8;
29
+ const IDENTITY_KEY_BUDGET = MAX_BUCKET_NAME_LENGTH -
30
+ IDENTITY_DIGEST_LENGTH -
31
+ SCOPE_BUDGET -
32
+ DATABASE_LABEL_LENGTH -
33
+ 3;
34
+ /**
35
+ * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
36
+ * with runs of separators collapsed and no leading or trailing hyphen.
37
+ *
38
+ * Dots are legal in a bucket name but deliberately dropped — a dotted name
39
+ * cannot be used with virtual-hosted-style HTTPS, because the wildcard
40
+ * certificate does not match a further label.
41
+ */
42
+ function sanitizeBucketNameComponent(value) {
43
+ return value
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, '-')
46
+ .replace(/^-+|-+$/g, '');
47
+ }
48
+ /** A readable component: sanitized, truncated to its own budget, hyphen-free at the edges. */
49
+ function readableComponent(value, budget) {
50
+ return sanitizeBucketNameComponent(value).slice(0, budget).replace(/-+$/, '');
51
+ }
52
+ /**
53
+ * Assemble a bounded, S3-legal name from readable components and the untruncated
54
+ * identity they were derived from.
55
+ *
56
+ * The digest is taken over the identity rather than over what survived
57
+ * truncation, so two buckets whose components agree only past their budgets
58
+ * still get distinct names. Nothing here reads the clock or a counter, so a
59
+ * given identity always mints the same name.
60
+ */
61
+ function assembleBucketName(readable, identity, describeIdentity) {
62
+ const digest = (0, crypto_1.createHash)('sha256')
63
+ .update(identity)
64
+ .digest('hex')
65
+ .slice(0, IDENTITY_DIGEST_LENGTH);
66
+ const name = [...readable, digest].filter((part) => part.length > 0).join('-');
67
+ // The digest alone already satisfies both bounds, so this is only reachable
68
+ // when every readable component sanitizes away to nothing.
69
+ if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) {
70
+ throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
71
+ }
72
+ return name;
73
+ }
74
+ /**
75
+ * The physical name a bucket gets from its own identity:
76
+ * `{scope}-{database label}-{bucketKey}-{digest}`
77
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
78
+ *
79
+ * This is the policy for a platform-provisioned bucket, and it takes no prefix
80
+ * because there is no global bucket namespace to prefix into: a bucket belongs
81
+ * to one database at one scope, and that is what its name should say. Callers
82
+ * derive names from here rather than composing a prefix of their own, so the
83
+ * policy can change in one place — and an already-provisioned bucket never
84
+ * consults it at all, since the row's recorded `physical_name` is authoritative.
85
+ *
86
+ * The scope is part of the digested identity untruncated, so two scopes of one
87
+ * database that declare the same bucket key stay distinct even when their
88
+ * readable labels truncate to the same thing.
89
+ */
90
+ function physicalBucketName(identity) {
91
+ const { scope, databaseId, bucketKey } = identity;
92
+ return assembleBucketName([
93
+ readableComponent(scope, SCOPE_BUDGET),
94
+ readableComponent(databaseId, DATABASE_LABEL_LENGTH),
95
+ readableComponent(bucketKey, IDENTITY_KEY_BUDGET),
96
+ ], `${scope}/${databaseId}/${bucketKey}`, `key "${bucketKey}" at scope "${scope}"`);
97
+ }
98
+ /**
99
+ * The prefixed physical-bucket naming policy:
100
+ * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
101
+ *
102
+ * For a deployment that names its own bucket namespace — a single-app
103
+ * installation, or the presigned-upload (lazy) path meeting a bucket an app
104
+ * configured. A platform-provisioned bucket uses {@link physicalBucketName}
105
+ * instead, whose components come from the bucket row rather than from
106
+ * deployment config.
107
+ */
108
+ function mintPhysicalBucketName(prefix, databaseId, bucketKey) {
109
+ return assembleBucketName([
110
+ readableComponent(prefix, PREFIX_BUDGET),
111
+ readableComponent(bucketKey, BUCKET_KEY_BUDGET),
112
+ ], `${prefix}/${databaseId}/${bucketKey}`, `key "${bucketKey}"`);
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/bucket-provisioner",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "S3-compatible bucket provisioning library — create buckets, configure privacy policies, CORS, and access controls",
6
6
  "main": "index.js",
@@ -44,5 +44,5 @@
44
44
  "storage",
45
45
  "constructive"
46
46
  ],
47
- "gitHead": "b5b6b9f05276b7e9cbd3d1f4acd31dd315c353ea"
47
+ "gitHead": "ec6638bb4a06080919cee7e9ebf48306a2535f2f"
48
48
  }
package/provisioner.d.ts CHANGED
@@ -22,8 +22,13 @@ export interface BucketProvisionerOptions {
22
22
  * Default allowed origins for CORS rules.
23
23
  * These are the domains where your app runs (e.g., ["https://app.example.com"]).
24
24
  * Required for browser-based presigned URL uploads.
25
+ *
26
+ * Empty (or omitted) means no browser origin may reach these buckets — a
27
+ * build's log store, say, which only server-side code ever opens. Such a
28
+ * bucket is provisioned with no CORS rule set at all rather than with a
29
+ * fabricated origin.
25
30
  */
26
- allowedOrigins: string[];
31
+ allowedOrigins?: string[];
27
32
  }
28
33
  /**
29
34
  * The BucketProvisioner handles creating and configuring S3-compatible
package/provisioner.js CHANGED
@@ -48,11 +48,8 @@ class BucketProvisioner {
48
48
  config;
49
49
  allowedOrigins;
50
50
  constructor(options) {
51
- if (!options.allowedOrigins || options.allowedOrigins.length === 0) {
52
- throw new types_1.ProvisionerError('INVALID_CONFIG', 'allowedOrigins must contain at least one origin for CORS configuration');
53
- }
54
51
  this.config = options.connection;
55
- this.allowedOrigins = options.allowedOrigins;
52
+ this.allowedOrigins = options.allowedOrigins ?? [];
56
53
  this.client = (0, client_1.createS3Client)(options.connection);
57
54
  }
58
55
  /**
@@ -93,12 +90,19 @@ class BucketProvisioner {
93
90
  // Ensure no leftover public policy on private/temp buckets
94
91
  await this.deleteBucketPolicy(bucketName);
95
92
  }
96
- // 4. Set CORS rules (per-bucket override takes precedence over default)
93
+ // 4. Set CORS rules (per-bucket override takes precedence over default).
94
+ // A bucket no browser origin may reach gets no rule set: cross-origin
95
+ // access is the bucket's own recorded data, and "none recorded" is a
96
+ // legitimate answer, not a missing setting to invent an origin for.
97
97
  const effectiveOrigins = options.allowedOrigins ?? this.allowedOrigins;
98
- const corsRules = accessType === 'private'
99
- ? (0, cors_1.buildPrivateCorsRules)(effectiveOrigins)
100
- : (0, cors_1.buildUploadCorsRules)(effectiveOrigins);
101
- await this.setCors(bucketName, corsRules);
98
+ const corsRules = effectiveOrigins.length === 0
99
+ ? []
100
+ : accessType === 'private'
101
+ ? (0, cors_1.buildPrivateCorsRules)(effectiveOrigins)
102
+ : (0, cors_1.buildUploadCorsRules)(effectiveOrigins);
103
+ if (corsRules.length > 0) {
104
+ await this.setCors(bucketName, corsRules);
105
+ }
102
106
  // 5. Versioning
103
107
  if (versioning) {
104
108
  await this.enableVersioning(bucketName);