@constructive-io/bucket-provisioner 0.25.1 → 0.27.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,16 @@ 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
+
188
199
  ### Policy Builders
189
200
 
190
201
  Standalone functions for generating S3 policy documents.
package/esm/index.d.ts CHANGED
@@ -28,7 +28,8 @@
28
28
  */
29
29
  export type { BucketProvisionerOptions } from './provisioner';
30
30
  export { BucketProvisioner } from './provisioner';
31
- export { mintPhysicalBucketName } from './naming';
31
+ export type { PhysicalBucketIdentity } from './naming';
32
+ export { physicalBucketName } from './naming';
32
33
  export { createS3Client } from './client';
33
34
  export type { BucketPolicyDocument, BucketPolicyStatement, PublicAccessBlockConfig, } from './policies';
34
35
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
package/esm/index.js CHANGED
@@ -27,8 +27,7 @@
27
27
  * ```
28
28
  */
29
29
  export { BucketProvisioner } from './provisioner';
30
- // Physical naming policy
31
- export { mintPhysicalBucketName } from './naming';
30
+ export { physicalBucketName } from './naming';
32
31
  // S3 client factory
33
32
  export { createS3Client } from './client';
34
33
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
package/esm/naming.d.ts CHANGED
@@ -1,18 +1,26 @@
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
+ }
1
10
  /**
2
- * The single physical-bucket naming policy:
3
- * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
11
+ * The physical name a bucket gets from its own identity:
12
+ * `{scope}-{database label}-{bucketKey}-{digest}`
13
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
4
14
  *
5
- * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path
6
- * derive names from this one function, so a bucket's physical name is identical
7
- * regardless of which path mints it.
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. The
18
+ * reconciler derives names from here rather than composing a prefix of its own,
19
+ * and consumers of a bucket row never consult this policy: the recorded
20
+ * `physical_name` is authoritative.
8
21
  *
9
- * The name is bounded and S3-legal by construction: the prefix and key are
10
- * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is
11
- * a digest of the *untruncated* identity — so two buckets whose keys agree only
12
- * past the truncation point, or the same key in two databases, still get distinct
13
- * names. Names remain stable for a given (prefix, databaseId, bucketKey) because
14
- * nothing here reads the clock or a counter; and an already-provisioned bucket
15
- * never consults this function at all, since `platform_buckets.physical_name` is
16
- * authoritative once recorded.
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.
17
25
  */
18
- export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
26
+ export declare function physicalBucketName(identity: PhysicalBucketIdentity): string;
package/esm/naming.js CHANGED
@@ -5,9 +5,25 @@ const MAX_BUCKET_NAME_LENGTH = 63;
5
5
  const MIN_BUCKET_NAME_LENGTH = 3;
6
6
  /** Hex characters of the identity digest kept as the uniqueness tail. */
7
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;
8
+ /**
9
+ * Readable budgets for a name minted from a bucket's own identity.
10
+ *
11
+ * Each component gets its own budget rather than sharing one prefix budget, so
12
+ * a long scope cannot eat the database label (or the label the key) — every
13
+ * component keeps the room it was given, and the digest below covers whatever
14
+ * truncation drops.
15
+ */
16
+ const SCOPE_BUDGET = 12;
17
+ /**
18
+ * How many characters of the owning database's id the readable head carries:
19
+ * the first group of a UUID, enough to recognise a tenant in a bucket listing.
20
+ */
21
+ const DATABASE_LABEL_LENGTH = 8;
22
+ const IDENTITY_KEY_BUDGET = MAX_BUCKET_NAME_LENGTH -
23
+ IDENTITY_DIGEST_LENGTH -
24
+ SCOPE_BUDGET -
25
+ DATABASE_LABEL_LENGTH -
26
+ 3;
11
27
  /**
12
28
  * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
13
29
  * with runs of separators collapsed and no leading or trailing hyphen.
@@ -22,33 +38,53 @@ function sanitizeBucketNameComponent(value) {
22
38
  .replace(/[^a-z0-9]+/g, '-')
23
39
  .replace(/^-+|-+$/g, '');
24
40
  }
41
+ /** A readable component: sanitized, truncated to its own budget, hyphen-free at the edges. */
42
+ function readableComponent(value, budget) {
43
+ return sanitizeBucketNameComponent(value).slice(0, budget).replace(/-+$/, '');
44
+ }
25
45
  /**
26
- * The single physical-bucket naming policy:
27
- * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
46
+ * Assemble a bounded, S3-legal name from readable components and the untruncated
47
+ * identity they were derived from.
28
48
  *
29
- * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path
30
- * derive names from this one function, so a bucket's physical name is identical
31
- * regardless of which path mints it.
32
- *
33
- * The name is bounded and S3-legal by construction: the prefix and key are
34
- * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is
35
- * a digest of the *untruncated* identity — so two buckets whose keys agree only
36
- * past the truncation point, or the same key in two databases, still get distinct
37
- * names. Names remain stable for a given (prefix, databaseId, bucketKey) because
38
- * nothing here reads the clock or a counter; and an already-provisioned bucket
39
- * never consults this function at all, since `platform_buckets.physical_name` is
40
- * authoritative once recorded.
49
+ * The digest is taken over the identity rather than over what survived
50
+ * truncation, so two buckets whose components agree only past their budgets
51
+ * still get distinct names. Nothing here reads the clock or a counter, so a
52
+ * given identity always mints the same name.
41
53
  */
42
- export function mintPhysicalBucketName(prefix, databaseId, bucketKey) {
43
- const identity = `${prefix}/${databaseId}/${bucketKey}`;
44
- const digest = createHash('sha256').update(identity).digest('hex').slice(0, IDENTITY_DIGEST_LENGTH);
45
- const safePrefix = sanitizeBucketNameComponent(prefix).slice(0, PREFIX_BUDGET).replace(/-+$/, '');
46
- const safeKey = sanitizeBucketNameComponent(bucketKey).slice(0, BUCKET_KEY_BUDGET).replace(/-+$/, '');
47
- const name = [safePrefix, safeKey, digest].filter((part) => part.length > 0).join('-');
54
+ function assembleBucketName(readable, identity, describeIdentity) {
55
+ const digest = createHash('sha256')
56
+ .update(identity)
57
+ .digest('hex')
58
+ .slice(0, IDENTITY_DIGEST_LENGTH);
59
+ const name = [...readable, digest].filter((part) => part.length > 0).join('-');
48
60
  // The digest alone already satisfies both bounds, so this is only reachable
49
- // when both readable components sanitize away to nothing.
61
+ // when every readable component sanitizes away to nothing.
50
62
  if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) {
51
- throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for key "${bucketKey}": got "${name}"`);
63
+ throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
52
64
  }
