From b68ad19a87e745b8fd098a4fdffcf78e041a7943 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 08:22:21 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20add=20@constructive-io/bucket-provi?= =?UTF-8?q?sioner=20package=20=E2=80=94=20S3-compatible=20bucket=20provisi?= =?UTF-8?q?oning=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/bucket-provisioner/README.md | 277 +++++++++ .../__tests__/client.test.ts | 98 +++ .../bucket-provisioner/__tests__/cors.test.ts | 86 +++ .../__tests__/lifecycle.test.ts | 50 ++ .../__tests__/policies.test.ts | 120 ++++ .../__tests__/provisioner.test.ts | 573 ++++++++++++++++++ .../__tests__/types.test.ts | 128 ++++ packages/bucket-provisioner/jest.config.js | 9 + packages/bucket-provisioner/package.json | 46 ++ packages/bucket-provisioner/src/client.ts | 58 ++ packages/bucket-provisioner/src/cors.ts | 96 +++ packages/bucket-provisioner/src/index.ts | 67 ++ packages/bucket-provisioner/src/lifecycle.ts | 51 ++ packages/bucket-provisioner/src/policies.ts | 168 +++++ .../bucket-provisioner/src/provisioner.ts | 510 ++++++++++++++++ packages/bucket-provisioner/src/types.ts | 174 ++++++ packages/bucket-provisioner/tsconfig.esm.json | 7 + packages/bucket-provisioner/tsconfig.json | 9 + pnpm-lock.yaml | 260 +++----- 19 files changed, 2599 insertions(+), 188 deletions(-) create mode 100644 packages/bucket-provisioner/README.md create mode 100644 packages/bucket-provisioner/__tests__/client.test.ts create mode 100644 packages/bucket-provisioner/__tests__/cors.test.ts create mode 100644 packages/bucket-provisioner/__tests__/lifecycle.test.ts create mode 100644 packages/bucket-provisioner/__tests__/policies.test.ts create mode 100644 packages/bucket-provisioner/__tests__/provisioner.test.ts create mode 100644 packages/bucket-provisioner/__tests__/types.test.ts create mode 100644 packages/bucket-provisioner/jest.config.js create mode 100644 packages/bucket-provisioner/package.json create mode 100644 packages/bucket-provisioner/src/client.ts create mode 100644 packages/bucket-provisioner/src/cors.ts create mode 100644 packages/bucket-provisioner/src/index.ts create mode 100644 packages/bucket-provisioner/src/lifecycle.ts create mode 100644 packages/bucket-provisioner/src/policies.ts create mode 100644 packages/bucket-provisioner/src/provisioner.ts create mode 100644 packages/bucket-provisioner/src/types.ts create mode 100644 packages/bucket-provisioner/tsconfig.esm.json create mode 100644 packages/bucket-provisioner/tsconfig.json diff --git a/packages/bucket-provisioner/README.md b/packages/bucket-provisioner/README.md new file mode 100644 index 0000000000..77c318916f --- /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 0000000000..facea29f37 --- /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 0000000000..ab6049845e --- /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 0000000000..7af21e98b7 --- /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 0000000000..82247fd6b8 --- /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 0000000000..8b5b04f8c8 --- /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 0000000000..adf8da2d93 --- /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 0000000000..e31cab5aea --- /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 0000000000..c243a7d95d --- /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 0000000000..bfc0903752 --- /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 0000000000..897a024f44 --- /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 0000000000..29fe922dd6 --- /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 0000000000..b1dde055ab --- /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 0000000000..f376a39201 --- /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 0000000000..5ada96b076 --- /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 0000000000..d505235047 --- /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 0000000000..03da56818b --- /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 0000000000..d8f755cb3d --- /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 7f95e43b3f..0f9a01b725 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: {} From 911ee4053fc84dacb7eaec5252ea29251de2dd1a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 08:35:50 +0000 Subject: [PATCH 2/6] ci: add bucket-provisioner and upload-client to CI test matrix --- .github/workflows/run-tests.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 3c86325ee2..62312579b1 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 From eb856feabf666175f99b6e26a775ca77f07f1eed Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 11:50:14 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20add=20graphile-bucket-provisioner-p?= =?UTF-8?q?lugin=20=E2=80=94=20auto-provisions=20S3=20buckets=20on=20bucke?= =?UTF-8?q?t=20table=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostGraphile v5 plugin with two provisioning pathways: 1. Auto-provisioning hook on create mutations tagged with @storageBuckets 2. Explicit provisionBucket GraphQL mutation for manual/retry provisioning - Reads per-database endpoint/provider/publicUrlPrefix overrides from storage_module table - Lazy S3 config resolution (function-based, cached after first call) - Graceful error handling (provisioning failures logged, never fail the mutation) - Custom bucket naming via prefix or resolveBucketName function - 46 passing tests covering plugin structure, mutation callback, auto-provisioning hook, connection config resolution, error handling, and bucket name resolution - Added to CI test matrix --- .github/workflows/run-tests.yaml | 2 + .../README.md | 167 ++++ .../__tests__/plugin.test.ts | 939 ++++++++++++++++++ .../__tests__/preset.test.ts | 94 ++ .../__tests__/types.test.ts | 192 ++++ .../jest.config.js | 18 + .../package.json | 59 ++ .../src/index.ts | 46 + .../src/plugin.ts | 434 ++++++++ .../src/preset.ts | 44 + .../src/types.ts | 110 ++ .../tsconfig.esm.json | 7 + .../tsconfig.json | 8 + pnpm-lock.yaml | 78 +- 14 files changed, 2177 insertions(+), 21 deletions(-) create mode 100644 graphile/graphile-bucket-provisioner-plugin/README.md create mode 100644 graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/jest.config.js create mode 100644 graphile/graphile-bucket-provisioner-plugin/package.json create mode 100644 graphile/graphile-bucket-provisioner-plugin/src/index.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/src/plugin.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/src/preset.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/src/types.ts create mode 100644 graphile/graphile-bucket-provisioner-plugin/tsconfig.esm.json create mode 100644 graphile/graphile-bucket-provisioner-plugin/tsconfig.json diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 62312579b1..099cbaa1ea 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -115,6 +115,8 @@ jobs: env: {} - package: packages/upload-client env: {} + - package: graphile/graphile-bucket-provisioner-plugin + env: {} env: PGHOST: localhost diff --git a/graphile/graphile-bucket-provisioner-plugin/README.md b/graphile/graphile-bucket-provisioner-plugin/README.md new file mode 100644 index 0000000000..00d3f658e9 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/README.md @@ -0,0 +1,167 @@ +# graphile-bucket-provisioner-plugin + +

+ +

+ +

+ + + + + +

