@aws-blocks/bb-file-bucket 0.1.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.
Files changed (87) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +197 -0
  3. package/dist/bucket-name.d.ts +9 -0
  4. package/dist/bucket-name.d.ts.map +1 -0
  5. package/dist/bucket-name.js +62 -0
  6. package/dist/bucket-name.test.d.ts +2 -0
  7. package/dist/bucket-name.test.d.ts.map +1 -0
  8. package/dist/bucket-name.test.js +61 -0
  9. package/dist/errors.d.ts +20 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +21 -0
  12. package/dist/file-server.d.ts +14 -0
  13. package/dist/file-server.d.ts.map +1 -0
  14. package/dist/file-server.js +169 -0
  15. package/dist/file-server.test.d.ts +2 -0
  16. package/dist/file-server.test.d.ts.map +1 -0
  17. package/dist/file-server.test.js +307 -0
  18. package/dist/index.aws.d.ts +47 -0
  19. package/dist/index.aws.d.ts.map +1 -0
  20. package/dist/index.aws.js +183 -0
  21. package/dist/index.browser.d.ts +4 -0
  22. package/dist/index.browser.d.ts.map +1 -0
  23. package/dist/index.browser.js +6 -0
  24. package/dist/index.cdk.d.ts +16 -0
  25. package/dist/index.cdk.d.ts.map +1 -0
  26. package/dist/index.cdk.js +75 -0
  27. package/dist/index.cdk.test.d.ts +2 -0
  28. package/dist/index.cdk.test.d.ts.map +1 -0
  29. package/dist/index.cdk.test.js +69 -0
  30. package/dist/index.mock.d.ts +246 -0
  31. package/dist/index.mock.d.ts.map +1 -0
  32. package/dist/index.mock.js +502 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +318 -0
  36. package/dist/middleware.d.ts +3 -0
  37. package/dist/middleware.d.ts.map +1 -0
  38. package/dist/middleware.js +62 -0
  39. package/dist/mock-middleware.d.ts +3 -0
  40. package/dist/mock-middleware.d.ts.map +1 -0
  41. package/dist/mock-middleware.js +62 -0
  42. package/dist/mock-utils.d.ts +11 -0
  43. package/dist/mock-utils.d.ts.map +1 -0
  44. package/dist/mock-utils.js +28 -0
  45. package/dist/path-containment.test.d.ts +2 -0
  46. package/dist/path-containment.test.d.ts.map +1 -0
  47. package/dist/path-containment.test.js +91 -0
  48. package/dist/paths.d.ts +25 -0
  49. package/dist/paths.d.ts.map +1 -0
  50. package/dist/paths.js +67 -0
  51. package/dist/scan.test.d.ts +2 -0
  52. package/dist/scan.test.d.ts.map +1 -0
  53. package/dist/scan.test.js +107 -0
  54. package/dist/tokens.d.ts +12 -0
  55. package/dist/tokens.d.ts.map +1 -0
  56. package/dist/tokens.js +42 -0
  57. package/dist/types.d.ts +170 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +3 -0
  60. package/dist/url-encoding.test.d.ts +2 -0
  61. package/dist/url-encoding.test.d.ts.map +1 -0
  62. package/dist/url-encoding.test.js +88 -0
  63. package/dist/version.d.ts +3 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +3 -0
  66. package/package.json +57 -0
  67. package/src/bucket-name.test.ts +103 -0
  68. package/src/bucket-name.ts +83 -0
  69. package/src/errors.ts +22 -0
  70. package/src/file-server.test.ts +381 -0
  71. package/src/file-server.ts +203 -0
  72. package/src/index.aws.ts +219 -0
  73. package/src/index.browser.ts +7 -0
  74. package/src/index.cdk.test.ts +84 -0
  75. package/src/index.cdk.ts +89 -0
  76. package/src/index.mock.ts +531 -0
  77. package/src/index.test.ts +366 -0
  78. package/src/middleware.ts +66 -0
  79. package/src/mock-middleware.ts +66 -0
  80. package/src/mock-utils.ts +31 -0
  81. package/src/path-containment.test.ts +122 -0
  82. package/src/paths.ts +78 -0
  83. package/src/scan.test.ts +137 -0
  84. package/src/tokens.ts +61 -0
  85. package/src/types.ts +206 -0
  86. package/src/url-encoding.test.ts +120 -0
  87. package/src/version.ts +3 -0
