@xcelsior/storage-api 1.0.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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "hosted-zone:account=192933325589:domainName=xcelsior.co:region=ap-southeast-2": {
3
+ "Id": "/hostedzone/Z08595403217R035ZUO3M",
4
+ "Name": "xcelsior.co."
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@xcelsior/storage-api",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "files": [
6
+ "packages/**",
7
+ "stacks/**",
8
+ "*.json",
9
+ "sst.config.ts"
10
+ ],
11
+ "dependencies": {
12
+ "@aws-sdk/client-s3": "^3.888.0",
13
+ "@aws-sdk/s3-request-presigner": "^3.888.0",
14
+ "sst": "^2.40.3",
15
+ "zod": "^3.22.4",
16
+ "@types/aws-lambda": "^8.10.152",
17
+ "aws-cdk-lib": "2.201.0",
18
+ "constructs": "10.3.0",
19
+ "dotenv": "^17.2.1",
20
+ "typescript": "^5.3.3",
21
+ "@tsconfig/node18": "^18.2.2",
22
+ "@types/node": "^20.11.16",
23
+ "@xcelsior/aws": "1.0.6",
24
+ "@xcelsior/monitoring": "1.0.4",
25
+ "@xcelsior/lambda-http": "1.0.5"
26
+ },
27
+ "scripts": {
28
+ "dev": "sst dev --stage dev --mode basic",
29
+ "build": "sst build",
30
+ "deploy": "sst deploy",
31
+ "remove": "sst remove",
32
+ "console": "sst console",
33
+ "typecheck": "tsc --noEmit"
34
+ }
35
+ }
@@ -0,0 +1,35 @@
1
+ import { createLogger, createTracer } from '@xcelsior/lambda-http';
2
+
3
+ // ─── Bucket names ─────────────────────────────────────────────────────────────
4
+ // Injected as CloudFormation-resolved env vars by StorageStack
5
+ export const PUBLIC_BUCKET_NAME = process.env.PUBLIC_BUCKET_NAME!;
6
+ export const SECURE_BUCKET_NAME = process.env.SECURE_BUCKET_NAME!;
7
+
8
+ // ─── CloudFront domains ───────────────────────────────────────────────────────
9
+ export const PUBLIC_CLOUDFRONT_DOMAIN = process.env.PUBLIC_CLOUDFRONT_DOMAIN!;
10
+ export const SECURE_CLOUDFRONT_DOMAIN = process.env.SECURE_CLOUDFRONT_DOMAIN!;
11
+
12
+ // ─── Upload config ────────────────────────────────────────────────────────────
13
+ /** Maximum file size the API will accept (MB). Validated in the request schema. */
14
+ export const MAX_FILE_SIZE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || '100') * 1024 * 1024;
15
+
16
+ /** How long presigned upload URLs and HMAC-signed access URLs are valid (seconds). Default: 5 min. */
17
+ export const PRESIGNED_URL_EXPIRY_SECONDS = Number(
18
+ process.env.PRESIGNED_URL_EXPIRY_SECONDS || '300'
19
+ );
20
+
21
+ /**
22
+ * HMAC-SHA256 signing key shared between this Lambda and the CloudFront Function.
23
+ * Used to produce per-file tokens — never placed directly in any URL.
24
+ */
25
+ export const SECURE_STORAGE_ACCESS_SECRET = process.env.SECURE_STORAGE_ACCESS_SECRET!;
26
+
27
+ // ─── Observability ────────────────────────────────────────────────────────────
28
+ export const logger = createLogger('@xcelsior/storage');
29
+ const tracer = createTracer('@xcelsior/storage');
30
+
31
+ export const middlewareConfig = {
32
+ logger,
33
+ tracer,
34
+ cors: { origin: '*' },
35
+ };
@@ -0,0 +1 @@
1
+ /// <reference types="sst/node/api" />
@@ -0,0 +1,88 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
3
+ import { z } from 'zod';
4
+ import { S3Service } from '@xcelsior/aws/src/s3';
5
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
6
+ import {
7
+ PUBLIC_BUCKET_NAME,
8
+ PUBLIC_CLOUDFRONT_DOMAIN,
9
+ MAX_FILE_SIZE_BYTES,
10
+ PRESIGNED_URL_EXPIRY_SECONDS,
11
+ logger,
12
+ middlewareConfig,
13
+ } from '../config';
14
+
15
+ const schema = z.object({
16
+ /** Original file name — used to preserve the extension */
17
+ fileName: z.string().min(1).max(255),
18
+ /** MIME type (e.g. "image/png", "application/pdf") */
19
+ contentType: z.string().min(1),
20
+ /** File size in bytes — validated against MAX_FILE_SIZE_MB */
21
+ fileSize: z
22
+ .number()
23
+ .int()
24
+ .positive()
25
+ .max(MAX_FILE_SIZE_BYTES, {
26
+ message: `File size must not exceed ${MAX_FILE_SIZE_BYTES / 1024 / 1024} MB`,
27
+ }),
28
+ /**
29
+ * Optional sub-folder (e.g. "avatars", "documents").
30
+ * Sanitised to prevent path traversal.
31
+ */
32
+ folder: z
33
+ .string()
34
+ .regex(
35
+ /^[a-zA-Z0-9_\-/]+$/,
36
+ 'folder must contain only alphanumeric, dash, underscore or slash characters'
37
+ )
38
+ .optional(),
39
+ });
40
+
41
+ const getPublicUploadUrlHandler = async (event: APIGatewayProxyEventV2) => {
42
+ let body: z.infer<typeof schema>;
43
+ try {
44
+ body = schema.parse(event.body);
45
+ } catch (error) {
46
+ if (error instanceof z.ZodError) {
47
+ throw new ValidationError('Invalid request body', error.errors);
48
+ }
49
+ throw error;
50
+ }
51
+
52
+ const { fileName, contentType, fileSize, folder } = body;
53
+
54
+ // Build a collision-free object key
55
+ const ext =
56
+ fileName
57
+ .split('.')
58
+ .pop()
59
+ ?.toLowerCase()
60
+ .replace(/[^a-z0-9]/g, '') || 'bin';
61
+ const prefix = folder ? `${folder}/` : 'uploads/';
62
+ const key = `${prefix}${randomUUID()}.${ext}`;
63
+
64
+ logger.info('Generating presigned public upload URL', { fileName, contentType, fileSize, key });
65
+
66
+ const uploadUrl = await S3Service.generatePresignedUploadUrl(
67
+ PUBLIC_BUCKET_NAME,
68
+ key,
69
+ contentType,
70
+ PRESIGNED_URL_EXPIRY_SECONDS
71
+ );
72
+
73
+ // Permanent CloudFront URL — no auth required, never expires
74
+ const fileUrl = `https://${PUBLIC_CLOUDFRONT_DOMAIN}/${key}`;
75
+
76
+ logger.info('Presigned public upload URL generated', { key, fileUrl });
77
+
78
+ return {
79
+ /** PUT to this URL to upload the file. Expires in `expiresIn` seconds. */
80
+ uploadUrl,
81
+ /** Permanent CloudFront URL — use this to read/serve the file after upload. */
82
+ fileUrl,
83
+ key,
84
+ expiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
85
+ };
86
+ };
87
+
88
+ export const handler = middyfy(getPublicUploadUrlHandler, middlewareConfig);
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createHmac } from 'node:crypto';
3
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
4
+ import { z } from 'zod';
5
+ import { S3Service } from '@xcelsior/aws/src/s3';
6
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
7
+ import {
8
+ SECURE_BUCKET_NAME,
9
+ SECURE_CLOUDFRONT_DOMAIN,
10
+ SECURE_STORAGE_ACCESS_SECRET,
11
+ MAX_FILE_SIZE_BYTES,
12
+ PRESIGNED_URL_EXPIRY_SECONDS,
13
+ logger,
14
+ middlewareConfig,
15
+ } from '../config';
16
+
17
+ const schema = z.object({
18
+ /** Original file name — used to preserve the extension */
19
+ fileName: z.string().min(1).max(255),
20
+ /** MIME type (e.g. "application/pdf", "image/jpeg") */
21
+ contentType: z.string().min(1),
22
+ /** File size in bytes — validated against MAX_FILE_SIZE_MB */
23
+ fileSize: z
24
+ .number()
25
+ .int()
26
+ .positive()
27
+ .max(MAX_FILE_SIZE_BYTES, {
28
+ message: `File size must not exceed ${MAX_FILE_SIZE_BYTES / 1024 / 1024} MB`,
29
+ }),
30
+ /**
31
+ * Optional sub-folder (e.g. "contracts", "invoices").
32
+ * Sanitised to prevent path traversal.
33
+ */
34
+ folder: z
35
+ .string()
36
+ .regex(
37
+ /^[a-zA-Z0-9_\-/]+$/,
38
+ 'folder must contain only alphanumeric, dash, underscore or slash characters'
39
+ )
40
+ .optional(),
41
+ });
42
+
43
+ /**
44
+ * Generates a permanent per-file HMAC-SHA256 token for CloudFront access.
45
+ *
46
+ * The signed message is the CloudFront request URI (e.g. `/secure-uploads/uuid.pdf`).
47
+ * The token is unique per file because the UUID key is part of the URI.
48
+ * A token for one file cannot be used to access a different file.
49
+ * The URL never expires — revocation requires rotating SECURE_STORAGE_ACCESS_SECRET.
50
+ */
51
+ function generateFileToken(uri: string): string {
52
+ return createHmac('sha256', SECURE_STORAGE_ACCESS_SECRET).update(uri).digest('hex');
53
+ }
54
+
55
+ const getSecureUploadUrlHandler = async (event: APIGatewayProxyEventV2) => {
56
+ let body: z.infer<typeof schema>;
57
+ try {
58
+ body = schema.parse(event.body);
59
+ } catch (error) {
60
+ if (error instanceof z.ZodError) {
61
+ throw new ValidationError('Invalid request body', error.errors);
62
+ }
63
+ throw error;
64
+ }
65
+
66
+ const { fileName, contentType, fileSize, folder } = body;
67
+
68
+ // Build a collision-free object key
69
+ const ext =
70
+ fileName
71
+ .split('.')
72
+ .pop()
73
+ ?.toLowerCase()
74
+ .replace(/[^a-z0-9]/g, '') || 'bin';
75
+ const prefix = folder ? `${folder}/` : 'secure-uploads/';
76
+ const key = `${prefix}${randomUUID()}.${ext}`;
77
+
78
+ // CloudFront uses a leading-slash URI (e.g. /secure-uploads/uuid.pdf)
79
+ const uri = `/${key}`;
80
+ const token = generateFileToken(uri);
81
+
82
+ logger.info('Generating presigned secure upload URL', { fileName, contentType, fileSize, key });
83
+
84
+ const uploadUrl = await S3Service.generatePresignedUploadUrl(
85
+ SECURE_BUCKET_NAME,
86
+ key,
87
+ contentType,
88
+ PRESIGNED_URL_EXPIRY_SECONDS
89
+ );
90
+
91
+ /**
92
+ * Permanent per-file CloudFront URL.
93
+ * ?token=<hex> — HMAC-SHA256(signingSecret, uri) — unique per file, never expires.
94
+ * The shared signing secret is never in this URL.
95
+ */
96
+ const fileUrl = `https://${SECURE_CLOUDFRONT_DOMAIN}${uri}?token=${token}`;
97
+
98
+ logger.info('Presigned secure upload URL generated', { key });
99
+
100
+ return {
101
+ /** PUT to this URL to upload the file. Expires in `uploadExpiresIn` seconds. */
102
+ uploadUrl,
103
+ /** Permanent HMAC-signed CloudFront URL — unique per file, never expires. */
104
+ fileUrl,
105
+ key,
106
+ uploadExpiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
107
+ };
108
+ };
109
+
110
+ export const handler = middyfy(getSecureUploadUrlHandler, middlewareConfig);
package/sst.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import 'dotenv/config';
2
+ import type { SSTConfig } from 'sst';
3
+ import { StorageStack } from './stacks/StorageStack';
4
+
5
+ export default {
6
+ config(_input) {
7
+ return {
8
+ name: 'storage',
9
+ region: 'ap-southeast-2',
10
+ };
11
+ },
12
+ stacks(app) {
13
+ app.stack(StorageStack);
14
+ },
15
+ } satisfies SSTConfig;
@@ -0,0 +1,429 @@
1
+ import 'dotenv/config';
2
+ import { ApiGatewayV1Api, type StackContext } from 'sst/constructs';
3
+ import * as s3 from 'aws-cdk-lib/aws-s3';
4
+ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
5
+ import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
6
+ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
7
+ import { CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager';
8
+ import * as route53 from 'aws-cdk-lib/aws-route53';
9
+ import { Duration, RemovalPolicy } from 'aws-cdk-lib';
10
+
11
+ // ─── Config helpers ──────────────────────────────────────────────────────────
12
+
13
+ function requireEnv(name: string): string {
14
+ const value = process.env[name];
15
+ if (!value) throw new Error(`Environment variable "${name}" is required`);
16
+ return value;
17
+ }
18
+
19
+ /**
20
+ * Builds a CloudFront Function (runtime 2.0, async) that validates a
21
+ * permanent per-file HMAC-SHA256 token delivered via `?token=<hex>`.
22
+ *
23
+ * How it works
24
+ * ─────────────
25
+ * 1. The Lambda generates a token: HMAC-SHA256(signingSecret, uri)
26
+ * 2. CloudFront Function re-computes the same HMAC and compares it with the
27
+ * token in the query string.
28
+ * 3. If the HMAC matches, the request is forwarded to S3 (token stripped so
29
+ * it never appears in S3 access logs).
30
+ *
31
+ * Security properties
32
+ * ────────────────────
33
+ * • The signing secret is never in any URL — only its HMAC derivative is.
34
+ * • Each file gets a different token because the UUID URI is the signed message.
35
+ * • A token for /a/b.pdf cannot be used for /a/c.pdf.
36
+ * • URLs never expire; rotate SECURE_STORAGE_ACCESS_SECRET to revoke all tokens.
37
+ *
38
+ * NOTE: The signing secret is embedded in the function source and is therefore
39
+ * visible in the CloudFront console. For higher-sensitivity workloads, migrate
40
+ * to CloudFront Functions runtime 2.0 + KeyValueStore.
41
+ */
42
+ function buildSecureAccessFunctionCode(signingSecret: string): string {
43
+ const escaped = signingSecret.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
44
+ return `
45
+ function hexToBytes(hex) {
46
+ var len = hex.length / 2;
47
+ var out = new Uint8Array(len);
48
+ for (var i = 0; i < len; i++) {
49
+ out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function deny(msg) {
55
+ return {
56
+ statusCode: 403,
57
+ statusDescription: 'Forbidden',
58
+ headers: { 'content-type': { value: 'application/json' } },
59
+ body: '{"error":"' + msg + '"}'
60
+ };
61
+ }
62
+
63
+ async function handler(event) {
64
+ var request = event.request;
65
+ var tokenP = request.querystring['token'];
66
+
67
+ if (!tokenP) {
68
+ return deny('Missing token.');
69
+ }
70
+
71
+ try {
72
+ var key = await crypto.subtle.importKey(
73
+ 'raw',
74
+ new TextEncoder().encode('${escaped}'),
75
+ { name: 'HMAC', hash: 'SHA-256' },
76
+ false,
77
+ ['verify']
78
+ );
79
+ var ok = await crypto.subtle.verify(
80
+ 'HMAC',
81
+ key,
82
+ hexToBytes(tokenP.value),
83
+ new TextEncoder().encode(request.uri)
84
+ );
85
+ if (!ok) return deny('Invalid token.');
86
+ } catch (_) {
87
+ return deny('Token validation failed.');
88
+ }
89
+
90
+ delete request.querystring['token'];
91
+ return request;
92
+ }`.trim();
93
+ }
94
+
95
+ // ─── Stack ───────────────────────────────────────────────────────────────────
96
+
97
+ export function StorageStack({ stack }: StackContext) {
98
+ const domainName = process.env.API_DOMAIN_NAME || 'xcelsior.co';
99
+ const isProd = ['production', 'prod'].includes(stack.stage);
100
+
101
+ // ── Configurable feature flags ──────────────────────────────────────────
102
+ // Versioning is configured independently for each bucket
103
+ const enableVersioningPublic = process.env.ENABLE_VERSIONING_PUBLIC === 'true';
104
+ const enableVersioningSecure = process.env.ENABLE_VERSIONING_SECURE === 'true';
105
+ const maxFileSizeMb = Number(process.env.MAX_FILE_SIZE_MB || '100');
106
+ const presignedUrlExpiry = Number(process.env.PRESIGNED_URL_EXPIRY_SECONDS || '300');
107
+
108
+ // ── Secure-bucket access secret ─────────────────────────────────────────
109
+ const accessSecret = requireEnv('SECURE_STORAGE_ACCESS_SECRET');
110
+
111
+ // ── Optional custom-domain config ───────────────────────────────────────
112
+ // Certificates are created automatically via DNS validation — no ARN needed.
113
+ const publicStorageDomain = process.env.PUBLIC_STORAGE_DOMAIN || '';
114
+ const secureStorageDomain = process.env.SECURE_STORAGE_DOMAIN || '';
115
+ const hostedZoneId = process.env.HOSTED_ZONE_ID || '';
116
+
117
+ // ── API subdomain ────────────────────────────────────────────────────────
118
+ const apiSubdomain = isProd
119
+ ? `storage.api.${domainName}`
120
+ : `${stack.stage}-storage.api.${domainName}`;
121
+
122
+ // ────────────────────────────────────────────────────────────────────────
123
+ // 1. S3 Buckets
124
+ // Both buckets are fully private; CloudFront is the only public entry point.
125
+ // ────────────────────────────────────────────────────────────────────────
126
+
127
+ const sharedCorsRules: s3.CorsRule[] = [
128
+ {
129
+ allowedOrigins: ['*'],
130
+ allowedHeaders: ['*'],
131
+ allowedMethods: [
132
+ s3.HttpMethods.GET,
133
+ s3.HttpMethods.PUT,
134
+ s3.HttpMethods.POST,
135
+ s3.HttpMethods.HEAD,
136
+ ],
137
+ maxAge: 86400, // 1 day
138
+ exposedHeaders: ['ETag'],
139
+ },
140
+ ];
141
+
142
+ function lifecycleRules(versioned: boolean): s3.LifecycleRule[] {
143
+ return versioned
144
+ ? [
145
+ {
146
+ enabled: true,
147
+ // Retain the 5 most recent non-current versions
148
+ noncurrentVersionsToRetain: 5,
149
+ // Hard-delete older non-current versions after 90 days
150
+ noncurrentVersionExpiration: Duration.days(90),
151
+ // Clean up stalled multipart uploads after 7 days
152
+ abortIncompleteMultipartUploadAfter: Duration.days(7),
153
+ },
154
+ ]
155
+ : [
156
+ {
157
+ enabled: true,
158
+ abortIncompleteMultipartUploadAfter: Duration.days(7),
159
+ },
160
+ ];
161
+ }
162
+
163
+ /** Public bucket — files are served to anyone via CloudFront */
164
+ const publicBucket = new s3.Bucket(stack, 'StoragePublicBucket', {
165
+ versioned: enableVersioningPublic,
166
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
167
+ enforceSSL: true,
168
+ removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
169
+ ...(isProd ? {} : { autoDeleteObjects: true }),
170
+ cors: sharedCorsRules,
171
+ lifecycleRules: lifecycleRules(enableVersioningPublic),
172
+ });
173
+
174
+ /**
175
+ * Secure bucket — files are private; access is via permanent HMAC-signed
176
+ * CloudFront URLs (?token=<hex>) issued by the upload Lambda. Each file
177
+ * receives a unique token; the signing secret is never placed in any URL.
178
+ */
179
+ const secureBucket = new s3.Bucket(stack, 'StorageSecureBucket', {
180
+ versioned: enableVersioningSecure,
181
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
182
+ enforceSSL: true,
183
+ // Encrypt at rest with SSE-S3 (default since Jan 2023, explicit here for clarity)
184
+ encryption: s3.BucketEncryption.S3_MANAGED,
185
+ removalPolicy: isProd ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
186
+ ...(isProd ? {} : { autoDeleteObjects: true }),
187
+ cors: sharedCorsRules,
188
+ lifecycleRules: lifecycleRules(enableVersioningSecure),
189
+ });
190
+
191
+ // ────────────────────────────────────────────────────────────────────────
192
+ // 2. CloudFront Function — validates the access secret on the secure dist
193
+ // ────────────────────────────────────────────────────────────────────────
194
+
195
+ const secureAccessFunction = new cloudfront.Function(stack, 'SecureAccessFunction', {
196
+ functionName: `${stack.stage}-storage-secure-access`,
197
+ // Runtime 2.0 is required for async handlers and the Web Crypto API (crypto.subtle)
198
+ runtime: cloudfront.FunctionRuntime.JS_2_0,
199
+ code: cloudfront.FunctionCode.fromInline(buildSecureAccessFunctionCode(accessSecret)),
200
+ comment: `HMAC-SHA256 per-file token validation for secure storage [${stack.stage}]`,
201
+ });
202
+
203
+ // ────────────────────────────────────────────────────────────────────────
204
+ // 3. CloudFront Origins (OAC — recommended over legacy OAI)
205
+ // S3BucketOrigin.withOriginAccessControl() automatically:
206
+ // • Creates an S3 Origin Access Control
207
+ // • Adds a bucket-policy statement granting cloudfront.amazonaws.com
208
+ // s3:GetObject scoped to the specific distribution ARN
209
+ // ────────────────────────────────────────────────────────────────────────
210
+
211
+ const publicOrigin = origins.S3BucketOrigin.withOriginAccessControl(publicBucket);
212
+ const secureOrigin = origins.S3BucketOrigin.withOriginAccessControl(secureBucket);
213
+
214
+ // ────────────────────────────────────────────────────────────────────────
215
+ // 4. CloudFront Distributions
216
+ // ────────────────────────────────────────────────────────────────────────
217
+
218
+ // ────────────────────────────────────────────────────────────────────────
219
+ // 4+5. CloudFront Distributions + optional custom domains
220
+ //
221
+ // When PUBLIC_STORAGE_DOMAIN / SECURE_STORAGE_DOMAIN are set (and
222
+ // HOSTED_ZONE_ID is provided), we automatically:
223
+ // a) Create an ACM certificate in us-east-1 via DNS validation
224
+ // (DnsValidatedCertificate deploys a custom-resource Lambda that
225
+ // creates & validates the cert in the correct region for CloudFront)
226
+ // b) Attach it to the distribution
227
+ // c) Create a Route53 alias A-record pointing to the distribution
228
+ // ────────────────────────────────────────────────────────────────────────
229
+
230
+ const hostedZone = route53.HostedZone.fromLookup(stack, 'hosted-zone', {
231
+ domainName: domainName,
232
+ });
233
+
234
+ function buildDistributionDomain(
235
+ certId: string,
236
+ domain: string
237
+ ): { domainNames: string[]; certificate: acm.ICertificate } | Record<string, never> {
238
+ if (!domain || !hostedZone) return {};
239
+ // DnsValidatedCertificate creates the cert in us-east-1 (required by CloudFront)
240
+ // and adds the DNS validation CNAME to the hosted zone automatically.
241
+ const certificate = new acm.DnsValidatedCertificate(stack, certId, {
242
+ domainName: domain,
243
+ hostedZone: hostedZone,
244
+ region: 'us-east-1',
245
+ });
246
+ return { domainNames: [domain], certificate: certificate };
247
+ }
248
+
249
+ /** Public distribution — standard CDN, response is cached and compressed */
250
+ const publicDistribution = new cloudfront.Distribution(stack, 'PublicStorageDistribution', {
251
+ comment: `Public storage CDN [${stack.stage}]`,
252
+ defaultBehavior: {
253
+ origin: publicOrigin,
254
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
255
+ cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
256
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
257
+ cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
258
+ compress: true,
259
+ },
260
+ ...buildDistributionDomain('PublicStorageCert', publicStorageDomain),
261
+ });
262
+
263
+ /**
264
+ * Secure distribution — caching disabled so every request passes through
265
+ * the CloudFront Function that validates the per-file HMAC token.
266
+ */
267
+ const secureDistribution = new cloudfront.Distribution(stack, 'SecureStorageDistribution', {
268
+ comment: `Secure storage CDN [${stack.stage}]`,
269
+ defaultBehavior: {
270
+ origin: secureOrigin,
271
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
272
+ cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
273
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
274
+ cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
275
+ compress: true,
276
+ functionAssociations: [
277
+ {
278
+ function: secureAccessFunction,
279
+ eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
280
+ },
281
+ ],
282
+ },
283
+ ...buildDistributionDomain('SecureStorageCert', secureStorageDomain),
284
+ });
285
+
286
+ // ────────────────────────────────────────────────────────────────────────
287
+ // 6. Lambda API
288
+ // ────────────────────────────────────────────────────────────────────────
289
+
290
+ const publicCdnDomain = publicStorageDomain || publicDistribution.distributionDomainName;
291
+ const secureCdnDomain = secureStorageDomain || secureDistribution.distributionDomainName;
292
+
293
+ const commonEnv: Record<string, string> = {
294
+ MONITORING_PROVIDER: process.env.MONITORING_PROVIDER ?? '',
295
+ SENTRY_DSN: process.env.SENTRY_DSN ?? '',
296
+ ROLLBAR_ACCESS_TOKEN: process.env.ROLLBAR_ACCESS_TOKEN ?? '',
297
+ POWERTOOLS_SERVICE_NAME: '@xcelsior/storage',
298
+ POWERTOOLS_METRICS_NAMESPACE: 'ExcelsiorStorage',
299
+ POWERTOOLS_LOGGER_LOG_LEVEL: 'INFO',
300
+ POWERTOOLS_LOGGER_LOG_EVENT: 'true',
301
+ POWERTOOLS_DEV: stack.stage === 'dev' ? 'true' : '',
302
+ LOGTAIL_TOKEN: process.env.LOGTAIL_TOKEN ?? '',
303
+ LOGTAIL_HTTP_API_URL: process.env.LOGTAIL_HTTP_API_URL ?? '',
304
+ APP_ENV: stack.stage,
305
+ NODE_ENV: stack.stage === 'dev' ? 'development' : 'production',
306
+ AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
307
+ // Runtime config
308
+ MAX_FILE_SIZE_MB: String(maxFileSizeMb),
309
+ PRESIGNED_URL_EXPIRY_SECONDS: String(presignedUrlExpiry),
310
+ PUBLIC_VERSIONING_ENABLED: String(enableVersioningPublic),
311
+ SECURE_VERSIONING_ENABLED: String(enableVersioningSecure),
312
+ // HMAC signing key — used by the secure-upload Lambda to produce per-file tokens
313
+ // that the CloudFront Function validates. Never appears in any generated URL.
314
+ SECURE_STORAGE_ACCESS_SECRET: accessSecret,
315
+ // CDN domains (resolved by CloudFormation at deploy time)
316
+ PUBLIC_CLOUDFRONT_DOMAIN: publicCdnDomain,
317
+ SECURE_CLOUDFRONT_DOMAIN: secureCdnDomain,
318
+ };
319
+
320
+ const api = new ApiGatewayV1Api(stack, 'StorageApi', {
321
+ customDomain: {
322
+ domainName: apiSubdomain,
323
+ hostedZone: domainName,
324
+ },
325
+ defaults: {
326
+ function: {
327
+ layers: [
328
+ 'arn:aws:lambda:ap-southeast-2:192933325589:layer:logtail-lambda-extension:1',
329
+ ],
330
+ environment: commonEnv,
331
+ nodejs: { esbuild: { minify: true } },
332
+ runtime: 'nodejs22.x',
333
+ tracing: 'active',
334
+ },
335
+ },
336
+ cors: true,
337
+ routes: {
338
+ /**
339
+ * POST /api/storage/public/upload-url
340
+ * Returns a presigned S3 PutObject URL for the public bucket.
341
+ * The uploaded file will be publicly accessible via CloudFront.
342
+ */
343
+ 'POST /api/storage/public/upload-url': {
344
+ function: {
345
+ handler: 'packages/functions/storage/getPublicUploadUrl.handler',
346
+ functionName: `${stack.stage}-storage-get-public-upload-url-function`,
347
+ environment: {
348
+ // Resolved by CloudFormation; not available as SST binding
349
+ // because we use a native CDK bucket for full versioning control
350
+ PUBLIC_BUCKET_NAME: publicBucket.bucketName,
351
+ },
352
+ },
353
+ cdk: { method: { apiKeyRequired: true } },
354
+ },
355
+
356
+ /**
357
+ * POST /api/storage/secure/upload-url
358
+ * Returns a presigned S3 PutObject URL (for uploading) and a CloudFront URL
359
+ * with a permanent per-file HMAC-SHA256 token (?token=…) for reading.
360
+ * Each file receives a unique, time-limited token — the shared signing secret
361
+ * is never present in any URL.
362
+ */
363
+ 'POST /api/storage/secure/upload-url': {
364
+ function: {
365
+ handler: 'packages/functions/storage/getSecureUploadUrl.handler',
366
+ functionName: `${stack.stage}-storage-get-secure-upload-url-function`,
367
+ environment: {
368
+ SECURE_BUCKET_NAME: secureBucket.bucketName,
369
+ },
370
+ },
371
+ cdk: { method: { apiKeyRequired: true } },
372
+ },
373
+ },
374
+ });
375
+
376
+ const publicUploadFn = api.getFunction('POST /api/storage/public/upload-url');
377
+ const secureUploadFn = api.getFunction('POST /api/storage/secure/upload-url');
378
+
379
+ // Public upload: only needs s3:PutObject to sign the upload URL
380
+ if (publicUploadFn) publicBucket.grantPut(publicUploadFn);
381
+
382
+ // Secure upload: only needs s3:PutObject — the access URL is an HMAC-signed
383
+ // CloudFront URL, so no s3:GetObject is required on the Lambda side
384
+ if (secureUploadFn) secureBucket.grantPut(secureUploadFn);
385
+
386
+ // ────────────────────────────────────────────────────────────────────────
387
+ // 7. API key + usage plan (all routes require the key)
388
+ // ────────────────────────────────────────────────────────────────────────
389
+
390
+ const apiKey = api.cdk.restApi.addApiKey('StorageApiKey', {
391
+ apiKeyName: `storage-api-key-${stack.stage}`,
392
+ });
393
+
394
+ const usagePlan = api.cdk.restApi.addUsagePlan('StorageApiUsagePlan', {
395
+ name: `storage-usage-plan-${stack.stage}`,
396
+ apiStages: [
397
+ {
398
+ api: api.cdk.restApi,
399
+ stage: api.cdk.restApi.deploymentStage,
400
+ },
401
+ ],
402
+ });
403
+
404
+ usagePlan.addApiKey(apiKey);
405
+
406
+ // ────────────────────────────────────────────────────────────────────────
407
+ // 8. Outputs
408
+ // ────────────────────────────────────────────────────────────────────────
409
+
410
+ stack.addOutputs({
411
+ ApiEndpoint: api.url,
412
+ PublicBucketName: publicBucket.bucketName,
413
+ SecureBucketName: secureBucket.bucketName,
414
+ PublicCloudFrontDomain: publicDistribution.distributionDomainName,
415
+ SecureCloudFrontDomain: secureDistribution.distributionDomainName,
416
+ ...(publicStorageDomain ? { PublicCustomDomain: `https://${publicStorageDomain}` } : {}),
417
+ ...(secureStorageDomain ? { SecureCustomDomain: `https://${secureStorageDomain}` } : {}),
418
+ PublicVersioningEnabled: String(enableVersioningPublic),
419
+ SecureVersioningEnabled: String(enableVersioningSecure),
420
+ });
421
+
422
+ return {
423
+ api,
424
+ publicBucket,
425
+ secureBucket,
426
+ publicDistribution,
427
+ secureDistribution,
428
+ };
429
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "@tsconfig/node18/tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "esnext",
5
+ "outDir": "dist",
6
+ "moduleResolution": "node",
7
+ "baseUrl": ".",
8
+ "skipLibCheck": true,
9
+ "paths": {
10
+ "@xcelsior/storage-api/*": ["*"]
11
+ }
12
+ },
13
+ "include": ["packages/**/*.ts", "stacks/**/*.ts", "sst.config.ts"]
14
+ }