@constructive-io/bucket-provisioner 0.25.1 → 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 +18 -2
- package/esm/index.d.ts +2 -1
- package/esm/index.js +1 -2
- package/esm/naming.d.ts +32 -13
- package/esm/naming.js +77 -22
- package/esm/provisioner.d.ts +6 -1
- package/esm/provisioner.js +13 -9
- package/index.d.ts +2 -1
- package/index.js +2 -2
- package/naming.d.ts +32 -13
- package/naming.js +78 -22
- package/package.json +2 -2
- package/provisioner.d.ts +6 -1
- package/provisioner.js +13 -9
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[]
|
|
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,7 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export type { BucketProvisionerOptions } from './provisioner';
|
|
30
30
|
export { BucketProvisioner } from './provisioner';
|
|
31
|
-
export {
|
|
31
|
+
export type { PhysicalBucketIdentity } from './naming';
|
|
32
|
+
export { mintPhysicalBucketName, 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
|
-
|
|
31
|
-
export { mintPhysicalBucketName } from './naming';
|
|
30
|
+
export { mintPhysicalBucketName, 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,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
|
+
}
|
|
1
10
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* `{
|
|
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.
|
|
4
21
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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`).
|
|
8
30
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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.
|
|
17
36
|
*/
|
|
18
37
|
export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
|
package/esm/naming.js
CHANGED
|
@@ -8,6 +8,25 @@ const IDENTITY_DIGEST_LENGTH = 12;
|
|
|
8
8
|
/** Readable budget: how much of the name the prefix and key may each occupy. */
|
|
9
9
|
const PREFIX_BUDGET = 20;
|
|
10
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;
|
|
11
30
|
/**
|
|
12
31
|
* Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
|
|
13
32
|
* with runs of separators collapsed and no leading or trailing hyphen.
|
|
@@ -22,33 +41,69 @@ function sanitizeBucketNameComponent(value) {
|
|
|
22
41
|
.replace(/[^a-z0-9]+/g, '-')
|
|
23
42
|
.replace(/^-+|-+$/g, '');
|
|
24
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
|
+
}
|
|
25
48
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
49
|
+
* Assemble a bounded, S3-legal name from readable components and the untruncated
|
|
50
|
+
* identity they were derived from.
|
|
32
51
|
*
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
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.
|
|
41
56
|
*/
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const name = [
|
|
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('-');
|
|
48
63
|
// The digest alone already satisfies both bounds, so this is only reachable
|
|
49
|
-
// when
|
|
64
|
+
// when every readable component sanitizes away to nothing.
|
|
50
65
|
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
|
|
66
|
+
throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
|
|
52
67
|
}
|
|
53
68
|
return name;
|
|
54
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
|
+
}
|
package/esm/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
|
|
31
|
+
allowedOrigins?: string[];
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
34
|
* The BucketProvisioner handles creating and configuring S3-compatible
|
package/esm/provisioner.js
CHANGED
|
@@ -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 =
|
|
96
|
-
?
|
|
97
|
-
:
|
|
98
|
-
|
|
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 {
|
|
31
|
+
export type { PhysicalBucketIdentity } from './naming';
|
|
32
|
+
export { mintPhysicalBucketName, 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,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.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.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
|
-
// Physical naming policy
|
|
35
34
|
var naming_1 = require("./naming");
|
|
36
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; } });
|
|
37
37
|
// S3 client factory
|
|
38
38
|
var client_1 = require("./client");
|
|
39
39
|
Object.defineProperty(exports, "createS3Client", { enumerable: true, get: function () { return client_1.createS3Client; } });
|
package/naming.d.ts
CHANGED
|
@@ -1,18 +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
|
+
}
|
|
1
10
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* `{
|
|
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.
|
|
4
21
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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`).
|
|
8
30
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
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.
|
|
17
36
|
*/
|
|
18
37
|
export declare function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string;
|
package/naming.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.physicalBucketName = physicalBucketName;
|
|
3
4
|
exports.mintPhysicalBucketName = mintPhysicalBucketName;
|
|
4
5
|
const crypto_1 = require("crypto");
|
|
5
6
|
/** S3's hard ceiling on a bucket name. */
|
|
@@ -11,6 +12,25 @@ const IDENTITY_DIGEST_LENGTH = 12;
|
|
|
11
12
|
/** Readable budget: how much of the name the prefix and key may each occupy. */
|
|
12
13
|
const PREFIX_BUDGET = 20;
|
|
13
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;
|
|
14
34
|
/**
|
|
15
35
|
* Reduce a component to the S3 bucket-name alphabet: lowercase, `[a-z0-9-]`,
|
|
16
36
|
* with runs of separators collapsed and no leading or trailing hyphen.
|
|
@@ -25,33 +45,69 @@ function sanitizeBucketNameComponent(value) {
|
|
|
25
45
|
.replace(/[^a-z0-9]+/g, '-')
|
|
26
46
|
.replace(/^-+|-+$/g, '');
|
|
27
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
|
+
}
|
|
28
52
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
53
|
+
* Assemble a bounded, S3-legal name from readable components and the untruncated
|
|
54
|
+
* identity they were derived from.
|
|
35
55
|
*
|
|
36
|
-
* The
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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.
|
|
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.
|
|
44
60
|
*/
|
|
45
|
-
function
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const name = [
|
|
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('-');
|
|
51
67
|
// The digest alone already satisfies both bounds, so this is only reachable
|
|
52
|
-
// when
|
|
68
|
+
// when every readable component sanitizes away to nothing.
|
|
53
69
|
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
|
|
70
|
+
throw new Error(`[bucket-provisioner] Cannot mint a legal S3 bucket name for ${describeIdentity}: got "${name}"`);
|
|
55
71
|
}
|
|
56
72
|
return name;
|
|
57
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.
|
|
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": "
|
|
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
|
|
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 =
|
|
99
|
-
?
|
|
100
|
-
:
|
|
101
|
-
|
|
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);
|