@@ -0,0 +1,203 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Dev server attachment for FileBucket.
6
+ * Serves token-gated GET/PUT requests at /.bb-file-bucket/{fullId}/{path}?token=...
7
+ * Mirrors S3 presigned URL behavior: method-scoped, time-limited, path-specific.
8
+ *
9
+ * Storage layout mirrors `index.mock.ts` via the shared `paths.ts` helpers, so
10
+ * user content (under `content/`) is never confused with internal metadata or
11
+ * version bookkeeping. PUT always delegates to the registered FileBucket
12
+ * instance so uploads get versioning, key validation, and metadata handling —
13
+ * there is no direct-write fallback.
14
+ */
15
+
16
+ import type { Server, IncomingMessage, ServerResponse } from 'node:http';
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { isBlocksError } from '@aws-blocks/core';
20
+ import { validateFileToken, LOCAL_FILE_SECRET } from './tokens.js';
21
+ import { assertContainedPath } from './mock-utils.js';
22
+ import { contentRoot, contentPath, metaPath, versionContentPath, versionMetaPath } from './paths.js';
23
+
24
+ const PREFIX = '/.bb-file-bucket/';
25
+
26
+ function parseUrl(url: string): { fullId: string; path: string; token: string; versionId?: string } | null {
27
+ if (!url.startsWith(PREFIX)) return null;
28
+ const [pathPart, query] = url.slice(PREFIX.length).split('?');
29
+ if (!pathPart || !query) return null;
30
+ const params = new URLSearchParams(query);
31
+ const token = params.get('token');
32
+ if (!token) return null;
33
+ const slashIdx = pathPart.indexOf('/');
34
+ if (slashIdx === -1) return null;
35
+ return {
36
+ fullId: pathPart.slice(0, slashIdx),
37
+ path: decodeURIComponent(pathPart.slice(slashIdx + 1)),
38
+ token,
39
+ versionId: params.get('versionId') ?? undefined,
40
+ };
41
+ }
42
+
43
+ function collectBody(req: IncomingMessage): Promise<Buffer> {
44
+ return new Promise((resolve, reject) => {
45
+ const chunks: Buffer[] = [];
46
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
47
+ req.on('end', () => resolve(Buffer.concat(chunks)));
48
+ req.on('error', reject);
49
+ });
50
+ }
51
+
52
+ function sendError(res: ServerResponse, status: number, error: string): void {
53
+ res.writeHead(status, { 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ error }));
55
+ }
56
+
57
+ export function attach(httpServer: Server) {
58
+ const originalListeners = httpServer.listeners('request').slice() as Array<(req: IncomingMessage, res: ServerResponse) => void>;
59
+ httpServer.removeAllListeners('request');
60
+
61
+ httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
62
+ const url = req.url || '';
63
+ if (!url.startsWith(PREFIX)) {
64
+ // Pass through to original handlers
65
+ for (const listener of originalListeners) {
66
+ listener(req, res);
67
+ }
68
+ return;
69
+ }
70
+
71
+ // CORS for browser uploads/downloads
72
+ const origin = req.headers.origin || '*';
73
+ res.setHeader('Access-Control-Allow-Origin', origin);
74
+ res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
75
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
76
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
77
+
78
+ if (req.method === 'OPTIONS') {
79
+ res.writeHead(200);
80
+ res.end();
81
+ return;
82
+ }
83
+
84
+ const parsed = parseUrl(url);
85
+ if (!parsed) {
86
+ sendError(res, 400, 'Invalid file URL');
87
+ return;
88
+ }
89
+
90
+ const { fullId, path, token, versionId } = parsed;
91
+ const dataDir = join(process.cwd(), '.bb-data', fullId);
92
+
93
+ try {
94
+ // User keys live under the content root; validate against it so a
95
+ // traversal attempt can't escape into internal meta/version storage.
96
+ assertContainedPath(contentRoot(dataDir), path);
97
+ } catch (err) {
98
+ const message = isBlocksError(err, 'ValidationFailed') ? err.message : 'Invalid path: traversal detected';
99
+ sendError(res, 400, message);
100
+ return;
101
+ }
102
+
103
+ // versionId is attacker-controlled and is joined into a filesystem path
104
+ // (versionContentPath). It must match the generated format (`v<n>`);
105
+ // anything else (e.g. `../../../etc/passwd`) is rejected so it can't be
106
+ // used for path traversal / arbitrary file read.
107
+ if (versionId !== undefined && !/^v\d{1,10}$/.test(versionId)) {
108
+ sendError(res, 400, 'Invalid versionId');
109
+ return;
110
+ }
111
+
112
+ if (req.method === 'GET') {
113
+ const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'GET');
114
+ if (!valid) {
115
+ sendError(res, 403, 'Invalid or expired token');
116
+ return;
117
+ }
118
+
119
+ // Resolve the file to read — specific version or current
120
+ let readPath = contentPath(dataDir, path);
121
+ let metaFilePath = metaPath(dataDir, path);
122
+ if (versionId) {
123
+ const vPath = versionContentPath(dataDir, path, versionId);
124
+ if (existsSync(vPath)) {
125
+ readPath = vPath;
126
+ metaFilePath = versionMetaPath(dataDir, path, versionId);
127
+ }
128
+ }
129
+
130
+ if (!existsSync(readPath)) {
131
+ sendError(res, 404, 'NoSuchKey');
132
+ return;
133
+ }
134
+
135
+ let contentType = 'application/octet-stream';
136
+ if (existsSync(metaFilePath)) {
137
+ try {
138
+ const meta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
139
+ contentType = meta.contentType ?? contentType;
140
+ } catch {}
141
+ }
142
+
143
+ const body = readFileSync(readPath);
144
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': body.length.toString() });
145
+ res.end(body);
146
+ } else if (req.method === 'PUT') {
147
+ const valid = validateFileToken(token, LOCAL_FILE_SECRET, fullId, path, 'PUT');
148
+ if (!valid) {
149
+ sendError(res, 403, 'Invalid or expired token');
150
+ return;
151
+ }
152
+
153
+ // PUT delegates to the registered FileBucket instance, which owns the
154
+ // storage layout (content/meta/versions) plus versioning and key
155
+ // validation. The dev server and the buckets share a process, so the
156
+ // instance is always registered in practice; if it's missing, fail
157
+ // loud rather than silently writing an unversioned object.
158
+ const registry: Map<string, { put?: unknown }> | undefined = (globalThis as any).__BLOCKS_FILE_BUCKET_REGISTRY__;
159
+ const bucket = registry?.get(fullId);
160
+ if (!bucket || typeof bucket.put !== 'function') {
161
+ sendError(res, 500, `No FileBucket registered for "${fullId}" — cannot handle upload`);
162
+ return;
163
+ }
164
+
165
+ // Content-Type parity with real S3: when a presigned PUT URL is
166
+ // minted with a contentType, the AWS SDK adds `content-type` to the
167
+ // signed headers, so S3 returns 403 SignatureDoesNotMatch if the
168
+ // uploaded request's Content-Type differs from (or omits) the signed
169
+ // value. The mock used to ignore the request header entirely and
170
+ // accept any upload, masking a failure that only surfaced in prod.
171
+ // Enforce the same check here so a mismatch fails loudly in local dev.
172
+ const requestContentType = req.headers['content-type'];
173
+ if (valid.contentType !== undefined && requestContentType !== valid.contentType) {
174
+ sendError(
175
+ res,
176
+ 403,
177
+ `SignatureDoesNotMatch: request Content-Type ${
178
+ requestContentType === undefined ? '(missing)' : `"${requestContentType}"`
179
+ } does not match the signed Content-Type "${valid.contentType}". ` +
180
+ `Send the same Content-Type header that was used to create the upload URL.`,
181
+ );
182
+ return;
183
+ }
184
+
185
+ collectBody(req).then(async (body) => {
186
+ // When a contentType was signed, it equals the (now validated)
187
+ // request header. Otherwise fall back to whatever the request
188
+ // sent, then octet-stream — matching S3's stored content type.
189
+ const contentType = valid.contentType || requestContentType || 'application/octet-stream';
190
+ await (bucket.put as (p: string, b: Buffer, o: { contentType: string }) => Promise<void>)(
191
+ path, body, { contentType },
192
+ );
193
+ res.writeHead(200);
194
+ res.end();
195
+ }).catch((err) => {
196
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
197
+ });
198
+ } else {
199
+ res.writeHead(405);
200
+ res.end();
201
+ }
202
+ });
203
+ }
@@ -0,0 +1,219 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ S3Client,
6
+ PutObjectCommand,
7
+ GetObjectCommand,
8
+ DeleteObjectCommand,
9
+ DeleteObjectsCommand,
10
+ ListObjectsV2Command,
11
+ ListObjectVersionsCommand,
12
+ CopyObjectCommand,
13
+ } from '@aws-sdk/client-s3';
14
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
15
+ import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
16
+ import type { ScopeParent } from '@aws-blocks/core';
17
+ import { BB_NAME, BB_VERSION } from './version.js';
18
+ import type {
19
+ FileBucketOptions, PutOptions, PutUrlOptions, ScanOptions,
20
+ FileContent, FileInfo, ExternalBucketRef,
21
+ FileDownloadClient, FileUploadClient, FileVersionInfo,
22
+ GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
23
+ } from './types.js';
24
+ import { Logger } from '@aws-blocks/bb-logger';
25
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
26
+
27
+ // Re-export public types
28
+ export { FileBucketErrors } from './errors.js';
29
+ export type {
30
+ FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions,
31
+ FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef,
32
+ FileDownloadClient, FileUploadClient, FileVersionInfo,
33
+ FileDownloadDescriptor, FileUploadDescriptor,
34
+ VersionedGetOptions, VersionedDeleteOptions, VersionedGetUrlOptions,
35
+ GetOptionsFor, DeleteOptionsFor, GetUrlOptionsFor,
36
+ } from './types.js';
37
+
38
+ /**
39
+ * File storage backed by Amazon S3.
40
+ *
41
+ * **When to use:** You need to store, retrieve, or serve binary files —
42
+ * user uploads, generated reports, images, videos, or static assets.
43
+ *
44
+ * **When NOT to use:** If you need structured key-value data with conditional
45
+ * writes, use `KVStore`. If you need queryable records with indexes, use
46
+ * `DistributedTable`.
47
+ *
48
+ * **Best practices:**
49
+ * - Use path prefixes to organize files (e.g., `uploads/{userId}/`, `reports/`)
50
+ * - Set `contentType` on `put()` to ensure correct MIME handling on download
51
+ * - Use `getFileHandle` / `createUploadHandle` for ergonomic browser file transfers
52
+ * - Use presigned URLs (`getUrl` / `putUrl`) when you need direct URL control
53
+ * - Prefer `scan({ prefix })` over unscoped `scan()` to limit enumeration cost
54
+ *
55
+ * **Scaling:** S3 scales automatically. No provisioned throughput. Costs are
56
+ * per-request plus storage. Individual objects up to 5 TB. For objects larger
57
+ * than ~100 MB, consider multipart upload.
58
+ */
59
+ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
60
+ readonly bbName = BB_NAME;
61
+ private s3: S3Client;
62
+
63
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
64
+ protected log: ChildLogger;
65
+
66
+ constructor(scope: ScopeParent, id: string, options?: O) {
67
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
68
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
69
+ this.registerClientMiddleware('@aws-blocks/bb-file-bucket/middleware');
70
+ const bucketName = options?.bucket ? options.bucket.bucketName : this.fullId;
71
+ registerSdkIdentifiers(this.fullId, { bucketName });
72
+ this.s3 = new S3Client({
73
+ customUserAgent: this.buildUserAgentChain(),
74
+ });
75
+ }
76
+
77
+ async put(path: string, body: Buffer | string, options?: PutOptions): Promise<void> {
78
+ await this.s3.send(new PutObjectCommand({
79
+ Bucket: getSdkIdentifiers(this).bucketName,
80
+ Key: path,
81
+ Body: typeof body === 'string' ? Buffer.from(body) : body,
82
+ ContentType: options?.contentType,
83
+ Metadata: options?.metadata,
84
+ CacheControl: options?.cacheControl,
85
+ }));
86
+ }
87
+
88
+ async get(path: string, options?: GetOptionsFor<O>): Promise<FileContent | null> {
89
+ try {
90
+ const result = await this.s3.send(new GetObjectCommand({
91
+ Bucket: getSdkIdentifiers(this).bucketName, Key: path,
92
+ ...(options ? { VersionId: (options as any).versionId } : {}),
93
+ }));
94
+ const bytes = await result.Body!.transformToByteArray();
95
+ return {
96
+ body: Buffer.from(bytes),
97
+ contentType: result.ContentType ?? 'application/octet-stream',
98
+ metadata: result.Metadata ?? {},
99
+ size: result.ContentLength ?? bytes.length,
100
+ };
101
+ } catch (e: any) {
102
+ if (e.name === 'NoSuchKey') return null;
103
+ throw e;
104
+ }
105
+ }
106
+
107
+ async delete(path: string, options?: DeleteOptionsFor<O>): Promise<void> {
108
+ await this.s3.send(new DeleteObjectCommand({
109
+ Bucket: getSdkIdentifiers(this).bucketName, Key: path,
110
+ ...(options ? { VersionId: (options as any).versionId } : {}),
111
+ }));
112
+ }
113
+
114
+ async deleteBatch(paths: string[]): Promise<void> {
115
+ const CHUNK_SIZE = 1000;
116
+ for (let i = 0; i < paths.length; i += CHUNK_SIZE) {
117
+ const chunk = paths.slice(i, i + CHUNK_SIZE);
118
+ await this.s3.send(new DeleteObjectsCommand({
119
+ Bucket: getSdkIdentifiers(this).bucketName,
120
+ Delete: { Objects: chunk.map(Key => ({ Key })), Quiet: true },
121
+ }));
122
+ }
123
+ }
124
+
125
+ async getUrl(path: string, options?: GetUrlOptionsFor<O>): Promise<string> {
126
+ const opts = options as any;
127
+ return getSignedUrl(this.s3, new GetObjectCommand({
128
+ Bucket: getSdkIdentifiers(this).bucketName, Key: path,
129
+ ...(opts?.versionId ? { VersionId: opts.versionId } : {}),
130
+ }), {
131
+ expiresIn: opts?.expiresIn ?? 3600,
132
+ });
133
+ }
134
+
135
+ async putUrl(path: string, options?: PutUrlOptions): Promise<string> {
136
+ return getSignedUrl(this.s3, new PutObjectCommand({
137
+ Bucket: getSdkIdentifiers(this).bucketName, Key: path, ContentType: options?.contentType,
138
+ }), { expiresIn: options?.expiresIn ?? 3600 });
139
+ }
140
+
141
+ async getFileHandle(path: string, options?: GetUrlOptionsFor<O>): Promise<FileDownloadClient> {
142
+ const url = await this.getUrl(path, options);
143
+ return {
144
+ download: async () => {
145
+ const res = await fetch(url);
146
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
147
+ return res.blob();
148
+ },
149
+ getUrl: () => url,
150
+ toJSON: () => ({ __blocks: 'file-bucket/download' as const, url }),
151
+ };
152
+ }
153
+
154
+ async createUploadHandle(path: string, options?: PutUrlOptions): Promise<FileUploadClient> {
155
+ const url = await this.putUrl(path, options);
156
+ const contentType = options?.contentType;
157
+ return {
158
+ upload: async (body: Blob | File | ArrayBuffer) => {
159
+ const headers: Record<string, string> = {};
160
+ if (contentType) headers['Content-Type'] = contentType;
161
+ const res = await fetch(url, { method: 'PUT', body, headers });
162
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
163
+ },
164
+ getUrl: () => url,
165
+ toJSON: () => ({ __blocks: 'file-bucket/upload' as const, url, contentType }),
166
+ };
167
+ }
168
+
169
+ async *scan(options?: ScanOptions): AsyncIterable<FileInfo> {
170
+ let continuationToken: string | undefined;
171
+ do {
172
+ const result = await this.s3.send(new ListObjectsV2Command({
173
+ Bucket: getSdkIdentifiers(this).bucketName, Prefix: options?.prefix, ContinuationToken: continuationToken,
174
+ }));
175
+ for (const obj of result.Contents ?? []) {
176
+ yield { path: obj.Key!, size: obj.Size ?? 0, lastModified: obj.LastModified ?? new Date() };
177
+ }
178
+ continuationToken = result.NextContinuationToken;
179
+ } while (continuationToken);
180
+ }
181
+
182
+ async listVersions(path: string): Promise<FileVersionInfo[]> {
183
+ const versions: FileVersionInfo[] = [];
184
+ let keyMarker: string | undefined;
185
+ let versionIdMarker: string | undefined;
186
+ do {
187
+ const result = await this.s3.send(new ListObjectVersionsCommand({
188
+ Bucket: getSdkIdentifiers(this).bucketName, Prefix: path, KeyMarker: keyMarker, VersionIdMarker: versionIdMarker,
189
+ }));
190
+ for (const v of result.Versions ?? []) {
191
+ if (v.Key !== path) continue; // prefix match may include other keys
192
+ versions.push({
193
+ versionId: v.VersionId!,
194
+ lastModified: v.LastModified ?? new Date(),
195
+ size: v.Size ?? 0,
196
+ isCurrent: v.IsLatest ?? false,
197
+ });
198
+ }
199
+ keyMarker = result.NextKeyMarker;
200
+ versionIdMarker = result.NextVersionIdMarker;
201
+ } while (keyMarker);
202
+ // Newest first
203
+ versions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
204
+ return versions;
205
+ }
206
+
207
+ async restoreVersion(path: string, versionId: string): Promise<void> {
208
+ const encodedPath = path.split('/').map(s => encodeURIComponent(s)).join('/');
209
+ await this.s3.send(new CopyObjectCommand({
210
+ Bucket: getSdkIdentifiers(this).bucketName,
211
+ Key: path,
212
+ CopySource: `${getSdkIdentifiers(this).bucketName}/${path}?versionId=${versionId}`,
213
+ }));
214
+ }
215
+
216
+ static fromExisting(bucketName: string): ExternalBucketRef {
217
+ return { __brand: 'ExternalBucketRef' as const, bucketName };
218
+ }
219
+ }
@@ -0,0 +1,7 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Browser stub - FileBucket runs server-side only
5
+ export class FileBucket {
6
+ constructor(...args: any[]) {}
7
+ }
@@ -0,0 +1,84 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CDK-side regression tests for FileBucket.
6
+ *
7
+ * History: FileBucket.fromExisting was advertised in types but the CDK
8
+ * constructor unconditionally provisioned a new S3 bucket. These tests pin
9
+ * the fix.
10
+ */
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert';
13
+ import * as cdk from 'aws-cdk-lib';
14
+ import type { Construct } from 'constructs';
15
+ import { Template } from 'aws-cdk-lib/assertions';
16
+ import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
17
+ import { FileBucket } from './index.cdk.js';
18
+
19
+ class StubBlocksStack extends cdk.Stack {
20
+ public readonly handler: cdk.aws_lambda.Function;
21
+ public readonly id: string;
22
+ constructor(scope: Construct, id: string) {
23
+ super(scope, id);
24
+ this.id = id;
25
+ (globalThis as any).CURRENT_BLOCKS_STACK = this;
26
+ this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
27
+ runtime: DEFAULT_NODE_RUNTIME,
28
+ handler: 'index.handler',
29
+ code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
30
+ });
31
+ }
32
+ }
33
+
34
+ function setup(): { stack: StubBlocksStack; parent: Scope } {
35
+ const app = new cdk.App();
36
+ // S3 bucket names must be lowercase. The default-mode FileBucket derives
37
+ // its bucket name from the scope chain, so keep ids lowercase.
38
+ const stack = new StubBlocksStack(app, 'teststack');
39
+ const parent = new Scope('app');
40
+ return { stack, parent };
41
+ }
42
+
43
+ test('CDK: default FileBucket provisions an S3 bucket', () => {
44
+ const { stack, parent } = setup();
45
+ new FileBucket(parent, 'uploads');
46
+ const template = Template.fromStack(stack);
47
+ template.resourceCountIs('AWS::S3::Bucket', 1);
48
+ });
49
+
50
+ test('CDK: FileBucket.fromExisting does NOT provision a bucket (regression)', () => {
51
+ const { stack, parent } = setup();
52
+ new FileBucket(parent, 'uploads', {
53
+ bucket: FileBucket.fromExisting('preexisting-bucket-123'),
54
+ });
55
+ const template = Template.fromStack(stack);
56
+ template.resourceCountIs('AWS::S3::Bucket', 0);
57
+ });
58
+
59
+ test('CDK: FileBucket.fromExisting returns a branded ref', () => {
60
+ const ref = FileBucket.fromExisting('foo');
61
+ assert.strictEqual(ref.bucketName, 'foo');
62
+ assert.strictEqual(ref.__brand, 'ExternalBucketRef');
63
+ });
64
+
65
+ test('CDK: default FileBucket with an over-long derived name throws at synth', () => {
66
+ const { parent } = setup();
67
+ // parent id "app" + "-" + a 60-char id => 64 chars, over the S3 limit.
68
+ assert.throws(
69
+ () => new FileBucket(parent, 'u'.repeat(60)),
70
+ (err: unknown) =>
71
+ err instanceof Error &&
72
+ err.name === 'ValidationFailed' &&
73
+ /63-character limit/.test(err.message),
74
+ );
75
+ });
76
+
77
+ test('CDK: fromExisting skips derived-name validation even when the chain is over-long', () => {
78
+ const { parent } = setup();
79
+ assert.doesNotThrow(() =>
80
+ new FileBucket(parent, 'u'.repeat(60), {
81
+ bucket: FileBucket.fromExisting('preexisting-bucket-123'),
82
+ }),
83
+ );
84
+ });
@@ -0,0 +1,89 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import * as s3 from 'aws-cdk-lib/aws-s3';
5
+ import * as cdk from 'aws-cdk-lib';
6
+ import { Duration, RemovalPolicy } from 'aws-cdk-lib';
7
+ import { Scope } from '@aws-blocks/core/cdk';
8
+ import type { ScopeParent } from '@aws-blocks/core';
9
+ import type { FileBucketOptions, CorsRule, LifecycleRule, ExternalBucketRef } from './types.js';
10
+ import { validateBucketName } from './bucket-name.js';
11
+
12
+ export { FileBucketErrors } from './errors.js';
13
+ export type { FileBucketOptions, PutOptions, GetUrlOptions, PutUrlOptions, ScanOptions, FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef } from './types.js';
14
+
15
+ const httpMethodMap: Record<string, s3.HttpMethods> = {
16
+ GET: s3.HttpMethods.GET,
17
+ PUT: s3.HttpMethods.PUT,
18
+ POST: s3.HttpMethods.POST,
19
+ DELETE: s3.HttpMethods.DELETE,
20
+ HEAD: s3.HttpMethods.HEAD,
21
+ };
22
+
23
+ export class FileBucket<O extends FileBucketOptions = FileBucketOptions> extends Scope {
24
+ private bucket: s3.IBucket;
25
+
26
+ /**
27
+ * Reference an existing S3 bucket instead of provisioning a new one.
28
+ * Mirrors the same factory exposed by the runtime build so the same code
29
+ * works in both contexts.
30
+ */
31
+ static fromExisting(bucketName: string): ExternalBucketRef {
32
+ return { __brand: 'ExternalBucketRef' as const, bucketName };
33
+ }
34
+
35
+ constructor(scope: ScopeParent, id: string, options?: O) {
36
+ super(id, { parent: scope });
37
+
38
+ if (options?.bucket) {
39
+ // `fromExisting`: don't provision; bind to the pre-existing bucket and
40
+ // grant read/write to the Blocks runtime Lambda.
41
+ this.bucket = s3.Bucket.fromBucketName(this, 'bucket', options.bucket.bucketName);
42
+ this.bucket.grantReadWrite(this.handler);
43
+ return;
44
+ }
45
+
46
+ // In sandbox mode, default to DESTROY + autoDeleteObjects so
47
+ // `cdk destroy` can fully clean up without manual bucket emptying.
48
+ // Explicit `removalPolicy` from the customer takes precedence.
49
+ // `autoDeleteObjects: true` is only valid paired with DESTROY (CDK
50
+ // validates this at construct time), so we tie the two together.
51
+ const isSandbox = cdk.Stack.of(this).node.tryGetContext('sandboxMode') === 'true';
52
+ const destroy = options?.removalPolicy === 'destroy' || (isSandbox && options?.removalPolicy === undefined);
53
+
54
+ // Bucket name is derived from the scope chain. Validate against S3's
55
+ // naming rules at synth so an invalid name fails here rather than at
56
+ // `cdk deploy` (where CloudFormation rejects it with a cryptic error).
57
+ validateBucketName(this.fullId);
58
+
59
+ this.bucket = new s3.Bucket(this, 'bucket', {
60
+ bucketName: this.fullId,
61
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
62
+ encryption: s3.BucketEncryption.S3_MANAGED,
63
+ versioned: options?.versioned ?? false,
64
+ removalPolicy: destroy
65
+ ? RemovalPolicy.DESTROY
66
+ : options?.removalPolicy === 'retain'
67
+ ? RemovalPolicy.RETAIN
68
+ : undefined,
69
+ autoDeleteObjects: destroy,
70
+ cors: options?.corsRules?.map((rule: CorsRule) => ({
71
+ allowedOrigins: rule.allowedOrigins,
72
+ allowedMethods: rule.allowedMethods.map(m => httpMethodMap[m]),
73
+ allowedHeaders: rule.allowedHeaders,
74
+ exposedHeaders: rule.exposedHeaders,
75
+ maxAge: rule.maxAge,
76
+ })),
77
+ lifecycleRules: options?.lifecycleRules?.map((rule: LifecycleRule) => ({
78
+ prefix: rule.prefix,
79
+ expiration: rule.expirationDays ? Duration.days(rule.expirationDays) : undefined,
80
+ transitions: rule.transitionToIaDays ? [{
81
+ storageClass: s3.StorageClass.INFREQUENT_ACCESS,
82
+ transitionAfter: Duration.days(rule.transitionToIaDays),
83
+ }] : undefined,
84
+ })),
85
+ });
86
+
87
+ this.bucket.grantReadWrite(this.handler);
88
+ }
89
+ }