+ +PostGraphile v5 plugin that automatically provisions S3-compatible buckets when bucket rows are created in the database. Wraps bucket creation mutations to call [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner) after the database row is inserted. + +## Features + +- **Auto-provisioning hook** — Wraps `create*` mutations on tables tagged with `@storageBuckets` to automatically provision S3 buckets after row creation +- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket +- **Per-database overrides** — Reads `endpoint`, `provider`, and `public_url_prefix` from the `storage_module` table for multi-tenant setups +- **Lazy S3 config** — Connection config can be a function (evaluated once, cached) to avoid eager env-var reads at import time +- **Graceful error handling** — Provisioning failures are logged but never fail the mutation (admin can retry via `provisionBucket`) +- **Custom bucket naming** — Supports prefix-based naming or a fully custom `resolveBucketName` function + +## Installation + +```bash +pnpm add graphile-bucket-provisioner-plugin +``` + +## Quick Start + +```typescript +import { createBucketProvisionerPlugin } from 'graphile-bucket-provisioner-plugin'; + +const BucketProvisionerPlugin = createBucketProvisionerPlugin({ + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }, + allowedOrigins: ['https://app.example.com'], +}); + +// Add to your PostGraphile preset +const preset: GraphileConfig.Preset = { + plugins: [BucketProvisionerPlugin], +}; +``` + +Or use the convenience preset: + +```typescript +import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; + +const preset: GraphileConfig.Preset = { + extends: [ + BucketProvisionerPreset({ + connection: () => ({ + provider: 'minio', + region: 'us-east-1', + endpoint: process.env.S3_ENDPOINT!, + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }), + allowedOrigins: ['https://app.example.com'], + }), + ], +}; +``` + +## How It Works + +### Auto-Provisioning (default) + +When a `createBucket` mutation runs on a table tagged with `@storageBuckets`: + +1. The original resolver runs first (creates the DB row via RLS) +2. The plugin reads the bucket's `key` and `type` from the mutation input +3. It reads the `storage_module` config for per-database endpoint/provider overrides +4. It calls `BucketProvisioner.provision()` to create and configure the S3 bucket +5. If provisioning fails, the error is logged but the mutation result is returned normally + +### Explicit Mutation + +The plugin also adds a `provisionBucket` mutation for manual provisioning or retrying failed provisions: + +```graphql +mutation { + provisionBucket(input: { bucketKey: "public" }) { + success + bucketName + accessType + provider + endpoint + error + } +} +``` + +This mutation: +1. Reads the bucket row from the database (protected by RLS) +2. Reads the storage module config for the current database +3. Provisions the S3 bucket with the appropriate settings +4. Returns a success/error payload + +## API + +### `createBucketProvisionerPlugin(options)` + +Creates the plugin instance. Returns a `GraphileConfig.Plugin`. + +| Option | Type | Description | +|--------|------|-------------| +| `connection` | `StorageConnectionConfig \| () => StorageConnectionConfig` | S3 connection config (static or lazy getter) | +| `allowedOrigins` | `string[]` | CORS allowed origins for bucket configuration | +| `bucketNamePrefix` | `string?` | Prefix for S3 bucket names (e.g., `"myapp"` → `"myapp-public"`) | +| `resolveBucketName` | `(bucketKey, databaseId) => string` | Custom bucket name resolver (takes precedence over prefix) | +| `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) | +| `autoProvision` | `boolean?` | Enable auto-provisioning hook on create mutations (default: `true`) | + +### `BucketProvisionerPreset(options)` + +Convenience function that wraps the plugin in a `GraphileConfig.Preset`. + +### Connection Config + +```typescript +interface StorageConnectionConfig { + provider: 's3' | 'minio' | 'r2' | 'gcs' | 'spaces'; + region: string; + endpoint?: string; + accessKeyId: string; + secretAccessKey: string; +} +``` + +### Smart Tag Detection + +The plugin detects tables tagged with `@storageBuckets` (set by the storage module generator in constructive-db): + +```sql +COMMENT ON TABLE app_public.buckets IS E'@storageBuckets\nStorage buckets table'; +``` + +Only `create*` mutations on tagged tables trigger auto-provisioning. Update and delete mutations are not wrapped. + +## Error Handling + +The plugin is designed to never break mutations: + +- **Auto-provisioning errors** are caught and logged. The mutation result is returned normally. The admin can retry via the `provisionBucket` mutation. +- **Explicit `provisionBucket` errors** return a structured payload with `success: false` and an `error` message. +- **Validation errors** (`INVALID_BUCKET_KEY`, `DATABASE_NOT_FOUND`, `STORAGE_MODULE_NOT_PROVISIONED`, `BUCKET_NOT_FOUND`) are thrown as exceptions since they indicate configuration issues. + +## Multi-Tenant Support + +The plugin reads per-database overrides from the `storage_module` table: + +- `endpoint` — Override the S3 endpoint for this database +- `provider` — Override the storage provider for this database +- `public_url_prefix` — CDN/public URL prefix for public buckets + +This allows different tenants to use different storage backends while sharing the same plugin configuration. diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts new file mode 100644 index 0000000000..d0eb8ad3e2 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -0,0 +1,939 @@ +/** + * Tests for the bucket provisioner plugin. + * + * Covers: + * - provisionBucket mutation (explicit provisioning) + * - Auto-provisioning hook on bucket create mutations + * - Error handling and graceful degradation + * - Connection config resolution (lazy getter, static) + * - Bucket name resolution (prefix, custom resolver) + * - Storage module config reading + */ + +// Mock @constructive-io/bucket-provisioner before any imports +const mockProvision = jest.fn(); +const mockBucketProvisionerConstructor = jest.fn(); + +jest.mock('@constructive-io/bucket-provisioner', () => ({ + BucketProvisioner: jest.fn().mockImplementation((opts: any) => { + mockBucketProvisionerConstructor(opts); + return { provision: mockProvision }; + }), +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + })), +})); + +// Mock grafast +let capturedLambdaCallback: Function | null = null; +jest.mock('grafast', () => ({ + context: jest.fn(() => ({ + get: jest.fn((key: string) => `mock-${key}`), + })), + lambda: jest.fn((_combined: any, callback: any) => { + capturedLambdaCallback = callback; + return 'lambda-step'; + }), + object: jest.fn((obj: any) => obj), +})); + +// Mock graphile-utils +// The extendSchema mock must invoke the plan function so that `lambda` gets +// called and capturedLambdaCallback is set. +const mockGetRaw = jest.fn(() => 'mock-input'); +jest.mock('graphile-utils', () => ({ + extendSchema: jest.fn((factory: any) => { + const schema = factory(); + // Invoke the provisionBucket plan to trigger the lambda mock, + // which captures the callback into capturedLambdaCallback. + if (schema.plans?.Mutation?.provisionBucket) { + schema.plans.Mutation.provisionBucket( + null, + { getRaw: mockGetRaw }, + ); + } + return { + name: 'ExtendSchemaPlugin', + schema: { + hooks: {}, + }, + _typeDefs: schema.typeDefs, + _plans: schema.plans, + }; + }), + gql: jest.fn((strings: TemplateStringsArray) => strings.join('')), +})); + +import { createBucketProvisionerPlugin } from '../src/plugin'; +import type { BucketProvisionerPluginOptions } from '../src/types'; + +// --- Test helpers --- + +function createDefaultOptions( + overrides: Partial = {}, +): BucketProvisionerPluginOptions { + return { + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + }, + allowedOrigins: ['https://app.example.com'], + ...overrides, + }; +} + +function createMockPgClient(overrides: Record = {}) { + const defaultQueries: Record = { + 'jwt_private.current_database_id': { + rows: [{ id: 'db-uuid-123' }], + }, + 'metaschema_modules_public.storage_module': { + rows: [{ + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + }], + }, + 'app_public': { + rows: [{ + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + }], + }, + }; + + return { + query: jest.fn((sql: string, _params?: any[]) => { + for (const [key, value] of Object.entries({ ...defaultQueries, ...overrides })) { + if (sql.includes(key)) { + return Promise.resolve(value); + } + } + return Promise.resolve({ rows: [] }); + }), + }; +} + +// --- Tests --- + +describe('createBucketProvisionerPlugin', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProvision.mockReset(); + mockBucketProvisionerConstructor.mockReset(); + capturedLambdaCallback = null; + + mockProvision.mockResolvedValue({ + bucketName: 'public', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + }); + + describe('plugin structure', () => { + it('returns a plugin object with name and schema hooks', () => { + const plugin = createBucketProvisionerPlugin(createDefaultOptions()); + + expect(plugin).toBeDefined(); + expect(plugin.name).toBe('BucketProvisionerPlugin'); + expect(plugin.version).toBe('0.1.0'); + expect(plugin.schema).toBeDefined(); + expect(plugin.schema!.hooks).toBeDefined(); + }); + + it('includes GraphQLObjectType_fields_field hook when autoProvision is true', () => { + const plugin = createBucketProvisionerPlugin(createDefaultOptions()); + + expect(plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBeDefined(); + expect(typeof plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBe('function'); + }); + + it('does not include fields_field hook when autoProvision is false', () => { + const plugin = createBucketProvisionerPlugin( + createDefaultOptions({ autoProvision: false }), + ); + + // When autoProvision is false, the plugin is just the extendSchema result + // which doesn't have the GraphQLObjectType_fields_field hook + const hooks = plugin.schema?.hooks ?? {}; + expect(hooks.GraphQLObjectType_fields_field).toBeUndefined(); + }); + + it('sets after dependencies for correct hook ordering', () => { + const plugin = createBucketProvisionerPlugin(createDefaultOptions()); + + expect(plugin.after).toContain('PgAttributesPlugin'); + expect(plugin.after).toContain('PgMutationCreatePlugin'); + }); + }); + + describe('provisionBucket mutation (via lambda callback)', () => { + it('provisions a public bucket successfully', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((settings: any, callback: any) => + callback(pgClient), + ); + + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }); + + expect(result.success).toBe(true); + expect(result.bucketName).toBe('public'); + expect(result.accessType).toBe('public'); + expect(result.provider).toBe('minio'); + expect(result.error).toBeNull(); + }); + + it('provisions a private bucket', async () => { + const privateBucketOverrides = { + 'app_public': { + rows: [{ + id: 'bucket-uuid-private', + key: 'private', + type: 'private', + is_public: false, + }], + }, + }; + + mockProvision.mockResolvedValue({ + bucketName: 'private', + accessType: 'private', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: true, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient(privateBucketOverrides); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'private' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }); + + expect(result.success).toBe(true); + expect(result.bucketName).toBe('private'); + expect(result.accessType).toBe('private'); + }); + + it('uses bucketNamePrefix when set', async () => { + createBucketProvisionerPlugin( + createDefaultOptions({ bucketNamePrefix: 'myapp' }), + ); + + mockProvision.mockResolvedValue({ + bucketName: 'myapp-public', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }); + + // The provision call should have the prefixed name + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ bucketName: 'myapp-public' }), + ); + expect(result.success).toBe(true); + }); + + it('uses custom resolveBucketName when provided', async () => { + const customResolver = jest.fn( + (bucketKey: string, databaseId: string) => `org-${databaseId}-${bucketKey}`, + ); + + createBucketProvisionerPlugin( + createDefaultOptions({ resolveBucketName: customResolver }), + ); + + mockProvision.mockResolvedValue({ + bucketName: 'org-db-uuid-123-public', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }); + + expect(customResolver).toHaveBeenCalledWith('public', 'db-uuid-123'); + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ bucketName: 'org-db-uuid-123-public' }), + ); + }); + + it('throws INVALID_BUCKET_KEY for empty key', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + await expect( + capturedLambdaCallback!({ + input: { bucketKey: '' }, + withPgClient: jest.fn(), + pgSettings: {}, + }), + ).rejects.toThrow('INVALID_BUCKET_KEY'); + }); + + it('throws DATABASE_NOT_FOUND when database_id is null', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'jwt_private.current_database_id': { rows: [{ id: null }] }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await expect( + capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }), + ).rejects.toThrow('DATABASE_NOT_FOUND'); + }); + + it('throws STORAGE_MODULE_NOT_PROVISIONED when no storage module exists', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'metaschema_modules_public.storage_module': { rows: [] }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await expect( + capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }), + ).rejects.toThrow('STORAGE_MODULE_NOT_PROVISIONED'); + }); + + it('throws BUCKET_NOT_FOUND when bucket does not exist', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'app_public': { rows: [] }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await expect( + capturedLambdaCallback!({ + input: { bucketKey: 'nonexistent' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }), + ).rejects.toThrow('BUCKET_NOT_FOUND'); + }); + + it('returns error payload when provisioning fails', async () => { + mockProvision.mockRejectedValue(new Error('S3 connection refused')); + + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('S3 connection refused'); + expect(result.bucketName).toBe('public'); + }); + + it('applies per-database endpoint override from storage module', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'metaschema_modules_public.storage_module': { + rows: [{ + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: 'http://custom-minio:9000', + public_url_prefix: 'https://cdn.example.com', + provider: 'minio', + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + // Check that the provisioner was created with the overridden endpoint + expect(mockBucketProvisionerConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + connection: expect.objectContaining({ + endpoint: 'http://custom-minio:9000', + provider: 'minio', + }), + }), + ); + }); + + it('passes versioning option to provision call', async () => { + createBucketProvisionerPlugin( + createDefaultOptions({ versioning: true }), + ); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ versioning: true }), + ); + }); + + it('passes publicUrlPrefix from storage module to provision call', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'metaschema_modules_public.storage_module': { + rows: [{ + id: 'sm-uuid-456', + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: 'https://cdn.example.com', + provider: null, + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ + publicUrlPrefix: 'https://cdn.example.com', + }), + ); + }); + }); + + describe('connection config resolution', () => { + it('resolves static connection config', () => { + const options = createDefaultOptions(); + createBucketProvisionerPlugin(options); + + // The connection should remain as-is (static object) + expect(typeof options.connection).toBe('object'); + }); + + it('resolves lazy getter connection config on first use', async () => { + const connectionConfig = { + provider: 'minio' as const, + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + }; + const getter = jest.fn(() => connectionConfig); + + const options = createDefaultOptions({ connection: getter }); + createBucketProvisionerPlugin(options); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(getter).toHaveBeenCalledTimes(1); + + // Second call should use cached value + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + // Still only 1 call because it was cached + expect(getter).toHaveBeenCalledTimes(1); + }); + }); + + describe('auto-provisioning hook (GraphQLObjectType_fields_field)', () => { + function getFieldsFieldHook(options?: Partial) { + const plugin = createBucketProvisionerPlugin(createDefaultOptions(options)); + return plugin.schema!.hooks!.GraphQLObjectType_fields_field as Function; + } + + it('skips non-mutation fields', () => { + const hook = getFieldsFieldHook(); + const field = { resolve: jest.fn() }; + const build = {}; + const context = { + scope: { + isRootMutation: false, + fieldName: 'buckets', + pgCodec: { name: 'Bucket', attributes: {} }, + }, + }; + + const result = hook(field, build, context); + expect(result).toBe(field); + }); + + it('skips when pgCodec is missing', () => { + const hook = getFieldsFieldHook(); + const field = { resolve: jest.fn() }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: null as any, + }, + }; + + const result = hook(field, build, context); + expect(result).toBe(field); + }); + + it('skips when pgCodec has no @storageBuckets tag', () => { + const hook = getFieldsFieldHook(); + const field = { resolve: jest.fn() }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {} }, + extensions: { tags: {} }, + }, + }, + }; + + const result = hook(field, build, context); + expect(result).toBe(field); + }); + + it('skips non-create mutations (e.g., updateBucket, deleteBucket)', () => { + const hook = getFieldsFieldHook(); + const field = { resolve: jest.fn() }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'updateBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const result = hook(field, build, context); + expect(result).toBe(field); + }); + + it('wraps create mutations on @storageBuckets-tagged tables', () => { + const hook = getFieldsFieldHook(); + const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'new-bucket' } }); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const result = hook(field, build, context); + + expect(result).not.toBe(field); + expect(result.resolve).toBeDefined(); + expect(typeof result.resolve).toBe('function'); + }); + + it('calls original resolver first then provisions', async () => { + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'new-bucket' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const graphqlContext = { + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }; + + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', type: 'public' } } }, + graphqlContext, + {}, + ); + + // Original resolver should be called + expect(originalResolve).toHaveBeenCalled(); + // The mutation result should be returned + expect(result).toBe(mutationResult); + // Provisioning should have been called + expect(mockProvision).toHaveBeenCalled(); + }); + + it('returns mutation result even if provisioning fails', async () => { + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'new-bucket' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + mockProvision.mockRejectedValue(new Error('S3 connection refused')); + + const wrapped = hook(field, build, context); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const graphqlContext = { + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, + }; + + // Should NOT throw — provisioning errors are logged, not thrown + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', type: 'public' } } }, + graphqlContext, + {}, + ); + + expect(result).toBe(mutationResult); + }); + + it('skips provisioning when key/type not in mutation input', async () => { + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'new-bucket' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const result = await wrapped.resolve( + null, + { input: { bucket: { name: 'test' } } }, // Missing key and type + { withPgClient: jest.fn(), pgSettings: {} }, + {}, + ); + + // Should still return the mutation result + expect(result).toBe(mutationResult); + // Should NOT call provision + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('skips provisioning when withPgClient not in context', async () => { + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'new-bucket' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', type: 'public' } } }, + { pgSettings: {} }, // No withPgClient + {}, + ); + + expect(result).toBe(mutationResult); + expect(mockProvision).not.toHaveBeenCalled(); + }); + + it('uses default resolver when field has no resolve', () => { + const hook = getFieldsFieldHook(); + const field = {}; // No resolve function + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'createBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + expect(wrapped.resolve).toBeDefined(); + }); + }); +}); + +describe('bucket name resolution', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProvision.mockReset(); + mockBucketProvisionerConstructor.mockReset(); + capturedLambdaCallback = null; + }); + + it('uses plain bucket key when no prefix or resolver', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'private', + accessType: 'private', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: true, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin({ + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + allowedOrigins: ['https://app.example.com'], + }); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid', + key: 'private', + type: 'private', + is_public: false, + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'private' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ bucketName: 'private' }), + ); + }); + + it('resolveBucketName takes precedence over bucketNamePrefix', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'custom-public', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + const customResolver = jest.fn( + (bucketKey: string) => `custom-${bucketKey}`, + ); + + createBucketProvisionerPlugin({ + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + allowedOrigins: ['https://app.example.com'], + bucketNamePrefix: 'should-be-ignored', + resolveBucketName: customResolver, + }); + + const pgClient = createMockPgClient(); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(customResolver).toHaveBeenCalled(); + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ bucketName: 'custom-public' }), + ); + }); +}); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts new file mode 100644 index 0000000000..0a833529b6 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for the bucket provisioner preset. + */ + +jest.mock('@constructive-io/bucket-provisioner', () => ({ + BucketProvisioner: jest.fn().mockImplementation(() => ({ + provision: jest.fn(), + })), +})); + +jest.mock('@pgpmjs/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + })), +})); + +jest.mock('grafast', () => ({ + context: jest.fn(() => ({ + get: jest.fn((key: string) => `mock-${key}`), + })), + lambda: jest.fn(), + object: jest.fn((obj: any) => obj), +})); + +jest.mock('graphile-utils', () => ({ + extendSchema: jest.fn((factory: any) => { + const schema = factory(); + return { + name: 'ExtendSchemaPlugin', + schema: { hooks: {} }, + _typeDefs: schema.typeDefs, + _plans: schema.plans, + }; + }), + gql: jest.fn((strings: TemplateStringsArray) => strings.join('')), +})); + +import { BucketProvisionerPreset } from '../src/preset'; + +describe('BucketProvisionerPreset', () => { + it('returns a preset with plugins array', () => { + const preset = BucketProvisionerPreset({ + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + allowedOrigins: ['https://app.example.com'], + }); + + expect(preset).toBeDefined(); + expect(preset.plugins).toBeDefined(); + expect(preset.plugins).toHaveLength(1); + }); + + it('passes options through to the plugin', () => { + const preset = BucketProvisionerPreset({ + connection: { + provider: 's3', + region: 'us-west-2', + accessKeyId: 'key', + secretAccessKey: 'secret', + }, + allowedOrigins: ['https://app.example.com'], + bucketNamePrefix: 'myapp', + versioning: true, + }); + + expect(preset.plugins).toHaveLength(1); + // The plugin should be the BucketProvisionerPlugin + const plugin = preset.plugins![0]; + expect(plugin).toBeDefined(); + }); + + it('creates a preset with lazy connection getter', () => { + const preset = BucketProvisionerPreset({ + connection: () => ({ + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }), + allowedOrigins: ['https://app.example.com'], + }); + + expect(preset.plugins).toHaveLength(1); + }); +}); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts new file mode 100644 index 0000000000..59b72cb026 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for the bucket provisioner plugin types. + * + * Validates type definitions, interfaces, and re-exports are correct. + */ + +import type { + BucketProvisionerPluginOptions, + ConnectionConfigOrGetter, + BucketNameResolver, + ProvisionBucketInput, + ProvisionBucketPayload, + StorageConnectionConfig, + StorageProvider, + BucketAccessType, + ProvisionResult, +} from '../src/types'; + +describe('BucketProvisionerPluginOptions', () => { + it('accepts static connection config', () => { + const options: BucketProvisionerPluginOptions = { + connection: { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }, + allowedOrigins: ['https://app.example.com'], + }; + + expect(options.connection).toBeDefined(); + expect(options.allowedOrigins).toHaveLength(1); + }); + + it('accepts lazy getter connection config', () => { + const options: BucketProvisionerPluginOptions = { + connection: () => ({ + provider: 's3', + region: 'us-west-2', + accessKeyId: 'key', + secretAccessKey: 'secret', + }), + allowedOrigins: ['https://app.example.com'], + }; + + expect(typeof options.connection).toBe('function'); + }); + + it('accepts all optional fields', () => { + const options: BucketProvisionerPluginOptions = { + connection: { + provider: 'r2', + region: 'auto', + endpoint: 'https://xxx.r2.cloudflarestorage.com', + accessKeyId: 'key', + secretAccessKey: 'secret', + }, + allowedOrigins: ['https://app.example.com', 'http://localhost:3000'], + bucketNamePrefix: 'myapp', + resolveBucketName: (key, dbId) => `${dbId}-${key}`, + versioning: true, + autoProvision: false, + }; + + expect(options.bucketNamePrefix).toBe('myapp'); + expect(options.resolveBucketName).toBeDefined(); + expect(options.versioning).toBe(true); + expect(options.autoProvision).toBe(false); + }); +}); + +describe('ConnectionConfigOrGetter', () => { + it('can be a static StorageConnectionConfig', () => { + const config: ConnectionConfigOrGetter = { + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://minio:9000', + accessKeyId: 'test', + secretAccessKey: 'test', + }; + + expect(typeof config).toBe('object'); + }); + + it('can be a function returning StorageConnectionConfig', () => { + const getter: ConnectionConfigOrGetter = () => ({ + provider: 's3', + region: 'us-east-1', + accessKeyId: 'key', + secretAccessKey: 'secret', + }); + + expect(typeof getter).toBe('function'); + const result = getter(); + expect(result.provider).toBe('s3'); + }); +}); + +describe('BucketNameResolver', () => { + it('takes bucketKey and databaseId and returns a string', () => { + const resolver: BucketNameResolver = (bucketKey, databaseId) => + `org-${databaseId}-${bucketKey}`; + + expect(resolver('public', 'db-123')).toBe('org-db-123-public'); + expect(resolver('private', 'db-456')).toBe('org-db-456-private'); + }); +}); + +describe('ProvisionBucketInput', () => { + it('has a bucketKey field', () => { + const input: ProvisionBucketInput = { + bucketKey: 'public', + }; + + expect(input.bucketKey).toBe('public'); + }); +}); + +describe('ProvisionBucketPayload', () => { + it('represents a successful provisioning result', () => { + const payload: ProvisionBucketPayload = { + success: true, + bucketName: 'myapp-public', + accessType: 'public', + provider: 'minio', + endpoint: 'http://minio:9000', + error: null, + }; + + expect(payload.success).toBe(true); + expect(payload.error).toBeNull(); + }); + + it('represents a failed provisioning result', () => { + const payload: ProvisionBucketPayload = { + success: false, + bucketName: 'myapp-public', + accessType: 'public', + provider: 'minio', + endpoint: 'http://minio:9000', + error: 'S3 connection refused', + }; + + expect(payload.success).toBe(false); + expect(payload.error).toBe('S3 connection refused'); + }); +}); + +describe('re-exported types from @constructive-io/bucket-provisioner', () => { + it('StorageProvider includes all supported providers', () => { + const providers: StorageProvider[] = ['s3', 'minio', 'r2', 'gcs', 'spaces']; + expect(providers).toHaveLength(5); + }); + + it('BucketAccessType includes all access types', () => { + 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: 'key', + secretAccessKey: 'secret', + }; + + expect(config.provider).toBe('minio'); + expect(config.region).toBe('us-east-1'); + expect(config.endpoint).toBe('http://minio:9000'); + }); + + it('ProvisionResult has all expected fields', () => { + const result: ProvisionResult = { + bucketName: 'test', + accessType: 'private', + endpoint: null, + provider: 's3', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: true, + versioning: false, + corsRules: [], + lifecycleRules: [], + }; + + expect(result.blockPublicAccess).toBe(true); + expect(result.corsRules).toEqual([]); + }); +}); diff --git a/graphile/graphile-bucket-provisioner-plugin/jest.config.js b/graphile/graphile-bucket-provisioner-plugin/jest.config.js new file mode 100644 index 0000000000..eecd073352 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/graphile/graphile-bucket-provisioner-plugin/package.json b/graphile/graphile-bucket-provisioner-plugin/package.json new file mode 100644 index 0000000000..234d295122 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/package.json @@ -0,0 +1,59 @@ +{ + "name": "graphile-bucket-provisioner-plugin", + "version": "0.1.0", + "description": "Bucket provisioning plugin for PostGraphile v5 — auto-provisions S3 buckets on bucket table mutations", + "author": "Constructive ", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --passWithNoTests" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "keywords": [ + "postgraphile", + "graphile", + "constructive", + "plugin", + "postgres", + "graphql", + "s3", + "bucket", + "provisioner", + "storage" + ], + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "dependencies": { + "@constructive-io/bucket-provisioner": "workspace:^", + "@pgpmjs/logger": "workspace:^" + }, + "peerDependencies": { + "grafast": "1.0.0", + "graphile-build": "5.0.0", + "graphile-build-pg": "5.0.0", + "graphile-config": "1.0.0", + "graphile-utils": "5.0.0", + "graphql": "16.13.0", + "postgraphile": "5.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.11", + "makage": "^0.3.0" + } +} diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts new file mode 100644 index 0000000000..2d6a99d8bb --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts @@ -0,0 +1,46 @@ +/** + * Bucket Provisioner Plugin for PostGraphile v5 + * + * Provides automatic S3 bucket provisioning for PostGraphile v5. + * When bucket rows are created via GraphQL mutations, this plugin + * automatically provisions the corresponding S3 bucket with the + * correct privacy policies, CORS rules, and lifecycle settings. + * + * Also provides an explicit `provisionBucket` mutation for manual + * provisioning or re-provisioning of S3 buckets. + * + * @example + * ```typescript + * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; + * + * const preset = { + * extends: [ + * BucketProvisionerPreset({ + * connection: { + * provider: 'minio', + * region: 'us-east-1', + * endpoint: 'http://minio:9000', + * accessKeyId: process.env.MINIO_ACCESS_KEY!, + * secretAccessKey: process.env.MINIO_SECRET_KEY!, + * }, + * allowedOrigins: ['https://app.example.com'], + * bucketNamePrefix: 'myapp', + * }), + * ], + * }; + * ``` + */ + +export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin'; +export { BucketProvisionerPreset } from './preset'; +export type { + BucketProvisionerPluginOptions, + ConnectionConfigOrGetter, + BucketNameResolver, + ProvisionBucketInput, + ProvisionBucketPayload, + StorageConnectionConfig, + StorageProvider, + BucketAccessType, + ProvisionResult, +} from './types'; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts new file mode 100644 index 0000000000..ec1b37519c --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -0,0 +1,434 @@ +/** + * Bucket Provisioner Plugin for PostGraphile v5 + * + * Adds S3 bucket provisioning support to PostGraphile v5: + * + * 1. `provisionBucket` mutation — explicitly provision an S3 bucket for a + * logical bucket row in the database. Reads the bucket config via RLS, + * then calls BucketProvisioner to create and configure the S3 bucket. + * + * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged + * with `@storageBuckets` to automatically provision the S3 bucket after + * the database row is created. + * + * Both pathways use `@constructive-io/bucket-provisioner` for the actual + * S3 operations (bucket creation, Block Public Access, CORS, policies, + * versioning, lifecycle rules). + * + * Detection: Uses the `@storageBuckets` smart tag on the codec (table). + * The storage module generator in constructive-db sets this tag on the + * generated buckets table via a smart comment: + * COMMENT ON TABLE buckets IS E'@storageBuckets\nStorage buckets table'; + */ + +import { context as grafastContext, lambda, object } from 'grafast'; +import type { GraphileConfig } from 'graphile-config'; +import { extendSchema, gql } from 'graphile-utils'; +import { Logger } from '@pgpmjs/logger'; +import { + BucketProvisioner, +} from '@constructive-io/bucket-provisioner'; +import type { StorageConnectionConfig, ProvisionResult } from '@constructive-io/bucket-provisioner'; + +import type { + BucketProvisionerPluginOptions, + BucketNameResolver, +} from './types'; + +const log = new Logger('graphile-bucket-provisioner:plugin'); + +// --- Storage module query (same as presigned-url-plugin) --- + +const STORAGE_MODULE_QUERY = ` + SELECT + sm.id, + bs.schema_name AS buckets_schema, + bt.name AS buckets_table, + sm.endpoint, + sm.public_url_prefix, + sm.provider + FROM metaschema_modules_public.storage_module sm + JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id + JOIN metaschema_public.schema bs ON bs.id = bt.schema_id + WHERE sm.database_id = $1 + LIMIT 1 +`; + +interface StorageModuleRow { + id: string; + buckets_schema: string; + buckets_table: string; + endpoint: string | null; + public_url_prefix: string | null; + provider: string | null; +} + +interface BucketRow { + id: string; + key: string; + type: string; + is_public: boolean; +} + +// --- Helpers --- + +/** + * Resolve the connection config from the options. If the option is a lazy + * getter function, call it (and cache the result). + */ +function resolveConnection( + options: BucketProvisionerPluginOptions, +): StorageConnectionConfig { + if (typeof options.connection === 'function') { + const resolved = options.connection(); + // Cache so subsequent calls don't re-evaluate + options.connection = resolved; + return resolved; + } + return options.connection; +} + +/** + * Resolve the S3 bucket name from a logical bucket key. + */ +function resolveBucketName( + bucketKey: string, + databaseId: string, + options: BucketProvisionerPluginOptions, +): string { + if (options.resolveBucketName) { + return options.resolveBucketName(bucketKey, databaseId); + } + if (options.bucketNamePrefix) { + return `${options.bucketNamePrefix}-${bucketKey}`; + } + return bucketKey; +} + +/** + * Resolve the database_id from the JWT context. + */ +async function resolveDatabaseId(pgClient: any): Promise { + const result = await pgClient.query( + `SELECT jwt_private.current_database_id() AS id`, + ); + return result.rows[0]?.id ?? null; +} + +/** + * Core provisioning logic shared by both the explicit mutation and the + * auto-provisioning hook. + */ +async function provisionBucketForRow( + pgClient: any, + databaseId: string, + bucketKey: string, + bucketType: string, + options: BucketProvisionerPluginOptions, +): Promise { + const connection = resolveConnection(options); + const s3BucketName = resolveBucketName(bucketKey, databaseId, options); + const accessType = bucketType as 'public' | 'private' | 'temp'; + + // Read storage module config to check for endpoint/provider overrides + const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]); + const storageModule: StorageModuleRow | null = smResult.rows[0] ?? null; + + // Build the effective connection config, applying per-database overrides + const effectiveConnection: StorageConnectionConfig = { + ...connection, + // Per-database endpoint override (if set in storage_module table) + ...(storageModule?.endpoint ? { endpoint: storageModule.endpoint } : {}), + // Per-database provider override (if set in storage_module table) + ...(storageModule?.provider + ? { provider: storageModule.provider as StorageConnectionConfig['provider'] } + : {}), + }; + + const provisioner = new BucketProvisioner({ + connection: effectiveConnection, + allowedOrigins: options.allowedOrigins, + }); + + log.info( + `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}") ` + + `for database ${databaseId}`, + ); + + const result = await provisioner.provision({ + bucketName: s3BucketName, + accessType, + versioning: options.versioning ?? false, + publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, + }); + + log.info( + `Successfully provisioned S3 bucket "${s3BucketName}" ` + + `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`, + ); + + return result; +} + +// --- Plugin factory --- + +/** + * Creates the bucket provisioner plugin. + * + * This plugin provides two provisioning pathways: + * + * 1. **Explicit `provisionBucket` mutation** — Call this mutation with a + * bucket key to provision (or re-provision) the S3 bucket. Protected + * by RLS on the buckets table. + * + * 2. **Auto-provisioning hook** — When `autoProvision` is true (default), + * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets` + * to automatically provision the S3 bucket after the row is created. + * + * @param options - Plugin configuration (S3 credentials, CORS origins, naming) + */ +export function createBucketProvisionerPlugin( + options: BucketProvisionerPluginOptions, +): GraphileConfig.Plugin { + const autoProvision = options.autoProvision ?? true; + + // The extendSchema plugin adds the explicit provisionBucket mutation + const mutationPlugin = extendSchema(() => ({ + typeDefs: gql` + input ProvisionBucketInput { + """The logical bucket key (e.g., "public", "private")""" + bucketKey: String! + } + + type ProvisionBucketPayload { + """Whether provisioning succeeded""" + success: Boolean! + """The S3 bucket name that was provisioned""" + bucketName: String! + """The access type applied""" + accessType: String! + """The storage provider used""" + provider: String! + """The S3 endpoint (null for AWS S3 default)""" + endpoint: String + """Error message if provisioning failed""" + error: String + } + + extend type Mutation { + """ + Provision an S3 bucket for a logical bucket in the database. + Reads the bucket config via RLS, then creates and configures + the S3 bucket with the appropriate privacy policies, CORS rules, + and lifecycle settings. + """ + provisionBucket( + input: ProvisionBucketInput! + ): ProvisionBucketPayload + } + `, + plans: { + Mutation: { + provisionBucket(_$mutation: any, fieldArgs: any) { + const $input = fieldArgs.getRaw('input'); + const $withPgClient = (grafastContext() as any).get('withPgClient'); + const $pgSettings = (grafastContext() as any).get('pgSettings'); + const $combined = object({ + input: $input, + withPgClient: $withPgClient, + pgSettings: $pgSettings, + }); + + return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => { + const { bucketKey } = input; + + if (!bucketKey || typeof bucketKey !== 'string') { + throw new Error('INVALID_BUCKET_KEY'); + } + + return withPgClient(pgSettings, async (pgClient: any) => { + // Resolve database ID from JWT context + const databaseId = await resolveDatabaseId(pgClient); + if (!databaseId) { + throw new Error('DATABASE_NOT_FOUND'); + } + + // Read storage module config + const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]); + if (smResult.rows.length === 0) { + throw new Error('STORAGE_MODULE_NOT_PROVISIONED'); + } + const storageModule = smResult.rows[0] as StorageModuleRow; + + // Look up the bucket row (RLS enforced via pgSettings) + const bucketResult = await pgClient.query( + `SELECT id, key, type, is_public + FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}" + WHERE key = $1 + LIMIT 1`, + [bucketKey], + ); + + if (bucketResult.rows.length === 0) { + throw new Error('BUCKET_NOT_FOUND'); + } + + const bucket = bucketResult.rows[0] as BucketRow; + + try { + const result = await provisionBucketForRow( + pgClient, + databaseId, + bucket.key, + bucket.type, + options, + ); + + return { + success: true, + bucketName: result.bucketName, + accessType: result.accessType, + provider: result.provider, + endpoint: result.endpoint, + error: null, + }; + } catch (err: any) { + log.error(`Failed to provision bucket "${bucketKey}": ${err.message}`); + return { + success: false, + bucketName: resolveBucketName(bucket.key, databaseId, options), + accessType: bucket.type, + provider: resolveConnection(options).provider, + endpoint: resolveConnection(options).endpoint ?? null, + error: err.message, + }; + } + }); + }); + }, + }, + }, + })); + + // If autoProvision is disabled, return only the mutation plugin + if (!autoProvision) { + return mutationPlugin; + } + + // Build a composite plugin that includes both the mutation and the hook + return { + ...mutationPlugin, + name: 'BucketProvisionerPlugin', + version: '0.1.0', + description: + 'Auto-provisions S3 buckets when bucket rows are created, ' + + 'and provides a provisionBucket mutation for explicit provisioning', + after: ['PgAttributesPlugin', 'PgMutationCreatePlugin'], + + schema: { + ...mutationPlugin.schema, + hooks: { + ...((mutationPlugin.schema as any)?.hooks ?? {}), + + /** + * Wrap create mutation resolvers on tables tagged with @storageBuckets. + * + * After the original resolver creates the bucket row, we provision + * the actual S3 bucket. If provisioning fails, the DB row still + * exists (the mutation already committed), and the error is logged. + * The admin can retry via the provisionBucket mutation. + */ + GraphQLObjectType_fields_field(field: any, build: any, context: any) { + const { + scope: { isRootMutation, fieldName, pgCodec }, + } = context; + + // Only wrap root mutation fields + if (!isRootMutation || !pgCodec || !pgCodec.attributes) { + return field; + } + + // Check for @storageBuckets smart tag + const tags = pgCodec.extensions?.tags; + if (!tags?.storageBuckets) { + return field; + } + + // Only wrap create mutations (not update/delete) + if (!fieldName.startsWith('create')) { + return field; + } + + log.debug(`Wrapping mutation "${fieldName}" for auto-provisioning (codec: ${pgCodec.name})`); + + const defaultResolver = (obj: any) => obj[fieldName]; + const { resolve: oldResolve = defaultResolver, ...rest } = field; + + return { + ...rest, + async resolve(source: any, args: any, graphqlContext: any, info: any) { + // Call the original resolver first (creates the DB row) + const result = await oldResolve(source, args, graphqlContext, info); + + // Extract the bucket data from the mutation input + // PostGraphile create mutations put the input under `input.{codecName}` + // e.g., createBucket → args.input.bucket + try { + const inputKey = Object.keys(args.input || {}).find( + (k) => k !== 'clientMutationId', + ); + const bucketInput = inputKey ? args.input[inputKey] : null; + + if (!bucketInput?.key || !bucketInput?.type) { + log.warn( + `Auto-provision skipped for "${fieldName}": ` + + `could not extract key/type from mutation input`, + ); + return result; + } + + // Use withPgClient to get a DB connection for reading storage config + const withPgClient = graphqlContext.withPgClient; + const pgSettings = graphqlContext.pgSettings; + + if (!withPgClient) { + log.warn('Auto-provision skipped: withPgClient not available in context'); + return result; + } + + await withPgClient(pgSettings, async (pgClient: any) => { + const databaseId = await resolveDatabaseId(pgClient); + if (!databaseId) { + log.warn('Auto-provision skipped: could not resolve database_id'); + return; + } + + await provisionBucketForRow( + pgClient, + databaseId, + bucketInput.key, + bucketInput.type, + options, + ); + }); + } catch (err: any) { + // Log the error but don't fail the mutation — the DB row was + // already created. Admin can retry via provisionBucket mutation. + log.error( + `Auto-provision failed for "${fieldName}": ${err.message}. ` + + `The bucket row was created but the S3 bucket was not provisioned. ` + + `Use the provisionBucket mutation to retry.`, + ); + } + + return result; + }, + }; + }, + }, + }, + }; +} + +export const BucketProvisionerPlugin = createBucketProvisionerPlugin; +export default BucketProvisionerPlugin; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts new file mode 100644 index 0000000000..7828bb2b3d --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts @@ -0,0 +1,44 @@ +/** + * PostGraphile v5 Bucket Provisioner Preset + * + * Provides a convenient preset for including bucket provisioning support + * in PostGraphile. Wraps the main plugin with sensible defaults. + */ + +import type { GraphileConfig } from 'graphile-config'; +import type { BucketProvisionerPluginOptions } from './types'; +import { createBucketProvisionerPlugin } from './plugin'; + +/** + * Creates a preset that includes the bucket provisioner plugin with the given options. + * + * @example + * ```typescript + * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; + * + * const preset = { + * extends: [ + * BucketProvisionerPreset({ + * connection: { + * provider: 'minio', + * region: 'us-east-1', + * endpoint: 'http://minio:9000', + * accessKeyId: process.env.MINIO_ACCESS_KEY!, + * secretAccessKey: process.env.MINIO_SECRET_KEY!, + * }, + * allowedOrigins: ['https://app.example.com'], + * bucketNamePrefix: 'myapp', + * }), + * ], + * }; + * ``` + */ +export function BucketProvisionerPreset( + options: BucketProvisionerPluginOptions, +): GraphileConfig.Preset { + return { + plugins: [createBucketProvisionerPlugin(options)], + }; +} + +export default BucketProvisionerPreset; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts new file mode 100644 index 0000000000..1f40d6b92f --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts @@ -0,0 +1,110 @@ +/** + * Types for the bucket provisioner plugin. + * + * Defines plugin options, connection configuration, and provisioning result + * types used by the Graphile plugin to auto-provision S3 buckets when + * bucket rows are created via GraphQL mutations. + */ + +import type { + StorageConnectionConfig, + StorageProvider, + BucketAccessType, + ProvisionResult, +} from '@constructive-io/bucket-provisioner'; + +// Re-export types that consumers will need +export type { StorageConnectionConfig, StorageProvider, BucketAccessType, ProvisionResult }; + +/** + * S3 connection configuration or a lazy getter that returns it on first use. + * + * When a function is provided, it will only be called when the first + * provisioning operation actually needs the S3 client — avoiding eager + * env-var reads and S3Client creation at module import time. + */ +export type ConnectionConfigOrGetter = + | StorageConnectionConfig + | (() => StorageConnectionConfig); + +/** + * Function to derive the actual S3 bucket name from a logical bucket key. + * + * @param bucketKey - The logical bucket key from the database (e.g., "public", "private") + * @param databaseId - The metaschema database UUID + * @returns The S3 bucket name to create/configure + */ +export type BucketNameResolver = (bucketKey: string, databaseId: string) => string; + +/** + * Plugin options for the bucket provisioner plugin. + */ +export interface BucketProvisionerPluginOptions { + /** + * S3 connection configuration (credentials, endpoint, provider). + * Can be a concrete object or a lazy getter function. + */ + connection: ConnectionConfigOrGetter; + + /** + * Allowed origins for CORS rules on provisioned buckets. + * These are the domains where your app runs (e.g., ["https://app.example.com"]). + * Required for browser-based presigned URL uploads. + */ + allowedOrigins: string[]; + + /** + * Optional prefix for S3 bucket names. + * When set, the S3 bucket name becomes `{prefix}-{bucketKey}`. + * Example: prefix "myapp" + key "public" → S3 bucket "myapp-public" + */ + bucketNamePrefix?: string; + + /** + * Optional custom function to derive S3 bucket names from logical bucket keys. + * Takes precedence over `bucketNamePrefix` when provided. + */ + resolveBucketName?: BucketNameResolver; + + /** + * Whether to enable versioning on provisioned buckets. + * Default: false + */ + versioning?: boolean; + + /** + * Whether to auto-provision S3 buckets when bucket rows are created + * via GraphQL mutations. When true, the plugin wraps create mutations + * on tables tagged with `@storageBuckets` to trigger provisioning + * after the mutation succeeds. + * + * Default: true + */ + autoProvision?: boolean; +} + +/** + * Input for the provisionBucket mutation. + */ +export interface ProvisionBucketInput { + /** The logical bucket key (e.g., "public", "private") */ + bucketKey: string; +} + +/** + * Result of the provisionBucket mutation. + */ +export interface ProvisionBucketPayload { + /** Whether provisioning succeeded */ + success: boolean; + /** The S3 bucket name that was provisioned */ + bucketName: string; + /** The access type applied */ + accessType: string; + /** The storage provider used */ + provider: string; + /** The S3 endpoint (null for AWS S3 default) */ + endpoint: string | null; + /** Error message if provisioning failed */ + error: string | null; +} diff --git a/graphile/graphile-bucket-provisioner-plugin/tsconfig.esm.json b/graphile/graphile-bucket-provisioner-plugin/tsconfig.esm.json new file mode 100644 index 0000000000..f624f96708 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "ESNext" + } +} diff --git a/graphile/graphile-bucket-provisioner-plugin/tsconfig.json b/graphile/graphile-bucket-provisioner-plugin/tsconfig.json new file mode 100644 index 0000000000..9c8a7d7c10 --- /dev/null +++ b/graphile/graphile-bucket-provisioner-plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f9a01b725..8d5d166d24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,44 @@ importers: version: 0.3.0 publishDirectory: dist + graphile/graphile-bucket-provisioner-plugin: + dependencies: + '@constructive-io/bucket-provisioner': + specifier: workspace:^ + version: link:../../packages/bucket-provisioner/dist + '@pgpmjs/logger': + specifier: workspace:^ + version: link:../../pgpm/logger/dist + grafast: + specifier: 1.0.0 + version: 1.0.0(graphql@16.13.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) + graphile-build-pg: + specifier: 5.0.0 + version: 5.0.0(@dataplan/pg@1.0.0(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0))(grafast@1.0.0(graphql@16.13.0))(graphile-build@5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0)(tamedevil@0.1.0) + graphile-config: + specifier: 1.0.0 + version: 1.0.0 + graphile-utils: + specifier: 5.0.0 + version: 5.0.0(@dataplan/pg@1.0.0(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0))(grafast@1.0.0(graphql@16.13.0))(graphile-build-pg@5.0.0(@dataplan/pg@1.0.0(@dataplan/json@1.0.0(grafast@1.0.0(graphql@16.13.0)))(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0))(grafast@1.0.0(graphql@16.13.0))(graphile-build@5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(pg-sql2@5.0.0)(pg@8.20.0)(tamedevil@0.1.0))(graphile-build@5.0.0(grafast@1.0.0(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0))(graphile-config@1.0.0)(graphql@16.13.0)(tamedevil@0.1.0) + graphql: + specifier: 16.13.0 + version: 16.13.0 + postgraphile: + specifier: 5.0.0 + version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) + devDependencies: + '@types/node': + specifier: ^22.19.11 + version: 22.19.15 + makage: + specifier: ^0.3.0 + version: 0.3.0 + publishDirectory: dist + graphile/graphile-cache: dependencies: '@pgpmjs/logger': @@ -178,7 +216,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.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) + 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) lru-cache: specifier: ^11.2.7 version: 11.2.7 @@ -187,7 +225,7 @@ importers: version: link:../../postgres/pg-cache/dist postgraphile: specifier: 5.0.0 - version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) + version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) devDependencies: '@types/express': specifier: ^5.0.6 @@ -200,7 +238,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist graphile/graphile-connection-filter: @@ -402,7 +440,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist graphile/graphile-search: @@ -498,7 +536,7 @@ importers: version: 1.0.0(graphql@16.13.0) grafserv: specifier: 1.0.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) + 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) 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 +587,7 @@ importers: version: 5.0.0 postgraphile: specifier: 5.0.0 - version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) + version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -583,7 +621,7 @@ importers: version: link:../../postgres/pgsql-test/dist ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist graphile/graphile-sql-expression-validator: @@ -831,7 +869,7 @@ importers: version: 5.2.1 grafserv: specifier: 1.0.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) + 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) graphile-cache: specifier: workspace:^ version: link:../../graphile/graphile-cache/dist @@ -852,7 +890,7 @@ importers: version: link:../../postgres/pg-env/dist postgraphile: specifier: 5.0.0 - version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) + version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) devDependencies: '@types/express': specifier: ^5.0.6 @@ -865,7 +903,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist graphql/gql-ast: @@ -1122,7 +1160,7 @@ importers: version: 1.0.0(graphql@16.13.0) grafserv: specifier: 1.0.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) + 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) 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 +1208,7 @@ importers: version: 5.0.0 postgraphile: specifier: 5.0.0 - version: 5.0.0(0c2cedda320a650bce7ee949e4b7e993) + version: 5.0.0(f35d86129e8192df0ebe9574df8f7655) request-ip: specifier: ^3.3.0 version: 3.3.0 @@ -1207,7 +1245,7 @@ importers: version: 3.1.14 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist graphql/server-test: @@ -1620,7 +1658,7 @@ importers: version: 7.2.2 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist jobs/knative-job-worker: @@ -1922,7 +1960,7 @@ importers: version: 0.3.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist packages/smtppostmaster: @@ -1951,7 +1989,7 @@ importers: version: 3.18.1 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + version: 10.9.2(@types/node@25.5.0)(typescript@5.9.3) publishDirectory: dist packages/upload-client: @@ -13144,7 +13182,6 @@ snapshots: '@types/node@25.5.0': dependencies: undici-types: 7.18.2 - optional: true '@types/nodemailer@7.0.11': dependencies: @@ -18312,14 +18349,14 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.5.0)(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': 22.19.15 + '@types/node': 25.5.0 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -18400,8 +18437,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: - optional: true + undici-types@7.18.2: {} undici@7.24.6: {} From d6c7685feb6c4152e70161780ea8a058f077d58a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 4 Apr 2026 01:49:37 +0000 Subject: [PATCH 4/6] feat: wire BucketProvisionerPreset into ConstructivePreset via getEnvOptions - Add bucket-provisioner-resolver.ts in graphile-settings (lazy init via getEnvOptions, same pattern as presigned-url-resolver.ts) - Wire BucketProvisionerPreset into constructive-preset.ts with lazy connection getter - Update JSDoc examples in bucket-provisioner-plugin to show getEnvOptions pattern instead of raw process.env - Export getBucketProvisionerConnection from graphile-settings index --- .../src/index.ts | 21 +++++--- .../src/preset.ts | 21 +++++--- graphile/graphile-settings/package.json | 1 + .../src/bucket-provisioner-resolver.ts | 48 +++++++++++++++++++ graphile/graphile-settings/src/index.ts | 3 ++ .../src/presets/constructive-preset.ts | 8 ++++ pnpm-lock.yaml | 43 +++++++++-------- 7 files changed, 112 insertions(+), 33 deletions(-) create mode 100644 graphile/graphile-settings/src/bucket-provisioner-resolver.ts diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts index 2d6a99d8bb..c9e988f3da 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/index.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts @@ -12,17 +12,24 @@ * @example * ```typescript * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; + * import { getEnvOptions } from '@constructive-io/graphql-env'; + * + * // Use a lazy getter so env vars are read at runtime, not import time + * function getConnection() { + * const { cdn } = getEnvOptions(); + * return { + * provider: cdn?.provider || 'minio', + * region: cdn?.awsRegion || 'us-east-1', + * endpoint: cdn?.endpoint || 'http://minio:9000', + * accessKeyId: cdn?.awsAccessKey!, + * secretAccessKey: cdn?.awsSecretKey!, + * }; + * } * * const preset = { * extends: [ * BucketProvisionerPreset({ - * connection: { - * provider: 'minio', - * region: 'us-east-1', - * endpoint: 'http://minio:9000', - * accessKeyId: process.env.MINIO_ACCESS_KEY!, - * secretAccessKey: process.env.MINIO_SECRET_KEY!, - * }, + * connection: getConnection, // pass function ref, NOT getConnection() * allowedOrigins: ['https://app.example.com'], * bucketNamePrefix: 'myapp', * }), diff --git a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts index 7828bb2b3d..500cfe76cc 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts @@ -15,17 +15,24 @@ import { createBucketProvisionerPlugin } from './plugin'; * @example * ```typescript * import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; + * import { getEnvOptions } from '@constructive-io/graphql-env'; + * + * // Use a lazy getter so env vars are read at runtime, not import time + * function getConnection() { + * const { cdn } = getEnvOptions(); + * return { + * provider: cdn?.provider || 'minio', + * region: cdn?.awsRegion || 'us-east-1', + * endpoint: cdn?.endpoint || 'http://minio:9000', + * accessKeyId: cdn?.awsAccessKey!, + * secretAccessKey: cdn?.awsSecretKey!, + * }; + * } * * const preset = { * extends: [ * BucketProvisionerPreset({ - * connection: { - * provider: 'minio', - * region: 'us-east-1', - * endpoint: 'http://minio:9000', - * accessKeyId: process.env.MINIO_ACCESS_KEY!, - * secretAccessKey: process.env.MINIO_SECRET_KEY!, - * }, + * connection: getConnection, // pass function ref, NOT getConnection() * allowedOrigins: ['https://app.example.com'], * bucketNamePrefix: 'myapp', * }), diff --git a/graphile/graphile-settings/package.json b/graphile/graphile-settings/package.json index 532183e63b..3f11bc43f0 100644 --- a/graphile/graphile-settings/package.json +++ b/graphile/graphile-settings/package.json @@ -44,6 +44,7 @@ "express": "^5.2.1", "grafast": "1.0.0", "grafserv": "1.0.0", + "graphile-bucket-provisioner-plugin": "workspace:*", "graphile-build": "5.0.0", "graphile-build-pg": "5.0.0", "graphile-config": "1.0.0", diff --git a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts new file mode 100644 index 0000000000..a999030ecb --- /dev/null +++ b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts @@ -0,0 +1,48 @@ +/** + * Bucket provisioner resolver for the Constructive bucket provisioner plugin. + * + * Reads CDN/S3 configuration from the standard env system + * (getEnvOptions -> pgpmDefaults + config files + env vars) and lazily + * returns a StorageConnectionConfig on first use. + * + * Follows the same lazy-init pattern as presigned-url-resolver.ts. + */ + +import { getEnvOptions } from '@constructive-io/graphql-env'; +import { Logger } from '@pgpmjs/logger'; +import type { StorageConnectionConfig } from 'graphile-bucket-provisioner-plugin'; + +const log = new Logger('bucket-provisioner-resolver'); + +let connectionConfig: StorageConnectionConfig | null = null; + +/** + * Lazily initialize and return the StorageConnectionConfig for the + * bucket provisioner plugin. + * + * Reads CDN config on first call via getEnvOptions() (which already merges + * pgpmDefaults -> config file -> env vars) and caches the result. + * Same CDN config source as presigned-url-resolver.ts. + */ +export function getBucketProvisionerConnection(): StorageConnectionConfig { + if (connectionConfig) return connectionConfig; + + const { cdn } = getEnvOptions(); + + // cdn is guaranteed populated — pgpmDefaults provides all CDN fields + const { provider, awsRegion, awsAccessKey, awsSecretKey, endpoint } = cdn!; + + log.info( + `[bucket-provisioner-resolver] Initializing: provider=${provider} endpoint=${endpoint}`, + ); + + connectionConfig = { + provider: (provider as StorageConnectionConfig['provider']) || 'minio', + region: awsRegion || 'us-east-1', + accessKeyId: awsAccessKey!, + secretAccessKey: awsSecretKey!, + ...(endpoint ? { endpoint, forcePathStyle: true } : {}), + }; + + return connectionConfig; +} diff --git a/graphile/graphile-settings/src/index.ts b/graphile/graphile-settings/src/index.ts index 134d54d859..a7779621b3 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -62,3 +62,6 @@ export { streamToStorage } from './upload-resolver'; // Presigned URL utilities export { getPresignedUrlS3Config } from './presigned-url-resolver'; + +// Bucket provisioner utilities +export { getBucketProvisionerConnection } from './bucket-provisioner-resolver'; diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index 46824e01e1..e2188f5ab2 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -16,9 +16,11 @@ import { UnifiedSearchPreset, createMatchesOperatorFactory, createTrgmOperatorFa import { GraphilePostgisPreset, createPostgisOperatorFactory } from 'graphile-postgis'; import { UploadPreset } from 'graphile-upload-plugin'; import { PresignedUrlPreset } from 'graphile-presigned-url-plugin'; +import { BucketProvisionerPreset } from 'graphile-bucket-provisioner-plugin'; import { SqlExpressionValidatorPreset } from 'graphile-sql-expression-validator'; import { constructiveUploadFieldDefinitions } from '../upload-resolver'; import { getPresignedUrlS3Config } from '../presigned-url-resolver'; +import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver'; /** * Constructive PostGraphile v5 Preset @@ -39,6 +41,8 @@ import { getPresignedUrlS3Config } from '../presigned-url-resolver'; * - PostGIS connection filter operators (spatial filtering on geometry/geography columns) * - Upload plugin (file upload to S3/MinIO for image, upload, attachment domain columns) * - Presigned URL plugin (requestUploadUrl, confirmUpload mutations + downloadUrl computed field) + * - Bucket provisioner plugin (auto-provisions S3 buckets on @storageBuckets table mutations, + * CORS management, provisionBucket mutation for manual/retry) * - SQL expression validator (validates @sqlExpression columns in mutations) * - PG type mappings (maps custom types like email, url to GraphQL scalars) * - pgvector search (auto-discovers vector columns: filter fields, distance computed fields, @@ -87,6 +91,10 @@ export const ConstructivePreset: GraphileConfig.Preset = { maxFileSize: 10 * 1024 * 1024, // 10MB }), PresignedUrlPreset({ s3: getPresignedUrlS3Config }), + BucketProvisionerPreset({ + connection: getBucketProvisionerConnection, + allowedOrigins: ['http://localhost:3000'], + }), SqlExpressionValidatorPreset(), PgTypeMappingsPreset, RequiredInputPreset, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d5d166d24..9407ff26e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,7 +216,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 @@ -225,7 +225,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 @@ -238,7 +238,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: @@ -440,7 +440,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: @@ -536,7 +536,10 @@ 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-bucket-provisioner-plugin: + specifier: workspace:* + version: link:../graphile-bucket-provisioner-plugin/dist 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) @@ -587,7 +590,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 @@ -621,7 +624,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: @@ -869,7 +872,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 @@ -890,7 +893,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 @@ -903,7 +906,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: @@ -1160,7 +1163,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) @@ -1208,7 +1211,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 @@ -1245,7 +1248,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: @@ -1658,7 +1661,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: @@ -1960,7 +1963,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: @@ -1989,7 +1992,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: @@ -13182,6 +13185,7 @@ snapshots: '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + optional: true '@types/nodemailer@7.0.11': dependencies: @@ -18349,14 +18353,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 @@ -18437,7 +18441,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true undici@7.24.6: {} From 4a2a5ebc5260535f4b2fb2b371c9df2949b86e1b Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 4 Apr 2026 02:14:21 +0000 Subject: [PATCH 5/6] test: update schema snapshot for BucketProvisionerPreset additions --- .../schema-snapshot.test.ts.snap | 732 +++++++++--------- 1 file changed, 385 insertions(+), 347 deletions(-) diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index 9d68671f0f..9a48b65b1e 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -1,160 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Schema Snapshot should generate consistent GraphQL SDL from the test schema 1`] = ` -""""The root query type which gives access points into the data universe.""" -type Query { - """Reads and enables pagination through a set of \`PostTag\`.""" - postTags( - """Only read the first \`n\` values of the set.""" - first: Int - - """Only read the last \`n\` values of the set.""" - last: Int - - """ - Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor - based pagination. May not be used with \`last\`. - """ - offset: Int - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """ - A filter to be used in determining which values should be returned by the collection. - """ - where: PostTagFilter - - """The method to use when ordering \`PostTag\`.""" - orderBy: [PostTagOrderBy!] = [PRIMARY_KEY_ASC] - ): PostTagConnection - - """Reads and enables pagination through a set of \`Tag\`.""" - tags( - """Only read the first \`n\` values of the set.""" - first: Int - - """Only read the last \`n\` values of the set.""" - last: Int - - """ - Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor - based pagination. May not be used with \`last\`. - """ - offset: Int - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """ - A filter to be used in determining which values should be returned by the collection. - """ - where: TagFilter - - """The method to use when ordering \`Tag\`.""" - orderBy: [TagOrderBy!] = [PRIMARY_KEY_ASC] - ): TagConnection - - """Reads and enables pagination through a set of \`User\`.""" - users( - """Only read the first \`n\` values of the set.""" - first: Int - - """Only read the last \`n\` values of the set.""" - last: Int - - """ - Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor - based pagination. May not be used with \`last\`. - """ - offset: Int - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """ - A filter to be used in determining which values should be returned by the collection. - """ - where: UserFilter - - """The method to use when ordering \`User\`.""" - orderBy: [UserOrderBy!] = [PRIMARY_KEY_ASC] - ): UserConnection - - """Reads and enables pagination through a set of \`Comment\`.""" - comments( - """Only read the first \`n\` values of the set.""" - first: Int - - """Only read the last \`n\` values of the set.""" - last: Int - - """ - Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor - based pagination. May not be used with \`last\`. - """ - offset: Int - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """ - A filter to be used in determining which values should be returned by the collection. - """ - where: CommentFilter - - """The method to use when ordering \`Comment\`.""" - orderBy: [CommentOrderBy!] = [PRIMARY_KEY_ASC] - ): CommentConnection - - """Reads and enables pagination through a set of \`Post\`.""" - posts( - """Only read the first \`n\` values of the set.""" - first: Int - - """Only read the last \`n\` values of the set.""" - last: Int - - """ - Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor - based pagination. May not be used with \`last\`. - """ - offset: Int - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """ - A filter to be used in determining which values should be returned by the collection. - """ - where: PostFilter - - """The method to use when ordering \`Post\`.""" - orderBy: [PostOrderBy!] = [PRIMARY_KEY_ASC] - ): PostConnection - - """ - Metadata about the database schema, including tables, fields, indexes, and constraints. Useful for code generation tools. - """ - _meta: MetaSchema -} - -"""A connection to a list of \`PostTag\` values.""" +""""A connection to a list of \`PostTag\` values.""" type PostTagConnection { """A list of \`PostTag\` objects.""" nodes: [PostTag]! @@ -1567,206 +1414,56 @@ type MetaQuery { delete: String } -""" -The root mutation type which contains root level fields which mutate data. -""" -type Mutation { - """Creates a single \`PostTag\`.""" - createPostTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: CreatePostTagInput! - ): CreatePostTagPayload +"""The output of our create \`PostTag\` mutation.""" +type CreatePostTagPayload { + """ + The exact same \`clientMutationId\` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String - """Creates a single \`Tag\`.""" - createTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: CreateTagInput! - ): CreateTagPayload + """The \`PostTag\` that was created by this mutation.""" + postTag: PostTag - """Creates a single \`User\`.""" - createUser( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: CreateUserInput! - ): CreateUserPayload + """ + Our root query field type. Allows us to run any query from our mutation payload. + """ + query: Query - """Creates a single \`Comment\`.""" - createComment( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: CreateCommentInput! - ): CreateCommentPayload + """An edge for our \`PostTag\`. May be used by Relay 1.""" + postTagEdge( + """The method to use when ordering \`PostTag\`.""" + orderBy: [PostTagOrderBy!]! = [PRIMARY_KEY_ASC] + ): PostTagEdge +} - """Creates a single \`Post\`.""" - createPost( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: CreatePostInput! - ): CreatePostPayload +"""All input for the create \`PostTag\` mutation.""" +input CreatePostTagInput { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String - """Updates a single \`PostTag\` using a unique key and a patch.""" - updatePostTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: UpdatePostTagInput! - ): UpdatePostTagPayload + """The \`PostTag\` to be created by this mutation.""" + postTag: PostTagInput! +} - """Updates a single \`Tag\` using a unique key and a patch.""" - updateTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: UpdateTagInput! - ): UpdateTagPayload +"""An input for mutations affecting \`PostTag\`""" +input PostTagInput { + id: UUID + postId: UUID! + tagId: UUID! + createdAt: Datetime +} - """Updates a single \`User\` using a unique key and a patch.""" - updateUser( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: UpdateUserInput! - ): UpdateUserPayload - - """Updates a single \`Comment\` using a unique key and a patch.""" - updateComment( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: UpdateCommentInput! - ): UpdateCommentPayload - - """Updates a single \`Post\` using a unique key and a patch.""" - updatePost( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: UpdatePostInput! - ): UpdatePostPayload - - """Deletes a single \`PostTag\` using a unique key.""" - deletePostTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: DeletePostTagInput! - ): DeletePostTagPayload - - """Deletes a single \`Tag\` using a unique key.""" - deleteTag( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: DeleteTagInput! - ): DeleteTagPayload - - """Deletes a single \`User\` using a unique key.""" - deleteUser( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: DeleteUserInput! - ): DeleteUserPayload - - """Deletes a single \`Comment\` using a unique key.""" - deleteComment( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: DeleteCommentInput! - ): DeleteCommentPayload - - """Deletes a single \`Post\` using a unique key.""" - deletePost( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: DeletePostInput! - ): DeletePostPayload - - """ - Request a presigned URL for uploading a file directly to S3. - Client computes SHA-256 of the file content and provides it here. - If a file with the same hash already exists (dedup), returns the - existing file ID and deduplicated=true with no uploadUrl. - """ - requestUploadUrl( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: RequestUploadUrlInput! - ): RequestUploadUrlPayload - - """ - Confirm that a file has been uploaded to S3. - Verifies the object exists in S3, checks content-type, - and transitions the file status from 'pending' to 'ready'. - """ - confirmUpload( - """ - The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. - """ - input: ConfirmUploadInput! - ): ConfirmUploadPayload -} - -"""The output of our create \`PostTag\` mutation.""" -type CreatePostTagPayload { - """ - The exact same \`clientMutationId\` that was provided in the mutation input, - unchanged and unused. May be used by a client to track mutations. - """ - clientMutationId: String - - """The \`PostTag\` that was created by this mutation.""" - postTag: PostTag - - """ - Our root query field type. Allows us to run any query from our mutation payload. - """ - query: Query - - """An edge for our \`PostTag\`. May be used by Relay 1.""" - postTagEdge( - """The method to use when ordering \`PostTag\`.""" - orderBy: [PostTagOrderBy!]! = [PRIMARY_KEY_ASC] - ): PostTagEdge -} - -"""All input for the create \`PostTag\` mutation.""" -input CreatePostTagInput { - """ - An arbitrary string value with no semantic meaning. Will be included in the - payload verbatim. May be used to track mutations by the client. - """ - clientMutationId: String - - """The \`PostTag\` to be created by this mutation.""" - postTag: PostTagInput! -} - -"""An input for mutations affecting \`PostTag\`""" -input PostTagInput { - id: UUID - postId: UUID! - tagId: UUID! - createdAt: Datetime -} - -"""The output of our create \`Tag\` mutation.""" -type CreateTagPayload { - """ - The exact same \`clientMutationId\` that was provided in the mutation input, - unchanged and unused. May be used by a client to track mutations. - """ - clientMutationId: String +"""The output of our create \`Tag\` mutation.""" +type CreateTagPayload { + """ + The exact same \`clientMutationId\` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String """The \`Tag\` that was created by this mutation.""" tag: Tag @@ -2417,5 +2114,346 @@ type ConfirmUploadPayload { """Whether confirmation succeeded""" success: Boolean! +} + +"""The root query type which gives access points into the data universe.""" +type Query { + """Reads and enables pagination through a set of \`PostTag\`.""" + postTags( + """Only read the first \`n\` values of the set.""" + first: Int + + """Only read the last \`n\` values of the set.""" + last: Int + + """ + Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor + based pagination. May not be used with \`last\`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """ + A filter to be used in determining which values should be returned by the collection. + """ + where: PostTagFilter + + """The method to use when ordering \`PostTag\`.""" + orderBy: [PostTagOrderBy!] = [PRIMARY_KEY_ASC] + ): PostTagConnection + + """Reads and enables pagination through a set of \`Tag\`.""" + tags( + """Only read the first \`n\` values of the set.""" + first: Int + + """Only read the last \`n\` values of the set.""" + last: Int + + """ + Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor + based pagination. May not be used with \`last\`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """ + A filter to be used in determining which values should be returned by the collection. + """ + where: TagFilter + + """The method to use when ordering \`Tag\`.""" + orderBy: [TagOrderBy!] = [PRIMARY_KEY_ASC] + ): TagConnection + + """Reads and enables pagination through a set of \`User\`.""" + users( + """Only read the first \`n\` values of the set.""" + first: Int + + """Only read the last \`n\` values of the set.""" + last: Int + + """ + Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor + based pagination. May not be used with \`last\`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """ + A filter to be used in determining which values should be returned by the collection. + """ + where: UserFilter + + """The method to use when ordering \`User\`.""" + orderBy: [UserOrderBy!] = [PRIMARY_KEY_ASC] + ): UserConnection + + """Reads and enables pagination through a set of \`Comment\`.""" + comments( + """Only read the first \`n\` values of the set.""" + first: Int + + """Only read the last \`n\` values of the set.""" + last: Int + + """ + Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor + based pagination. May not be used with \`last\`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """ + A filter to be used in determining which values should be returned by the collection. + """ + where: CommentFilter + + """The method to use when ordering \`Comment\`.""" + orderBy: [CommentOrderBy!] = [PRIMARY_KEY_ASC] + ): CommentConnection + + """Reads and enables pagination through a set of \`Post\`.""" + posts( + """Only read the first \`n\` values of the set.""" + first: Int + + """Only read the last \`n\` values of the set.""" + last: Int + + """ + Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor + based pagination. May not be used with \`last\`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """ + A filter to be used in determining which values should be returned by the collection. + """ + where: PostFilter + + """The method to use when ordering \`Post\`.""" + orderBy: [PostOrderBy!] = [PRIMARY_KEY_ASC] + ): PostConnection + + """ + Metadata about the database schema, including tables, fields, indexes, and constraints. Useful for code generation tools. + """ + _meta: MetaSchema +} + +""" +The root mutation type which contains root level fields which mutate data. +""" +type Mutation { + """Creates a single \`PostTag\`.""" + createPostTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreatePostTagInput! + ): CreatePostTagPayload + + """Creates a single \`Tag\`.""" + createTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateTagInput! + ): CreateTagPayload + + """Creates a single \`User\`.""" + createUser( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateUserInput! + ): CreateUserPayload + + """Creates a single \`Comment\`.""" + createComment( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateCommentInput! + ): CreateCommentPayload + + """Creates a single \`Post\`.""" + createPost( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreatePostInput! + ): CreatePostPayload + + """Updates a single \`PostTag\` using a unique key and a patch.""" + updatePostTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: UpdatePostTagInput! + ): UpdatePostTagPayload + + """Updates a single \`Tag\` using a unique key and a patch.""" + updateTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: UpdateTagInput! + ): UpdateTagPayload + + """Updates a single \`User\` using a unique key and a patch.""" + updateUser( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: UpdateUserInput! + ): UpdateUserPayload + + """Updates a single \`Comment\` using a unique key and a patch.""" + updateComment( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: UpdateCommentInput! + ): UpdateCommentPayload + + """Updates a single \`Post\` using a unique key and a patch.""" + updatePost( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: UpdatePostInput! + ): UpdatePostPayload + + """Deletes a single \`PostTag\` using a unique key.""" + deletePostTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: DeletePostTagInput! + ): DeletePostTagPayload + + """Deletes a single \`Tag\` using a unique key.""" + deleteTag( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: DeleteTagInput! + ): DeleteTagPayload + + """Deletes a single \`User\` using a unique key.""" + deleteUser( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: DeleteUserInput! + ): DeleteUserPayload + + """Deletes a single \`Comment\` using a unique key.""" + deleteComment( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: DeleteCommentInput! + ): DeleteCommentPayload + + """Deletes a single \`Post\` using a unique key.""" + deletePost( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: DeletePostInput! + ): DeletePostPayload + + """ + Request a presigned URL for uploading a file directly to S3. + Client computes SHA-256 of the file content and provides it here. + If a file with the same hash already exists (dedup), returns the + existing file ID and deduplicated=true with no uploadUrl. + """ + requestUploadUrl( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: RequestUploadUrlInput! + ): RequestUploadUrlPayload + + """ + Confirm that a file has been uploaded to S3. + Verifies the object exists in S3, checks content-type, + and transitions the file status from 'pending' to 'ready'. + """ + confirmUpload( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: ConfirmUploadInput! + ): ConfirmUploadPayload + + """ + Provision an S3 bucket for a logical bucket in the database. + Reads the bucket config via RLS, then creates and configures + the S3 bucket with the appropriate privacy policies, CORS rules, + and lifecycle settings. + """ + provisionBucket( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: ProvisionBucketInput! + ): ProvisionBucketPayload +} + +input ProvisionBucketInput { + """The logical bucket key (e.g., "public", "private")""" + bucketKey: String! +} + +type ProvisionBucketPayload { + """Whether provisioning succeeded""" + success: Boolean! + + """The S3 bucket name that was provisioned""" + bucketName: String! + + """The access type applied""" + accessType: String! + + """The storage provider used""" + provider: String! + + """The S3 endpoint (null for AWS S3 default)""" + endpoint: String + + """Error message if provisioning failed""" + error: String }" `; From 89d891b1296eb11839ccd6ed210d8767c39fb724 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 4 Apr 2026 02:20:16 +0000 Subject: [PATCH 6/6] test: update graphile-test introspection snapshot for BucketProvisionerPreset --- .../__snapshots__/graphile-test.test.ts.snap | 758 +++++++++++------- 1 file changed, 456 insertions(+), 302 deletions(-) diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index d64fa45ba0..d6ae8caf01 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -117,121 +117,6 @@ exports[`introspection query snapshot: introspection 1`] = ` }, "subscriptionType": null, "types": [ - { - "description": "The root query type which gives access points into the data universe.", - "enumValues": null, - "fields": [ - { - "args": [ - { - "defaultValue": null, - "description": "Only read the first \`n\` values of the set.", - "name": "first", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "Only read the last \`n\` values of the set.", - "name": "last", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor -based pagination. May not be used with \`last\`.", - "name": "offset", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "Read all values in the set before (above) this cursor.", - "name": "before", - "type": { - "kind": "SCALAR", - "name": "Cursor", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "Read all values in the set after (below) this cursor.", - "name": "after", - "type": { - "kind": "SCALAR", - "name": "Cursor", - "ofType": null, - }, - }, - { - "defaultValue": null, - "description": "A filter to be used in determining which values should be returned by the collection.", - "name": "where", - "type": { - "kind": "INPUT_OBJECT", - "name": "UserFilter", - "ofType": null, - }, - }, - { - "defaultValue": "[PRIMARY_KEY_ASC]", - "description": "The method to use when ordering \`User\`.", - "name": "orderBy", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UserOrderBy", - "ofType": null, - }, - }, - }, - }, - ], - "deprecationReason": null, - "description": "Reads and enables pagination through a set of \`User\`.", - "isDeprecated": false, - "name": "users", - "type": { - "kind": "OBJECT", - "name": "UserConnection", - "ofType": null, - }, - }, - { - "args": [], - "deprecationReason": null, - "description": "Metadata about the database schema, including tables, fields, indexes, and constraints. Useful for code generation tools.", - "isDeprecated": false, - "name": "_meta", - "type": { - "kind": "OBJECT", - "name": "MetaSchema", - "ofType": null, - }, - }, - ], - "inputFields": null, - "interfaces": [], - "kind": "OBJECT", - "name": "Query", - "possibleTypes": null, - }, { "description": "A connection to a list of \`User\` values.", "enumValues": null, @@ -2857,146 +2742,78 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "The root mutation type which contains root level fields which mutate data.", + "description": "The output of our create \`User\` mutation.", "enumValues": null, "fields": [ { - "args": [ - { - "defaultValue": null, - "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", - "name": "input", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateUserInput", - "ofType": null, - }, - }, - }, - ], - "deprecationReason": null, - "description": "Creates a single \`User\`.", - "isDeprecated": false, - "name": "createUser", - "type": { - "kind": "OBJECT", - "name": "CreateUserPayload", - "ofType": null, - }, - }, - { - "args": [ - { - "defaultValue": null, - "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", - "name": "input", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateUserInput", - "ofType": null, - }, - }, - }, - ], + "args": [], "deprecationReason": null, - "description": "Updates a single \`User\` using a unique key and a patch.", + "description": "The exact same \`clientMutationId\` that was provided in the mutation input, +unchanged and unused. May be used by a client to track mutations.", "isDeprecated": false, - "name": "updateUser", + "name": "clientMutationId", "type": { - "kind": "OBJECT", - "name": "UpdateUserPayload", + "kind": "SCALAR", + "name": "String", "ofType": null, }, }, { - "args": [ - { - "defaultValue": null, - "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", - "name": "input", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DeleteUserInput", - "ofType": null, - }, - }, - }, - ], + "args": [], "deprecationReason": null, - "description": "Deletes a single \`User\` using a unique key.", + "description": "The \`User\` that was created by this mutation.", "isDeprecated": false, - "name": "deleteUser", + "name": "user", "type": { "kind": "OBJECT", - "name": "DeleteUserPayload", + "name": "User", "ofType": null, }, }, { - "args": [ - { - "defaultValue": null, - "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", - "name": "input", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestUploadUrlInput", - "ofType": null, - }, - }, - }, - ], + "args": [], "deprecationReason": null, - "description": "Request a presigned URL for uploading a file directly to S3. -Client computes SHA-256 of the file content and provides it here. -If a file with the same hash already exists (dedup), returns the -existing file ID and deduplicated=true with no uploadUrl.", + "description": "Our root query field type. Allows us to run any query from our mutation payload.", "isDeprecated": false, - "name": "requestUploadUrl", + "name": "query", "type": { "kind": "OBJECT", - "name": "RequestUploadUrlPayload", + "name": "Query", "ofType": null, }, }, { "args": [ { - "defaultValue": null, - "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", - "name": "input", + "defaultValue": "[PRIMARY_KEY_ASC]", + "description": "The method to use when ordering \`User\`.", + "name": "orderBy", "type": { "kind": "NON_NULL", "name": null, "ofType": { - "kind": "INPUT_OBJECT", - "name": "ConfirmUploadInput", - "ofType": null, + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "UserOrderBy", + "ofType": null, + }, + }, }, }, }, ], "deprecationReason": null, - "description": "Confirm that a file has been uploaded to S3. -Verifies the object exists in S3, checks content-type, -and transitions the file status from 'pending' to 'ready'.", + "description": "An edge for our \`User\`. May be used by Relay 1.", "isDeprecated": false, - "name": "confirmUpload", + "name": "userEdge", "type": { "kind": "OBJECT", - "name": "ConfirmUploadPayload", + "name": "UserEdge", "ofType": null, }, }, @@ -3004,97 +2821,14 @@ and transitions the file status from 'pending' to 'ready'.", "inputFields": null, "interfaces": [], "kind": "OBJECT", - "name": "Mutation", + "name": "CreateUserPayload", "possibleTypes": null, }, { - "description": "The output of our create \`User\` mutation.", + "description": "All input for the create \`User\` mutation.", "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": "The exact same \`clientMutationId\` that was provided in the mutation input, -unchanged and unused. May be used by a client to track mutations.", - "isDeprecated": false, - "name": "clientMutationId", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null, - }, - }, - { - "args": [], - "deprecationReason": null, - "description": "The \`User\` that was created by this mutation.", - "isDeprecated": false, - "name": "user", - "type": { - "kind": "OBJECT", - "name": "User", - "ofType": null, - }, - }, - { - "args": [], - "deprecationReason": null, - "description": "Our root query field type. Allows us to run any query from our mutation payload.", - "isDeprecated": false, - "name": "query", - "type": { - "kind": "OBJECT", - "name": "Query", - "ofType": null, - }, - }, - { - "args": [ - { - "defaultValue": "[PRIMARY_KEY_ASC]", - "description": "The method to use when ordering \`User\`.", - "name": "orderBy", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UserOrderBy", - "ofType": null, - }, - }, - }, - }, - }, - ], - "deprecationReason": null, - "description": "An edge for our \`User\`. May be used by Relay 1.", - "isDeprecated": false, - "name": "userEdge", - "type": { - "kind": "OBJECT", - "name": "UserEdge", - "ofType": null, - }, - }, - ], - "inputFields": null, - "interfaces": [], - "kind": "OBJECT", - "name": "CreateUserPayload", - "possibleTypes": null, - }, - { - "description": "All input for the create \`User\` mutation.", - "enumValues": null, - "fields": null, - "inputFields": [ + "fields": null, + "inputFields": [ { "defaultValue": null, "description": "An arbitrary string value with no semantic meaning. Will be included in the @@ -3712,6 +3446,426 @@ to unexpected results.", "name": "ConfirmUploadPayload", "possibleTypes": null, }, + { + "description": "The root query type which gives access points into the data universe.", + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "Only read the first \`n\` values of the set.", + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null, + }, + }, + { + "defaultValue": null, + "description": "Only read the last \`n\` values of the set.", + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null, + }, + }, + { + "defaultValue": null, + "description": "Skip the first \`n\` values from our \`after\` cursor, an alternative to cursor +based pagination. May not be used with \`last\`.", + "name": "offset", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null, + }, + }, + { + "defaultValue": null, + "description": "Read all values in the set before (above) this cursor.", + "name": "before", + "type": { + "kind": "SCALAR", + "name": "Cursor", + "ofType": null, + }, + }, + { + "defaultValue": null, + "description": "Read all values in the set after (below) this cursor.", + "name": "after", + "type": { + "kind": "SCALAR", + "name": "Cursor", + "ofType": null, + }, + }, + { + "defaultValue": null, + "description": "A filter to be used in determining which values should be returned by the collection.", + "name": "where", + "type": { + "kind": "INPUT_OBJECT", + "name": "UserFilter", + "ofType": null, + }, + }, + { + "defaultValue": "[PRIMARY_KEY_ASC]", + "description": "The method to use when ordering \`User\`.", + "name": "orderBy", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "UserOrderBy", + "ofType": null, + }, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Reads and enables pagination through a set of \`User\`.", + "isDeprecated": false, + "name": "users", + "type": { + "kind": "OBJECT", + "name": "UserConnection", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Metadata about the database schema, including tables, fields, indexes, and constraints. Useful for code generation tools.", + "isDeprecated": false, + "name": "_meta", + "type": { + "kind": "OBJECT", + "name": "MetaSchema", + "ofType": null, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Query", + "possibleTypes": null, + }, + { + "description": "The root mutation type which contains root level fields which mutate data.", + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateUserInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Creates a single \`User\`.", + "isDeprecated": false, + "name": "createUser", + "type": { + "kind": "OBJECT", + "name": "CreateUserPayload", + "ofType": null, + }, + }, + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateUserInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Updates a single \`User\` using a unique key and a patch.", + "isDeprecated": false, + "name": "updateUser", + "type": { + "kind": "OBJECT", + "name": "UpdateUserPayload", + "ofType": null, + }, + }, + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DeleteUserInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Deletes a single \`User\` using a unique key.", + "isDeprecated": false, + "name": "deleteUser", + "type": { + "kind": "OBJECT", + "name": "DeleteUserPayload", + "ofType": null, + }, + }, + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RequestUploadUrlInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Request a presigned URL for uploading a file directly to S3. +Client computes SHA-256 of the file content and provides it here. +If a file with the same hash already exists (dedup), returns the +existing file ID and deduplicated=true with no uploadUrl.", + "isDeprecated": false, + "name": "requestUploadUrl", + "type": { + "kind": "OBJECT", + "name": "RequestUploadUrlPayload", + "ofType": null, + }, + }, + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ConfirmUploadInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Confirm that a file has been uploaded to S3. +Verifies the object exists in S3, checks content-type, +and transitions the file status from 'pending' to 'ready'.", + "isDeprecated": false, + "name": "confirmUpload", + "type": { + "kind": "OBJECT", + "name": "ConfirmUploadPayload", + "ofType": null, + }, + }, + { + "args": [ + { + "defaultValue": null, + "description": "The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.", + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ProvisionBucketInput", + "ofType": null, + }, + }, + }, + ], + "deprecationReason": null, + "description": "Provision an S3 bucket for a logical bucket in the database. +Reads the bucket config via RLS, then creates and configures +the S3 bucket with the appropriate privacy policies, CORS rules, +and lifecycle settings.", + "isDeprecated": false, + "name": "provisionBucket", + "type": { + "kind": "OBJECT", + "name": "ProvisionBucketPayload", + "ofType": null, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Mutation", + "possibleTypes": null, + }, + { + "description": null, + "enumValues": null, + "fields": null, + "inputFields": [ + { + "defaultValue": null, + "description": "The logical bucket key (e.g., "public", "private")", + "name": "bucketKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + ], + "interfaces": null, + "kind": "INPUT_OBJECT", + "name": "ProvisionBucketInput", + "possibleTypes": null, + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "Whether provisioning succeeded", + "isDeprecated": false, + "name": "success", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "The S3 bucket name that was provisioned", + "isDeprecated": false, + "name": "bucketName", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "The access type applied", + "isDeprecated": false, + "name": "accessType", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "The storage provider used", + "isDeprecated": false, + "name": "provider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "The S3 endpoint (null for AWS S3 default)", + "isDeprecated": false, + "name": "endpoint", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Error message if provisioning failed", + "isDeprecated": false, + "name": "error", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "ProvisionBucketPayload", + "possibleTypes": null, + }, { "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", "enumValues": null,