53
65
  return name;
54
66
  }
67
+ /**
68
+ * The physical name a bucket gets from its own identity:
69
+ * `{scope}-{database label}-{bucketKey}-{digest}`
70
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
71
+ *
72
+ * This is the policy for a platform-provisioned bucket, and it takes no prefix
73
+ * because there is no global bucket namespace to prefix into: a bucket belongs
74
+ * to one database at one scope, and that is what its name should say. The
75
+ * reconciler derives names from here rather than composing a prefix of its own,
76
+ * and consumers of a bucket row never consult this policy: the recorded
77
+ * `physical_name` is authoritative.
78
+ *
79
+ * The scope is part of the digested identity untruncated, so two scopes of one
80
+ * database that declare the same bucket key stay distinct even when their
81
+ * readable labels truncate to the same thing.
82
+ */
83
+ export function physicalBucketName(identity) {
84
+ const { scope, databaseId, bucketKey } = identity;
85
+ return assembleBucketName([
86
+ readableComponent(scope, SCOPE_BUDGET),
87
+ readableComponent(databaseId, DATABASE_LABEL_LENGTH),
88
+ readableComponent(bucketKey, IDENTITY_KEY_BUDGET),
89
+ ], `${scope}/${databaseId}/${bucketKey}`, `key "${bucketKey}" at scope "${scope}"`);
90
+ }
@@ -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,7 +28,8 @@
28
28
  */
29
29
  export type { BucketProvisionerOptions } from './provisioner';
30
30
  export { BucketProvisioner } from './provisioner';
31
- export { mintPhysicalBucketName } from './naming';
31
+ export type { PhysicalBucketIdentity } from './naming';
32
+ export { physicalBucketName } from './naming';
32
33
  export { createS3Client } from './client';
33
34
  export type { BucketPolicyDocument, BucketPolicyStatement, PublicAccessBlockConfig, } from './policies';
34
35
  export { buildCloudFrontOacPolicy, buildPresignedUrlIamPolicy, buildPublicReadPolicy, getPublicAccessBlock, } from './policies';
package/index.js CHANGED
@@ -28,12 +28,11 @@
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.mintPhysicalBucketName = 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.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
- // Physical naming policy
35
34
  var naming_1 = require("./naming");
36
- Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return naming_1.mintPhysicalBucketName; } });
35
+ Object.defineProperty(exports, "physicalBucketName", { enumerable: true, get: function () { return naming_1.physicalBucketName; } });
37
36
  // S3 client factory
38
37
  var client_1 = require("./client");
39
38
  Object.defineProperty(exports, "createS3Client", { enumerable: true, get: function () { return client_1.createS3Client; } });
