@aws-blocks/bb-file-bucket 0.1.4 → 0.2.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/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 ?? false;
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
- const plain = new FileBucket(scope, 'plain');
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
- /** Enable object versioning. Default: false. */
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
- /** Wrap an existing S3 bucket instead of creating one. */
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: true } ? VersionedGetOptions : undefined;
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: true } ? VersionedDeleteOptions : undefined;
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: true } ? VersionedGetUrlOptions : GetUrlOptions;
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
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'FileBucket';
3
- export const BB_VERSION = '0.1.4';
3
+ export const BB_VERSION = '0.2.0';