diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 3c86325ee..62312579b 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -111,6 +111,10 @@ jobs: env: {} - package: graphile/graphile-presigned-url-plugin env: {} + - package: packages/bucket-provisioner + env: {} + - package: packages/upload-client + env: {} env: PGHOST: localhost diff --git a/packages/bucket-provisioner/README.md b/packages/bucket-provisioner/README.md new file mode 100644 index 000000000..77c318916 --- /dev/null +++ b/packages/bucket-provisioner/README.md @@ -0,0 +1,277 @@ +# @constructive-io/bucket-provisioner + +

+ +

+ +

+ + + + + +

+ +S3-compatible bucket provisioning library for the Constructive storage module. Creates and configures buckets with the correct privacy policies, CORS rules, versioning, and lifecycle settings for private, public, and temporary file storage. + +## Features + +- **Privacy enforcement** — Block All Public Access for private/temp buckets, public-read policy for public buckets +- **CORS configuration** — Browser-compatible rules for presigned URL uploads +- **Lifecycle rules** — Auto-cleanup for temp buckets (abandoned uploads) +- **Versioning** — Optional S3 versioning for durability +- **Multi-provider** — Works with AWS S3, MinIO, Cloudflare R2, Google Cloud Storage, and DigitalOcean Spaces +- **Inspect/audit** — Read back a bucket's current configuration for verification +- **Typed errors** — Structured `ProvisionerError` with error codes for programmatic handling + +## Installation + +```bash +pnpm add @constructive-io/bucket-provisioner +``` + +## Quick Start + +```typescript +import { BucketProvisioner } from '@constructive-io/bucket-provisioner'; + +const provisioner = new BucketProvisioner({ + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + }, + allowedOrigins: ['https://app.example.com'], +}); + +// Provision a private bucket (presigned URLs only) +const result = await provisioner.provision({ + bucketName: 'my-app-private', + accessType: 'private', + versioning: true, +}); + +console.log(result); +// { +// bucketName: 'my-app-private', +// accessType: 'private', +// blockPublicAccess: true, +// versioning: true, +// corsRules: [...], +// lifecycleRules: [], +// ... +// } +``` + +## Usage + +### Provision a Public Bucket + +Public buckets serve files via direct URL or CDN. The provisioner applies a public-read bucket policy and configures CORS for browser uploads. + +```typescript +const result = await provisioner.provision({ + bucketName: 'my-app-public', + accessType: 'public', + publicUrlPrefix: 'https://cdn.example.com/public', +}); +// result.blockPublicAccess === false +// result.publicUrlPrefix === 'https://cdn.example.com/public' +``` + +### Provision a Temp Bucket + +Temp buckets are staging areas for uploads. They behave like private buckets but include a lifecycle rule to auto-delete objects after a configurable period. + +```typescript +const result = await provisioner.provision({ + bucketName: 'my-app-temp', + accessType: 'temp', +}); +// result.lifecycleRules[0].id === 'temp-cleanup' +// result.lifecycleRules[0].expirationDays === 1 +``` + +### Inspect an Existing Bucket + +Read back a bucket's current configuration to verify it matches expectations. + +```typescript +const config = await provisioner.inspect('my-app-private', 'private'); +console.log(config.blockPublicAccess); // true +console.log(config.versioning); // true +console.log(config.corsRules.length); // 1 +``` + +### Use with AWS S3 + +For AWS S3, no endpoint is needed — just region and credentials. + +```typescript +const provisioner = new BucketProvisioner({ + connection: { + provider: 's3', + region: 'us-west-2', + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + allowedOrigins: ['https://app.example.com'], +}); +``` + +### Use with Cloudflare R2 + +```typescript +const provisioner = new BucketProvisioner({ + connection: { + provider: 'r2', + region: 'auto', + endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`, + accessKeyId: R2_ACCESS_KEY, + secretAccessKey: R2_SECRET_KEY, + }, + allowedOrigins: ['https://app.example.com'], +}); +``` + +## API + +### `BucketProvisioner` + +The main class that orchestrates bucket creation and configuration. + +#### `new BucketProvisioner(options)` + +| Option | Type | Description | +|--------|------|-------------| +| `connection.provider` | `'s3' \| 'minio' \| 'r2' \| 'gcs' \| 'spaces'` | Storage provider type | +| `connection.region` | `string` | S3 region (e.g., `'us-east-1'`) | +| `connection.endpoint` | `string?` | S3-compatible endpoint URL. Required for non-AWS providers. | +| `connection.accessKeyId` | `string` | AWS access key ID | +| `connection.secretAccessKey` | `string` | AWS secret access key | +| `connection.forcePathStyle` | `boolean?` | Force path-style URLs (auto-detected per provider) | +| `allowedOrigins` | `string[]` | Domains allowed for CORS (e.g., `['https://app.example.com']`) | + +#### `provisioner.provision(options): Promise` + +Creates and configures a bucket. Steps: + +1. Creates the bucket (or verifies it exists) +2. Configures Block Public Access +3. Applies bucket policy (public-read or none) +4. Sets CORS rules for presigned URL uploads +5. Optionally enables versioning +6. Adds lifecycle rules for temp buckets + +| Option | Type | Description | +|--------|------|-------------| +| `bucketName` | `string` | S3 bucket name | +| `accessType` | `'public' \| 'private' \| 'temp'` | Determines which policies are applied | +| `region` | `string?` | Override region for this bucket | +| `versioning` | `boolean?` | Enable S3 versioning (default: `false`) | +| `publicUrlPrefix` | `string?` | CDN/public URL for public buckets | + +#### `provisioner.inspect(bucketName, accessType): Promise` + +Reads back a bucket's current configuration (policy, CORS, versioning, lifecycle). + +#### `provisioner.getClient(): S3Client` + +Returns the underlying `@aws-sdk/client-s3` S3Client for advanced operations. + +#### `provisioner.bucketExists(bucketName): Promise` + +Checks if a bucket exists and is accessible. + +### Policy Builders + +Standalone functions for generating S3 policy documents. + +#### `getPublicAccessBlock(accessType)` + +Returns the Block Public Access configuration for a given access type. + +#### `buildPublicReadPolicy(bucketName, keyPrefix?)` + +Builds a public-read bucket policy document. + +#### `buildCloudFrontOacPolicy(bucketName, distributionArn, keyPrefix?)` + +Builds a CloudFront Origin Access Control bucket policy. + +#### `buildPresignedUrlIamPolicy(bucketName)` + +Builds the minimum-permission IAM policy for the presigned URL plugin. + +### CORS Builders + +#### `buildUploadCorsRules(allowedOrigins, maxAgeSeconds?)` + +CORS rules for public/temp buckets (PUT, GET, HEAD). + +#### `buildPrivateCorsRules(allowedOrigins, maxAgeSeconds?)` + +CORS rules for private buckets (PUT, HEAD only — no GET). + +### Lifecycle Builders + +#### `buildTempCleanupRule(expirationDays?, prefix?)` + +Lifecycle rule for auto-expiring temp bucket objects. + +#### `buildAbortIncompleteMultipartRule(days?)` + +Lifecycle rule for cleaning up incomplete multipart uploads. + +### Error Handling + +All errors thrown by the provisioner are instances of `ProvisionerError`: + +```typescript +import { ProvisionerError } from '@constructive-io/bucket-provisioner'; + +try { + await provisioner.provision({ bucketName: 'test', accessType: 'private' }); +} catch (err) { + if (err instanceof ProvisionerError) { + console.error(err.code); // 'POLICY_FAILED', 'CORS_FAILED', etc. + console.error(err.message); // Human-readable description + console.error(err.cause); // Original AWS SDK error + } +} +``` + +Error codes: + +| Code | Description | +|------|-------------| +| `CONNECTION_FAILED` | Could not connect to the storage endpoint | +| `BUCKET_ALREADY_EXISTS` | Bucket exists and is owned by another account | +| `BUCKET_NOT_FOUND` | Bucket does not exist (for inspect/read operations) | +| `INVALID_CONFIG` | Invalid configuration (missing credentials, origins, etc.) | +| `POLICY_FAILED` | Failed to apply Block Public Access or bucket policy | +| `CORS_FAILED` | Failed to set CORS configuration | +| `LIFECYCLE_FAILED` | Failed to set lifecycle rules | +| `VERSIONING_FAILED` | Failed to enable versioning | +| `ACCESS_DENIED` | Credentials lack required permissions | +| `PROVIDER_ERROR` | Generic provider error (check `cause` for details) | + +## Privacy Model + +| Access Type | Block Public Access | Bucket Policy | CORS Methods | Lifecycle | +|-------------|-------------------|---------------|--------------|-----------| +| `private` | All blocked | None (deleted) | PUT, HEAD | None | +| `public` | Partially relaxed | Public-read | PUT, GET, HEAD | None | +| `temp` | All blocked | None (deleted) | PUT, GET, HEAD | Auto-expire (1 day) | + +## Provider Notes + +| Provider | Endpoint Required | Path Style | Notes | +|----------|------------------|------------|-------| +| `s3` | No | Virtual-hosted | AWS default | +| `minio` | Yes | Path-style | Local development, self-hosted | +| `r2` | Yes | Path-style | Cloudflare R2 | +| `gcs` | Yes | Path-style | GCS S3-compatible API | +| `spaces` | Yes | Virtual-hosted | DigitalOcean Spaces | diff --git a/packages/bucket-provisioner/__tests__/client.test.ts b/packages/bucket-provisioner/__tests__/client.test.ts new file mode 100644 index 000000000..facea29f3 --- /dev/null +++ b/packages/bucket-provisioner/__tests__/client.test.ts @@ -0,0 +1,98 @@ +/** + * Tests for S3 client factory. + */ + +import { createS3Client } from '../src/client'; +import { ProvisionerError } from '../src/types'; +import type { StorageConnectionConfig } from '../src/types'; + +describe('createS3Client', () => { + const baseConfig: StorageConnectionConfig = { + provider: 's3', + region: 'us-east-1', + accessKeyId: 'AKIATEST', + secretAccessKey: 'secrettest', + }; + + it('creates client for AWS S3', () => { + const client = createS3Client(baseConfig); + expect(client).toBeDefined(); + expect(typeof client.send).toBe('function'); + }); + + it('creates client for MinIO with endpoint', () => { + const client = createS3Client({ + ...baseConfig, + provider: 'minio', + endpoint: 'http://minio:9000', + }); + expect(client).toBeDefined(); + }); + + it('creates client for R2 with endpoint', () => { + const client = createS3Client({ + ...baseConfig, + provider: 'r2', + endpoint: 'https://account.r2.cloudflarestorage.com', + }); + expect(client).toBeDefined(); + }); + + it('creates client for GCS with endpoint', () => { + const client = createS3Client({ + ...baseConfig, + provider: 'gcs', + endpoint: 'https://storage.googleapis.com', + }); + expect(client).toBeDefined(); + }); + + it('creates client for DO Spaces with endpoint', () => { + const client = createS3Client({ + ...baseConfig, + provider: 'spaces', + endpoint: 'https://nyc3.digitaloceanspaces.com', + }); + expect(client).toBeDefined(); + }); + + it('throws on missing accessKeyId', () => { + expect(() => + createS3Client({ ...baseConfig, accessKeyId: '' }), + ).toThrow(ProvisionerError); + }); + + it('throws on missing secretAccessKey', () => { + expect(() => + createS3Client({ ...baseConfig, secretAccessKey: '' }), + ).toThrow(ProvisionerError); + }); + + it('throws on missing region', () => { + expect(() => + createS3Client({ ...baseConfig, region: '' }), + ).toThrow(ProvisionerError); + }); + + it('throws on non-AWS provider without endpoint', () => { + expect(() => + createS3Client({ ...baseConfig, provider: 'minio' }), + ).toThrow(ProvisionerError); + expect(() => + createS3Client({ ...baseConfig, provider: 'minio' }), + ).toThrow("endpoint is required for provider 'minio'"); + }); + + it('does not throw on AWS S3 without endpoint', () => { + expect(() => createS3Client(baseConfig)).not.toThrow(); + }); + + it('respects explicit forcePathStyle override', () => { + // S3 normally uses virtual-hosted style, but user can force path-style + const client = createS3Client({ + ...baseConfig, + forcePathStyle: true, + }); + expect(client).toBeDefined(); + }); +}); diff --git a/packages/bucket-provisioner/__tests__/cors.test.ts b/packages/bucket-provisioner/__tests__/cors.test.ts new file mode 100644 index 000000000..ab6049845 --- /dev/null +++ b/packages/bucket-provisioner/__tests__/cors.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for CORS configuration builders. + */ + +import { buildUploadCorsRules, buildPrivateCorsRules } from '../src/cors'; + +describe('buildUploadCorsRules', () => { + it('builds rules with allowed origins', () => { + const rules = buildUploadCorsRules(['https://app.example.com']); + expect(rules).toHaveLength(1); + + const rule = rules[0]; + expect(rule.allowedOrigins).toEqual(['https://app.example.com']); + expect(rule.allowedMethods).toContain('PUT'); + expect(rule.allowedMethods).toContain('GET'); + expect(rule.allowedMethods).toContain('HEAD'); + }); + + it('includes required headers for presigned uploads', () => { + const rules = buildUploadCorsRules(['https://app.example.com']); + const rule = rules[0]; + + expect(rule.allowedHeaders).toContain('Content-Type'); + expect(rule.allowedHeaders).toContain('Content-Length'); + expect(rule.allowedHeaders).toContain('Authorization'); + }); + + it('exposes ETag and Content-Length', () => { + const rules = buildUploadCorsRules(['https://app.example.com']); + const rule = rules[0]; + + expect(rule.exposedHeaders).toContain('ETag'); + expect(rule.exposedHeaders).toContain('Content-Length'); + expect(rule.exposedHeaders).toContain('Content-Type'); + }); + + it('uses default maxAgeSeconds of 3600', () => { + const rules = buildUploadCorsRules(['https://app.example.com']); + expect(rules[0].maxAgeSeconds).toBe(3600); + }); + + it('accepts custom maxAgeSeconds', () => { + const rules = buildUploadCorsRules(['https://app.example.com'], 7200); + expect(rules[0].maxAgeSeconds).toBe(7200); + }); + + it('supports multiple origins', () => { + const origins = ['https://app.example.com', 'https://staging.example.com']; + const rules = buildUploadCorsRules(origins); + expect(rules[0].allowedOrigins).toEqual(origins); + }); + + it('throws on empty origins', () => { + expect(() => buildUploadCorsRules([])).toThrow('allowedOrigins must contain at least one origin'); + }); +}); + +describe('buildPrivateCorsRules', () => { + it('builds rules with PUT and HEAD only (no GET)', () => { + const rules = buildPrivateCorsRules(['https://app.example.com']); + expect(rules).toHaveLength(1); + + const rule = rules[0]; + expect(rule.allowedMethods).toContain('PUT'); + expect(rule.allowedMethods).toContain('HEAD'); + expect(rule.allowedMethods).not.toContain('GET'); + }); + + it('includes required headers for presigned uploads', () => { + const rules = buildPrivateCorsRules(['https://app.example.com']); + const rule = rules[0]; + + expect(rule.allowedHeaders).toContain('Content-Type'); + expect(rule.allowedHeaders).toContain('Content-Length'); + expect(rule.allowedHeaders).toContain('Authorization'); + }); + + it('uses default maxAgeSeconds of 3600', () => { + const rules = buildPrivateCorsRules(['https://app.example.com']); + expect(rules[0].maxAgeSeconds).toBe(3600); + }); + + it('throws on empty origins', () => { + expect(() => buildPrivateCorsRules([])).toThrow('allowedOrigins must contain at least one origin'); + }); +}); diff --git a/packages/bucket-provisioner/__tests__/lifecycle.test.ts b/packages/bucket-provisioner/__tests__/lifecycle.test.ts new file mode 100644 index 000000000..7af21e98b --- /dev/null +++ b/packages/bucket-provisioner/__tests__/lifecycle.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for lifecycle rule builders. + */ + +import { buildTempCleanupRule, buildAbortIncompleteMultipartRule } from '../src/lifecycle'; + +describe('buildTempCleanupRule', () => { + it('builds rule with default 1-day expiration', () => { + const rule = buildTempCleanupRule(); + expect(rule.id).toBe('temp-cleanup'); + expect(rule.prefix).toBe(''); + expect(rule.expirationDays).toBe(1); + expect(rule.enabled).toBe(true); + }); + + it('builds rule with custom expiration days', () => { + const rule = buildTempCleanupRule(7); + expect(rule.expirationDays).toBe(7); + }); + + it('builds rule with custom prefix', () => { + const rule = buildTempCleanupRule(1, 'tmp/'); + expect(rule.prefix).toBe('tmp/'); + }); + + it('returns enabled: true by default', () => { + const rule = buildTempCleanupRule(30); + expect(rule.enabled).toBe(true); + }); +}); + +describe('buildAbortIncompleteMultipartRule', () => { + it('builds rule with default 1-day threshold', () => { + const rule = buildAbortIncompleteMultipartRule(); + expect(rule.id).toBe('abort-incomplete-multipart'); + expect(rule.prefix).toBe(''); + expect(rule.expirationDays).toBe(1); + expect(rule.enabled).toBe(true); + }); + + it('builds rule with custom days', () => { + const rule = buildAbortIncompleteMultipartRule(3); + expect(rule.expirationDays).toBe(3); + }); + + it('returns enabled: true by default', () => { + const rule = buildAbortIncompleteMultipartRule(5); + expect(rule.enabled).toBe(true); + }); +}); diff --git a/packages/bucket-provisioner/__tests__/policies.test.ts b/packages/bucket-provisioner/__tests__/policies.test.ts new file mode 100644 index 000000000..82247fd6b --- /dev/null +++ b/packages/bucket-provisioner/__tests__/policies.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for bucket policy builders. + */ + +import { + getPublicAccessBlock, + buildPublicReadPolicy, + buildCloudFrontOacPolicy, + buildPresignedUrlIamPolicy, +} from '../src/policies'; + +describe('getPublicAccessBlock', () => { + it('returns full lockdown for private buckets', () => { + const config = getPublicAccessBlock('private'); + expect(config).toEqual({ + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }); + }); + + it('returns full lockdown for temp buckets', () => { + const config = getPublicAccessBlock('temp'); + expect(config).toEqual({ + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }); + }); + + it('relaxes policy blocks for public buckets', () => { + const config = getPublicAccessBlock('public'); + expect(config.BlockPublicAcls).toBe(true); + expect(config.IgnorePublicAcls).toBe(true); + expect(config.BlockPublicPolicy).toBe(false); + expect(config.RestrictPublicBuckets).toBe(false); + }); +}); + +describe('buildPublicReadPolicy', () => { + it('builds policy for entire bucket', () => { + const policy = buildPublicReadPolicy('my-bucket'); + expect(policy.Version).toBe('2012-10-17'); + expect(policy.Statement).toHaveLength(1); + + const stmt = policy.Statement[0]; + expect(stmt.Sid).toBe('PublicReadAccess'); + expect(stmt.Effect).toBe('Allow'); + expect(stmt.Principal).toBe('*'); + expect(stmt.Action).toBe('s3:GetObject'); + expect(stmt.Resource).toBe('arn:aws:s3:::my-bucket/*'); + }); + + it('builds policy with key prefix restriction', () => { + const policy = buildPublicReadPolicy('my-bucket', 'public/'); + const stmt = policy.Statement[0]; + expect(stmt.Resource).toBe('arn:aws:s3:::my-bucket/public/*'); + }); + + it('handles empty prefix same as no prefix', () => { + const noPrefix = buildPublicReadPolicy('my-bucket'); + const emptyPrefix = buildPublicReadPolicy('my-bucket', ''); + // Empty string is falsy, so treated same as undefined + expect(noPrefix.Statement[0].Resource).toBe(emptyPrefix.Statement[0].Resource); + }); +}); + +describe('buildCloudFrontOacPolicy', () => { + const distArn = 'arn:aws:cloudfront::123456789012:distribution/E1234567890'; + + it('builds CloudFront OAC policy for entire bucket', () => { + const policy = buildCloudFrontOacPolicy('my-bucket', distArn); + expect(policy.Version).toBe('2012-10-17'); + expect(policy.Statement).toHaveLength(1); + + const stmt = policy.Statement[0]; + expect(stmt.Sid).toBe('AllowCloudFrontOACRead'); + expect(stmt.Effect).toBe('Allow'); + expect(stmt.Principal).toEqual({ Service: 'cloudfront.amazonaws.com' }); + expect(stmt.Action).toBe('s3:GetObject'); + expect(stmt.Resource).toBe('arn:aws:s3:::my-bucket/*'); + expect(stmt.Condition).toEqual({ + StringEquals: { 'AWS:SourceArn': distArn }, + }); + }); + + it('builds CloudFront OAC policy with key prefix', () => { + const policy = buildCloudFrontOacPolicy('my-bucket', distArn, 'public/'); + const stmt = policy.Statement[0]; + expect(stmt.Resource).toBe('arn:aws:s3:::my-bucket/public/*'); + }); +}); + +describe('buildPresignedUrlIamPolicy', () => { + it('builds minimum-permission IAM policy', () => { + const policy = buildPresignedUrlIamPolicy('my-bucket'); + expect(policy.Version).toBe('2012-10-17'); + expect(policy.Statement).toHaveLength(1); + + const stmt = policy.Statement[0]; + expect(stmt.Sid).toBe('PresignedUrlPluginAccess'); + expect(stmt.Effect).toBe('Allow'); + expect(stmt.Action).toEqual(['s3:PutObject', 's3:GetObject', 's3:HeadObject']); + expect(stmt.Resource).toBe('arn:aws:s3:::my-bucket/*'); + }); + + it('does not include DeleteObject', () => { + const policy = buildPresignedUrlIamPolicy('my-bucket'); + const actions = policy.Statement[0].Action; + expect(actions).not.toContain('s3:DeleteObject'); + }); + + it('does not include ListBucket', () => { + const policy = buildPresignedUrlIamPolicy('my-bucket'); + const actions = policy.Statement[0].Action; + expect(actions).not.toContain('s3:ListBucket'); + }); +}); diff --git a/packages/bucket-provisioner/__tests__/provisioner.test.ts b/packages/bucket-provisioner/__tests__/provisioner.test.ts new file mode 100644 index 000000000..8b5b04f8c --- /dev/null +++ b/packages/bucket-provisioner/__tests__/provisioner.test.ts @@ -0,0 +1,573 @@ +/** + * Tests for BucketProvisioner — the core orchestrator. + * + * All S3 calls are mocked via aws-sdk-client-mock style: + * we mock the S3Client.send method and assert the right commands + * are sent with the right parameters. + */ + +import { BucketProvisioner } from '../src/provisioner'; +import type { BucketProvisionerOptions } from '../src/provisioner'; +import { ProvisionerError } from '../src/types'; + +// We mock the S3Client.send at the instance level +const mockSend = jest.fn(); + +jest.mock('@aws-sdk/client-s3', () => { + const actual = jest.requireActual('@aws-sdk/client-s3'); + return { + ...actual, + S3Client: jest.fn().mockImplementation(() => ({ + send: mockSend, + })), + }; +}); + +const defaultOptions: BucketProvisionerOptions = { + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + }, + allowedOrigins: ['https://app.example.com'], +}; + +beforeEach(() => { + mockSend.mockReset(); + // Default: all sends succeed + mockSend.mockResolvedValue({}); +}); + +describe('BucketProvisioner constructor', () => { + it('creates provisioner with valid options', () => { + const provisioner = new BucketProvisioner(defaultOptions); + expect(provisioner).toBeInstanceOf(BucketProvisioner); + }); + + it('throws on empty allowedOrigins', () => { + expect( + () => new BucketProvisioner({ ...defaultOptions, allowedOrigins: [] }), + ).toThrow(ProvisionerError); + expect( + () => new BucketProvisioner({ ...defaultOptions, allowedOrigins: [] }), + ).toThrow('allowedOrigins must contain at least one origin'); + }); + + it('exposes S3Client via getClient()', () => { + const provisioner = new BucketProvisioner(defaultOptions); + const client = provisioner.getClient(); + expect(client).toBeDefined(); + expect(typeof client.send).toBe('function'); + }); +}); + +describe('BucketProvisioner.provision — private bucket', () => { + it('provisions a private bucket with correct steps', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-private', + accessType: 'private', + }); + + expect(result.bucketName).toBe('test-private'); + expect(result.accessType).toBe('private'); + expect(result.provider).toBe('minio'); + expect(result.region).toBe('us-east-1'); + expect(result.endpoint).toBe('http://minio:9000'); + expect(result.blockPublicAccess).toBe(true); + expect(result.versioning).toBe(false); + expect(result.publicUrlPrefix).toBeNull(); + expect(result.lifecycleRules).toHaveLength(0); + expect(result.corsRules).toHaveLength(1); + + // CORS for private bucket: PUT + HEAD only (no GET) + expect(result.corsRules[0].allowedMethods).toContain('PUT'); + expect(result.corsRules[0].allowedMethods).toContain('HEAD'); + expect(result.corsRules[0].allowedMethods).not.toContain('GET'); + }); + + it('calls S3 commands in correct order', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.provision({ + bucketName: 'test-private', + accessType: 'private', + }); + + // Should have called: CreateBucket, PutPublicAccessBlock, DeleteBucketPolicy, PutBucketCors + expect(mockSend).toHaveBeenCalledTimes(4); + + const commandNames = mockSend.mock.calls.map( + (call: any[]) => call[0].constructor.name, + ); + expect(commandNames[0]).toBe('CreateBucketCommand'); + expect(commandNames[1]).toBe('PutPublicAccessBlockCommand'); + expect(commandNames[2]).toBe('DeleteBucketPolicyCommand'); + expect(commandNames[3]).toBe('PutBucketCorsCommand'); + }); + + it('deletes leftover bucket policy for private buckets', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.provision({ + bucketName: 'test-private', + accessType: 'private', + }); + + const deletePolicyCall = mockSend.mock.calls.find( + (call: any[]) => call[0].constructor.name === 'DeleteBucketPolicyCommand', + ); + expect(deletePolicyCall).toBeDefined(); + }); +}); + +describe('BucketProvisioner.provision — public bucket', () => { + it('provisions a public bucket with correct steps', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-public', + accessType: 'public', + publicUrlPrefix: 'https://cdn.example.com', + }); + + expect(result.bucketName).toBe('test-public'); + expect(result.accessType).toBe('public'); + expect(result.blockPublicAccess).toBe(false); + expect(result.publicUrlPrefix).toBe('https://cdn.example.com'); + expect(result.corsRules).toHaveLength(1); + + // CORS for public bucket: PUT + GET + HEAD + expect(result.corsRules[0].allowedMethods).toContain('PUT'); + expect(result.corsRules[0].allowedMethods).toContain('GET'); + expect(result.corsRules[0].allowedMethods).toContain('HEAD'); + }); + + it('applies public-read bucket policy', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.provision({ + bucketName: 'test-public', + accessType: 'public', + }); + + const putPolicyCall = mockSend.mock.calls.find( + (call: any[]) => call[0].constructor.name === 'PutBucketPolicyCommand', + ); + expect(putPolicyCall).toBeDefined(); + + const policyInput = putPolicyCall![0].input; + const policyDoc = JSON.parse(policyInput.Policy); + expect(policyDoc.Statement[0].Effect).toBe('Allow'); + expect(policyDoc.Statement[0].Principal).toBe('*'); + expect(policyDoc.Statement[0].Action).toBe('s3:GetObject'); + }); + + it('returns null publicUrlPrefix when not provided', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-public', + accessType: 'public', + }); + expect(result.publicUrlPrefix).toBeNull(); + }); +}); + +describe('BucketProvisioner.provision — temp bucket', () => { + it('provisions a temp bucket with lifecycle rules', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-temp', + accessType: 'temp', + }); + + expect(result.bucketName).toBe('test-temp'); + expect(result.accessType).toBe('temp'); + expect(result.blockPublicAccess).toBe(true); + expect(result.publicUrlPrefix).toBeNull(); + expect(result.lifecycleRules).toHaveLength(1); + expect(result.lifecycleRules[0].id).toBe('temp-cleanup'); + expect(result.lifecycleRules[0].expirationDays).toBe(1); + }); + + it('calls PutBucketLifecycleConfiguration for temp buckets', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.provision({ + bucketName: 'test-temp', + accessType: 'temp', + }); + + const lifecycleCall = mockSend.mock.calls.find( + (call: any[]) => call[0].constructor.name === 'PutBucketLifecycleConfigurationCommand', + ); + expect(lifecycleCall).toBeDefined(); + }); + + it('uses upload CORS (PUT + GET + HEAD) for temp buckets', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-temp', + accessType: 'temp', + }); + + // Temp uses buildUploadCorsRules (not private) + expect(result.corsRules[0].allowedMethods).toContain('GET'); + }); +}); + +describe('BucketProvisioner.provision — versioning', () => { + it('enables versioning when requested', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-versioned', + accessType: 'private', + versioning: true, + }); + + expect(result.versioning).toBe(true); + + const versioningCall = mockSend.mock.calls.find( + (call: any[]) => call[0].constructor.name === 'PutBucketVersioningCommand', + ); + expect(versioningCall).toBeDefined(); + }); + + it('skips versioning when not requested', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-no-version', + accessType: 'private', + }); + + expect(result.versioning).toBe(false); + + const versioningCall = mockSend.mock.calls.find( + (call: any[]) => call[0].constructor.name === 'PutBucketVersioningCommand', + ); + expect(versioningCall).toBeUndefined(); + }); +}); + +describe('BucketProvisioner.provision — region handling', () => { + it('uses connection region by default', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-region', + accessType: 'private', + }); + + expect(result.region).toBe('us-east-1'); + }); + + it('overrides region with per-bucket option', async () => { + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.provision({ + bucketName: 'test-region', + accessType: 'private', + region: 'eu-west-1', + }); + + expect(result.region).toBe('eu-west-1'); + }); +}); + +describe('BucketProvisioner.createBucket — error handling', () => { + it('tolerates BucketAlreadyOwnedByYou', async () => { + const err = new Error('Bucket already owned'); + (err as any).name = 'BucketAlreadyOwnedByYou'; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + // Should not throw + await provisioner.createBucket('existing-bucket'); + }); + + it('tolerates BucketAlreadyExists', async () => { + const err = new Error('Bucket already exists'); + (err as any).name = 'BucketAlreadyExists'; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.createBucket('existing-bucket'); + }); + + it('wraps unknown errors in ProvisionerError', async () => { + mockSend.mockRejectedValue(new Error('Network failure')); + + const provisioner = new BucketProvisioner(defaultOptions); + await expect(provisioner.createBucket('fail-bucket')).rejects.toThrow( + ProvisionerError, + ); + + await expect(provisioner.createBucket('fail-bucket')).rejects.toThrow( + "Failed to create bucket 'fail-bucket'", + ); + + // Reset to default + mockSend.mockReset(); + mockSend.mockResolvedValue({}); + }); +}); + +describe('BucketProvisioner.bucketExists', () => { + it('returns true when bucket exists', async () => { + mockSend.mockResolvedValueOnce({}); + const provisioner = new BucketProvisioner(defaultOptions); + const exists = await provisioner.bucketExists('existing'); + expect(exists).toBe(true); + }); + + it('returns false when bucket does not exist (404)', async () => { + const err = new Error('Not Found'); + (err as any).$metadata = { httpStatusCode: 404 }; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + const exists = await provisioner.bucketExists('missing'); + expect(exists).toBe(false); + }); + + it('returns false when NotFound error name', async () => { + const err = new Error('Not Found'); + (err as any).name = 'NotFound'; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + const exists = await provisioner.bucketExists('missing'); + expect(exists).toBe(false); + }); + + it('throws ACCESS_DENIED on 403', async () => { + const err = new Error('Forbidden'); + (err as any).$metadata = { httpStatusCode: 403 }; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + await expect(provisioner.bucketExists('forbidden')).rejects.toThrow( + ProvisionerError, + ); + }); + + it('throws PROVIDER_ERROR on other errors', async () => { + mockSend.mockRejectedValueOnce(new Error('Unknown error')); + + const provisioner = new BucketProvisioner(defaultOptions); + await expect(provisioner.bucketExists('error')).rejects.toThrow( + ProvisionerError, + ); + }); +}); + +describe('BucketProvisioner.deleteBucketPolicy', () => { + it('tolerates NoSuchBucketPolicy', async () => { + const err = new Error('No such policy'); + (err as any).name = 'NoSuchBucketPolicy'; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.deleteBucketPolicy('no-policy-bucket'); + }); + + it('tolerates 404 status code', async () => { + const err = new Error('Not found'); + (err as any).$metadata = { httpStatusCode: 404 }; + mockSend.mockRejectedValueOnce(err); + + const provisioner = new BucketProvisioner(defaultOptions); + await provisioner.deleteBucketPolicy('no-policy-bucket'); + }); +}); + +describe('BucketProvisioner.inspect', () => { + it('inspects an existing private bucket', async () => { + // HeadBucket succeeds + mockSend.mockResolvedValueOnce({}); + + // GetPublicAccessBlock + mockSend.mockResolvedValueOnce({ + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }, + }); + + // GetBucketPolicy — no policy + const noPolicyErr = new Error('No policy'); + (noPolicyErr as any).name = 'NoSuchBucketPolicy'; + mockSend.mockRejectedValueOnce(noPolicyErr); + + // GetBucketCors + mockSend.mockResolvedValueOnce({ + CORSRules: [ + { + AllowedOrigins: ['https://app.example.com'], + AllowedMethods: ['PUT', 'HEAD'], + AllowedHeaders: ['Content-Type'], + ExposeHeaders: ['ETag'], + MaxAgeSeconds: 3600, + }, + ], + }); + + // GetBucketVersioning + mockSend.mockResolvedValueOnce({ Status: 'Enabled' }); + + // GetBucketLifecycle — no rules + const noLifecycleErr = new Error('No lifecycle'); + (noLifecycleErr as any).name = 'NoSuchLifecycleConfiguration'; + mockSend.mockRejectedValueOnce(noLifecycleErr); + + const provisioner = new BucketProvisioner(defaultOptions); + const result = await provisioner.inspect('test-bucket', 'private'); + + expect(result.bucketName).toBe('test-bucket'); + expect(result.accessType).toBe('private'); + expect(result.blockPublicAccess).toBe(true); + expect(result.versioning).toBe(true); + expect(result.corsRules).toHaveLength(1); + expect(result.corsRules[0].allowedMethods).toContain('PUT'); + expect(result.lifecycleRules).toHaveLength(0); + }); + + it('throws BUCKET_NOT_FOUND for non-existent bucket', async () => { + const makeNotFoundErr = () => { + const e = new Error('Not Found'); + (e as any).name = 'NotFound'; + return e; + }; + mockSend.mockRejectedValueOnce(makeNotFoundErr()); + + const provisioner = new BucketProvisioner(defaultOptions); + await expect( + provisioner.inspect('missing-bucket', 'private'), + ).rejects.toThrow(ProvisionerError); + + mockSend.mockRejectedValueOnce(makeNotFoundErr()); + await expect( + provisioner.inspect('missing-bucket', 'private'), + ).rejects.toThrow("Bucket 'missing-bucket' does not exist"); + }); +}); + +describe('BucketProvisioner — S3 provider', () => { + it('works with AWS S3 config (no endpoint)', () => { + const provisioner = new BucketProvisioner({ + connection: { + provider: 's3', + region: 'us-east-1', + accessKeyId: 'AKIATEST', + secretAccessKey: 'secrettest', + }, + allowedOrigins: ['https://app.example.com'], + }); + expect(provisioner).toBeDefined(); + }); + + it('returns null endpoint for S3', async () => { + const provisioner = new BucketProvisioner({ + connection: { + provider: 's3', + region: 'us-west-2', + accessKeyId: 'AKIATEST', + secretAccessKey: 'secrettest', + }, + allowedOrigins: ['https://app.example.com'], + }); + + const result = await provisioner.provision({ + bucketName: 'aws-bucket', + accessType: 'private', + }); + + expect(result.endpoint).toBeNull(); + expect(result.provider).toBe('s3'); + expect(result.region).toBe('us-west-2'); + }); +}); + +describe('BucketProvisioner — error propagation', () => { + it('wraps PutPublicAccessBlock failure as POLICY_FAILED', async () => { + // CreateBucket succeeds + mockSend.mockResolvedValueOnce({}); + // PutPublicAccessBlock fails + mockSend.mockRejectedValueOnce(new Error('Access denied')); + + const provisioner = new BucketProvisioner(defaultOptions); + try { + await provisioner.provision({ + bucketName: 'fail-bucket', + accessType: 'private', + }); + fail('Expected ProvisionerError'); + } catch (err) { + expect(err).toBeInstanceOf(ProvisionerError); + expect((err as ProvisionerError).code).toBe('POLICY_FAILED'); + } + }); + + it('wraps PutBucketCors failure as CORS_FAILED', async () => { + // CreateBucket, PutPublicAccessBlock, DeleteBucketPolicy succeed + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + // PutBucketCors fails + mockSend.mockRejectedValueOnce(new Error('CORS error')); + + const provisioner = new BucketProvisioner(defaultOptions); + try { + await provisioner.provision({ + bucketName: 'cors-fail', + accessType: 'private', + }); + fail('Expected ProvisionerError'); + } catch (err) { + expect(err).toBeInstanceOf(ProvisionerError); + expect((err as ProvisionerError).code).toBe('CORS_FAILED'); + } + }); + + it('wraps PutBucketVersioning failure as VERSIONING_FAILED', async () => { + // CreateBucket, PutPublicAccessBlock, DeleteBucketPolicy, PutBucketCors succeed + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + // PutBucketVersioning fails + mockSend.mockRejectedValueOnce(new Error('Versioning error')); + + const provisioner = new BucketProvisioner(defaultOptions); + try { + await provisioner.provision({ + bucketName: 'version-fail', + accessType: 'private', + versioning: true, + }); + fail('Expected ProvisionerError'); + } catch (err) { + expect(err).toBeInstanceOf(ProvisionerError); + expect((err as ProvisionerError).code).toBe('VERSIONING_FAILED'); + } + }); + + it('wraps PutBucketLifecycleConfiguration failure as LIFECYCLE_FAILED', async () => { + // CreateBucket, PutPublicAccessBlock, DeleteBucketPolicy, PutBucketCors succeed + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + mockSend.mockResolvedValueOnce({}); + // PutBucketLifecycleConfiguration fails + mockSend.mockRejectedValueOnce(new Error('Lifecycle error')); + + const provisioner = new BucketProvisioner(defaultOptions); + try { + await provisioner.provision({ + bucketName: 'lifecycle-fail', + accessType: 'temp', + }); + fail('Expected ProvisionerError'); + } catch (err) { + expect(err).toBeInstanceOf(ProvisionerError); + expect((err as ProvisionerError).code).toBe('LIFECYCLE_FAILED'); + } + }); +}); diff --git a/packages/bucket-provisioner/__tests__/types.test.ts b/packages/bucket-provisioner/__tests__/types.test.ts new file mode 100644 index 000000000..adf8da2d9 --- /dev/null +++ b/packages/bucket-provisioner/__tests__/types.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for types and error classes. + */ + +import { ProvisionerError } from '../src/types'; +import type { + StorageProvider, + StorageConnectionConfig, + BucketAccessType, + CreateBucketOptions, + CorsRule, + LifecycleRule, + ProvisionResult, + ProvisionerErrorCode, +} from '../src/types'; + +describe('ProvisionerError', () => { + it('creates error with code and message', () => { + const err = new ProvisionerError('INVALID_CONFIG', 'bad config'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ProvisionerError); + expect(err.name).toBe('ProvisionerError'); + expect(err.code).toBe('INVALID_CONFIG'); + expect(err.message).toBe('bad config'); + expect(err.cause).toBeUndefined(); + }); + + it('creates error with cause', () => { + const original = new Error('original'); + const err = new ProvisionerError('PROVIDER_ERROR', 'wrapped', original); + expect(err.code).toBe('PROVIDER_ERROR'); + expect(err.cause).toBe(original); + }); + + it('supports all error codes', () => { + const codes: ProvisionerErrorCode[] = [ + 'CONNECTION_FAILED', + 'BUCKET_ALREADY_EXISTS', + 'BUCKET_NOT_FOUND', + 'INVALID_CONFIG', + 'POLICY_FAILED', + 'CORS_FAILED', + 'LIFECYCLE_FAILED', + 'VERSIONING_FAILED', + 'ACCESS_DENIED', + 'PROVIDER_ERROR', + ]; + for (const code of codes) { + const err = new ProvisionerError(code, `test ${code}`); + expect(err.code).toBe(code); + } + }); +}); + +describe('Type definitions', () => { + it('StorageProvider accepts valid values', () => { + const providers: StorageProvider[] = ['s3', 'minio', 'r2', 'gcs', 'spaces']; + expect(providers).toHaveLength(5); + }); + + it('BucketAccessType accepts valid values', () => { + const types: BucketAccessType[] = ['public', 'private', 'temp']; + expect(types).toHaveLength(3); + }); + + it('StorageConnectionConfig has required fields', () => { + const config: StorageConnectionConfig = { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + forcePathStyle: true, + }; + expect(config.provider).toBe('minio'); + expect(config.endpoint).toBe('http://minio:9000'); + }); + + it('CreateBucketOptions has required and optional fields', () => { + const opts: CreateBucketOptions = { + bucketName: 'test-bucket', + accessType: 'private', + region: 'us-east-1', + versioning: true, + publicUrlPrefix: undefined, + }; + expect(opts.bucketName).toBe('test-bucket'); + expect(opts.versioning).toBe(true); + }); + + it('CorsRule has all fields', () => { + const rule: CorsRule = { + allowedOrigins: ['https://example.com'], + allowedMethods: ['PUT', 'GET'], + allowedHeaders: ['Content-Type'], + exposedHeaders: ['ETag'], + maxAgeSeconds: 3600, + }; + expect(rule.allowedMethods).toContain('PUT'); + }); + + it('LifecycleRule has all fields', () => { + const rule: LifecycleRule = { + id: 'temp-cleanup', + prefix: '', + expirationDays: 1, + enabled: true, + }; + expect(rule.id).toBe('temp-cleanup'); + }); + + it('ProvisionResult has all fields', () => { + const result: ProvisionResult = { + bucketName: 'test', + accessType: 'private', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: true, + versioning: false, + corsRules: [], + lifecycleRules: [], + }; + expect(result.blockPublicAccess).toBe(true); + expect(result.publicUrlPrefix).toBeNull(); + }); +}); diff --git a/packages/bucket-provisioner/jest.config.js b/packages/bucket-provisioner/jest.config.js new file mode 100644 index 000000000..e31cab5ae --- /dev/null +++ b/packages/bucket-provisioner/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { useESM: false }], + }, + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['/dist/'], +}; diff --git a/packages/bucket-provisioner/package.json b/packages/bucket-provisioner/package.json new file mode 100644 index 000000000..c243a7d95 --- /dev/null +++ b/packages/bucket-provisioner/package.json @@ -0,0 +1,46 @@ +{ + "name": "@constructive-io/bucket-provisioner", + "version": "0.1.0", + "author": "Constructive ", + "description": "S3-compatible bucket provisioning library — create buckets, configure privacy policies, CORS, and access controls", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1009.0" + }, + "devDependencies": { + "makage": "^0.3.0" + }, + "keywords": [ + "s3", + "bucket", + "provisioner", + "minio", + "cloudflare-r2", + "storage", + "constructive" + ] +} diff --git a/packages/bucket-provisioner/src/client.ts b/packages/bucket-provisioner/src/client.ts new file mode 100644 index 000000000..bfc090375 --- /dev/null +++ b/packages/bucket-provisioner/src/client.ts @@ -0,0 +1,58 @@ +/** + * S3 client factory. + * + * Creates a configured S3Client from a StorageConnectionConfig. + * Handles provider-specific settings (path-style for MinIO, etc.). + */ + +import { S3Client } from '@aws-sdk/client-s3'; +import type { StorageConnectionConfig } from './types'; +import { ProvisionerError } from './types'; + +/** + * Create an S3Client from a storage connection config. + * + * Provider-specific defaults: + * - `minio`: forces path-style URLs (required by MinIO) + * - `r2`: forces path-style URLs (required by Cloudflare R2) + * - `s3`: uses virtual-hosted style (AWS default) + * - `gcs`: forces path-style URLs (GCS S3-compatible API) + * - `spaces`: uses virtual-hosted style (DigitalOcean default) + */ +export function createS3Client(config: StorageConnectionConfig): S3Client { + if (!config.accessKeyId || !config.secretAccessKey) { + throw new ProvisionerError( + 'INVALID_CONFIG', + 'accessKeyId and secretAccessKey are required', + ); + } + + if (!config.region) { + throw new ProvisionerError( + 'INVALID_CONFIG', + 'region is required', + ); + } + + // Providers that require path-style URLs + const pathStyleProviders = new Set(['minio', 'r2', 'gcs']); + const forcePathStyle = config.forcePathStyle ?? pathStyleProviders.has(config.provider); + + // Non-AWS providers require an endpoint + if (config.provider !== 's3' && !config.endpoint) { + throw new ProvisionerError( + 'INVALID_CONFIG', + `endpoint is required for provider '${config.provider}'`, + ); + } + + return new S3Client({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); +} diff --git a/packages/bucket-provisioner/src/cors.ts b/packages/bucket-provisioner/src/cors.ts new file mode 100644 index 000000000..897a024f4 --- /dev/null +++ b/packages/bucket-provisioner/src/cors.ts @@ -0,0 +1,96 @@ +/** + * CORS configuration builders. + * + * Generates CORS rules for S3 buckets to allow browser-based + * presigned URL uploads. Without CORS, the browser will block + * the cross-origin PUT request to the S3 endpoint. + */ + +import type { CorsRule } from './types'; + +/** + * Build the default CORS rules for presigned URL uploads. + * + * This allows: + * - PUT: for presigned uploads from the browser + * - GET: for presigned downloads and public file access + * - HEAD: for confirmUpload verification and cache headers + * + * @param allowedOrigins - Domains allowed to make cross-origin requests. + * Use specific domains in production (e.g., ["https://app.example.com"]). + * Never use ["*"] in production. + * @param maxAgeSeconds - Preflight cache duration (default: 3600 = 1 hour) + */ +export function buildUploadCorsRules( + allowedOrigins: string[], + maxAgeSeconds: number = 3600, +): CorsRule[] { + if (allowedOrigins.length === 0) { + throw new Error('allowedOrigins must contain at least one origin'); + } + + return [ + { + allowedOrigins, + allowedMethods: ['PUT', 'GET', 'HEAD'], + allowedHeaders: [ + 'Content-Type', + 'Content-Length', + 'Content-MD5', + 'x-amz-content-sha256', + 'x-amz-date', + 'x-amz-security-token', + 'Authorization', + ], + exposedHeaders: [ + 'ETag', + 'Content-Length', + 'Content-Type', + 'x-amz-request-id', + 'x-amz-id-2', + ], + maxAgeSeconds, + }, + ]; +} + +/** + * Build restrictive CORS rules for private-only buckets. + * + * Similar to upload CORS but without GET (private files use + * presigned URLs which include auth in the query string, + * so CORS is less of a concern for downloads). + * + * @param allowedOrigins - Domains allowed to make cross-origin requests. + * @param maxAgeSeconds - Preflight cache duration (default: 3600 = 1 hour) + */ +export function buildPrivateCorsRules( + allowedOrigins: string[], + maxAgeSeconds: number = 3600, +): CorsRule[] { + if (allowedOrigins.length === 0) { + throw new Error('allowedOrigins must contain at least one origin'); + } + + return [ + { + allowedOrigins, + allowedMethods: ['PUT', 'HEAD'], + allowedHeaders: [ + 'Content-Type', + 'Content-Length', + 'Content-MD5', + 'x-amz-content-sha256', + 'x-amz-date', + 'x-amz-security-token', + 'Authorization', + ], + exposedHeaders: [ + 'ETag', + 'Content-Length', + 'x-amz-request-id', + ], + maxAgeSeconds, + }, + ]; +} diff --git a/packages/bucket-provisioner/src/index.ts b/packages/bucket-provisioner/src/index.ts new file mode 100644 index 000000000..29fe922dd --- /dev/null +++ b/packages/bucket-provisioner/src/index.ts @@ -0,0 +1,67 @@ +/** + * @constructive-io/bucket-provisioner + * + * S3-compatible bucket provisioning library for the Constructive storage module. + * Creates and configures buckets with the correct privacy policies, CORS rules, + * versioning, and lifecycle settings for private and public file storage. + * + * @example + * ```typescript + * import { BucketProvisioner } from '@constructive-io/bucket-provisioner'; + * + * const provisioner = new BucketProvisioner({ + * connection: { + * provider: 'minio', + * region: 'us-east-1', + * endpoint: 'http://minio:9000', + * accessKeyId: 'minioadmin', + * secretAccessKey: 'minioadmin', + * }, + * allowedOrigins: ['https://app.example.com'], + * }); + * + * const result = await provisioner.provision({ + * bucketName: 'my-app-storage', + * accessType: 'private', + * }); + * ``` + */ + +// Core provisioner +export { BucketProvisioner } from './provisioner'; +export type { BucketProvisionerOptions } from './provisioner'; + +// S3 client factory +export { createS3Client } from './client'; + +// Policy builders +export { + getPublicAccessBlock, + buildPublicReadPolicy, + buildCloudFrontOacPolicy, + buildPresignedUrlIamPolicy, +} from './policies'; +export type { + PublicAccessBlockConfig, + BucketPolicyDocument, + BucketPolicyStatement, +} from './policies'; + +// CORS builders +export { buildUploadCorsRules, buildPrivateCorsRules } from './cors'; + +// Lifecycle builders +export { buildTempCleanupRule, buildAbortIncompleteMultipartRule } from './lifecycle'; + +// Types +export type { + StorageProvider, + StorageConnectionConfig, + BucketAccessType, + CreateBucketOptions, + CorsRule, + LifecycleRule, + ProvisionResult, + ProvisionerErrorCode, +} from './types'; +export { ProvisionerError } from './types'; diff --git a/packages/bucket-provisioner/src/lifecycle.ts b/packages/bucket-provisioner/src/lifecycle.ts new file mode 100644 index 000000000..b1dde055a --- /dev/null +++ b/packages/bucket-provisioner/src/lifecycle.ts @@ -0,0 +1,51 @@ +/** + * Lifecycle rule builders. + * + * Generates S3 lifecycle configurations for automatic object expiration. + * Primarily used for temp buckets where uploads should be cleaned up + * after a configurable period. + */ + +import type { LifecycleRule } from './types'; + +/** + * Build a lifecycle rule for temp bucket cleanup. + * + * Temp buckets hold staging uploads (files with status='pending' that + * were never confirmed). This rule automatically deletes objects after + * a set number of days, preventing storage cost accumulation from + * abandoned uploads. + * + * @param expirationDays - Days after which objects expire (default: 1) + * @param prefix - Key prefix to target (default: "" = entire bucket) + */ +export function buildTempCleanupRule( + expirationDays: number = 1, + prefix: string = '', +): LifecycleRule { + return { + id: 'temp-cleanup', + prefix, + expirationDays, + enabled: true, + }; +} + +/** + * Build a lifecycle rule for incomplete multipart upload cleanup. + * + * Incomplete multipart uploads consume storage but serve no purpose. + * This rule aborts them after a set number of days. + * + * @param expirationDays - Days after which incomplete uploads are aborted (default: 1) + */ +export function buildAbortIncompleteMultipartRule( + expirationDays: number = 1, +): LifecycleRule { + return { + id: 'abort-incomplete-multipart', + prefix: '', + expirationDays, + enabled: true, + }; +} diff --git a/packages/bucket-provisioner/src/policies.ts b/packages/bucket-provisioner/src/policies.ts new file mode 100644 index 000000000..f376a3920 --- /dev/null +++ b/packages/bucket-provisioner/src/policies.ts @@ -0,0 +1,168 @@ +/** + * S3 bucket policy builders. + * + * Generates the JSON policy documents for private and public bucket + * configurations. These are the actual S3 bucket policies that control + * who can access objects in the bucket. + * + * Privacy model: + * - Private buckets: Block All Public Access enabled, no bucket policy needed. + * All access goes through presigned URLs generated server-side. + * - Public buckets: Block Public Access disabled for GetObject only. + * A bucket policy grants public read access (optionally restricted to a key prefix). + */ + +import type { BucketAccessType } from './types'; + +/** + * S3 Block Public Access configuration. + * + * For private/temp buckets: all four flags are true (maximum lockdown). + * For public buckets: BlockPublicPolicy and RestrictPublicBuckets are false + * so that a public-read bucket policy can be applied. + */ +export interface PublicAccessBlockConfig { + BlockPublicAcls: boolean; + IgnorePublicAcls: boolean; + BlockPublicPolicy: boolean; + RestrictPublicBuckets: boolean; +} + +/** + * Get the Block Public Access configuration for a bucket access type. + * + * - private/temp: all blocks enabled (maximum security) + * - public: ACL blocks enabled (ACLs are deprecated), but policy blocks + * disabled so a public-read bucket policy can be attached + */ +export function getPublicAccessBlock(accessType: BucketAccessType): PublicAccessBlockConfig { + if (accessType === 'public') { + return { + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: false, + RestrictPublicBuckets: false, + }; + } + + // private and temp: full lockdown + return { + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }; +} + +/** + * AWS IAM-style policy document for S3 bucket policies. + */ +export interface BucketPolicyDocument { + Version: string; + Statement: BucketPolicyStatement[]; +} + +export interface BucketPolicyStatement { + Sid: string; + Effect: 'Allow' | 'Deny'; + Principal: string | { AWS: string } | { Service: string }; + Action: string | string[]; + Resource: string | string[]; + Condition?: Record>; +} + +/** + * Build a public-read bucket policy. + * + * Grants anonymous GetObject access to the entire bucket or a specific prefix. + * This is the standard way to serve public files via direct URL or CDN. + * + * @param bucketName - S3 bucket name + * @param keyPrefix - Optional key prefix to restrict public reads (e.g., "public/"). + * If provided, only objects under this prefix are publicly readable. + * If omitted, the entire bucket is publicly readable. + */ +export function buildPublicReadPolicy( + bucketName: string, + keyPrefix?: string, +): BucketPolicyDocument { + const resource = keyPrefix + ? `arn:aws:s3:::${bucketName}/${keyPrefix}*` + : `arn:aws:s3:::${bucketName}/*`; + + return { + Version: '2012-10-17', + Statement: [ + { + Sid: 'PublicReadAccess', + Effect: 'Allow', + Principal: '*', + Action: 's3:GetObject', + Resource: resource, + }, + ], + }; +} + +/** + * Build a CloudFront Origin Access Control (OAC) bucket policy. + * + * This is the recommended way to serve public files through CloudFront + * without making the S3 bucket itself public. CloudFront authenticates + * to S3 using the OAC, and the bucket policy only allows CloudFront. + * + * @param bucketName - S3 bucket name + * @param cloudFrontDistributionArn - The CloudFront distribution ARN + * @param keyPrefix - Optional key prefix to restrict access + */ +export function buildCloudFrontOacPolicy( + bucketName: string, + cloudFrontDistributionArn: string, + keyPrefix?: string, +): BucketPolicyDocument { + const resource = keyPrefix + ? `arn:aws:s3:::${bucketName}/${keyPrefix}*` + : `arn:aws:s3:::${bucketName}/*`; + + return { + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowCloudFrontOACRead', + Effect: 'Allow', + Principal: { Service: 'cloudfront.amazonaws.com' }, + Action: 's3:GetObject', + Resource: resource, + Condition: { + StringEquals: { + 'AWS:SourceArn': cloudFrontDistributionArn, + }, + }, + }, + ], + }; +} + +/** + * Build the IAM policy for the presigned URL plugin's S3 credentials. + * + * This is the minimum-permission policy that the GraphQL server's + * S3 access key should have. It only allows PutObject, GetObject, + * and HeadObject — no delete, no list, no bucket management. + * + * @param bucketName - S3 bucket name + */ +export function buildPresignedUrlIamPolicy(bucketName: string): BucketPolicyDocument { + return { + Version: '2012-10-17', + Statement: [ + { + Sid: 'PresignedUrlPluginAccess', + Effect: 'Allow', + Principal: '*', + Action: ['s3:PutObject', 's3:GetObject', 's3:HeadObject'], + Resource: `arn:aws:s3:::${bucketName}/*`, + }, + ], + }; +} diff --git a/packages/bucket-provisioner/src/provisioner.ts b/packages/bucket-provisioner/src/provisioner.ts new file mode 100644 index 000000000..5ada96b07 --- /dev/null +++ b/packages/bucket-provisioner/src/provisioner.ts @@ -0,0 +1,510 @@ +/** + * Bucket Provisioner — core provisioning logic. + * + * Orchestrates S3 bucket creation, privacy configuration, CORS setup, + * versioning, and lifecycle rules. Uses the AWS SDK S3 client for all + * operations, which works with any S3-compatible backend (MinIO, R2, etc.). + * + * Privacy model: + * - Private/temp buckets: Block All Public Access, no bucket policy, presigned URLs only + * - Public buckets: Block Public Access partially relaxed, public-read bucket policy applied + */ + +import { + CreateBucketCommand, + PutPublicAccessBlockCommand, + PutBucketPolicyCommand, + DeleteBucketPolicyCommand, + PutBucketCorsCommand, + PutBucketVersioningCommand, + PutBucketLifecycleConfigurationCommand, + HeadBucketCommand, + GetBucketPolicyCommand, + GetBucketCorsCommand, + GetBucketVersioningCommand, + GetBucketLifecycleConfigurationCommand, + GetPublicAccessBlockCommand, +} from '@aws-sdk/client-s3'; +import type { S3Client } from '@aws-sdk/client-s3'; + +import type { + StorageConnectionConfig, + CreateBucketOptions, + CorsRule, + LifecycleRule, + ProvisionResult, + BucketAccessType, +} from './types'; +import { ProvisionerError } from './types'; +import { createS3Client } from './client'; +import { getPublicAccessBlock, buildPublicReadPolicy } from './policies'; +import type { BucketPolicyDocument, PublicAccessBlockConfig } from './policies'; +import { buildUploadCorsRules, buildPrivateCorsRules } from './cors'; +import { buildTempCleanupRule } from './lifecycle'; + +/** + * Options for the BucketProvisioner constructor. + */ +export interface BucketProvisionerOptions { + /** Storage connection config — credentials, endpoint, provider */ + connection: StorageConnectionConfig; + /** + * Default allowed origins for CORS rules. + * These are the domains where your app runs (e.g., ["https://app.example.com"]). + * Required for browser-based presigned URL uploads. + */ + allowedOrigins: string[]; +} + +/** + * The BucketProvisioner handles creating and configuring S3-compatible + * buckets with the correct privacy settings, CORS rules, and policies + * for the Constructive storage module. + * + * @example + * ```typescript + * const provisioner = new BucketProvisioner({ + * connection: { + * provider: 'minio', + * region: 'us-east-1', + * endpoint: 'http://minio:9000', + * accessKeyId: 'minioadmin', + * secretAccessKey: 'minioadmin', + * }, + * allowedOrigins: ['https://app.example.com'], + * }); + * + * // Provision a private bucket + * const result = await provisioner.provision({ + * bucketName: 'my-app-storage', + * accessType: 'private', + * }); + * ``` + */ +export class BucketProvisioner { + private readonly client: S3Client; + private readonly config: StorageConnectionConfig; + private readonly allowedOrigins: string[]; + + constructor(options: BucketProvisionerOptions) { + if (!options.allowedOrigins || options.allowedOrigins.length === 0) { + throw new ProvisionerError( + 'INVALID_CONFIG', + 'allowedOrigins must contain at least one origin for CORS configuration', + ); + } + + this.config = options.connection; + this.allowedOrigins = options.allowedOrigins; + this.client = createS3Client(options.connection); + } + + /** + * Get the underlying S3Client instance. + * Useful for advanced operations not covered by the provisioner. + */ + getClient(): S3Client { + return this.client; + } + + /** + * Provision a fully configured S3 bucket. + * + * This is the main entry point. It: + * 1. Creates the bucket (or verifies it exists) + * 2. Configures Block Public Access based on access type + * 3. Applies the appropriate bucket policy (public-read or none) + * 4. Sets CORS rules for presigned URL uploads + * 5. Optionally enables versioning + * 6. Optionally adds lifecycle rules (auto-enabled for temp buckets) + * + * @param options - Bucket creation options + * @returns ProvisionResult with all configuration details + */ + async provision(options: CreateBucketOptions): Promise { + const { bucketName, accessType, versioning = false } = options; + const region = options.region ?? this.config.region; + + // 1. Create the bucket + await this.createBucket(bucketName, region); + + // 2. Configure Block Public Access + const publicAccessBlock = getPublicAccessBlock(accessType); + await this.setPublicAccessBlock(bucketName, publicAccessBlock); + + // 3. Apply bucket policy + if (accessType === 'public') { + const policy = buildPublicReadPolicy(bucketName); + await this.setBucketPolicy(bucketName, policy); + } else { + // Ensure no leftover public policy on private/temp buckets + await this.deleteBucketPolicy(bucketName); + } + + // 4. Set CORS rules + const corsRules = accessType === 'private' + ? buildPrivateCorsRules(this.allowedOrigins) + : buildUploadCorsRules(this.allowedOrigins); + await this.setCors(bucketName, corsRules); + + // 5. Versioning + if (versioning) { + await this.enableVersioning(bucketName); + } + + // 6. Lifecycle rules for temp buckets + const lifecycleRules: LifecycleRule[] = []; + if (accessType === 'temp') { + const tempRule = buildTempCleanupRule(1); + lifecycleRules.push(tempRule); + await this.setLifecycleRules(bucketName, lifecycleRules); + } + + // Build result + const publicUrlPrefix = accessType === 'public' + ? (options.publicUrlPrefix ?? null) + : null; + + return { + bucketName, + accessType, + endpoint: this.config.endpoint ?? null, + provider: this.config.provider, + region, + publicUrlPrefix, + blockPublicAccess: accessType !== 'public', + versioning, + corsRules, + lifecycleRules, + }; + } + + /** + * Create an S3 bucket. Handles the "bucket already exists" case gracefully. + */ + async createBucket(bucketName: string, region?: string): Promise { + try { + const command = new CreateBucketCommand({ + Bucket: bucketName, + ...(region && region !== 'us-east-1' + ? { CreateBucketConfiguration: { LocationConstraint: region as any } } + : {}), + }); + await this.client.send(command); + } catch (err: any) { + // Bucket already exists and we own it — that's fine + if ( + err.name === 'BucketAlreadyOwnedByYou' || + err.name === 'BucketAlreadyExists' || + err.Code === 'BucketAlreadyOwnedByYou' || + err.Code === 'BucketAlreadyExists' + ) { + return; + } + throw new ProvisionerError( + 'PROVIDER_ERROR', + `Failed to create bucket '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Check if a bucket exists and is accessible. + */ + async bucketExists(bucketName: string): Promise { + try { + await this.client.send(new HeadBucketCommand({ Bucket: bucketName })); + return true; + } catch (err: any) { + if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) { + return false; + } + if (err.$metadata?.httpStatusCode === 403) { + throw new ProvisionerError( + 'ACCESS_DENIED', + `Access denied to bucket '${bucketName}'`, + err, + ); + } + throw new ProvisionerError( + 'PROVIDER_ERROR', + `Failed to check bucket '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Configure S3 Block Public Access settings. + */ + async setPublicAccessBlock( + bucketName: string, + config: PublicAccessBlockConfig, + ): Promise { + try { + await this.client.send( + new PutPublicAccessBlockCommand({ + Bucket: bucketName, + PublicAccessBlockConfiguration: config, + }), + ); + } catch (err: any) { + throw new ProvisionerError( + 'POLICY_FAILED', + `Failed to set public access block on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Apply an S3 bucket policy. + */ + async setBucketPolicy( + bucketName: string, + policy: BucketPolicyDocument, + ): Promise { + try { + await this.client.send( + new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify(policy), + }), + ); + } catch (err: any) { + throw new ProvisionerError( + 'POLICY_FAILED', + `Failed to set bucket policy on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Delete an S3 bucket policy (used to clear leftover public policies). + */ + async deleteBucketPolicy(bucketName: string): Promise { + try { + await this.client.send( + new DeleteBucketPolicyCommand({ Bucket: bucketName }), + ); + } catch (err: any) { + // No policy to delete — that's fine + if (err.name === 'NoSuchBucketPolicy' || err.$metadata?.httpStatusCode === 404) { + return; + } + throw new ProvisionerError( + 'POLICY_FAILED', + `Failed to delete bucket policy on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Set CORS configuration on an S3 bucket. + */ + async setCors(bucketName: string, rules: CorsRule[]): Promise { + try { + await this.client.send( + new PutBucketCorsCommand({ + Bucket: bucketName, + CORSConfiguration: { + CORSRules: rules.map((rule) => ({ + AllowedOrigins: rule.allowedOrigins, + AllowedMethods: rule.allowedMethods, + AllowedHeaders: rule.allowedHeaders, + ExposeHeaders: rule.exposedHeaders, + MaxAgeSeconds: rule.maxAgeSeconds, + })), + }, + }), + ); + } catch (err: any) { + throw new ProvisionerError( + 'CORS_FAILED', + `Failed to set CORS on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Enable versioning on an S3 bucket. + */ + async enableVersioning(bucketName: string): Promise { + try { + await this.client.send( + new PutBucketVersioningCommand({ + Bucket: bucketName, + VersioningConfiguration: { Status: 'Enabled' }, + }), + ); + } catch (err: any) { + throw new ProvisionerError( + 'VERSIONING_FAILED', + `Failed to enable versioning on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Set lifecycle rules on an S3 bucket. + */ + async setLifecycleRules( + bucketName: string, + rules: LifecycleRule[], + ): Promise { + try { + await this.client.send( + new PutBucketLifecycleConfigurationCommand({ + Bucket: bucketName, + LifecycleConfiguration: { + Rules: rules.map((rule) => ({ + ID: rule.id, + Filter: { Prefix: rule.prefix }, + Status: rule.enabled ? 'Enabled' : 'Disabled', + Expiration: { Days: rule.expirationDays }, + })), + }, + }), + ); + } catch (err: any) { + throw new ProvisionerError( + 'LIFECYCLE_FAILED', + `Failed to set lifecycle rules on '${bucketName}': ${err.message}`, + err, + ); + } + } + + /** + * Inspect the current configuration of an existing bucket. + * + * Reads the bucket's policy, CORS, versioning, lifecycle, and public access + * settings and returns them in a structured format. Useful for auditing + * or verifying that a bucket is correctly configured. + * + * @param bucketName - S3 bucket name + * @param accessType - Expected access type (used in the result) + */ + async inspect(bucketName: string, accessType: BucketAccessType): Promise { + const exists = await this.bucketExists(bucketName); + if (!exists) { + throw new ProvisionerError( + 'BUCKET_NOT_FOUND', + `Bucket '${bucketName}' does not exist`, + ); + } + + // Read all configurations in parallel + const [publicAccessBlock, policy, cors, versioning, lifecycle] = await Promise.all([ + this.getPublicAccessBlock(bucketName), + this.getBucketPolicy(bucketName), + this.getBucketCors(bucketName), + this.getBucketVersioning(bucketName), + this.getBucketLifecycle(bucketName), + ]); + + const isFullyBlocked = publicAccessBlock + ? publicAccessBlock.BlockPublicAcls === true && + publicAccessBlock.IgnorePublicAcls === true && + publicAccessBlock.BlockPublicPolicy === true && + publicAccessBlock.RestrictPublicBuckets === true + : false; + + return { + bucketName, + accessType, + endpoint: this.config.endpoint ?? null, + provider: this.config.provider, + region: this.config.region, + publicUrlPrefix: null, + blockPublicAccess: isFullyBlocked, + versioning: versioning === 'Enabled', + corsRules: cors, + lifecycleRules: lifecycle, + }; + } + + // --- Private read methods for inspect --- + + private async getPublicAccessBlock( + bucketName: string, + ): Promise { + try { + const result = await this.client.send( + new GetPublicAccessBlockCommand({ Bucket: bucketName }), + ); + const config = result.PublicAccessBlockConfiguration; + if (!config) return null; + return { + BlockPublicAcls: config.BlockPublicAcls ?? false, + IgnorePublicAcls: config.IgnorePublicAcls ?? false, + BlockPublicPolicy: config.BlockPublicPolicy ?? false, + RestrictPublicBuckets: config.RestrictPublicBuckets ?? false, + }; + } catch { + return null; + } + } + + private async getBucketPolicy( + bucketName: string, + ): Promise { + try { + const result = await this.client.send( + new GetBucketPolicyCommand({ Bucket: bucketName }), + ); + return result.Policy ? JSON.parse(result.Policy) : null; + } catch { + return null; + } + } + + private async getBucketCors(bucketName: string): Promise { + try { + const result = await this.client.send( + new GetBucketCorsCommand({ Bucket: bucketName }), + ); + return (result.CORSRules ?? []).map((rule) => ({ + allowedOrigins: rule.AllowedOrigins ?? [], + allowedMethods: (rule.AllowedMethods ?? []) as CorsRule['allowedMethods'], + allowedHeaders: rule.AllowedHeaders ?? [], + exposedHeaders: rule.ExposeHeaders ?? [], + maxAgeSeconds: rule.MaxAgeSeconds ?? 0, + })); + } catch { + return []; + } + } + + private async getBucketVersioning(bucketName: string): Promise { + try { + const result = await this.client.send( + new GetBucketVersioningCommand({ Bucket: bucketName }), + ); + return result.Status ?? 'Disabled'; + } catch { + return 'Disabled'; + } + } + + private async getBucketLifecycle(bucketName: string): Promise { + try { + const result = await this.client.send( + new GetBucketLifecycleConfigurationCommand({ Bucket: bucketName }), + ); + return (result.Rules ?? []).map((rule) => ({ + id: rule.ID ?? '', + prefix: (rule.Filter as any)?.Prefix ?? '', + expirationDays: rule.Expiration?.Days ?? 0, + enabled: rule.Status === 'Enabled', + })); + } catch { + return []; + } + } +} diff --git a/packages/bucket-provisioner/src/types.ts b/packages/bucket-provisioner/src/types.ts new file mode 100644 index 000000000..d50523504 --- /dev/null +++ b/packages/bucket-provisioner/src/types.ts @@ -0,0 +1,174 @@ +/** + * Types for the bucket provisioner library. + * + * Defines the configuration interfaces for S3-compatible storage providers, + * bucket creation options, privacy policies, and CORS rules. + */ + +// --- Provider configuration --- + +/** + * Supported storage provider identifiers. + * + * Used to select provider-specific behavior (e.g., path-style URLs for MinIO, + * jurisdiction headers for R2). + */ +export type StorageProvider = 's3' | 'minio' | 'r2' | 'gcs' | 'spaces'; + +/** + * Connection configuration for an S3-compatible storage backend. + * + * This is the input you provide to connect to your storage provider. + * For AWS S3, only `region` and credentials are needed. + * For MinIO/R2/etc., also provide `endpoint`. + */ +export interface StorageConnectionConfig { + /** Storage provider type */ + provider: StorageProvider; + /** S3 region (e.g., "us-east-1"). Required for AWS S3. */ + region: string; + /** S3-compatible endpoint URL (e.g., "http://minio:9000"). Required for non-AWS providers. */ + endpoint?: string; + /** AWS access key ID */ + accessKeyId: string; + /** AWS secret access key */ + secretAccessKey: string; + /** Use path-style URLs (required for MinIO, optional for others) */ + forcePathStyle?: boolean; +} + +// --- Bucket configuration --- + +/** + * Bucket access type, matching the database bucket `type` column. + * + * - `public`: Files served via CDN/public URL. Bucket policy allows public reads. + * - `private`: Files served via presigned GET URLs only. No public access. + * - `temp`: Staging area for uploads. Treated as private. Lifecycle rules may apply. + */ +export type BucketAccessType = 'public' | 'private' | 'temp'; + +/** + * Options for creating or configuring an S3 bucket. + */ +export interface CreateBucketOptions { + /** The S3 bucket name (globally unique for AWS, locally unique for MinIO) */ + bucketName: string; + /** Bucket access type — determines which policies are applied */ + accessType: BucketAccessType; + /** S3 region for bucket creation (defaults to connection config region) */ + region?: string; + /** Whether to enable versioning (recommended for durability) */ + versioning?: boolean; + /** + * Public URL prefix for public buckets. + * This is the CDN or public endpoint URL that serves files from the bucket. + * Only meaningful for `accessType: 'public'`. + * Example: "https://cdn.example.com/public" + */ + publicUrlPrefix?: string; +} + +// --- CORS configuration --- + +/** + * CORS rule for S3 bucket configuration. + * + * Required for browser-based presigned URL uploads. + * The presigned PUT request is a cross-origin request from the client + * to the S3 endpoint, so CORS must be configured on the bucket. + */ +export interface CorsRule { + /** Allowed origin domains (e.g., ["https://app.example.com"]) */ + allowedOrigins: string[]; + /** Allowed HTTP methods */ + allowedMethods: ('GET' | 'PUT' | 'HEAD' | 'POST' | 'DELETE')[]; + /** Allowed request headers */ + allowedHeaders: string[]; + /** Headers exposed to the browser */ + exposedHeaders: string[]; + /** Preflight cache duration in seconds */ + maxAgeSeconds: number; +} + +// --- Lifecycle configuration --- + +/** + * Lifecycle rule for automatic object expiration. + * + * Useful for temp buckets where uploads expire after a set period. + */ +export interface LifecycleRule { + /** Rule ID (descriptive name) */ + id: string; + /** S3 key prefix to apply the rule to (empty string = entire bucket) */ + prefix: string; + /** Number of days after which objects expire */ + expirationDays: number; + /** Whether the rule is enabled */ + enabled: boolean; +} + +// --- Provisioning result --- + +/** + * Result of a bucket provisioning operation. + * + * Contains all the information needed to configure the `storage_module` + * table and the presigned URL plugin. + */ +export interface ProvisionResult { + /** The S3 bucket name */ + bucketName: string; + /** Bucket access type */ + accessType: BucketAccessType; + /** S3 endpoint URL (null for AWS S3 default) */ + endpoint: string | null; + /** Storage provider type */ + provider: StorageProvider; + /** S3 region */ + region: string; + /** + * Public URL prefix for download URLs. + * For public buckets: the CDN/public endpoint. + * For private buckets: null (presigned URLs only). + */ + publicUrlPrefix: string | null; + /** Whether Block Public Access is enabled */ + blockPublicAccess: boolean; + /** Whether versioning is enabled */ + versioning: boolean; + /** CORS rules applied */ + corsRules: CorsRule[]; + /** Lifecycle rules applied */ + lifecycleRules: LifecycleRule[]; +} + +// --- Error types --- + +export type ProvisionerErrorCode = + | 'CONNECTION_FAILED' + | 'BUCKET_ALREADY_EXISTS' + | 'BUCKET_NOT_FOUND' + | 'INVALID_CONFIG' + | 'POLICY_FAILED' + | 'CORS_FAILED' + | 'LIFECYCLE_FAILED' + | 'VERSIONING_FAILED' + | 'ACCESS_DENIED' + | 'PROVIDER_ERROR'; + +/** + * Structured error thrown by the bucket provisioner. + */ +export class ProvisionerError extends Error { + readonly code: ProvisionerErrorCode; + readonly cause?: unknown; + + constructor(code: ProvisionerErrorCode, message: string, cause?: unknown) { + super(message); + this.name = 'ProvisionerError'; + this.code = code; + this.cause = cause; + } +} diff --git a/packages/bucket-provisioner/tsconfig.esm.json b/packages/bucket-provisioner/tsconfig.esm.json new file mode 100644 index 000000000..03da56818 --- /dev/null +++ b/packages/bucket-provisioner/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ES2022", + "outDir": "./dist/esm" + } +} diff --git a/packages/bucket-provisioner/tsconfig.json b/packages/bucket-provisioner/tsconfig.json new file mode 100644 index 000000000..d8f755cb3 --- /dev/null +++ b/packages/bucket-provisioner/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "__tests__"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f95e43b3..0f9a01b72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,7 +178,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) + version: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) lru-cache: specifier: ^11.2.7 version: 11.2.7 @@ -187,7 +187,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.0 - version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) + version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) devDependencies: '@types/express': specifier: ^5.0.6 @@ -200,7 +200,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist graphile/graphile-connection-filter: @@ -402,7 +402,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist graphile/graphile-search: @@ -498,7 +498,7 @@ importers: version: 1.0.0(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) + version: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) graphile-build: specifier: 5.0.0 version: 5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0) @@ -549,7 +549,7 @@ importers: version: 5.0.0 postgraphile: specifier: 5.0.0 - version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) + version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -583,7 +583,7 @@ importers: version: link:../../postgres/pgsql-test/dist ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist graphile/graphile-sql-expression-validator: @@ -831,7 +831,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) + version: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -852,7 +852,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.0 - version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) + version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) devDependencies: '@types/express': specifier: ^5.0.6 @@ -865,7 +865,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -1122,7 +1122,7 @@ importers: version: 1.0.0(graphql@16.13.0) grafserv: specifier: 1.0.0 - version: 1.0.0(@types/node@25.5.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) + version: 1.0.0(@types/node@22.19.15)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))(ws@8.19.0) graphile-build: specifier: 5.0.0 version: 5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0) @@ -1170,7 +1170,7 @@ importers: version: 5.0.0 postgraphile: specifier: 5.0.0 - version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) + version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1207,7 +1207,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist graphql/server-test: @@ -1620,7 +1620,7 @@ importers: version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist jobs/knative-job-worker: @@ -1672,6 +1672,17 @@ importers: version: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) publishDirectory: dist + packages/bucket-provisioner: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1009.0 + version: 3.1009.0 + devDependencies: + makage: + specifier: ^0.3.0 + version: 0.3.0 + publishDirectory: dist + packages/cli: dependencies: '@constructive-io/graphql-codegen': @@ -1911,7 +1922,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist packages/smtppostmaster: @@ -1940,7 +1951,7 @@ importers: version: 3.18.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) publishDirectory: dist packages/upload-client: @@ -2842,10 +2853,6 @@ packages: resolution: {integrity: sha512-luy8CxallkoiGWTqU86ca/BbvkWJjs0oala7uIIRN1JtQxMb5i4Yl/PBZVcQFhbK9kQi0PK0GfD8gIpLkI91fw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.20': - resolution: {integrity: sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.24': resolution: {integrity: sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==} engines: {node: '>=20.0.0'} @@ -2920,10 +2927,6 @@ packages: resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.20': - resolution: {integrity: sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.25': resolution: {integrity: sha512-4xJL7O+XkhbSkT4yAYshkAww+mxJvtGQneNHH0MOpe+w8Vo2z87M9z06UO3G6zPM2c3Ef2yKczvZpTgdArMHfg==} engines: {node: '>=20.0.0'} @@ -2952,10 +2955,6 @@ packages: resolution: {integrity: sha512-7j8rOFHHq4e9McCSuWBmBSADriW5CjPUem4inckRh/cyQGaijBwDbkNbVTgDVDWqFo29SoVVUfI6HCOnck6HZw==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.8': - resolution: {integrity: sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1009.0': resolution: {integrity: sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==} engines: {node: '>=20.0.0'} @@ -2992,10 +2991,6 @@ packages: aws-crt: optional: true - '@aws-sdk/xml-builder@3.972.11': - resolution: {integrity: sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.15': resolution: {integrity: sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==} engines: {node: '>=20.0.0'} @@ -4879,10 +4874,6 @@ packages: resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.11': - resolution: {integrity: sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.12': resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} engines: {node: '>=18.0.0'} @@ -4959,10 +4950,6 @@ packages: resolution: {integrity: sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.14': - resolution: {integrity: sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.15': resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} engines: {node: '>=18.0.0'} @@ -5071,10 +5058,6 @@ packages: resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.19': - resolution: {integrity: sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==} - engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.20': resolution: {integrity: sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==} engines: {node: '>=18.0.0'} @@ -6699,16 +6682,9 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-xml-builder@1.1.3: - resolution: {integrity: sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==} - fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.4.1: - resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} - hasBin: true - fast-xml-parser@5.5.8: resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} hasBin: true @@ -8531,10 +8507,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.1.3: - resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} - engines: {node: '>=14.0.0'} - path-expression-matcher@1.2.0: resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} engines: {node: '>=14.0.0'} @@ -9994,7 +9966,7 @@ snapshots: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/credential-provider-node': 3.972.21 '@aws-sdk/middleware-bucket-endpoint': 3.972.8 '@aws-sdk/middleware-expect-continue': 3.972.8 @@ -10003,17 +9975,17 @@ snapshots: '@aws-sdk/middleware-location-constraint': 3.972.8 '@aws-sdk/middleware-logger': 3.972.8 '@aws-sdk/middleware-recursion-detection': 3.972.8 - '@aws-sdk/middleware-sdk-s3': 3.972.20 + '@aws-sdk/middleware-sdk-s3': 3.972.25 '@aws-sdk/middleware-ssec': 3.972.8 '@aws-sdk/middleware-user-agent': 3.972.21 '@aws-sdk/region-config-resolver': 3.972.8 - '@aws-sdk/signature-v4-multi-region': 3.996.8 + '@aws-sdk/signature-v4-multi-region': 3.996.13 '@aws-sdk/types': 3.973.6 '@aws-sdk/util-endpoints': 3.996.5 '@aws-sdk/util-user-agent-browser': 3.972.8 '@aws-sdk/util-user-agent-node': 3.973.7 '@smithy/config-resolver': 4.4.11 - '@smithy/core': 3.23.11 + '@smithy/core': 3.23.12 '@smithy/eventstream-serde-browser': 4.2.12 '@smithy/eventstream-serde-config-resolver': 4.3.12 '@smithy/eventstream-serde-node': 4.2.12 @@ -10024,14 +9996,14 @@ snapshots: '@smithy/invalid-dependency': 4.2.12 '@smithy/md5-js': 4.2.12 '@smithy/middleware-content-length': 4.2.12 - '@smithy/middleware-endpoint': 4.4.25 + '@smithy/middleware-endpoint': 4.4.27 '@smithy/middleware-retry': 4.4.42 - '@smithy/middleware-serde': 4.2.14 + '@smithy/middleware-serde': 4.2.15 '@smithy/middleware-stack': 4.2.12 '@smithy/node-config-provider': 4.3.12 '@smithy/node-http-handler': 4.4.16 '@smithy/protocol-http': 5.3.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 '@smithy/url-parser': 4.2.12 '@smithy/util-base64': 4.3.2 @@ -10042,29 +10014,13 @@ snapshots: '@smithy/util-endpoints': 3.3.3 '@smithy/util-middleware': 4.2.12 '@smithy/util-retry': 4.2.12 - '@smithy/util-stream': 4.5.19 + '@smithy/util-stream': 4.5.20 '@smithy/util-utf8': 4.2.2 '@smithy/util-waiter': 4.2.13 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.20': - dependencies: - '@aws-sdk/types': 3.973.6 - '@aws-sdk/xml-builder': 3.972.11 - '@smithy/core': 3.23.11 - '@smithy/node-config-provider': 4.3.12 - '@smithy/property-provider': 4.2.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/signature-v4': 5.3.12 - '@smithy/smithy-client': 4.12.5 - '@smithy/types': 4.13.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-middleware': 4.2.12 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@aws-sdk/core@3.973.24': dependencies: '@aws-sdk/types': 3.973.6 @@ -10088,7 +10044,7 @@ snapshots: '@aws-sdk/credential-provider-env@3.972.18': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/types': 3.973.6 '@smithy/property-provider': 4.2.12 '@smithy/types': 4.13.1 @@ -10096,20 +10052,20 @@ snapshots: '@aws-sdk/credential-provider-http@3.972.20': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/types': 3.973.6 '@smithy/fetch-http-handler': 5.3.15 '@smithy/node-http-handler': 4.4.16 '@smithy/property-provider': 4.2.12 '@smithy/protocol-http': 5.3.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 - '@smithy/util-stream': 4.5.19 + '@smithy/util-stream': 4.5.20 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.972.20': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/credential-provider-env': 3.972.18 '@aws-sdk/credential-provider-http': 3.972.20 '@aws-sdk/credential-provider-login': 3.972.20 @@ -10128,7 +10084,7 @@ snapshots: '@aws-sdk/credential-provider-login@3.972.20': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/nested-clients': 3.996.10 '@aws-sdk/types': 3.973.6 '@smithy/property-provider': 4.2.12 @@ -10158,7 +10114,7 @@ snapshots: '@aws-sdk/credential-provider-process@3.972.18': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/types': 3.973.6 '@smithy/property-provider': 4.2.12 '@smithy/shared-ini-file-loader': 4.4.7 @@ -10167,7 +10123,7 @@ snapshots: '@aws-sdk/credential-provider-sso@3.972.20': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/nested-clients': 3.996.10 '@aws-sdk/token-providers': 3.1009.0 '@aws-sdk/types': 3.973.6 @@ -10180,7 +10136,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.972.20': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/nested-clients': 3.996.10 '@aws-sdk/types': 3.973.6 '@smithy/property-provider': 4.2.12 @@ -10223,7 +10179,7 @@ snapshots: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/crc64-nvme': 3.972.5 '@aws-sdk/types': 3.973.6 '@smithy/is-array-buffer': 4.2.2 @@ -10231,7 +10187,7 @@ snapshots: '@smithy/protocol-http': 5.3.12 '@smithy/types': 4.13.1 '@smithy/util-middleware': 4.2.12 - '@smithy/util-stream': 4.5.19 + '@smithy/util-stream': 4.5.20 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 @@ -10262,23 +10218,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.20': - dependencies: - '@aws-sdk/core': 3.973.20 - '@aws-sdk/types': 3.973.6 - '@aws-sdk/util-arn-parser': 3.972.3 - '@smithy/core': 3.23.11 - '@smithy/node-config-provider': 4.3.12 - '@smithy/protocol-http': 5.3.12 - '@smithy/signature-v4': 5.3.12 - '@smithy/smithy-client': 4.12.5 - '@smithy/types': 4.13.1 - '@smithy/util-config-provider': 4.2.2 - '@smithy/util-middleware': 4.2.12 - '@smithy/util-stream': 4.5.19 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.25': dependencies: '@aws-sdk/core': 3.973.24 @@ -10304,10 +10243,10 @@ snapshots: '@aws-sdk/middleware-user-agent@3.972.21': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/types': 3.973.6 '@aws-sdk/util-endpoints': 3.996.5 - '@smithy/core': 3.23.11 + '@smithy/core': 3.23.12 '@smithy/protocol-http': 5.3.12 '@smithy/types': 4.13.1 '@smithy/util-retry': 4.2.12 @@ -10317,7 +10256,7 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/middleware-host-header': 3.972.8 '@aws-sdk/middleware-logger': 3.972.8 '@aws-sdk/middleware-recursion-detection': 3.972.8 @@ -10328,19 +10267,19 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.8 '@aws-sdk/util-user-agent-node': 3.973.7 '@smithy/config-resolver': 4.4.11 - '@smithy/core': 3.23.11 + '@smithy/core': 3.23.12 '@smithy/fetch-http-handler': 5.3.15 '@smithy/hash-node': 4.2.12 '@smithy/invalid-dependency': 4.2.12 '@smithy/middleware-content-length': 4.2.12 - '@smithy/middleware-endpoint': 4.4.25 + '@smithy/middleware-endpoint': 4.4.27 '@smithy/middleware-retry': 4.4.42 - '@smithy/middleware-serde': 4.2.14 + '@smithy/middleware-serde': 4.2.15 '@smithy/middleware-stack': 4.2.12 '@smithy/node-config-provider': 4.3.12 '@smithy/node-http-handler': 4.4.16 '@smithy/protocol-http': 5.3.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 '@smithy/url-parser': 4.2.12 '@smithy/util-base64': 4.3.2 @@ -10384,18 +10323,9 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.8': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.20 - '@aws-sdk/types': 3.973.6 - '@smithy/protocol-http': 5.3.12 - '@smithy/signature-v4': 5.3.12 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@aws-sdk/token-providers@3.1009.0': dependencies: - '@aws-sdk/core': 3.973.20 + '@aws-sdk/core': 3.973.24 '@aws-sdk/nested-clients': 3.996.10 '@aws-sdk/types': 3.973.6 '@smithy/property-provider': 4.2.12 @@ -10449,12 +10379,6 @@ snapshots: '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.11': - dependencies: - '@smithy/types': 4.13.1 - fast-xml-parser: 5.4.1 - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.15': dependencies: '@smithy/types': 4.13.1 @@ -12615,19 +12539,6 @@ snapshots: '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/core@3.23.11': - dependencies: - '@smithy/protocol-http': 5.3.12 - '@smithy/types': 4.13.1 - '@smithy/url-parser': 4.2.12 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-middleware': 4.2.12 - '@smithy/util-stream': 4.5.19 - '@smithy/util-utf8': 4.2.2 - '@smithy/uuid': 1.1.2 - tslib: 2.8.1 - '@smithy/core@3.23.12': dependencies: '@smithy/protocol-http': 5.3.12 @@ -12734,8 +12645,8 @@ snapshots: '@smithy/middleware-endpoint@4.4.25': dependencies: - '@smithy/core': 3.23.11 - '@smithy/middleware-serde': 4.2.14 + '@smithy/core': 3.23.12 + '@smithy/middleware-serde': 4.2.15 '@smithy/node-config-provider': 4.3.12 '@smithy/shared-ini-file-loader': 4.4.7 '@smithy/types': 4.13.1 @@ -12759,20 +12670,13 @@ snapshots: '@smithy/node-config-provider': 4.3.12 '@smithy/protocol-http': 5.3.12 '@smithy/service-error-classification': 4.2.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 '@smithy/util-middleware': 4.2.12 '@smithy/util-retry': 4.2.12 '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.14': - dependencies: - '@smithy/core': 3.23.11 - '@smithy/protocol-http': 5.3.12 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@smithy/middleware-serde@4.2.15': dependencies: '@smithy/core': 3.23.12 @@ -12843,12 +12747,12 @@ snapshots: '@smithy/smithy-client@4.12.5': dependencies: - '@smithy/core': 3.23.11 - '@smithy/middleware-endpoint': 4.4.25 + '@smithy/core': 3.23.12 + '@smithy/middleware-endpoint': 4.4.27 '@smithy/middleware-stack': 4.2.12 '@smithy/protocol-http': 5.3.12 '@smithy/types': 4.13.1 - '@smithy/util-stream': 4.5.19 + '@smithy/util-stream': 4.5.20 tslib: 2.8.1 '@smithy/smithy-client@4.12.7': @@ -12902,7 +12806,7 @@ snapshots: '@smithy/util-defaults-mode-browser@4.3.41': dependencies: '@smithy/property-provider': 4.2.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 tslib: 2.8.1 @@ -12912,7 +12816,7 @@ snapshots: '@smithy/credential-provider-imds': 4.2.12 '@smithy/node-config-provider': 4.3.12 '@smithy/property-provider': 4.2.12 - '@smithy/smithy-client': 4.12.5 + '@smithy/smithy-client': 4.12.7 '@smithy/types': 4.13.1 tslib: 2.8.1 @@ -12937,17 +12841,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.19': - dependencies: - '@smithy/fetch-http-handler': 5.3.15 - '@smithy/node-http-handler': 4.4.16 - '@smithy/types': 4.13.1 - '@smithy/util-base64': 4.3.2 - '@smithy/util-buffer-from': 4.2.2 - '@smithy/util-hex-encoding': 4.2.2 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - '@smithy/util-stream@4.5.20': dependencies: '@smithy/fetch-http-handler': 5.3.15 @@ -13251,6 +13144,7 @@ snapshots: '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + optional: true '@types/nodemailer@7.0.11': dependencies: @@ -13824,7 +13718,7 @@ snapshots: fs-minipass: 3.0.3 glob: 10.5.0 lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 minipass-collect: 2.0.1 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 @@ -14694,19 +14588,10 @@ snapshots: fast-uri@3.1.0: {} - fast-xml-builder@1.1.3: - dependencies: - path-expression-matcher: 1.1.3 - fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.2.0 - fast-xml-parser@5.4.1: - dependencies: - fast-xml-builder: 1.1.3 - strnum: 2.2.0 - fast-xml-parser@5.5.8: dependencies: fast-xml-builder: 1.1.4 @@ -14836,7 +14721,7 @@ snapshots: fs-minipass@3.0.3: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 fs.realpath@1.0.0: {} @@ -14942,7 +14827,7 @@ snapshots: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.9 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -16322,7 +16207,7 @@ snapshots: cacache: 18.0.4 http-cache-semantics: 4.2.0 is-lambda: 1.0.1 - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch: 3.0.5 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 @@ -16474,7 +16359,7 @@ snapshots: minipass-fetch@3.0.5: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-sized: 1.0.3 minizlib: 2.1.2 optionalDependencies: @@ -17282,8 +17167,6 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.1.3: {} - path-expression-matcher@1.2.0: {} path-is-absolute@1.0.1: {} @@ -18429,14 +18312,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@25.5.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.5.0 + '@types/node': 22.19.15 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -18517,7 +18400,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true undici@7.24.6: {}