package/naming.d.ts CHANGED
@@ -1,18 +1,26 @@
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
+ }
1
10
  /**
2
- * The single physical-bucket naming policy:
3
- * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
11
+ * The physical name a bucket gets from its own identity:
12
+ * `{scope}-{database label}-{bucketKey}-{digest}`
13
+ * (e.g. `database-028752cb-buildlogs-3f9c1a2b7e04`).
4
14
  *
5
- * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path
6
- * derive names from this one function, so a bucket's physical name is identical
7
- * regardless of which path mints it.
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. The
18
+ * reconciler derives names from here rather than composing a prefix of its own,
19
+ * and consumers of a bucket row never consult this policy: the recorded
20
+ * `physical_name` is authoritative.
8
21
  *
9
- * The name is bounded and S3-legal by construction: the prefix and key are
10
- * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is
11
- * a digest of the *untruncated* identity — so two buckets whose keys agree only
12
- * past the truncation point, or the same key in two databases, still get distinct
13
- * names. Names remain stable for a given (prefix, databaseId, bucketKey) because
14
- * nothing here reads the clock or a counter; and an already-provisioned bucket
15
- * never consults this function at all, since `platform_buckets.physical_name` is
16
- * authoritative once recorded.
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.
17
25
  */
18
- export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
26
+ export declare function physicalBucketName(identity: PhysicalBucketIdentity): string;
package/naming.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.mintPhysicalBucketName = mintPhysicalBucketName;
3
+ exports.physicalBucketName = physicalBucketName;
4
4
  const crypto_1 = require("crypto");
5
5
  /** S3's hard ceiling on a bucket name. */
6
6
  const MAX_BUCKET_NAME_LENGTH = 63;
@@ -8,9 +8,25 @@ const MAX_BUCKET_NAME_LENGTH = 63;
8
8
  const MIN_BUCKET_NAME_LENGTH = 3;
9
9
  /** Hex characters of the identity digest kept as the uniqueness tail. */
10
10
  const IDENTITY_DIGEST_LENGTH = 12;
11
- /** Readable budget: how much of the name the prefix and key may each occupy. */
12
- const PREFIX_BUDGET = 20;
13
- 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;
14
30
  /**
15
31
  * Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
16
32
  * with runs of separators collapsed and no leading or trailing hyphen.
@@ -25,33 +41,53 @@ function sanitizeBucketNameComponent(value) {
25
41
  .replace(/[^a-z0-9]+/g, '-')
26
42
  .replace(/^-+|-+$/g, '');
27
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
+ }
28
48
  /**
29
- * The single physical-bucket naming policy:
30
- * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`).
49
+ * Assemble a bounded, S3-legal name from readable components and the untruncated
50
+ * identity they were derived from.
31
51
  *
32
- * Both the presigned-upload (lazy) path and the bucket-provisioner (eager) path
33
- * derive names from this one function, so a bucket's physical name is identical
34
- * regardless of which path mints it.
35
- *
36
- * The name is bounded and S3-legal by construction: the prefix and key are
37
- * sanitized to `[a-z0-9-]` and truncated to a readable budget, and the tail is
38
- * a digest of the *untruncated* identity — so two buckets whose keys agree only
39
- * past the truncation point, or the same key in two databases, still get distinct
40
- * names. Names remain stable for a given (prefix, databaseId, bucketKey) because
41
- * nothing here reads the clock or a counter; and an already-provisioned bucket
42
- * never consults this function at all, since `platform_buckets.physical_name` is
43
- * authoritative once recorded.
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.
44
56
  */
45
- function mintPhysicalBucketName(prefix, databaseId, bucketKey) {
46
- const identity = `${prefix}/${databaseId}/${bucketKey}`;
47
- const digest = (0, crypto_1.createHash)('sha256').update(identity).digest('hex').slice(0, IDENTITY_DIGEST_LENGTH);
48
- const safePrefix = sanitizeBucketNameComponent(prefix).slice(0, PREFIX_BUDGET).replace(/-+$/, '');
49
- const safeKey = sanitizeBucketNameComponent(bucketKey).slice(0, BUCKET_KEY_BUDGET).replace(/-+$/, '');
50
- const name = [safePrefix, safeKey, digest].filter((part) => part.length > 0).join('-');
57
+ function assembleBucketName(readable, identity, describeIdentity) {
58
+ const digest = (0, crypto_1.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('-');
51
63
  // The digest alone already satisfies both bounds, so this is only reachable
52
- // when both readable components sanitize away to nothing.
64
+ // when every readable component sanitizes away to nothing.
53
65
  if (name.length < MIN_BUCKET_NAME_LENGTH || name.length > MAX_BUCKET_NAME_LENGTH) {
54
- throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for key "${bucketKey}": got "${name}"`);
66
+ throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
55
67
  }
56
68
  return name;
57
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. The
78
+ * reconciler derives names from here rather than composing a prefix of its own,
79
+ * and consumers of a bucket row never consult this policy: the recorded
80
+ * `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
+ 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/bucket-provisioner",
3
- "version": "0.25.1",
3
+ "version": "0.27.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": "7e6cc68f5659d12ec852835a7d8fcca6b05a8f50"
47
+ "gitHead": "77d58bb93796be91d4e00156bb6847392584d3bc"
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);