@aws-blocks/bb-file-bucket 0.1.5 → 0.2.1
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/DESIGN.md +36 -5
- package/README.md +26 -3
- package/dist/file-server.d.ts.map +1 -1
- package/dist/file-server.js +10 -1
- package/dist/file-server.test.js +16 -0
- package/dist/index.cdk.d.ts +2 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +115 -25
- package/dist/index.cdk.test.js +261 -3
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +41 -1
- package/dist/index.test.js +85 -1
- package/dist/types.d.ts +69 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -3
- package/src/file-server.test.ts +22 -0
- package/src/file-server.ts +10 -1
- package/src/index.cdk.test.ts +347 -3
- package/src/index.cdk.ts +130 -25
- package/src/index.mock.ts +49 -2
- package/src/index.test.ts +131 -1
- package/src/types.ts +66 -5
- package/src/version.ts +1 -1
package/src/index.mock.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { mintFileToken, LOCAL_FILE_SECRET } from './tokens.js';
|
|
|
16
16
|
import { validateBucketName } from './bucket-name.js';
|
|
17
17
|
import type {
|
|
18
18
|
FileBucketOptions, PutOptions, PutUrlOptions, ScanOptions,
|
|
19
|
-
FileContent, FileInfo, ExternalBucketRef,
|
|
19
|
+
FileContent, FileInfo, ExternalBucketRef, CorsRule,
|
|
20
20
|
FileDownloadClient, FileUploadClient, FileVersionInfo,
|
|
21
21
|
GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
|
|
22
22
|
} from './types.js';
|
|
@@ -40,6 +40,17 @@ export { FileBucketErrors } from './errors.js';
|
|
|
40
40
|
|
|
41
41
|
const MAX_KEY_BYTES = 1024; // S3 key limit
|
|
42
42
|
|
|
43
|
+
// Mirrors of the CDK synth-time-guard constants (index.cdk.ts). Duplicated here
|
|
44
|
+
// rather than imported — the CDK entry (index.cdk.ts) pulls in aws-cdk-lib and
|
|
45
|
+
// must never be imported into the mock runtime. Kept byte-identical to the CDK
|
|
46
|
+
// definitions so the two guards below reject exactly what `cdk synth` rejects.
|
|
47
|
+
|
|
48
|
+
/** Default number of days after which noncurrent object versions expire. */
|
|
49
|
+
const DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS = 90;
|
|
50
|
+
|
|
51
|
+
/** HTTP methods that mutate bucket state; unsafe to expose to wildcard origins. */
|
|
52
|
+
const MUTATING_CORS_METHODS: ReadonlyArray<CorsRule['allowedMethods'][number]> = ['PUT', 'POST', 'DELETE'];
|
|
53
|
+
|
|
43
54
|
function blocksError(name: string, message: string): Error {
|
|
44
55
|
const err = new Error(`${name}: ${message}`);
|
|
45
56
|
err.name = name;
|
|
@@ -94,9 +105,45 @@ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends
|
|
|
94
105
|
// deployed bucket name), not the `mock-` prefixed local name, to keep
|
|
95
106
|
// parity with the CDK path.
|
|
96
107
|
if (!options?.bucket) validateBucketName(this.fullId);
|
|
108
|
+
// Mirror the CDK's two synth-time guards (index.cdk.ts) VERBATIM so a
|
|
109
|
+
// local/unit run rejects exactly what `cdk synth` would. Gated on
|
|
110
|
+
// `!options?.bucket` alongside validateBucketName: the CDK's
|
|
111
|
+
// external-bucket branch returns before these checks, so a wrapped
|
|
112
|
+
// bucket bypasses them here too. Plain `throw new Error(...)` (no
|
|
113
|
+
// `name`, no blocksError factory) to match the CDK path exactly — using
|
|
114
|
+
// blocksError() would set an `error.name` the CDK guards don't, breaking
|
|
115
|
+
// mock↔cdk parity.
|
|
116
|
+
if (!options?.bucket) {
|
|
117
|
+
// Reject unsafe CORS: a wildcard origin ('*') combined with a mutating
|
|
118
|
+
// method (PUT/POST/DELETE) lets any site issue state-changing
|
|
119
|
+
// cross-origin requests.
|
|
120
|
+
for (const rule of options?.corsRules ?? []) {
|
|
121
|
+
if (rule.allowedOrigins.includes('*')) {
|
|
122
|
+
const mutating = rule.allowedMethods.filter(m => MUTATING_CORS_METHODS.includes(m));
|
|
123
|
+
if (mutating.length > 0) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`FileBucket "${this.fullId}": CORS rule with wildcard origin '*' must not allow mutating method(s) ${mutating.join(', ')}. ` +
|
|
126
|
+
`Specify explicit allowedOrigins (e.g. 'https://app.example.com') for ${mutating.join(', ')} instead of '*'.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Reject a non-positive or non-integer noncurrent-version expiration.
|
|
132
|
+
// The FORMAT is validated regardless of `versioned` (matching CDK) so
|
|
133
|
+
// a malformed value is caught even when versioning is off.
|
|
134
|
+
if (options?.noncurrentVersionExpirationDays !== undefined) {
|
|
135
|
+
const days = options.noncurrentVersionExpirationDays;
|
|
136
|
+
if (!Number.isInteger(days) || days <= 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`FileBucket "${this.fullId}": noncurrentVersionExpirationDays must be a positive integer (got ${days}). ` +
|
|
139
|
+
`Omit it to use the default of ${DEFAULT_NONCURRENT_VERSION_EXPIRATION_DAYS} days.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
97
144
|
this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
|
|
98
145
|
this.dataDir = getMockDataDir(this);
|
|
99
|
-
this.versioned = options?.versioned ??
|
|
146
|
+
this.versioned = options?.versioned ?? true;
|
|
100
147
|
this.registerClientMiddleware('@aws-blocks/bb-file-bucket/middleware');
|
|
101
148
|
this.registerDevAttachment('@aws-blocks/bb-file-bucket/file-server');
|
|
102
149
|
registerSdkIdentifiers(this.fullId, { bucketName: `mock-${this.fullId}` });
|
package/src/index.test.ts
CHANGED
|
@@ -344,8 +344,11 @@ test('versioned: listVersions returns newest first', async () => {
|
|
|
344
344
|
// ── Static type checks: conditional version types ───────────────────────────
|
|
345
345
|
|
|
346
346
|
function _conditionalVersionTypeChecks() {
|
|
347
|
-
|
|
347
|
+
// Explicit opt-out selects the non-versioned option types.
|
|
348
|
+
const plain = new FileBucket(scope, 'plain', { versioned: false });
|
|
348
349
|
const versioned = new FileBucket(scope, 'versioned', { versioned: true });
|
|
350
|
+
// No options now defaults to versioned-aware typings (Default: true).
|
|
351
|
+
const dflt = new FileBucket(scope, 'dflt');
|
|
349
352
|
|
|
350
353
|
// Non-versioned: get/delete accept no options
|
|
351
354
|
plain.get('file.txt');
|
|
@@ -363,4 +366,131 @@ function _conditionalVersionTypeChecks() {
|
|
|
363
366
|
versioned.delete('file.txt');
|
|
364
367
|
versioned.delete('file.txt', { versionId: 'v1' });
|
|
365
368
|
versioned.getUrl('file.txt', { versionId: 'v1', expiresIn: 600 });
|
|
369
|
+
|
|
370
|
+
// Default (no options) is versioned-aware: versionId is accepted.
|
|
371
|
+
dflt.get('file.txt');
|
|
372
|
+
dflt.get('file.txt', { versionId: 'v1' });
|
|
373
|
+
dflt.delete('file.txt', { versionId: 'v1' });
|
|
374
|
+
dflt.getUrl('file.txt', { versionId: 'v1', expiresIn: 600 });
|
|
366
375
|
}
|
|
376
|
+
|
|
377
|
+
// ── Versioning on by default (new secure default) ───────────────────────────
|
|
378
|
+
|
|
379
|
+
test('default (no options) bucket is versioned: put creates versions', async () => {
|
|
380
|
+
const bucket = new FileBucket(scope, 'default-versioned');
|
|
381
|
+
await bucket.put('file.txt', 'v1');
|
|
382
|
+
await bucket.put('file.txt', 'v2');
|
|
383
|
+
const versions = await bucket.listVersions('file.txt');
|
|
384
|
+
assert.strictEqual(versions.length, 2);
|
|
385
|
+
assert.strictEqual(versions[0].isCurrent, true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('versioned:false opt-out disables versioning at runtime', async () => {
|
|
389
|
+
const bucket = new FileBucket(scope, 'optout-versioned', { versioned: false });
|
|
390
|
+
await bucket.put('file.txt', 'v1');
|
|
391
|
+
await bucket.put('file.txt', 'v2');
|
|
392
|
+
const versions = await bucket.listVersions('file.txt');
|
|
393
|
+
assert.strictEqual(versions.length, 0);
|
|
394
|
+
const file = await bucket.get('file.txt');
|
|
395
|
+
assert.ok(file);
|
|
396
|
+
assert.strictEqual(file.body.toString(), 'v2');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── Synth-time validation parity (mock mirrors the CDK's index.cdk.ts guards) ─
|
|
400
|
+
// The mock reproduces the CDK's two synth-time guards VERBATIM so a local/unit
|
|
401
|
+
// run fails the same way `cdk synth` would (mock↔cdk parity). Mirrors the
|
|
402
|
+
// corresponding cases in index.cdk.test.ts.
|
|
403
|
+
|
|
404
|
+
test('mock: noncurrentVersionExpirationDays of 0 throws', () => {
|
|
405
|
+
assert.throws(
|
|
406
|
+
() => new FileBucket(scope, 'uploads', { noncurrentVersionExpirationDays: 0 }),
|
|
407
|
+
(err: unknown) =>
|
|
408
|
+
err instanceof Error &&
|
|
409
|
+
/noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
|
|
410
|
+
/got 0/.test(err.message),
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('mock: negative noncurrentVersionExpirationDays throws', () => {
|
|
415
|
+
assert.throws(
|
|
416
|
+
() => new FileBucket(scope, 'uploads', { noncurrentVersionExpirationDays: -1 }),
|
|
417
|
+
(err: unknown) =>
|
|
418
|
+
err instanceof Error &&
|
|
419
|
+
/noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
|
|
420
|
+
/got -1/.test(err.message),
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('mock: non-integer noncurrentVersionExpirationDays throws', () => {
|
|
425
|
+
assert.throws(
|
|
426
|
+
() => new FileBucket(scope, 'uploads', { noncurrentVersionExpirationDays: 1.5 }),
|
|
427
|
+
(err: unknown) =>
|
|
428
|
+
err instanceof Error &&
|
|
429
|
+
/noncurrentVersionExpirationDays must be a positive integer/.test(err.message),
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('mock: noncurrentVersionExpirationDays FORMAT is validated even when versioned:false', () => {
|
|
434
|
+
// Format guard is decoupled from the `versioned` gate (matches CDK): a
|
|
435
|
+
// malformed value must fail regardless of whether versioning is on.
|
|
436
|
+
assert.throws(
|
|
437
|
+
() => new FileBucket(scope, 'uploads', { versioned: false, noncurrentVersionExpirationDays: 0 }),
|
|
438
|
+
(err: unknown) =>
|
|
439
|
+
err instanceof Error &&
|
|
440
|
+
/noncurrentVersionExpirationDays must be a positive integer/.test(err.message) &&
|
|
441
|
+
/got 0/.test(err.message),
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('mock: valid noncurrentVersionExpirationDays does not throw', () => {
|
|
446
|
+
assert.doesNotThrow(
|
|
447
|
+
() => new FileBucket(scope, 'uploads', { noncurrentVersionExpirationDays: 30 }),
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('mock: wildcard-origin CORS with a mutating method throws', () => {
|
|
452
|
+
assert.throws(
|
|
453
|
+
() =>
|
|
454
|
+
new FileBucket(scope, 'uploads', {
|
|
455
|
+
corsRules: [{ allowedOrigins: ['*'], allowedMethods: ['GET', 'PUT'] }],
|
|
456
|
+
}),
|
|
457
|
+
(err: unknown) =>
|
|
458
|
+
err instanceof Error &&
|
|
459
|
+
/\*/.test(err.message) &&
|
|
460
|
+
/PUT/.test(err.message),
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('mock: wildcard-origin CORS with only safe methods is allowed', () => {
|
|
465
|
+
assert.doesNotThrow(() =>
|
|
466
|
+
new FileBucket(scope, 'uploads', {
|
|
467
|
+
corsRules: [{ allowedOrigins: ['*'], allowedMethods: ['GET', 'HEAD'] }],
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('mock: explicit-origin CORS with a mutating method is allowed', () => {
|
|
473
|
+
assert.doesNotThrow(() =>
|
|
474
|
+
new FileBucket(scope, 'uploads', {
|
|
475
|
+
corsRules: [{ allowedOrigins: ['https://app.example.com'], allowedMethods: ['PUT', 'POST'] }],
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('mock: when both guards are tripped, the CORS guard fires first (matches CDK order)', () => {
|
|
481
|
+
// The CDK (index.cdk.ts) evaluates the CORS guard before the noncurrent
|
|
482
|
+
// check, so a config that violates BOTH must surface the CORS message — not
|
|
483
|
+
// the noncurrent one — in the mock too, keeping mock↔cdk parity.
|
|
484
|
+
assert.throws(
|
|
485
|
+
() =>
|
|
486
|
+
new FileBucket(scope, 'uploads', {
|
|
487
|
+
noncurrentVersionExpirationDays: 0,
|
|
488
|
+
corsRules: [{ allowedOrigins: ['*'], allowedMethods: ['PUT'] }],
|
|
489
|
+
}),
|
|
490
|
+
(err: unknown) =>
|
|
491
|
+
err instanceof Error &&
|
|
492
|
+
/CORS rule with wildcard origin/.test(err.message) &&
|
|
493
|
+
/PUT/.test(err.message) &&
|
|
494
|
+
!/noncurrentVersionExpirationDays/.test(err.message),
|
|
495
|
+
);
|
|
496
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -10,13 +10,61 @@ import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
|
10
10
|
// ── Constructor options ─────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
export interface FileBucketOptions {
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Enable object versioning. Default: true. Pass `false` to opt out.
|
|
15
|
+
*
|
|
16
|
+
* Note for the version-aware method typings (`get`/`delete`/`getUrl`/
|
|
17
|
+
* `getFileHandle`): selecting the NON-versioned (optionless) typings
|
|
18
|
+
* requires the literal `versioned: false`. A non-literal `boolean` value
|
|
19
|
+
* (e.g. one widened from a variable) — or omitting the option entirely —
|
|
20
|
+
* resolves to the versioned-aware typings, which match the default-on
|
|
21
|
+
* runtime behavior. See {@link GetOptionsFor} et al.
|
|
22
|
+
*/
|
|
14
23
|
versioned?: boolean;
|
|
15
24
|
/** CORS rules for browser-based access. */
|
|
16
25
|
corsRules?: CorsRule[];
|
|
17
26
|
/** Lifecycle rules for automatic object expiration or transitions. */
|
|
18
27
|
lifecycleRules?: LifecycleRule[];
|
|
19
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Enable S3 server access logging. Default: false (opt-in).
|
|
30
|
+
*
|
|
31
|
+
* When `true`, a dedicated, locked-down log bucket is provisioned
|
|
32
|
+
* (all public access blocked, S3-managed encryption, SSL enforced) and
|
|
33
|
+
* the main bucket delivers its access logs there under the
|
|
34
|
+
* `access-logs/` prefix. Access logs are expired automatically after the
|
|
35
|
+
* stack-wide `logRetention` default (see `BlocksDefaults.logRetention`);
|
|
36
|
+
* `RetentionDays.INFINITE` keeps them indefinitely (no expiry rule).
|
|
37
|
+
*
|
|
38
|
+
* Resolves from the stack `defaults.accessLogging` when omitted, so a
|
|
39
|
+
* production-postured stack can opt every FileBucket into logging without
|
|
40
|
+
* a per-block option. Ignored by the mock and browser runtimes (no AWS
|
|
41
|
+
* resource).
|
|
42
|
+
*/
|
|
43
|
+
accessLogging?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Days after which NONCURRENT object versions are permanently expired,
|
|
46
|
+
* bounding the storage cost that versioning would otherwise let grow
|
|
47
|
+
* unbounded. Only applies when versioning is enabled (the default).
|
|
48
|
+
* Default: 90.
|
|
49
|
+
*
|
|
50
|
+
* Must be a positive integer; a non-positive or non-integer value is
|
|
51
|
+
* rejected at synth. There is no "disable" sentinel — noncurrent-version
|
|
52
|
+
* expiration is always on for a versioned bucket to cap version growth. To
|
|
53
|
+
* opt out entirely, disable versioning (`versioned: false`), which drops
|
|
54
|
+
* the rule. Ignored by the mock and browser runtimes (no AWS resource).
|
|
55
|
+
*/
|
|
56
|
+
noncurrentVersionExpirationDays?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Wrap an existing S3 bucket instead of creating one.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* When set, FileBucket binds to the supplied bucket as-is and returns early:
|
|
62
|
+
* NONE of the secure defaults this construct normally applies are applied to
|
|
63
|
+
* an externally-supplied bucket — not `enforceSSL`, versioning + noncurrent-version
|
|
64
|
+
* expiration, server access logging, `blockPublicAccess`, encryption, nor the
|
|
65
|
+
* wildcard-CORS guard. You own that bucket's security posture; configure these
|
|
66
|
+
* on the bucket itself (or via its own CDK construct) before wrapping it.
|
|
67
|
+
*/
|
|
20
68
|
bucket?: ExternalBucketRef;
|
|
21
69
|
/**
|
|
22
70
|
* CDK removal behavior for the underlying S3 bucket. When omitted,
|
|
@@ -94,23 +142,31 @@ export interface VersionedGetUrlOptions extends GetUrlOptions {
|
|
|
94
142
|
/**
|
|
95
143
|
* Resolves the get options type based on whether versioning is enabled.
|
|
96
144
|
* Versioned buckets accept `{ versionId }`, non-versioned accept no options.
|
|
145
|
+
* Versioning is on by default, so only an explicit `versioned: false` selects
|
|
146
|
+
* the non-versioned (optionless) shape. A non-literal `boolean` (widened from
|
|
147
|
+
* a variable) or an absent `versioned` resolves to the versioned-aware shape,
|
|
148
|
+
* matching the default-on runtime behavior.
|
|
97
149
|
*/
|
|
98
150
|
export type GetOptionsFor<O extends FileBucketOptions> =
|
|
99
|
-
O extends { versioned:
|
|
151
|
+
O extends { versioned: false } ? undefined : VersionedGetOptions;
|
|
100
152
|
|
|
101
153
|
/**
|
|
102
154
|
* Resolves the delete options type based on whether versioning is enabled.
|
|
103
155
|
* Versioned buckets accept `{ versionId }`, non-versioned accept no options.
|
|
156
|
+
* Versioning is on by default, so only an explicit `versioned: false` selects
|
|
157
|
+
* the non-versioned (optionless) shape.
|
|
104
158
|
*/
|
|
105
159
|
export type DeleteOptionsFor<O extends FileBucketOptions> =
|
|
106
|
-
O extends { versioned:
|
|
160
|
+
O extends { versioned: false } ? undefined : VersionedDeleteOptions;
|
|
107
161
|
|
|
108
162
|
/**
|
|
109
163
|
* Resolves the getUrl/getFileHandle options type based on whether versioning is enabled.
|
|
110
164
|
* Versioned buckets accept `{ expiresIn, versionId }`, non-versioned accept `{ expiresIn }`.
|
|
165
|
+
* Versioning is on by default, so only an explicit `versioned: false` selects
|
|
166
|
+
* the non-versioned shape.
|
|
111
167
|
*/
|
|
112
168
|
export type GetUrlOptionsFor<O extends FileBucketOptions> =
|
|
113
|
-
O extends { versioned:
|
|
169
|
+
O extends { versioned: false } ? GetUrlOptions : VersionedGetUrlOptions;
|
|
114
170
|
|
|
115
171
|
// ── Return types ────────────────────────────────────────────────────────────
|
|
116
172
|
|
|
@@ -165,6 +221,11 @@ export interface LifecycleRule {
|
|
|
165
221
|
transitionToIaDays?: number;
|
|
166
222
|
}
|
|
167
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A reference to a pre-existing S3 bucket to wrap via {@link FileBucketOptions.bucket}
|
|
226
|
+
* (see that field's remarks — a wrapped bucket does not receive FileBucket's
|
|
227
|
+
* secure defaults). Produced by `FileBucket.fromExisting(bucketName)`.
|
|
228
|
+
*/
|
|
168
229
|
export interface ExternalBucketRef {
|
|
169
230
|
readonly __brand: 'ExternalBucketRef';
|
|
170
231
|
readonly bucketName: string;
|
package/src/version.ts
CHANGED