From eb856feabf666175f99b6e26a775ca77f07f1eed Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 11:50:14 +0000 Subject: [PATCH 1/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 e3108898c0d78ad100401d3f064161cc840dbcba Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 23:11:23 +0000 Subject: [PATCH 2/6] feat: add CORS management to bucket provisioner plugin - Add per-call allowedOrigins override to BucketProvisioner.provision() - Add updateCors() method for CORS-only updates on existing buckets - Export UpdateCorsOptions type from bucket-provisioner package - Update graphile plugin with 3-tier CORS resolution hierarchy: bucket-level > storage_module-level > plugin config - Wrap update* mutations on @storageBuckets tables to detect allowed_origins changes and re-apply CORS rules - Read allowed_origins from bucket row in provisionBucket mutation - Support wildcard CORS (['*']) for CDN/public buckets - Add 8 new tests for CORS hierarchy, update hooks, error handling - All 54 plugin tests + 84 bucket-provisioner tests passing --- .../__tests__/plugin.test.ts | 394 +++++++++++++++++- .../src/plugin.ts | 278 +++++++++--- packages/bucket-provisioner/src/index.ts | 1 + .../bucket-provisioner/src/provisioner.ts | 36 +- packages/bucket-provisioner/src/types.ts | 19 + 5 files changed, 661 insertions(+), 67 deletions(-) diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts index d0eb8ad3e2..0ee54bc9d5 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -4,6 +4,9 @@ * Covers: * - provisionBucket mutation (explicit provisioning) * - Auto-provisioning hook on bucket create mutations + * - CORS update hook on bucket update mutations + * - CORS resolution hierarchy (bucket > storage_module > plugin) + * - Wildcard CORS handling (['*']) * - Error handling and graceful degradation * - Connection config resolution (lazy getter, static) * - Bucket name resolution (prefix, custom resolver) @@ -12,12 +15,13 @@ // Mock @constructive-io/bucket-provisioner before any imports const mockProvision = jest.fn(); +const mockUpdateCors = jest.fn(); const mockBucketProvisionerConstructor = jest.fn(); jest.mock('@constructive-io/bucket-provisioner', () => ({ BucketProvisioner: jest.fn().mockImplementation((opts: any) => { mockBucketProvisionerConstructor(opts); - return { provision: mockProvision }; + return { provision: mockProvision, updateCors: mockUpdateCors }; }), })); @@ -104,6 +108,7 @@ function createMockPgClient(overrides: Record = {}) { endpoint: null, public_url_prefix: null, provider: null, + allowed_origins: null, }], }, 'app_public': { @@ -112,6 +117,7 @@ function createMockPgClient(overrides: Record = {}) { key: 'public', type: 'public', is_public: true, + allowed_origins: null, }], }, }; @@ -613,14 +619,14 @@ describe('createBucketProvisionerPlugin', () => { expect(result).toBe(field); }); - it('skips non-create mutations (e.g., updateBucket, deleteBucket)', () => { + it('skips delete mutations (only wraps create and update)', () => { const hook = getFieldsFieldHook(); const field = { resolve: jest.fn() }; const build = {}; const context = { scope: { isRootMutation: true, - fieldName: 'updateBucket', + fieldName: 'deleteBucket', pgCodec: { name: 'Bucket', attributes: { key: {} }, @@ -633,6 +639,29 @@ describe('createBucketProvisionerPlugin', () => { expect(result).toBe(field); }); + it('wraps update mutations on @storageBuckets-tagged tables', () => { + const hook = getFieldsFieldHook(); + const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'updated' } }); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'updateBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {}, allowed_origins: {} }, + 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('wraps create mutations on @storageBuckets-tagged tables', () => { const hook = getFieldsFieldHook(); const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'new-bucket' } }); @@ -828,6 +857,364 @@ describe('createBucketProvisionerPlugin', () => { const wrapped = hook(field, build, context); expect(wrapped.resolve).toBeDefined(); }); + + it('update mutation skips CORS update when allowed_origins not in input', async () => { + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'updated' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'updateBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {}, allowed_origins: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', type: 'public' } } }, // No allowed_origins + { withPgClient: jest.fn(), pgSettings: {} }, + {}, + ); + + expect(result).toBe(mutationResult); + expect(mockUpdateCors).not.toHaveBeenCalled(); + }); + + it('update mutation calls updateCors when allowed_origins is in input', async () => { + mockUpdateCors.mockResolvedValue([]); + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'updated' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'updateBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {}, allowed_origins: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + allowed_origins: ['https://new-origin.example.com'], + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', allowed_origins: ['https://new-origin.example.com'] } } }, + { withPgClient: mockWithPgClient, pgSettings: {} }, + {}, + ); + + expect(result).toBe(mutationResult); + expect(mockUpdateCors).toHaveBeenCalledWith( + expect.objectContaining({ + bucketName: 'public', + accessType: 'public', + allowedOrigins: ['https://new-origin.example.com'], + }), + ); + }); + + it('update mutation returns result even if CORS update fails', async () => { + mockUpdateCors.mockRejectedValue(new Error('S3 CORS update failed')); + const hook = getFieldsFieldHook(); + const mutationResult = { data: { id: 'updated' } }; + const originalResolve = jest.fn().mockResolvedValue(mutationResult); + const field = { resolve: originalResolve }; + const build = {}; + const context = { + scope: { + isRootMutation: true, + fieldName: 'updateBucket', + pgCodec: { + name: 'Bucket', + attributes: { key: {}, type: {}, allowed_origins: {} }, + extensions: { tags: { storageBuckets: true } }, + }, + }, + }; + + const wrapped = hook(field, build, context); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-789', + key: 'public', + type: 'public', + is_public: true, + allowed_origins: ['https://bad-origin.com'], + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + // Should NOT throw + const result = await wrapped.resolve( + null, + { input: { bucket: { key: 'public', allowed_origins: ['https://bad-origin.com'] } } }, + { withPgClient: mockWithPgClient, pgSettings: {} }, + {}, + ); + + expect(result).toBe(mutationResult); + }); + }); +}); + +describe('CORS resolution hierarchy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockProvision.mockReset(); + mockUpdateCors.mockReset(); + mockBucketProvisionerConstructor.mockReset(); + capturedLambdaCallback = null; + }); + + it('uses bucket-level allowed_origins when set', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'cdn-assets', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-cdn', + key: 'cdn-assets', + type: 'public', + is_public: true, + allowed_origins: ['*'], + }], + }, + '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, + allowed_origins: ['https://db-default.example.com'], + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'cdn-assets' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + // Bucket-level ['*'] should take precedence over storage_module and plugin defaults + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ + bucketName: 'cdn-assets', + allowedOrigins: ['*'], + }), + ); + }); + + it('falls back to storage_module allowed_origins when bucket has none', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'uploads', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-uploads', + key: 'uploads', + type: 'public', + is_public: true, + allowed_origins: null, // No bucket-level override + }], + }, + '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, + allowed_origins: ['https://db-default.example.com'], + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'uploads' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + // Should fall back to storage_module level + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ + bucketName: 'uploads', + allowedOrigins: ['https://db-default.example.com'], + }), + ); + }); + + it('falls back to plugin config allowedOrigins when both bucket and storage_module are null', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'docs', + accessType: 'private', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: null, + blockPublicAccess: true, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin(createDefaultOptions({ + allowedOrigins: ['https://plugin-default.example.com'], + })); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-docs', + key: 'docs', + type: 'private', + is_public: false, + allowed_origins: null, + }], + }, + '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, + allowed_origins: null, + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'docs' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + // Should fall back to plugin config + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ + bucketName: 'docs', + allowedOrigins: ['https://plugin-default.example.com'], + }), + ); + }); + + it('wildcard CORS (["*"]) passes through correctly for CDN buckets', async () => { + mockProvision.mockResolvedValue({ + bucketName: 'cdn-public', + accessType: 'public', + endpoint: 'http://minio:9000', + provider: 'minio', + region: 'us-east-1', + publicUrlPrefix: 'https://cdn.example.com', + blockPublicAccess: false, + versioning: false, + corsRules: [], + lifecycleRules: [], + }); + + createBucketProvisionerPlugin(createDefaultOptions()); + + const pgClient = createMockPgClient({ + 'app_public': { + rows: [{ + id: 'bucket-uuid-cdn', + key: 'cdn-public', + type: 'public', + is_public: true, + allowed_origins: ['*'], + }], + }, + }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => + callback(pgClient), + ); + + await capturedLambdaCallback!({ + input: { bucketKey: 'cdn-public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, + }); + + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ + allowedOrigins: ['*'], + }), + ); }); }); @@ -835,6 +1222,7 @@ describe('bucket name resolution', () => { beforeEach(() => { jest.clearAllMocks(); mockProvision.mockReset(); + mockUpdateCors.mockReset(); mockBucketProvisionerConstructor.mockReset(); capturedLambdaCallback = null; }); diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts index ec1b37519c..342e499277 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -11,6 +11,16 @@ * with `@storageBuckets` to automatically provision the S3 bucket after * the database row is created. * + * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables + * to detect changes to `allowed_origins` and re-apply CORS rules to the + * S3 bucket. + * + * CORS resolution hierarchy (most specific wins): + * 1. Bucket-level `allowed_origins` column (per-bucket override) + * 2. Storage-module-level `allowed_origins` column (per-database default) + * 3. Plugin config `allowedOrigins` (global fallback) + * Supports `['*']` for open/CDN mode (wildcard CORS). + * * Both pathways use `@constructive-io/bucket-provisioner` for the actual * S3 operations (bucket creation, Block Public Access, CORS, policies, * versioning, lifecycle rules). @@ -46,7 +56,8 @@ const STORAGE_MODULE_QUERY = ` bt.name AS buckets_table, sm.endpoint, sm.public_url_prefix, - sm.provider + sm.provider, + sm.allowed_origins 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 @@ -61,6 +72,7 @@ interface StorageModuleRow { endpoint: string | null; public_url_prefix: string | null; provider: string | null; + allowed_origins: string[] | null; } interface BucketRow { @@ -68,6 +80,7 @@ interface BucketRow { key: string; type: string; is_public: boolean; + allowed_origins: string[] | null; } // --- Helpers --- @@ -115,6 +128,49 @@ async function resolveDatabaseId(pgClient: any): Promise { return result.rows[0]?.id ?? null; } +/** + * Resolve the effective CORS allowed origins using the 3-tier hierarchy: + * 1. Bucket-level allowed_origins (per-bucket override) + * 2. Storage-module-level allowed_origins (per-database default) + * 3. Plugin config allowedOrigins (global fallback) + */ +function resolveAllowedOrigins( + bucketOrigins: string[] | null | undefined, + storageModuleOrigins: string[] | null | undefined, + pluginOrigins: string[], +): string[] { + if (bucketOrigins && bucketOrigins.length > 0) { + return bucketOrigins; + } + if (storageModuleOrigins && storageModuleOrigins.length > 0) { + return storageModuleOrigins; + } + return pluginOrigins; +} + +/** + * Build a BucketProvisioner with per-database connection overrides. + */ +function buildProvisioner( + options: BucketProvisionerPluginOptions, + storageModule: StorageModuleRow | null, + effectiveOrigins: string[], +): BucketProvisioner { + const connection = resolveConnection(options); + const effectiveConnection: StorageConnectionConfig = { + ...connection, + ...(storageModule?.endpoint ? { endpoint: storageModule.endpoint } : {}), + ...(storageModule?.provider + ? { provider: storageModule.provider as StorageConnectionConfig['provider'] } + : {}), + }; + + return new BucketProvisioner({ + connection: effectiveConnection, + allowedOrigins: effectiveOrigins, + }); +} + /** * Core provisioning logic shared by both the explicit mutation and the * auto-provisioning hook. @@ -124,35 +180,28 @@ async function provisionBucketForRow( databaseId: string, bucketKey: string, bucketType: string, + bucketAllowedOrigins: string[] | null | undefined, 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 + // Read storage module config to check for endpoint/provider/CORS 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'] } - : {}), - }; + // Resolve CORS origins using the 3-tier hierarchy + const effectiveOrigins = resolveAllowedOrigins( + bucketAllowedOrigins, + storageModule?.allowed_origins, + options.allowedOrigins, + ); - const provisioner = new BucketProvisioner({ - connection: effectiveConnection, - allowedOrigins: options.allowedOrigins, - }); + const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); log.info( - `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}") ` + - `for database ${databaseId}`, + `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}", ` + + `origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, ); const result = await provisioner.provision({ @@ -160,6 +209,7 @@ async function provisionBucketForRow( accessType, versioning: options.versioning ?? false, publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, + allowedOrigins: effectiveOrigins, }); log.info( @@ -170,6 +220,45 @@ async function provisionBucketForRow( return result; } +/** + * Update CORS on an existing S3 bucket when allowed_origins changes. + */ +async function updateBucketCors( + pgClient: any, + databaseId: string, + bucketKey: string, + bucketType: string, + bucketAllowedOrigins: string[] | null | undefined, + options: BucketProvisionerPluginOptions, +): Promise { + const s3BucketName = resolveBucketName(bucketKey, databaseId, options); + const accessType = bucketType as 'public' | 'private' | 'temp'; + + const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]); + const storageModule: StorageModuleRow | null = smResult.rows[0] ?? null; + + const effectiveOrigins = resolveAllowedOrigins( + bucketAllowedOrigins, + storageModule?.allowed_origins, + options.allowedOrigins, + ); + + const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); + + log.info( + `Updating CORS on S3 bucket "${s3BucketName}" ` + + `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, + ); + + await provisioner.updateCors({ + bucketName: s3BucketName, + accessType, + allowedOrigins: effectiveOrigins, + }); + + log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`); +} + // --- Plugin factory --- /** @@ -262,7 +351,7 @@ export function createBucketProvisionerPlugin( // Look up the bucket row (RLS enforced via pgSettings) const bucketResult = await pgClient.query( - `SELECT id, key, type, is_public + `SELECT id, key, type, is_public, allowed_origins FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}" WHERE key = $1 LIMIT 1`, @@ -281,6 +370,7 @@ export function createBucketProvisionerPlugin( databaseId, bucket.key, bucket.type, + bucket.allowed_origins, options, ); @@ -322,8 +412,9 @@ export function createBucketProvisionerPlugin( version: '0.1.0', description: 'Auto-provisions S3 buckets when bucket rows are created, ' + + 'updates CORS when allowed_origins changes on update, ' + 'and provides a provisionBucket mutation for explicit provisioning', - after: ['PgAttributesPlugin', 'PgMutationCreatePlugin'], + after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'], schema: { ...mutationPlugin.schema, @@ -331,12 +422,13 @@ export function createBucketProvisionerPlugin( ...((mutationPlugin.schema as any)?.hooks ?? {}), /** - * Wrap create mutation resolvers on tables tagged with @storageBuckets. + * Wrap create and update 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. + * - create*: After the row is created, provision the S3 bucket. + * - update*: After the row is updated, re-apply CORS if allowed_origins changed. + * + * If provisioning/CORS update fails, the DB row still exists (the mutation + * already committed), and the error is logged. Admin can retry via provisionBucket. */ GraphQLObjectType_fields_field(field: any, build: any, context: any) { const { @@ -354,12 +446,15 @@ export function createBucketProvisionerPlugin( return field; } - // Only wrap create mutations (not update/delete) - if (!fieldName.startsWith('create')) { + const isCreate = fieldName.startsWith('create'); + const isUpdate = fieldName.startsWith('update'); + + // Only wrap create and update mutations (not delete) + if (!isCreate && !isUpdate) { return field; } - log.debug(`Wrapping mutation "${fieldName}" for auto-provisioning (codec: ${pgCodec.name})`); + log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`); const defaultResolver = (obj: any) => obj[fieldName]; const { resolve: oldResolve = defaultResolver, ...rest } = field; @@ -367,57 +462,118 @@ export function createBucketProvisionerPlugin( return { ...rest, async resolve(source: any, args: any, graphqlContext: any, info: any) { - // Call the original resolver first (creates the DB row) + // Call the original resolver first (creates/updates 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'); + log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} 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; + if (isCreate) { + // --- CREATE: full provisioning --- + if (!bucketInput?.key || !bucketInput?.type) { + log.warn( + `Auto-provision skipped for "${fieldName}": ` + + `could not extract key/type from mutation input`, + ); + 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, + bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, + options, + ); + }); + } else { + // --- UPDATE: re-apply CORS if allowed_origins is in the patch --- + const hasOriginsUpdate = bucketInput && + ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput); + + if (!hasOriginsUpdate) { + // allowed_origins not being changed, nothing to do + return result; } - await provisionBucketForRow( - pgClient, - databaseId, - bucketInput.key, - bucketInput.type, - options, - ); - }); + await withPgClient(pgSettings, async (pgClient: any) => { + const databaseId = await resolveDatabaseId(pgClient); + if (!databaseId) { + log.warn('CORS update skipped: could not resolve database_id'); + return; + } + + // Read the updated bucket row to get full state + const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]); + if (smResult.rows.length === 0) { + log.warn('CORS update skipped: storage module not provisioned'); + return; + } + const storageModule = smResult.rows[0] as StorageModuleRow; + + // We need the bucket key — it may come from input or patch + // For updates, PostGraphile uses nodeId or the row's PK, so + // we read the bucket from the patch's key or from the nodeId + const patchKey = bucketInput?.key; + if (!patchKey) { + log.warn( + `CORS update skipped for "${fieldName}": ` + + `could not determine bucket key from mutation input`, + ); + return; + } + + // Read the full bucket row (post-update) to get type + origins + const bucketResult = await pgClient.query( + `SELECT id, key, type, is_public, allowed_origins + FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}" + WHERE key = $1 + LIMIT 1`, + [patchKey], + ); + + if (bucketResult.rows.length === 0) { + log.warn(`CORS update skipped: bucket "${patchKey}" not found`); + return; + } + + const bucket = bucketResult.rows[0] as BucketRow; + + await updateBucketCors( + pgClient, + databaseId, + bucket.key, + bucket.type, + bucket.allowed_origins, + 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.`, + `${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` + + (isCreate + ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.` + : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`), ); } diff --git a/packages/bucket-provisioner/src/index.ts b/packages/bucket-provisioner/src/index.ts index 29fe922dd6..9fdd9700fe 100644 --- a/packages/bucket-provisioner/src/index.ts +++ b/packages/bucket-provisioner/src/index.ts @@ -59,6 +59,7 @@ export type { StorageConnectionConfig, BucketAccessType, CreateBucketOptions, + UpdateCorsOptions, CorsRule, LifecycleRule, ProvisionResult, diff --git a/packages/bucket-provisioner/src/provisioner.ts b/packages/bucket-provisioner/src/provisioner.ts index 5ada96b076..dd1c7af9d6 100644 --- a/packages/bucket-provisioner/src/provisioner.ts +++ b/packages/bucket-provisioner/src/provisioner.ts @@ -30,6 +30,7 @@ import type { S3Client } from '@aws-sdk/client-s3'; import type { StorageConnectionConfig, CreateBucketOptions, + UpdateCorsOptions, CorsRule, LifecycleRule, ProvisionResult, @@ -141,10 +142,11 @@ export class BucketProvisioner { await this.deleteBucketPolicy(bucketName); } - // 4. Set CORS rules + // 4. Set CORS rules (per-bucket override takes precedence over default) + const effectiveOrigins = options.allowedOrigins ?? this.allowedOrigins; const corsRules = accessType === 'private' - ? buildPrivateCorsRules(this.allowedOrigins) - : buildUploadCorsRules(this.allowedOrigins); + ? buildPrivateCorsRules(effectiveOrigins) + : buildUploadCorsRules(effectiveOrigins); await this.setCors(bucketName, corsRules); // 5. Versioning @@ -380,6 +382,34 @@ export class BucketProvisioner { } } + /** + * Update CORS configuration on an existing S3 bucket. + * + * Call this when the `allowed_origins` column changes on a bucket row. + * Builds the appropriate CORS rule set for the bucket's access type + * and applies it to the S3 bucket. + * + * @param options - Bucket name, access type, and new allowed origins + * @returns The CORS rules that were applied + */ + async updateCors(options: UpdateCorsOptions): Promise { + const { bucketName, accessType, allowedOrigins } = options; + + if (!allowedOrigins || allowedOrigins.length === 0) { + throw new ProvisionerError( + 'INVALID_CONFIG', + 'allowedOrigins must contain at least one origin for CORS configuration', + ); + } + + const corsRules = accessType === 'private' + ? buildPrivateCorsRules(allowedOrigins) + : buildUploadCorsRules(allowedOrigins); + + await this.setCors(bucketName, corsRules); + return corsRules; + } + /** * Inspect the current configuration of an existing bucket. * diff --git a/packages/bucket-provisioner/src/types.ts b/packages/bucket-provisioner/src/types.ts index d505235047..bdc369c03b 100644 --- a/packages/bucket-provisioner/src/types.ts +++ b/packages/bucket-provisioner/src/types.ts @@ -67,6 +67,25 @@ export interface CreateBucketOptions { * Example: "https://cdn.example.com/public" */ publicUrlPrefix?: string; + /** + * Per-bucket CORS allowed origins override. + * When provided, these origins are used instead of the provisioner's default allowedOrigins. + * Use ['*'] for open/CDN mode (wildcard CORS, any origin can fetch). + * NULL/undefined = use the provisioner's default allowedOrigins. + */ + allowedOrigins?: string[]; +} + +/** + * Options for updating CORS on an existing S3 bucket. + */ +export interface UpdateCorsOptions { + /** The S3 bucket name */ + bucketName: string; + /** Bucket access type — determines which CORS rule set to apply */ + accessType: BucketAccessType; + /** The allowed origins to set. Use ['*'] for open/CDN mode. */ + allowedOrigins: string[]; } // --- CORS configuration --- From 20d734093dbfddb526702a6029ef4dcfa6a58a37 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 3 Apr 2026 23:13:11 +0000 Subject: [PATCH 3/6] docs: update README to document CORS update hook and 3-tier hierarchy --- graphile/graphile-bucket-provisioner-plugin/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphile/graphile-bucket-provisioner-plugin/README.md b/graphile/graphile-bucket-provisioner-plugin/README.md index 00d3f658e9..bbcd597e18 100644 --- a/graphile/graphile-bucket-provisioner-plugin/README.md +++ b/graphile/graphile-bucket-provisioner-plugin/README.md @@ -17,10 +17,13 @@ PostGraphile v5 plugin that automatically provisions S3-compatible buckets when ## Features - **Auto-provisioning hook** — Wraps `create*` mutations on tables tagged with `@storageBuckets` to automatically provision S3 buckets after row creation +- **CORS update hook** — Wraps `update*` mutations to detect `allowed_origins` changes and re-apply CORS rules to the S3 bucket +- **3-tier CORS resolution** — Bucket-level `allowed_origins` → storage module-level `allowed_origins` → plugin config `allowedOrigins` +- **Wildcard CORS** — Set `allowed_origins = ['*']` on a bucket for fully open CDN/public deployments - **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 +- **Per-database overrides** — Reads `endpoint`, `provider`, `public_url_prefix`, and `allowed_origins` 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`) +- **Graceful error handling** — Provisioning and CORS update 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 @@ -146,7 +149,7 @@ The plugin detects tables tagged with `@storageBuckets` (set by the storage modu 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. +The plugin wraps `create*` mutations for auto-provisioning and `update*` mutations for CORS change detection. Delete mutations are not wrapped. ## Error Handling 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,