diff --git a/graphile/graphile-bucket-provisioner-plugin/README.md b/graphile/graphile-bucket-provisioner-plugin/README.md index 83110bca90..1c686a8550 100644 --- a/graphile/graphile-bucket-provisioner-plugin/README.md +++ b/graphile/graphile-bucket-provisioner-plugin/README.md @@ -1,27 +1,12 @@ # graphile-bucket-provisioner-plugin -

- -

+PostGraphile v5 plugin that exposes a `provisionBucket` mutation for manually +queueing storage reconciliation. -

- - - - - -

- -PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through a GraphQL mutation using [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner). - -## Features - -- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any 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 -- **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 -- **Deployment-controlled naming** — Requires a `resolveBucketName` policy for tenant-aware physical bucket names +The constructive-db `storage:provision_bucket` job is the only component that +mints physical S3 bucket names, provisions S3, and records `physical_name`. +This plugin resolves the logical bucket under RLS and enqueues that job; it +does not call S3 or derive a physical name. ## Installation @@ -29,137 +14,43 @@ PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through 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: +## Usage ```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'], - }), - ], +const preset = { + extends: [BucketProvisionerPreset()], }; ``` -## 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: +The mutation accepts a logical bucket key and optionally an owner entity ID: ```graphql mutation { provisionBucket(input: { bucketKey: "public" }) { - success - bucketName - accessType - provider - endpoint - error + bucketId + bucketKey + physicalName + jobId } } ``` -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 | -| `resolveBucketName` | `(databaseId, bucketKey) => string` | Deployment policy for deriving a tenant-aware physical bucket name | -| `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) | - -### `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'; -``` - -The plugin wraps `create*` mutations for auto-provisioning and `update*` mutations for CORS change detection. 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 +`provisionBucket` resolves the bucket row under RLS and enqueues the same +`storage:provision_bucket` job used by the database INSERT trigger. Database +scope jobs include `database_id`, use the `bucket:` queue, and have +25 attempts. Platform scope jobs use the platform trigger payload. Enqueue +failures are returned as GraphQL errors. -The plugin reads per-database overrides from the `storage_module` table: +The payload fields are: -- `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 +| Field | Description | +|-------|-------------| +| `bucketId` | Logical bucket row ID | +| `bucketKey` | Logical bucket key | +| `physicalName` | Recorded physical name, or `null` while reconciliation is pending | +| `jobId` | ID of the queued reconciler job | -This allows different tenants to use different storage backends while sharing the same plugin configuration. +There are no plugin options: S3 connection details, naming, CORS, versioning, +and physical-name recording belong to the reconciler. diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts index 5d48364b27..8b7b1d949b 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -1,27 +1,9 @@ /** - * Tests for the explicit bucket provisioning mutation. + * Tests for the explicit bucket reconciliation enqueue mutation. */ -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(), - })), -})); - let capturedLambdaCallback: Function | null = null; + jest.mock('grafast', () => ({ context: jest.fn(() => ({ get: jest.fn((key: string) => `mock-${key}`), @@ -51,433 +33,282 @@ jest.mock('graphile-utils', () => ({ })); import { createBucketProvisionerPlugin } from '../src/plugin'; -import type { BucketProvisionerPluginOptions } from '../src/types'; - -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'], - resolveBucketName: (databaseId, bucketKey) => `tenant-${databaseId}-${bucketKey}`, - ...overrides, - }; + +const DATABASE_ID = 'db-uuid-123'; +const BUCKET_ID = 'bucket-uuid-789'; +const BUCKETS_TABLE_ID = 'buckets-table-uuid'; +const OWNER_ID = 'owner-uuid-222'; +const JOB_ID = 'job-uuid-999'; + +interface MockOptions { + scope: string; + entityField: string | null; + physicalName?: string | null; + scopeKey?: string | null; + orgResolver?: { + entity_type: string; + get_org_fn_schema: string; + get_org_fn: string; + } | null; + bucketFound?: boolean; } -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', - scope: 'app', - entity_table_id: null, - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: null, - entity_schema: null, - entity_table: null, - }], - }, - app_public: { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: null, - physical_name: null, - }], - }, - }; - - return { - query: jest.fn((arg: any) => { - const sql: string = typeof arg === 'string' ? arg : arg.text; - for (const [key, value] of Object.entries({ ...defaultQueries, ...overrides })) { - if (sql.includes(key)) return Promise.resolve(value); - } - return Promise.resolve({ rows: [] }); - }), - }; +function createMockPgClient({ + scope, + entityField, + physicalName = null, + scopeKey = null, + orgResolver = null, + bucketFound = true, +}: MockOptions) { + const query = jest.fn((arg: any) => { + const sql: string = typeof arg === 'string' ? arg : arg.text; + if (sql.includes('jwt_private.current_database_id')) { + return Promise.resolve({ rows: [{ id: DATABASE_ID }] }); + } + if (sql.includes('metaschema_modules_public.storage_module')) { + return Promise.resolve({ + rows: [{ + id: 'sm-uuid-456', + database_id: DATABASE_ID, + buckets_table_id: BUCKETS_TABLE_ID, + scope, + entity_field: entityField, + entity_table_id: entityField === 'owner_id' ? 'entity-table-uuid' : null, + buckets_schema: 'app_public', + buckets_table: 'buckets', + endpoint: null, + public_url_prefix: null, + provider: null, + allowed_origins: null, + entity_schema: entityField === 'owner_id' ? 'app_public' : null, + entity_table: entityField === 'owner_id' ? 'accounts' : null, + }], + }); + } + if (sql.includes('FROM app_public.accounts')) { + return Promise.resolve({ rows: [{ id: OWNER_ID }] }); + } + if (sql.includes('FROM app_public.buckets')) { + return Promise.resolve({ + rows: bucketFound + ? [{ + id: BUCKET_ID, + key: 'public', + physical_name: physicalName, + scope_key: scopeKey, + }] + : [], + }); + } + if (sql.includes('metaschema.resolve_entity_context_by_field')) { + return Promise.resolve({ rows: orgResolver ? [orgResolver] : [{ + entity_type: scope, + get_org_fn_schema: null, + get_org_fn: null, + }] }); + } + if (sql.includes('app_jobs.add_job')) { + return Promise.resolve({ rows: [{ id: JOB_ID }] }); + } + throw new Error(`unexpected query: ${sql}`); + }); + return { query }; +} + +async function invoke(pgClient: any, input: Record = { bucketKey: 'public' }) { + const withPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); + return capturedLambdaCallback!({ + input, + withPgClient, + pgSettings: { role: 'admin' }, + }); +} + +function enqueueCall(pgClient: any): { text: string; values: unknown[] } { + const call = pgClient.query.mock.calls.find((args: any[]) => + args[0]?.text?.includes('app_jobs.add_job')); + expect(call).toBeDefined(); + return call[0]; } describe('createBucketProvisionerPlugin', () => { beforeEach(() => { jest.clearAllMocks(); - mockProvision.mockReset(); - mockBucketProvisionerConstructor.mockReset(); capturedLambdaCallback = null; - mockProvision.mockResolvedValue({ - bucketName: 'tenant-db-uuid-123-public', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); }); it('returns a mutation-only plugin', () => { - const plugin = createBucketProvisionerPlugin(createDefaultOptions()); + const plugin = createBucketProvisionerPlugin(); expect(plugin).toBeDefined(); expect(plugin.name).toBe('ExtendSchemaPlugin'); expect(plugin.schema).toBeDefined(); - expect(plugin.schema!.hooks).toEqual({}); - }); - - 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('tenant-db-uuid-123-public'); - expect(result.accessType).toBe('public'); - expect(result.provider).toBe('minio'); - expect(result.error).toBeNull(); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'tenant-db-uuid-123-public' }), - ); }); - it('uses the database-first resolver order and never passes the bare key', async () => { - const resolveBucketName = jest.fn( - (databaseId: string, bucketKey: string) => `physical-${databaseId}-${bucketKey}`, - ); - createBucketProvisionerPlugin(createDefaultOptions({ resolveBucketName })); - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, + it('enqueues the global-scope reconciliation job without entity attribution', async () => { + createBucketProvisionerPlugin(); + const pgClient = createMockPgClient({ scope: 'app', entityField: null }); + + const result = await invoke(pgClient); + const enqueue = enqueueCall(pgClient); + + expect(enqueue.text).toContain("identifier => 'storage:provision_bucket'"); + expect(enqueue.text).toContain("'id', $1::uuid"); + expect(enqueue.text).toContain("'scope', $2::text"); + expect(enqueue.text).toContain("queue_name => 'bucket:' || $1::text"); + expect(enqueue.text).toContain('max_attempts => 25'); + expect(enqueue.text).toContain('priority => 0'); + expect(enqueue.text).not.toContain('entity_id =>'); + expect(enqueue.text).not.toContain('organization_id =>'); + expect(enqueue.values).toEqual([BUCKET_ID, 'app']); + expect(result).toEqual({ + bucketId: BUCKET_ID, + bucketKey: 'public', + physicalName: null, + jobId: JOB_ID, }); - - expect(resolveBucketName).toHaveBeenCalledWith('db-uuid-123', 'public'); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'physical-db-uuid-123-public' }), - ); - expect(mockProvision).not.toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'public' }), - ); }); - it('throws when no physical bucket naming policy is configured', async () => { - createBucketProvisionerPlugin(createDefaultOptions({ resolveBucketName: undefined })); - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - - await expect( - capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }), - ).rejects.toThrow('STORAGE_BUCKET_NAME_POLICY_MISSING'); - expect(mockProvision).not.toHaveBeenCalled(); - }); - - it('provisions a private bucket', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'tenant-db-uuid-123-private', - accessType: 'private', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: true, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - createBucketProvisionerPlugin(createDefaultOptions()); + it('enqueues the platform global-scope reconciliation job', async () => { + createBucketProvisionerPlugin(); const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-private', - key: 'private', - type: 'private', - is_public: false, - allowed_origins: null, - physical_name: null, - }], - }, + scope: 'platform', + entityField: null, + physicalName: 'existing-physical-name', }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'private' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }); + const result = await invoke(pgClient); + const enqueue = enqueueCall(pgClient); - expect(result.success).toBe(true); - expect(result.accessType).toBe('private'); + expect(enqueue.text).not.toContain('db_id =>'); + expect(enqueue.text).not.toContain('entity_type =>'); + expect(enqueue.values).toEqual([BUCKET_ID, 'platform']); + expect(result.physicalName).toBe('existing-physical-name'); }); - it('throws INVALID_BUCKET_KEY for an 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 modules exist', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + it('enqueues the exact database-scope reconciliation job', async () => { + createBucketProvisionerPlugin(); 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 the 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: 'missing' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }), - ).rejects.toThrow('BUCKET_NOT_FOUND'); - }); - - it('returns an 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: {}, + scope: 'database', + entityField: 'database_id', + scopeKey: DATABASE_ID, }); - expect(result.success).toBe(false); - expect(result.error).toBe('S3 connection refused'); - expect(result.bucketName).toBe('tenant-db-uuid-123-public'); + const result = await invoke(pgClient); + const enqueue = enqueueCall(pgClient); + const bucketLookup = pgClient.query.mock.calls.find((args: any[]) => + args[0]?.text?.includes('FROM app_public.buckets')); + + expect(bucketLookup[0].text).toContain('database_id AS scope_key'); + expect(bucketLookup[0].values).toEqual(['public']); + expect(enqueue.text).toContain("'database_id', $2::uuid"); + expect(enqueue.text).toContain("'id', $1::uuid"); + expect(enqueue.text).toContain("'scope', $3::text"); + expect(enqueue.text).toContain('db_id => $2'); + expect(enqueue.text).toContain('entity_id => $2'); + expect(enqueue.text).toContain('organization_id => NULL'); + expect(enqueue.text).toContain('entity_type => $3'); + expect(enqueue.values).toEqual([BUCKET_ID, DATABASE_ID, 'database']); + expect(result.jobId).toBe(JOB_ID); }); - it('records the physical name with the record-once guard', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }); - - const update = pgClient.query.mock.calls.find( - (call: any[]) => call[0]?.text?.includes('SET physical_name'), - ); - expect(update).toBeDefined(); - expect(update![0].text).toContain('physical_name IS NULL'); - expect(update![0].values).toEqual(['tenant-db-uuid-123-public', 'bucket-uuid-789']); - expect(mockWithPgClient).toHaveBeenCalledWith(null, expect.any(Function)); - }); - - it('provisions the stored physical name verbatim', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + it('enqueues an entity-scope job with the resolved organization function', async () => { + createBucketProvisionerPlugin(); const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: null, - physical_name: 'preexisting-cdn-bucket', - }], + scope: 'org', + entityField: 'owner_id', + scopeKey: OWNER_ID, + orgResolver: { + entity_type: 'org', + get_org_fn_schema: 'org_private', + get_org_fn: 'get_organization_id', }, }); - mockProvision.mockResolvedValue({ - bucketName: 'preexisting-cdn-bucket', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }); - expect(result.success).toBe(true); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'preexisting-cdn-bucket' }), + await invoke(pgClient, { bucketKey: 'public', ownerId: OWNER_ID }); + const enqueue = enqueueCall(pgClient); + const bucketLookup = pgClient.query.mock.calls.find((args: any[]) => + args[0]?.text?.includes('FROM app_public.buckets')); + const resolverLookup = pgClient.query.mock.calls.find((args: any[]) => + args[0]?.text?.includes('metaschema.resolve_entity_context_by_field')); + + expect(bucketLookup[0].text).toContain('owner_id AS scope_key'); + expect(bucketLookup[0].values).toEqual(['public', OWNER_ID]); + expect(resolverLookup[0].values).toEqual([ + DATABASE_ID, + BUCKETS_TABLE_ID, + 'owner_id', + ]); + expect(enqueue.text).toContain("'id', $1::uuid"); + expect(enqueue.text).toContain("'owner_id', $2::uuid"); + expect(enqueue.text).toContain("'scope', $3::text"); + expect(enqueue.text).toContain('entity_id => $2'); + expect(enqueue.text).toContain( + 'organization_id => org_private.get_organization_id($3::text, $2::uuid)', ); - const update = pgClient.query.mock.calls.find( - (call: any[]) => call[0]?.text?.includes('SET physical_name'), - ); - expect(update).toBeDefined(); - expect(update![0].text).toContain('physical_name IS NULL'); + expect(enqueue.text).toContain('entity_type => $3'); + expect(enqueue.values).toEqual([BUCKET_ID, OWNER_ID, 'org']); }); - it('does not record a name 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)); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, + it('enqueues an entity-scope job with NULL organization without a resolver', async () => { + createBucketProvisionerPlugin(); + const pgClient = createMockPgClient({ + scope: 'user', + entityField: 'owner_id', + scopeKey: OWNER_ID, }); - const update = pgClient.query.mock.calls.find( - (call: any[]) => call[0]?.text?.includes('SET physical_name'), - ); - expect(update).toBeUndefined(); + await invoke(pgClient, { bucketKey: 'public', ownerId: OWNER_ID }); + const enqueue = enqueueCall(pgClient); + const resolverLookup = pgClient.query.mock.calls.find((args: any[]) => + args[0]?.text?.includes('metaschema.resolve_entity_context_by_field')); + + expect(resolverLookup[0].values).toEqual([ + DATABASE_ID, + BUCKETS_TABLE_ID, + 'owner_id', + ]); + expect(enqueue.text).toContain("'owner_id', $2::uuid"); + expect(enqueue.text).toContain('entity_id => $2'); + expect(enqueue.text).toContain('organization_id => NULL'); + expect(enqueue.text).toContain('entity_type => $3'); + expect(enqueue.values).toEqual([BUCKET_ID, OWNER_ID, 'user']); }); - it('applies storage-module endpoint and public URL overrides', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + it('propagates enqueue failures as GraphQL errors', async () => { + createBucketProvisionerPlugin(); const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - scope: 'app', - entity_table_id: null, - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: 'http://custom-minio:9000', - public_url_prefix: 'https://cdn.example.com', - provider: 'minio', - allowed_origins: null, - entity_schema: null, - entity_table: null, - }], - }, + scope: 'database', + entityField: 'database_id', + scopeKey: DATABASE_ID, }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, + pgClient.query.mockImplementation((arg: any): Promise => { + const sql: string = typeof arg === 'string' ? arg : arg.text; + if (sql.includes('app_jobs.add_job')) { + return Promise.reject(new Error('enqueue failed')); + } + return createMockPgClient({ + scope: 'database', + entityField: 'database_id', + scopeKey: DATABASE_ID, + }).query(arg); }); - expect(mockBucketProvisionerConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - connection: expect.objectContaining({ - endpoint: 'http://custom-minio:9000', - provider: 'minio', - }), - }), - ); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ publicUrlPrefix: 'https://cdn.example.com' }), - ); + await expect(invoke(pgClient)).rejects.toThrow('enqueue failed'); }); - it('passes the versioning option to the provisioner', 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: {}, - }); + it('throws for invalid and missing bucket inputs', async () => { + createBucketProvisionerPlugin(); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ versioning: true }), - ); - }); + await expect(invoke({ query: jest.fn() }, { bucketKey: '' })) + .rejects.toThrow('INVALID_BUCKET_KEY'); - it('caches a lazy connection getter', async () => { - const connection = { - provider: 'minio' as const, - region: 'us-east-1', - endpoint: 'http://minio:9000', - accessKeyId: 'minioadmin', - secretAccessKey: 'minioadmin', - }; - const getter = jest.fn(() => connection); - 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: {}, - }); - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, + const pgClient = createMockPgClient({ + scope: 'app', + entityField: null, + bucketFound: false, }); - - expect(getter).toHaveBeenCalledTimes(1); + await expect(invoke(pgClient)).rejects.toThrow('BUCKET_NOT_FOUND'); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts index 1500b907af..46eda8289f 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts @@ -1,26 +1,9 @@ /** - * Tests for the bucket provisioner preset. + * Tests for the bucket reconciliation 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}`), - })), + context: jest.fn(), lambda: jest.fn(), object: jest.fn((obj: any) => obj), })); @@ -41,53 +24,10 @@ jest.mock('graphile-utils', () => ({ 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'], - 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'], - }); + it('returns a preset with the reconciliation plugin', () => { + const preset = BucketProvisionerPreset(); expect(preset.plugins).toHaveLength(1); + expect(preset.plugins![0]).toBeDefined(); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts index 8fd78d139d..dd7f01894a 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts @@ -1,188 +1,33 @@ /** - * Tests for the bucket provisioner plugin types. - * - * Validates type definitions, interfaces, and re-exports are correct. + * Tests for the bucket reconciliation mutation types. */ -import type { - BucketAccessType, - BucketNameResolver, - BucketProvisionerPluginOptions, - ConnectionConfigOrGetter, - ProvisionBucketInput, - ProvisionBucketPayload, - ProvisionResult, - StorageConnectionConfig, - StorageProvider, -} 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'], - resolveBucketName: (dbId, key) => `${dbId}-${key}`, - versioning: true, - }; - - expect(options.resolveBucketName).toBeDefined(); - expect(options.versioning).toBe(true); - }); -}); - -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 databaseId and bucketKey and returns a string', () => { - const resolver: BucketNameResolver = (databaseId, bucketKey) => - `org-${databaseId}-${bucketKey}`; - - expect(resolver('db-123', 'public')).toBe('org-db-123-public'); - expect(resolver('db-456', 'private')).toBe('org-db-456-private'); - }); -}); +import type { ProvisionBucketInput, ProvisionBucketPayload } from '../src/types'; describe('ProvisionBucketInput', () => { - it('has a bucketKey field', () => { + it('has a bucket key and optional owner id', () => { const input: ProvisionBucketInput = { bucketKey: 'public', + ownerId: 'owner-123', }; - expect(input.bucketKey).toBe('public'); + expect(input).toEqual({ + bucketKey: 'public', + ownerId: 'owner-123', + }); }); }); 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', () => { + it('represents a queued reconciliation job', () => { 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: [], + bucketId: 'bucket-123', + bucketKey: 'public', + physicalName: null, + jobId: 'job-123', }; - expect(result.blockPublicAccess).toBe(true); - expect(result.corsRules).toEqual([]); + expect(payload.physicalName).toBeNull(); + expect(payload.jobId).toBe('job-123'); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts index 6541939a94..ede59b325e 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/index.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts @@ -1,46 +1,13 @@ /** * Bucket Provisioner Plugin for PostGraphile v5 * - * Provides an explicit `provisionBucket` mutation for PostGraphile v5. - * - * @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: getConnection, // pass function ref, NOT getConnection() - * allowedOrigins: ['https://app.example.com'], - * }), - * ], - * }; - * ``` + * Provides an explicit `provisionBucket` reconciliation enqueue mutation for + * PostGraphile v5. */ export { BucketProvisionerPlugin, createBucketProvisionerPlugin } from './plugin'; export { BucketProvisionerPreset } from './preset'; export type { - BucketAccessType, - BucketNameResolver, - BucketProvisionerPluginOptions, - ConnectionConfigOrGetter, ProvisionBucketInput, ProvisionBucketPayload, - ProvisionResult, - StorageConnectionConfig, - StorageProvider, } from './types'; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts index 03e435bcd5..16714cf632 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -3,13 +3,9 @@ * * Adds S3 bucket provisioning support to PostGraphile v5: * - * 1. `provisionBucket` mutation — explicitly provision an S3 bucket for a + * 1. `provisionBucket` mutation — explicitly enqueue reconciliation for a * logical bucket row in the database. Reads the bucket config via RLS, - * then calls BucketProvisioner to create and configure the S3 bucket. - * - * This plugin uses `@constructive-io/bucket-provisioner` for the actual - * S3 operations (bucket creation, Block Public Access, CORS, policies, - * versioning, lifecycle rules). + * then queues the same storage reconciler job used by the INSERT trigger. * * Detection: Uses the `@storageBuckets` smart tag on the codec (table). * The storage module generator in constructive-db sets this tag on the @@ -17,23 +13,11 @@ * COMMENT ON TABLE buckets IS E'@storageBuckets\nStorage buckets table'; */ -import type { ProvisionResult,StorageConnectionConfig } from '@constructive-io/bucket-provisioner'; -import { - BucketProvisioner, -} from '@constructive-io/bucket-provisioner'; -import { Logger } from '@pgpmjs/logger'; import { QuoteUtils } from '@pgsql/quotes'; import { context as grafastContext, lambda, object } from 'grafast'; import type { GraphileConfig } from 'graphile-config'; -import { recordPhysicalName as recordPhysicalBucketName } from 'graphile-storage-registry'; import { extendSchema, gql } from 'graphile-utils'; -import type { - BucketProvisionerPluginOptions, -} from './types'; - -const log = new Logger('graphile-bucket-provisioner:plugin'); - // --- Storage module queries --- /** @@ -43,7 +27,10 @@ const log = new Logger('graphile-bucket-provisioner:plugin'); const ALL_STORAGE_MODULES_QUERY = ` SELECT sm.id, + sm.database_id, + sm.buckets_table_id, sm.scope, + sm.entity_field, sm.entity_table_id, bs.schema_name AS buckets_schema, bt.name AS buckets_table, @@ -63,7 +50,10 @@ const ALL_STORAGE_MODULES_QUERY = ` interface StorageModuleRow { id: string; + database_id: string; + buckets_table_id: string; scope: string; + entity_field: string | null; entity_table_id: string | null; buckets_schema: string; buckets_table: string; @@ -91,6 +81,21 @@ function runQuery( return pgClient.query(values === undefined ? { text } : { text, values }); } +function scopeKeySelect(storageModule: StorageModuleRow): string { + return storageModule.entity_field === null + ? '' + : `, ${QuoteUtils.quoteIdentifier(storageModule.entity_field)} AS scope_key`; +} + +function scopeKeyColumn(storageModule: StorageModuleRow): string { + if (storageModule.entity_field === null) { + throw new Error( + `STORAGE_BUCKET_SCOPE_KEY_MISSING: storage module ${storageModule.id} has no entity field`, + ); + } + return QuoteUtils.quoteIdentifier(storageModule.entity_field); +} + /** * The explicit provisionBucket mutation's resolution: find the plane that * actually holds the named bucket row. @@ -130,9 +135,9 @@ async function resolveBucketByKey( const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table); const bucketResult = await runQuery( pgClient, - `SELECT id, key, type, is_public, allowed_origins, physical_name + `SELECT id, key, type, is_public, allowed_origins, physical_name${scopeKeySelect(mod)} FROM ${bucketsTable} - WHERE key = $1 AND owner_id = $2 + WHERE key = $1 AND ${scopeKeyColumn(mod)} = $2 LIMIT 1`, [bucketKey, ownerId], ); @@ -147,7 +152,7 @@ async function resolveBucketByKey( const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table); const bucketResult = await runQuery( pgClient, - `SELECT id, key, type, is_public, allowed_origins, physical_name + `SELECT id, key, type, is_public, allowed_origins, physical_name${scopeKeySelect(mod)} FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, @@ -172,61 +177,12 @@ async function resolveBucketByKey( interface BucketRow { id: string; key: string; - type: string; - is_public: boolean; - allowed_origins: string[] | null; physical_name: string | null; -} - -/** - * Normalize the recorded physical coordinate at the DB boundary. - * - * A bucket row either carries a recorded coordinate or it does not; SQL nulls - * and absent columns both mean "never provisioned". Collapsing them here is - * the single place that shape is interpreted — callers branch on `string` - * vs `null` and never coalesce a bucket name into existence. - */ -function storedPhysicalName(row: Pick): string | null { - return row.physical_name == null ? null : row.physical_name; + scope_key: string | null; } // --- 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( - databaseId: string, - bucketKey: string, - options: BucketProvisionerPluginOptions, -): string { - if (!options.resolveBucketName) { - throw new Error( - 'STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' + - `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` + - 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' + - 'connection default and is never a tenant bucket.', - ); - } - return options.resolveBucketName(databaseId, bucketKey); -} - /** * Resolve the database_id from the JWT context. */ @@ -238,91 +194,111 @@ 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'] } - : {}), +async function resolveEntityContext( + pgClient: any, + storageModule: StorageModuleRow, +): Promise<{ entity_type: string | null; get_org_fn_schema: string | null; get_org_fn: string | null }> { + const result = await runQuery( + pgClient, + `SELECT r.entity_type, r.get_org_fn_schema, r.get_org_fn + FROM metaschema.resolve_entity_context_by_field($1::uuid, $2::uuid, $3::text) r`, + [storageModule.database_id, storageModule.buckets_table_id, storageModule.entity_field], + ); + return result.rows[0] ?? { + entity_type: null, + get_org_fn_schema: null, + get_org_fn: null, }; - - return new BucketProvisioner({ - connection: effectiveConnection, - allowedOrigins: effectiveOrigins, - }); } /** - * Core provisioning logic for the explicit mutation. + * Mirror the storage module generator's data_job_trigger shape from + * `packages/metaschema-generators/deploy/schemas/metaschema_generators/procedures/storage_module.sql` + * and the add_job argument assembly in + * `packages/ast-plpgsql/deploy/schemas/ast_plpgsql_helpers/procedures/triggers/job_trigger.sql`. + * A callable enqueue function beside the trigger would be the durable fix for + * this deliberate mirroring, but is out of scope here. */ -async function provisionBucketForRow( +async function enqueueReconciliationJob( + pgClient: any, storageModule: StorageModuleRow, - databaseId: string, - bucketKey: string, - bucketType: string, - bucketAllowedOrigins: string[] | null | undefined, - options: BucketProvisionerPluginOptions, - s3BucketName: string, -): Promise { - const accessType = bucketType as 'public' | 'private' | 'temp'; - - // Resolve CORS origins using the 3-tier hierarchy - const effectiveOrigins = resolveAllowedOrigins( - bucketAllowedOrigins, - storageModule?.allowed_origins, - options.allowedOrigins, - ); - - const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); - - log.info( - `Provisioning S3 bucket "${s3BucketName}" (key="${bucketKey}", type="${accessType}", ` + - `origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, - ); - - const result = await provisioner.provision({ - bucketName: s3BucketName, - accessType, - versioning: options.versioning ?? false, - publicUrlPrefix: storageModule?.public_url_prefix ?? undefined, - allowedOrigins: effectiveOrigins, - }); - - log.info( - `Successfully provisioned S3 bucket "${s3BucketName}" ` + - `(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`, - ); + bucket: BucketRow, +): Promise { + const scope = storageModule.scope; + const entityField = storageModule.entity_field; + + let text: string; + let values: unknown[]; + + if (entityField === null) { + text = `SELECT (app_jobs.add_job( + identifier => 'storage:provision_bucket', + payload => json_build_object( + 'id', $1::uuid, + 'scope', $2::text + ), + queue_name => 'bucket:' || $1::text, + max_attempts => 25, + priority => 0 + )).id AS id`; + values = [bucket.id, scope]; + } else if (entityField === 'database_id') { + const databaseId = bucket.scope_key; + if (!databaseId) { + throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no database_id`); + } + text = `SELECT (app_jobs.add_job( + identifier => 'storage:provision_bucket', + payload => json_build_object( + 'database_id', $2::uuid, + 'id', $1::uuid, + 'scope', $3::text + ), + db_id => $2, + queue_name => 'bucket:' || $1::text, + max_attempts => 25, + priority => 0, + entity_id => $2, + organization_id => NULL, + entity_type => $3 + )).id AS id`; + values = [bucket.id, databaseId, scope]; + } else if (entityField === 'owner_id') { + const ownerId = bucket.scope_key; + if (!ownerId) { + throw new Error(`STORAGE_BUCKET_SCOPE_KEY_MISSING: bucket ${bucket.id} has no owner_id`); + } + const context = await resolveEntityContext(pgClient, storageModule); + const orgFunction = context.get_org_fn_schema && context.get_org_fn + ? `${QuoteUtils.quoteQualifiedIdentifier(context.get_org_fn_schema, context.get_org_fn)}($3::text, $2::uuid)` + : 'NULL'; + text = `SELECT (app_jobs.add_job( + identifier => 'storage:provision_bucket', + payload => json_build_object( + 'id', $1::uuid, + 'owner_id', $2::uuid, + 'scope', $3::text + ), + queue_name => 'bucket:' || $1::text, + max_attempts => 25, + priority => 0, + entity_id => $2, + organization_id => ${orgFunction}, + entity_type => $3 + )).id AS id`; + values = [bucket.id, ownerId, scope]; + } else { + throw new Error( + `STORAGE_BUCKET_ENTITY_FIELD_UNSUPPORTED: ${entityField}`, + ); + } - return result; + const result = await runQuery(pgClient, text, values); + const jobId = result.rows[0]?.id; + if (!jobId) { + throw new Error(`STORAGE_BUCKET_JOB_ID_MISSING: bucket ${bucket.id}`); + } + return jobId; } // --- Plugin factory --- @@ -330,17 +306,14 @@ async function provisionBucketForRow( /** * Creates the bucket provisioner plugin. * - * This plugin provides one provisioning pathway: + * This plugin provides one reconciliation pathway: * * 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. + * bucket key to enqueue reconciliation (or re-reconciliation) for the + * bucket. Protected by RLS on the buckets table. * - * @param options - Plugin configuration (S3 credentials, CORS origins, naming) */ -export function createBucketProvisionerPlugin( - options: BucketProvisionerPluginOptions, -): GraphileConfig.Plugin { +export function createBucketProvisionerPlugin(): GraphileConfig.Plugin { // The extendSchema plugin adds the explicit provisionBucket mutation const mutationPlugin = extendSchema(() => ({ typeDefs: gql` @@ -355,26 +328,22 @@ export function createBucketProvisionerPlugin( } 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 + """The logical bucket row that was queued for reconciliation.""" + bucketId: UUID! + bucketKey: String! + """The physical bucket name already recorded, or null when reconciliation has not completed.""" + physicalName: String + """The reconciler job enqueued to provision this bucket.""" + jobId: UUID! } 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. + Reconcile an S3 bucket for a logical bucket in the database. + Reads the bucket config via RLS, then enqueues the same + storage:provision_bucket job used by the INSERT trigger. This is + idempotent for an already-reconciled bucket; enqueue failures become + GraphQL errors. """ provisionBucket( input: ProvisionBucketInput! @@ -414,56 +383,14 @@ export function createBucketProvisionerPlugin( throw new Error('BUCKET_NOT_FOUND'); } const { storageModule, bucket } = resolution; - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); - - // First provision mints a name; afterwards the stored coordinate - // is authoritative and the naming hook is never consulted again. - const recorded = storedPhysicalName(bucket); - const s3BucketName = recorded === null - ? resolveBucketName(databaseId, bucket.key, options) - : recorded; - - try { - const result = await provisionBucketForRow( - storageModule, - databaseId, - bucket.key, - bucket.type, - bucket.allowed_origins, - options, - s3BucketName, - ); - - // Record the exact provisioned name on the source row. - await withPgClient(null, (client: any) => - recordPhysicalBucketName( - (query) => runQuery(client, query.text, query.values), - bucketsTable, - bucket.id, - result.bucketName, - ), - ); - log.info(`Recorded physical_name="${result.bucketName}" on bucket ${bucket.id}`); - - 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: s3BucketName, - accessType: bucket.type, - provider: resolveConnection(options).provider, - endpoint: resolveConnection(options).endpoint ?? null, - error: err.message, - }; - } + const jobId = await enqueueReconciliationJob(pgClient, storageModule, bucket); + + return { + bucketId: bucket.id, + bucketKey: bucket.key, + physicalName: bucket.physical_name, + jobId, + }; }); }); }, diff --git a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts index 446c447bad..89d01aea9c 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts @@ -8,43 +8,13 @@ import type { GraphileConfig } from 'graphile-config'; import { createBucketProvisionerPlugin } from './plugin'; -import type { BucketProvisionerPluginOptions } from './types'; /** - * Creates a preset that includes the bucket provisioner plugin with the given options. - * - * @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: getConnection, // pass function ref, NOT getConnection() - * allowedOrigins: ['https://app.example.com'], - * }), - * ], - * }; - * ``` + * Creates a preset that includes the bucket reconciliation plugin. */ -export function BucketProvisionerPreset( - options: BucketProvisionerPluginOptions, -): GraphileConfig.Preset { +export function BucketProvisionerPreset(): GraphileConfig.Preset { return { - plugins: [createBucketProvisionerPlugin(options)], + plugins: [createBucketProvisionerPlugin()], }; } diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts index 5761b5052c..92829b76cb 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/types.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts @@ -2,66 +2,6 @@ * Types for the bucket provisioner plugin. */ -import type { - BucketAccessType, - ProvisionResult, - StorageConnectionConfig, - StorageProvider, -} from '@constructive-io/bucket-provisioner'; - -// Re-export types that consumers will need -export type { BucketAccessType, ProvisionResult,StorageConnectionConfig, StorageProvider }; - -/** - * 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 databaseId - The metaschema database UUID - * @param bucketKey - The logical bucket key from the database (e.g., "public", "private") - * @returns The S3 bucket name to create/configure - */ -export type BucketNameResolver = (databaseId: string, bucketKey: 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 custom function to derive S3 bucket names from logical bucket keys. - * Naming is a deployment policy and must be supplied by the caller. - */ - resolveBucketName?: BucketNameResolver; - - /** - * Whether to enable versioning on provisioned buckets. - * Default: false - */ - versioning?: boolean; -} - /** * Input for the provisionBucket mutation. */ @@ -76,19 +16,15 @@ export interface ProvisionBucketInput { } /** - * Result of the provisionBucket mutation. + * Result of the provisionBucket reconciliation enqueue 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; + /** The logical bucket row queued for reconciliation */ + bucketId: string; + /** The logical bucket key */ + bucketKey: string; + /** The physical name already recorded, or null while reconciliation is pending */ + physicalName: string | null; + /** The queued reconciler job */ + jobId: string; } diff --git a/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts index 56578087f1..e7a011d85e 100644 --- a/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts +++ b/graphile/graphile-presigned-url-plugin/__tests__/managed-upload.test.ts @@ -117,7 +117,6 @@ function options(): PresignedUrlPluginOptions { region: 'us-east-1', publicUrlPrefix: 'https://cdn.example.com', }, - resolveBucketName: (databaseId: string, bucketKey: string) => `myapp-${bucketKey}-${databaseId}`, }; } @@ -265,33 +264,32 @@ describe('resolveManagedUploadTarget', () => { ).rejects.toThrow('BUCKET_PATH_KEYED'); }); - it('records the physical name on first provision instead of re-minting it', async () => { + it('rejects an unreconciled bucket without calling S3 or provisioning', async () => { const { resolveManagedUploadTarget } = await import('../src/managed-upload'); - const ensureBucketProvisioned = jest.fn().mockResolvedValue(undefined); + const send = jest.fn(); + const baseS3 = options().s3 as S3Config; const db = fakeDb([ SET_CONFIG, NO_REGISTRY_ROW, STORAGE_MODULES, { match: /resolve_default_bucket/, rows: () => [{ bucket_id: BUCKET_ID, resolved_key: 'default-public', bucket_type: 'public', physical_name: null }] }, { match: /SELECT id, key, type/, rows: () => [bucketRow({ physical_name: null })] }, - { match: /UPDATE storage_public\.app_buckets/, rows: () => [] }, ]); - const target = await resolveManagedUploadTarget({ - options: { ...options(), ensureBucketProvisioned }, + await expect(resolveManagedUploadTarget({ + options: { + ...options(), + s3: { ...baseS3, client: { send } as any }, + }, withPgClient: db.withPgClient, pgSettings: null, databaseId: DATABASE_ID, field: FIELD, defaultPublicAccess: true, - }); + })).rejects.toThrow('STORAGE_BUCKET_NOT_RECONCILED'); - expect(target.physicalName).toBe(`myapp-default-public-${DATABASE_ID}`); - expect(ensureBucketProvisioned).toHaveBeenCalledWith( - `myapp-default-public-${DATABASE_ID}`, 'public', DATABASE_ID, null, - ); - const update = db.queries.find((q) => /UPDATE/.test(q.text)); - expect(update?.values).toEqual([`myapp-default-public-${DATABASE_ID}`, BUCKET_ID]); + expect(send).not.toHaveBeenCalled(); + expect(db.queries.some((q) => /UPDATE/.test(q.text))).toBe(false); }); it('raises when the database has no storage module to default to', async () => { diff --git a/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts b/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts index 1fb4c8e620..d9c7485d24 100644 --- a/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts +++ b/graphile/graphile-presigned-url-plugin/__tests__/s3-failure.test.ts @@ -9,11 +9,13 @@ * reachable as `cause`. */ -import { provisionAndRecordPhysicalBucket } from '../src/physical-bucket'; +import { + assertBucketReconciled, + StorageBucketNotReconciledError, +} from '../src/physical-bucket'; import { describeS3Failure, s3FailureError } from '../src/s3-failure'; import { generatePresignedPutUrl } from '../src/s3-signer'; -import { clearStorageModuleCache } from '../src/storage-module-cache'; -import type { BucketConfig, PresignedUrlPluginOptions, StorageModuleConfig } from '../src/types'; +import type { BucketConfig } from '../src/types'; /** An unreachable endpoint, as undici surfaces it: no message of its own. */ function connectionRefused(): AggregateError { @@ -113,36 +115,32 @@ describe('the upload lane', () => { ).rejects.toThrow(/PRESIGN_PUT_FAILED.*endpoint=http:\/\/localhost:9000/s); }); - it('reports an unreachable object store when provisioning a bucket, naming the endpoint', async () => { - clearStorageModuleCache(); - const cause = connectionRefused(); - const options: PresignedUrlPluginOptions = { - s3: { client: {} as any, bucket: 'connection-default', endpoint: 'http://localhost:9000' }, - resolveBucketName: () => 'app-public-abc', - ensureBucketProvisioned: () => Promise.reject(cause), - }; - const withPgClient = jest.fn(); + it('reports an unreconciled bucket with a retryable typed error', () => { + const bucket = { + id: 'bucket-1', + key: 'public', + physical_name: null, + } as BucketConfig; + + expect(() => assertBucketReconciled( + bucket, + '00000000-0000-0000-0000-0000000000db', + )).toThrow('STORAGE_BUCKET_NOT_RECONCILED'); - let thrown: any; try { - await provisionAndRecordPhysicalBucket( - options, - withPgClient as any, - { bucketsQualifiedName: 'app_public.buckets' } as StorageModuleConfig, - '00000000-0000-0000-0000-0000000000db', - { id: 'bucket-1', key: 'public', type: 'public', physical_name: null } as BucketConfig, - null, - ); - } catch (err) { - thrown = err; + assertBucketReconciled(bucket, '00000000-0000-0000-0000-0000000000db'); + } catch (err: any) { + expect(err).toBeInstanceOf(StorageBucketNotReconciledError); + expect(err.code).toBe('STORAGE_BUCKET_NOT_RECONCILED'); + expect(err.retryable).toBe(true); + expect(err.extensions).toEqual({ + code: 'STORAGE_BUCKET_NOT_RECONCILED', + retryable: true, + }); + expect(err.message).toContain('public'); + expect(err.message).toContain('bucket-1'); + expect(err.message).toContain('00000000-0000-0000-0000-0000000000db'); + expect(err.message).toContain('reconciler has not yet recorded a physical name'); } - - expect(thrown?.message).toContain('BUCKET_PROVISION_FAILED'); - expect(thrown?.message).toContain('endpoint=http://localhost:9000'); - expect(thrown?.message).toContain('bucket=app-public-abc'); - expect(thrown?.message).toContain('ECONNREFUSED'); - expect(thrown?.cause).toBe(cause); - // A failed provision must not record a physical name. - expect(withPgClient).not.toHaveBeenCalled(); }); }); diff --git a/graphile/graphile-presigned-url-plugin/src/index.ts b/graphile/graphile-presigned-url-plugin/src/index.ts index dcb4dde5d5..d4c1963c5d 100644 --- a/graphile/graphile-presigned-url-plugin/src/index.ts +++ b/graphile/graphile-presigned-url-plugin/src/index.ts @@ -47,17 +47,20 @@ export { type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload'; -export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket'; +export { + assertBucketReconciled, + resolveS3, + resolveS3ForDatabase, + StorageBucketNotReconciledError, +} from './physical-bucket'; export { createPresignedUrlPlugin,PresignedUrlPlugin } from './plugin'; export { PresignedUrlPreset } from './preset'; export { type WithPgClient, withRequestPgClient } from './request-pg-client'; export { describeS3Failure, s3FailureError } from './s3-failure'; export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer'; -export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache'; +export { clearBucketCache, clearStorageModuleCache, getBucketConfig, loadAllStorageModules,resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache'; export type { BucketConfig, - BucketNameResolver, - EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, diff --git a/graphile/graphile-presigned-url-plugin/src/managed-upload.ts b/graphile/graphile-presigned-url-plugin/src/managed-upload.ts index 659a403236..25dbb318c1 100644 --- a/graphile/graphile-presigned-url-plugin/src/managed-upload.ts +++ b/graphile/graphile-presigned-url-plugin/src/managed-upload.ts @@ -23,7 +23,7 @@ import { Logger } from '@pgpmjs/logger'; import { resolveDefaultBucket } from './default-bucket'; import { isLiveFileRow, statusSelectFragment } from './file-lifecycle'; import { type FileRefFieldBinding, getFileRefFieldBinding } from './file-ref-registry'; -import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket'; +import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket'; import { type WithPgClient, withRequestPgClient } from './request-pg-client'; import { copyS3Object, deleteS3Object } from './s3-signer'; import { recordManagedFile } from './storage-file-recorder'; @@ -212,11 +212,7 @@ export async function resolveManagedUploadTarget(args: { ); } - const physicalName = bucket.physical_name === null - ? await provisionAndRecordPhysicalBucket( - options, withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins, - ) - : bucket.physical_name; + const physicalName = assertBucketReconciled(bucket, databaseId); return { databaseId, diff --git a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts index cf04907ee0..9778ef73e8 100644 --- a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts +++ b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts @@ -1,23 +1,31 @@ /** - * Physical bucket coordinates: minting a name once, recording it, and building - * an S3 config against a *known* name. + * Physical bucket coordinates: reading the reconciler's recorded name and + * building an S3 config against that known name. * * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The - * mapping is recorded on the bucket row the first time it is provisioned, and - * from then on the recorded value is the only coordinate anything reads — no - * name is ever recomputed from a prefix convention, and there is no - * environment-level bucket standing in for a tenant's. + * mapping is recorded on the bucket row by the storage reconciler, and that + * value is the only coordinate anything reads — no name is ever recomputed. */ -import { Logger } from '@pgpmjs/logger'; -import { recordPhysicalName } from 'graphile-storage-registry'; - -import { type WithPgClient, withRequestPgClient } from './request-pg-client'; -import { s3FailureError } from './s3-failure'; -import { isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache'; import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types'; -const log = new Logger('graphile-presigned-url:physical-bucket'); +export class StorageBucketNotReconciledError extends Error { + readonly code = 'STORAGE_BUCKET_NOT_RECONCILED'; + readonly retryable = true; + readonly extensions = { + code: 'STORAGE_BUCKET_NOT_RECONCILED', + retryable: true, + }; + + constructor(bucket: BucketConfig, databaseId: string) { + super( + `STORAGE_BUCKET_NOT_RECONCILED: bucket "${bucket.key}" (id=${bucket.id}) ` + + `for database ${databaseId} has not yet been reconciled; the reconciler has ` + + 'not yet recorded a physical name', + ); + this.name = 'StorageBucketNotReconciledError'; + } +} /** * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing @@ -36,38 +44,11 @@ export function resolveS3(options: PresignedUrlPluginOptions): S3Config { return options.s3; } -/** - * Mint the physical S3 bucket name for a logical bucket's first provision. - * - * This is a naming *policy*, consulted exactly once per bucket — before the - * physical bucket exists. Once provisioned, the recorded `physical_name` on the - * row is authoritative and this function must not be consulted again. - * - * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket - * name is not a tenant's storage, and silently minting one is how objects ended - * up in a bucket no database owned. A deployment that wants per-tenant buckets - * must supply the policy. - */ -export function mintPhysicalBucketName( - options: PresignedUrlPluginOptions, - databaseId: string, - bucketKey: string, -): string { - if (!options.resolveBucketName) { - throw new Error( - 'STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' + - `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` + - 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' + - 'connection default and is never a tenant bucket.', - ); - } - return options.resolveBucketName(databaseId, bucketKey); -} /** * Build the S3 config for a *known* physical bucket. `physicalName` is - * required — callers must resolve the coordinate (stored row value, or a - * freshly provisioned name) before getting here. No name is ever recomputed. + * required — callers must resolve the coordinate from the stored row value + * before getting here. No name is ever recomputed. */ export function resolveS3ForDatabase( options: PresignedUrlPluginOptions, @@ -91,68 +72,11 @@ export function resolveS3ForDatabase( } /** - * First provision of a logical bucket: mint a name, create the physical S3 - * bucket, and record the exact name on the source row. Returns the recorded - * physical name. - * - * Only called when the row has no `physical_name` yet. Afterwards the stored - * value is the durable coordinate: route resolution and every later read use - * it verbatim; nothing is recomputed. - * - * The record write runs in the system lane (privileged role, so it bypasses the - * RLS that stops request roles from UPDATE-ing bucket rows) — it is server - * bookkeeping, not request data. It still carries the tenant `database_id` - * claim, because the buckets table's catalog-sync trigger calls - * `jwt_private.current_database_id()` and would otherwise raise - * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the - * write's transaction without switching off the privileged role. - * `bucket` (the cached config) is mutated in place so subsequent reads observe - * the recorded name without a DB round-trip. + * Return the reconciler's recorded physical name, or fail with a typed, + * retryable error while reconciliation is still pending. */ -export async function provisionAndRecordPhysicalBucket( - options: PresignedUrlPluginOptions, - withPgClient: WithPgClient, - storageConfig: StorageModuleConfig, - databaseId: string, - bucket: BucketConfig, - allowedOrigins: string[] | null, -): Promise { - const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key); - - if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) { - log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`); - try { - await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins); - } catch (err) { - // The first upload to a bucket is where an unreachable object store is - // discovered, and the transport's own message is routinely empty: name the - // endpoint it could not reach so the response says what is misconfigured. - throw s3FailureError( - 'BUCKET_PROVISION_FAILED', - { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId }, - err, - ); - } - markS3BucketProvisioned(s3BucketName); - log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`); - } +export function assertBucketReconciled(bucket: BucketConfig, databaseId: string): string { + if (bucket.physical_name !== null) return bucket.physical_name; - // Record the physical coordinate on the source row. The `physical_name IS NULL` - // guard keeps this idempotent and race-safe across concurrent first uploads. - // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the - // write runs under the resolved database claim (privileged role preserved). - await withRequestPgClient( - withPgClient, - { 'jwt.claims.database_id': databaseId }, - (client) => - recordPhysicalName( - (query) => client.query(query), - storageConfig.bucketsQualifiedName, - bucket.id, - s3BucketName, - ), - ); - bucket.physical_name = s3BucketName; - log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`); - return s3BucketName; + throw new StorageBucketNotReconciledError(bucket, databaseId); } diff --git a/graphile/graphile-presigned-url-plugin/src/plugin.ts b/graphile/graphile-presigned-url-plugin/src/plugin.ts index 8b846e102c..544a65295b 100644 --- a/graphile/graphile-presigned-url-plugin/src/plugin.ts +++ b/graphile/graphile-presigned-url-plugin/src/plugin.ts @@ -31,7 +31,7 @@ import { validateCustomKey } from './custom-key'; import { resolveDefaultBucket } from './default-bucket'; import { isLiveFileRow, statusSelectFragment } from './file-lifecycle'; import { buildFileProjection, type FileProjection } from './managed-upload'; -import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket'; +import { assertBucketReconciled, resolveS3ForDatabase } from './physical-bucket'; import { withRequestPgClient } from './request-pg-client'; import { deleteS3Object,generatePresignedPutUrl } from './s3-signer'; import { recordManagedFile } from './storage-file-recorder'; @@ -272,11 +272,9 @@ export function createPresignedUrlPlugin( ); if (!bucket) throw new Error('BUCKET_NOT_FOUND'); - // First provision mints + records the coordinate; afterwards the - // stored physical_name is authoritative and nothing is recomputed. - const physicalName = bucket.physical_name === null - ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins) - : bucket.physical_name; + // The reconciler records the coordinate; consumers never + // recompute it from the logical bucket row. + const physicalName = assertBucketReconciled(bucket, databaseId); const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName); // File row INSERT under the request role (RLS enforced). @@ -407,11 +405,9 @@ export function createPresignedUrlPlugin( ); } - // First provision mints + records the coordinate; afterwards the - // stored physical_name is authoritative and nothing is recomputed. - const physicalName = bucket.physical_name === null - ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins) - : bucket.physical_name; + // The reconciler records the coordinate; consumers never + // recompute it from the logical bucket row. + const physicalName = assertBucketReconciled(bucket, databaseId); const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName); // File row INSERTs under the request role (RLS enforced). diff --git a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts index 9c4c77542c..54e74b5422 100644 --- a/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts +++ b/graphile/graphile-presigned-url-plugin/src/storage-module-cache.ts @@ -347,38 +347,6 @@ export async function getBucketConfig( return config; } -// --- S3 bucket existence cache --- - -/** - * In-memory set of S3 bucket names that are known to exist. - * - * Used by the lazy provisioning logic in the presigned URL plugin: - * before generating a presigned PUT URL, the plugin checks this set. - * If the bucket name is absent, it calls `ensureBucketProvisioned` - * to create the S3 bucket, then adds the name here. Subsequent - * requests for the same bucket skip the provisioning entirely. - * - * No TTL needed — S3 buckets are never deleted during normal operation. - * The set resets on server restart, which is fine because the - * provisioner's createBucket is idempotent (handles "already exists"). - */ -const provisionedBuckets = new Set(); - -/** - * Check whether an S3 bucket has already been provisioned (cached). - */ -export function isS3BucketProvisioned(s3BucketName: string): boolean { - return provisionedBuckets.has(s3BucketName); -} - -/** - * Mark an S3 bucket as provisioned in the in-memory cache. - */ -export function markS3BucketProvisioned(s3BucketName: string): void { - provisionedBuckets.add(s3BucketName); - log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`); -} - /** * Clear the storage module cache AND bucket cache. * Useful for testing or schema changes. @@ -386,7 +354,6 @@ export function markS3BucketProvisioned(s3BucketName: string): void { export function clearStorageModuleCache(): void { storageModuleCache.clear(); bucketCache.clear(); - provisionedBuckets.clear(); } /** diff --git a/graphile/graphile-presigned-url-plugin/src/types.ts b/graphile/graphile-presigned-url-plugin/src/types.ts index c64d7436ce..510bda872e 100644 --- a/graphile/graphile-presigned-url-plugin/src/types.ts +++ b/graphile/graphile-presigned-url-plugin/src/types.ts @@ -13,10 +13,9 @@ export interface BucketConfig { max_file_size: number | null; allow_custom_keys: boolean; /** - * The physical S3/MinIO bucket name recorded when the physical bucket was - * first provisioned. NULL until the first upload provisions it. Once set, - * it is the source of truth for the physical bucket — reads never - * reconstruct the name from a prefix convention. + * The physical S3/MinIO bucket name recorded by reconciliation. NULL until + * reconciliation completes. Once set, it is the source of truth for the + * physical bucket — reads never reconstruct the name. */ physical_name: string | null; } @@ -198,40 +197,6 @@ export interface S3Config { */ export type S3ConfigOrGetter = S3Config | (() => S3Config); -/** - * Function to derive the actual S3 bucket name for a given database and bucket key. - * - * When provided, the presigned URL plugin calls this on every request - * to determine which S3 bucket to use — enabling per-(database, bucketKey) - * isolation. If not provided, falls back to `s3Config.bucket` (global). - * - * @param databaseId - The metaschema database UUID - * @param bucketKey - The logical bucket key (e.g., "public", "private") - * @returns The S3 bucket name for this database + bucket key - */ -export type BucketNameResolver = (databaseId: string, bucketKey: string) => string; - -/** - * Callback to lazily provision an S3 bucket on first use. - * - * Called by the presigned URL plugin before generating a presigned PUT URL - * when the bucket has not been seen before (tracked in an in-memory cache). - * The implementation should create and fully configure the S3 bucket - * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the - * bucket already exists. - * - * @param bucketName - The S3 bucket name to provision - * @param accessType - The logical bucket type ('public', 'private', 'temp') - * @param databaseId - The metaschema database UUID - * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback - */ -export type EnsureBucketProvisioned = ( - bucketName: string, - accessType: 'public' | 'private' | 'temp', - databaseId: string, - allowedOrigins: string[] | null, -) => Promise; - /** * Plugin options for the presigned URL plugin. */ @@ -239,19 +204,4 @@ export interface PresignedUrlPluginOptions { /** S3 configuration (concrete or lazy getter) */ s3: S3ConfigOrGetter; - /** - * Optional function to resolve S3 bucket name per-database. - * When set, each database gets its own S3 bucket instead of sharing - * the global `s3Config.bucket`. The S3 credentials (client) remain shared. - */ - resolveBucketName?: BucketNameResolver; - - /** - * Optional callback to lazily provision an S3 bucket on first upload. - * When set, the plugin calls this before generating a presigned PUT URL - * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache). - * This enables graceful bucket creation without requiring buckets to - * exist at database provisioning time. - */ - ensureBucketProvisioned?: EnsureBucketProvisioned; } diff --git a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts deleted file mode 100644 index e9ba0d31b5..0000000000 --- a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Unit test: ConstructivePreset wires the tenant-aware bucket-name resolver - * into BucketProvisionerPreset (not just into the presigned URL plugin). - * - * Regression guard for the bug where eager `provisionBucket` fell back to the - * bare logical bucket key because the provisioner preset was left without a - * resolveBucketName — diverging from the lazy first-upload path and risking - * cross-tenant bucket-name collisions. - */ - -const captured: { bucketProvisionerOptions?: any } = {}; - -// Capture the options handed to BucketProvisionerPreset without pulling in the -// real plugin (and its S3 machinery). -jest.mock('graphile-bucket-provisioner-plugin', () => ({ - BucketProvisionerPreset: jest.fn((options: any) => { - captured.bucketProvisionerOptions = options; - return { plugins: [] as any[] }; - }), -})); - -// The preset reads CDN config eagerly when building the presigned/provisioner -// plugin options; provide a prefix so name minting is deterministic. -const PREFIX = 'test-bucket'; -jest.mock('@constructive-io/graphql-env', () => ({ - getEnvOptions: jest.fn(() => ({ - cdn: { - bucketName: PREFIX, - provider: 'minio', - awsRegion: 'us-east-1', - awsAccessKey: 'test', - awsSecretKey: 'test', - endpoint: 'http://localhost:9000', - }, - })), -})); - -import { createConstructivePreset } from '../src/presets/constructive-preset'; - -const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; - -describe('ConstructivePreset bucket-provisioner wiring', () => { - beforeEach(() => { - captured.bucketProvisionerOptions = undefined; - }); - - it('passes a resolveBucketName into BucketProvisionerPreset when presigned uploads are enabled', () => { - createConstructivePreset(); - - const options = captured.bucketProvisionerOptions; - expect(options).toBeDefined(); - expect(typeof options.resolveBucketName).toBe('function'); - }); - - it('the wired resolver mints the tenant-aware {prefix}-{bucketKey}-{digest} name', () => { - createConstructivePreset(); - - const { resolveBucketName } = captured.bucketProvisionerOptions; - // Both plugins use the signature: (databaseId, bucketKey) - expect(resolveBucketName(DATABASE_ID, 'public')).toMatch( - new RegExp(`^${PREFIX}-public-[a-f0-9]{12}$`), - ); - expect(resolveBucketName(DATABASE_ID, 'private')).toMatch( - new RegExp(`^${PREFIX}-private-[a-f0-9]{12}$`), - ); - // The digest is what carries the tenant, so two databases cannot collide. - expect(resolveBucketName(DATABASE_ID, 'public')).not.toBe( - resolveBucketName('11111111-2222-3333-4444-555555555555', 'public'), - ); - }); - - it('does not wire the provisioner preset when presigned uploads are disabled', () => { - createConstructivePreset({ enablePresignedUploads: false }); - expect(captured.bucketProvisionerOptions).toBeUndefined(); - }); -}); diff --git a/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts b/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts index 4c148388b6..49ecd6b151 100644 --- a/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts +++ b/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts @@ -1,19 +1,15 @@ /** - * Unit tests for the bucket-name resolvers. - * - * The presigned (lazy) upload path and the bucket-provisioner (eager) path must - * mint the *same* physical S3 bucket name for a given (database, bucket key) - * pair — `{prefix}-{bucketKey}-{digest}` — so a bucket's physical coordinate is - * identical regardless of which path first provisions it. - * - * Both plugins consume the same resolver with the same argument order. The - * remaining tests pin the properties S3 enforces on a bucket name: bounded - * length, a restricted alphabet, and — because the name is truncated — a tail - * that still separates identities the readable part can no longer distinguish. + * Unit tests for the connection-default S3 configuration. */ interface CdnOptions { + provider?: string; bucketName?: string; + awsRegion?: string; + awsAccessKey?: string; + awsSecretKey?: string; + endpoint?: string; + publicUrlPrefix?: string; } async function loadResolverModule(cdn: CdnOptions | undefined) { @@ -22,74 +18,61 @@ async function loadResolverModule(cdn: CdnOptions | undefined) { jest.doMock('@constructive-io/graphql-env', () => ({ getEnvOptions: jest.fn(() => ({ cdn })), })); + jest.doMock('@constructive-io/s3-utils', () => ({ + createS3Client: jest.fn(() => ({ send: jest.fn() })), + })); + jest.doMock('@pgpmjs/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ info: jest.fn() })), + })); return import('../src/presigned-url-resolver'); } -const PREFIX = 'test-bucket'; -const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; -const S3_BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; - -describe('bucket-name resolvers', () => { - it('presigned resolver mints {prefix}-{bucketKey}-{digest}', async () => { - const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX }); - const resolve = createBucketNameResolver(); - - // presigned plugin signature: (databaseId, bucketKey) - expect(resolve(DATABASE_ID, 'public')).toMatch(/^test-bucket-public-[a-f0-9]{12}$/); - expect(resolve(DATABASE_ID, 'private')).toMatch(/^test-bucket-private-[a-f0-9]{12}$/); - }); - - it('mints the identical name used by the bucket provisioner', async () => { - const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX }); - const resolve = createBucketNameResolver(); - - expect(resolve(DATABASE_ID, 'public')).toBe(resolve(DATABASE_ID, 'public')); +const BASE_CDN: CdnOptions = { + provider: 'minio', + bucketName: 'connection-default', + awsRegion: 'us-east-1', + awsAccessKey: 'access', + awsSecretKey: 'secret', + endpoint: 'http://localhost:9000', + publicUrlPrefix: 'https://cdn.example.com', +}; + +describe('getPresignedUrlS3Config', () => { + it('returns the configured connection-default bucket', async () => { + const { getPresignedUrlS3Config } = await loadResolverModule(BASE_CDN); + + expect(getPresignedUrlS3Config()).toEqual(expect.objectContaining({ + bucket: 'connection-default', + region: 'us-east-1', + endpoint: 'http://localhost:9000', + publicUrlPrefix: 'https://cdn.example.com', + })); }); - it('names are stable across calls and resolver instances', async () => { - const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX }); + it('caches the initialized S3 configuration', async () => { + const { getPresignedUrlS3Config } = await loadResolverModule(BASE_CDN); - const first = createBucketNameResolver(); - const second = createBucketNameResolver(); - - expect(first(DATABASE_ID, 'public')).toBe(first(DATABASE_ID, 'public')); - expect(second(DATABASE_ID, 'public')).toBe(first(DATABASE_ID, 'public')); + expect(getPresignedUrlS3Config()).toBe(getPresignedUrlS3Config()); }); - it('stays inside S3 length and alphabet limits for oversized, mixed-case inputs', async () => { - const { createBucketNameResolver } = await loadResolverModule({ - bucketName: 'Some_Very.Long CDN Prefix That Nobody Would Choose', + it('requires a CDN bucket name for the connection default', async () => { + const { getPresignedUrlS3Config } = await loadResolverModule({ + ...BASE_CDN, + bucketName: undefined, }); - const resolve = createBucketNameResolver(); - - const name = resolve(DATABASE_ID, 'Marketing_Site/Assets — 2024'.repeat(5)); - expect(name.length).toBeLessThanOrEqual(63); - expect(name.length).toBeGreaterThanOrEqual(3); - expect(name).toMatch(S3_BUCKET_NAME); + expect(() => getPresignedUrlS3Config()).toThrow(/CDN_BUCKET_NAME/); }); - it('separates identities that survive truncation identically', async () => { - const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX }); - const resolve = createBucketNameResolver(); - - const shared = 'a'.repeat(80); - // Same truncated prefix, different full keys. - expect(resolve(DATABASE_ID, `${shared}-one`)).not.toBe(resolve(DATABASE_ID, `${shared}-two`)); - // Same key, different tenant. - expect(resolve(DATABASE_ID, 'public')).not.toBe( - resolve('11111111-2222-3333-4444-555555555555', 'public'), - ); - }); + it('requires CDN configuration and credentials', async () => { + const missingConfig = await loadResolverModule(undefined); + expect(() => missingConfig.getPresignedUrlS3Config()).toThrow(/CDN config not found/); - it('presigned resolver throws (no default bucket name) when the prefix is missing', async () => { - const { createBucketNameResolver } = await loadResolverModule({}); - expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); - }); - - it('throws when CDN config is entirely absent', async () => { - const { createBucketNameResolver } = await loadResolverModule(undefined); - expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); + const missingCredentials = await loadResolverModule({ + ...BASE_CDN, + awsAccessKey: undefined, + }); + expect(() => missingCredentials.getPresignedUrlS3Config()).toThrow(/S3 credentials/); }); }); diff --git a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts b/graphile/graphile-settings/src/bucket-provisioner-resolver.ts deleted file mode 100644 index 28107a0d6d..0000000000 --- a/graphile/graphile-settings/src/bucket-provisioner-resolver.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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(); - - if (!cdn) { - throw new Error( - '[bucket-provisioner-resolver] CDN config not found. ' + - 'Ensure CDN environment variables (AWS_ACCESS_KEY, AWS_SECRET_KEY, etc.) ' + - 'are set or that pgpmDefaults provides CDN fields.', - ); - } - - const { provider, awsRegion, awsAccessKey, awsSecretKey, endpoint } = cdn; - - if (!awsAccessKey || !awsSecretKey) { - throw new Error( - '[bucket-provisioner-resolver] Missing S3 credentials. ' + - 'Set AWS_ACCESS_KEY and AWS_SECRET_KEY environment variables.', - ); - } - - 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 afa9154a82..44cdb45c6b 100644 --- a/graphile/graphile-settings/src/index.ts +++ b/graphile/graphile-settings/src/index.ts @@ -60,6 +60,3 @@ export { makePgService }; // 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 fcd08fd78a..bf15e4cb24 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -13,7 +13,6 @@ import { RealtimeSubscriptionsPreset } from 'graphile-realtime-subscriptions'; import { createMatchesOperatorFactory, createTrgmOperatorFactories,UnifiedSearchPreset } from 'graphile-search'; import { UploadPreset } from 'graphile-upload-plugin'; -import { getBucketProvisionerConnection } from '../bucket-provisioner-resolver'; import { ConflictDetectorPreset, EnableAllFilterColumnsPreset, @@ -26,7 +25,7 @@ import { PgTypeMappingsPreset, RequiredInputPreset } from '../plugins'; -import { createBucketNameResolver, createEnsureBucketProvisioned, getAllowedOrigins,getPresignedUrlS3Config } from '../presigned-url-resolver'; +import { getPresignedUrlS3Config } from '../presigned-url-resolver'; import { constructiveUploadFieldDefinitions } from '../upload-resolver'; /** @@ -201,14 +200,8 @@ export function createConstructivePreset( presets.push( PresignedUrlPreset({ s3: getPresignedUrlS3Config, - resolveBucketName: createBucketNameResolver(), - ensureBucketProvisioned: createEnsureBucketProvisioned() }), - BucketProvisionerPreset({ - connection: getBucketProvisionerConnection, - allowedOrigins: getAllowedOrigins(), - resolveBucketName: createBucketNameResolver() - }) + BucketProvisionerPreset() ); } diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts index 821cb0156c..5e12f141e6 100644 --- a/graphile/graphile-settings/src/presigned-url-resolver.ts +++ b/graphile/graphile-settings/src/presigned-url-resolver.ts @@ -5,19 +5,13 @@ * (getEnvOptions → pgpmDefaults + config files + env vars) and lazily * initializes an S3Client on first use. * - * Also provides a per-database bucket name resolver that derives the - * S3 bucket name from the database UUID + a configurable prefix. - * * Follows the same lazy-init pattern as upload-resolver.ts. */ -import { BucketProvisioner, mintPhysicalBucketName } from '@constructive-io/bucket-provisioner'; import { getEnvOptions } from '@constructive-io/graphql-env'; import { createS3Client } from '@constructive-io/s3-utils'; import { Logger } from '@pgpmjs/logger'; -import type { BucketNameResolver, EnsureBucketProvisioned,S3Config } from 'graphile-presigned-url-plugin'; - -import { getBucketProvisionerConnection } from './bucket-provisioner-resolver'; +import type { S3Config } from 'graphile-presigned-url-plugin'; const log = new Logger('presigned-url-resolver'); @@ -32,8 +26,8 @@ let s3Config: S3Config | null = null; * * NOTE: The `bucket` field here is only the connection's default and is never * uploaded to. Every managed upload names its bucket explicitly, resolved from - * the tenant's logical bucket row via `resolveBucketName`; there is no - * environment-global upload bucket. + * the tenant's logical bucket row; there is no environment-global upload + * bucket. */ export function getPresignedUrlS3Config(): S3Config { if (s3Config) return s3Config; @@ -86,100 +80,3 @@ export function getPresignedUrlS3Config(): S3Config { return s3Config; } - -/** - * Read the configured physical-bucket-name prefix (CDN_BUCKET_NAME). - * - * There is no default: a missing prefix throws, mirroring - * getPresignedUrlS3Config, so an untenanted bucket name can never be minted. - */ -function getBucketNamePrefix(): string { - const { cdn } = getEnvOptions(); - const prefix = cdn?.bucketName; - - if (!prefix) { - throw new Error( - '[presigned-url-resolver] Missing CDN bucket name prefix. ' + - 'Set CDN_BUCKET_NAME environment variable; there is no default bucket name.', - ); - } - - return prefix; -} - -/** - * Create a per-(database, bucketKey) bucket name resolver for the presigned - * URL plugin (argument order: `(databaseId, bucketKey)`). - * - * Uses CDN_BUCKET_NAME as a prefix. For each (database, bucketKey) pair, the - * S3 bucket name becomes `{prefix}-{bucketKey}-{digest}`. - * - * This aligns with the bucket provisioner plugin which creates separate - * S3 buckets per logical bucket key. - */ -export function createBucketNameResolver(): BucketNameResolver { - const prefix = getBucketNamePrefix(); - return (databaseId: string, bucketKey: string): string => - mintPhysicalBucketName(prefix, databaseId, bucketKey); -} - -/** - * Resolve CORS allowed origins from the env/config system. - * - * Reads SERVER_ORIGIN from the standard env hierarchy - * (pgpmDefaults → config file → env vars) and wraps it in an array. - * Falls back to ['http://localhost:3000'] for local development. - */ -export function getAllowedOrigins(): string[] { - const { server } = getEnvOptions(); - if (server?.origin) return [server.origin]; - return ['*']; -} - -/** - * Create a lazy bucket provisioner callback for the presigned URL plugin. - * - * On the first upload to an S3 bucket that doesn't exist yet, this callback - * uses the BucketProvisioner to create and fully configure the bucket - * (Block Public Access, CORS, policies, lifecycle rules for temp buckets). - * - * Uses the same S3 connection config as the bucket provisioner plugin - * (getBucketProvisionerConnection) and reads CORS origins from - * SERVER_ORIGIN env var (falls back to localhost for local dev). - */ -export function createEnsureBucketProvisioned(): EnsureBucketProvisioned { - let provisioner: BucketProvisioner | null = null; - - return async ( - bucketName: string, - accessType: 'public' | 'private' | 'temp', - databaseId: string, - allowedOrigins: string[] | null, - ): Promise => { - // Per-database origins from storage_module, falling back to global SERVER_ORIGIN - const effectiveOrigins = (allowedOrigins && allowedOrigins.length > 0) - ? allowedOrigins - : getAllowedOrigins(); - - if (!provisioner) { - provisioner = new BucketProvisioner({ - connection: getBucketProvisionerConnection(), - allowedOrigins: effectiveOrigins, - }); - } - - log.info( - `[lazy-provision] Provisioning S3 bucket "${bucketName}" ` + - `(type=${accessType}) for database ${databaseId}`, - ); - - await provisioner.provision({ - bucketName, - accessType, - versioning: false, - allowedOrigins: effectiveOrigins, - }); - - log.info(`[lazy-provision] S3 bucket "${bucketName}" provisioned successfully`); - }; -} diff --git a/graphile/graphile-settings/src/upload-resolver.ts b/graphile/graphile-settings/src/upload-resolver.ts index 104f1459cb..6cfe7d6847 100644 --- a/graphile/graphile-settings/src/upload-resolver.ts +++ b/graphile/graphile-settings/src/upload-resolver.ts @@ -44,11 +44,7 @@ import type { import { checkTypeAgreement } from 'mime-bytes'; import { Transform } from 'stream'; -import { - createBucketNameResolver, - createEnsureBucketProvisioned, - getPresignedUrlS3Config, -} from './presigned-url-resolver'; +import { getPresignedUrlS3Config } from './presigned-url-resolver'; const log = new Logger('upload-resolver'); const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml']; @@ -87,12 +83,10 @@ function getStreamer(): Streamer { /** * The upload lane's view of the presigned plugin's options: the same S3 - * connection, physical-name policy, and provisioning hook the presigned lane - * uses, so both transports resolve identical coordinates for a bucket. + * connection the presigned lane uses, so both transports resolve identical + * coordinates for a bucket. * - * Built on first upload rather than at import time — `createBucketNameResolver` - * throws on a missing name prefix, and that must surface as a failed upload, not - * as a server that will not boot. + * Built on first upload rather than at import time. */ let managedOptions: PresignedUrlPluginOptions | null = null; @@ -100,8 +94,6 @@ function getManagedOptions(): PresignedUrlPluginOptions { if (!managedOptions) { managedOptions = { s3: getPresignedUrlS3Config, - resolveBucketName: createBucketNameResolver(), - ensureBucketProvisioned: createEnsureBucketProvisioned(), }; } return managedOptions; diff --git a/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql index cc20136d05..8f531bc6ed 100644 --- a/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/db-scope-storage/test-data.sql @@ -88,10 +88,10 @@ VALUES ( true ) ON CONFLICT (id) DO NOTHING; -INSERT INTO "tess-storage-public".buckets (id, key, type, is_public) +INSERT INTO "tess-storage-public".buckets (id, key, type, is_public, physical_name) VALUES - ('ce557000-0000-4000-8000-000000000001', 'public', 'public', true), - ('ce557000-0000-4000-8000-000000000002', 'private', 'private', false) + ('ce557000-0000-4000-8000-000000000001', 'public', 'public', true, 'database-ce551000-public-d12a972ce5d3'), + ('ce557000-0000-4000-8000-000000000002', 'private', 'private', false, 'database-ce551000-private-5509785f9933') ON CONFLICT (id) DO NOTHING; INSERT INTO routing_public.database_settings (id, database_id) diff --git a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql index 91a5443af6..f56bbe68cc 100644 --- a/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql +++ b/graphql/server-test/__fixtures__/seed/simple-seed-storage/test-data.sql @@ -95,10 +95,10 @@ VALUES ( -- ALICE BUCKET SEED DATA -- ===================================================== -INSERT INTO "simple-storage-public".app_buckets (id, key, type, is_public) +INSERT INTO "simple-storage-public".app_buckets (id, key, type, is_public, physical_name) VALUES - ('d0000001-0000-0000-0000-000000000001', 'public', 'public', true), - ('d0000001-0000-0000-0000-000000000002', 'private', 'private', false) + ('d0000001-0000-0000-0000-000000000001', 'public', 'public', true, 'app-80a2eaaf-public-c7156cb19fe3'), + ('d0000001-0000-0000-0000-000000000002', 'private', 'private', false, 'app-80a2eaaf-private-cc079c527e7d') ON CONFLICT (id) DO NOTHING; -- ===================================================== @@ -194,10 +194,10 @@ VALUES ( -- BOB BUCKET SEED DATA -- ===================================================== -INSERT INTO "bob-storage-public".app_buckets (id, key, type, is_public) +INSERT INTO "bob-storage-public".app_buckets (id, key, type, is_public, physical_name) VALUES - ('d2d2d2d2-0000-0000-0000-000000000001', 'public', 'public', true), - ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false) + ('d2d2d2d2-0000-0000-0000-000000000001', 'public', 'public', true, 'app-a1a1a1a1-public-4db6afbff1a2'), + ('d2d2d2d2-0000-0000-0000-000000000002', 'private', 'private', false, 'app-a1a1a1a1-private-a83152dee184') ON CONFLICT (id) DO NOTHING; -- Pre-seed a file in Bob's public bucket for mutation attack testing @@ -323,10 +323,10 @@ VALUES ( -- MALLORY BUCKET SEED DATA -- ===================================================== -INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public) +INSERT INTO "mallory-storage-public".app_buckets (id, key, type, is_public, physical_name) VALUES - ('fa77fa77-0000-0000-0000-000000000001', 'public', 'public', true), - ('fa77fa77-0000-0000-0000-000000000002', 'private', 'private', false) + ('fa77fa77-0000-0000-0000-000000000001', 'public', 'public', true, 'app-fa11fa11-public-c1aac75adc22'), + ('fa77fa77-0000-0000-0000-000000000002', 'private', 'private', false, 'app-fa11fa11-private-c8db3baa1b20') ON CONFLICT (id) DO NOTHING; -- Pre-seed files in Mallory's buckets for RLS testing 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 cb40656a7d..ac8ffcafd4 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -1118,10 +1118,11 @@ type Mutation { ): DeleteUserPayload """ - 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. + Reconcile an S3 bucket for a logical bucket in the database. + Reads the bucket config via RLS, then enqueues the same + storage:provision_bucket job used by the INSERT trigger. This is + idempotent for an already-reconciled bucket; enqueue failures become + GraphQL errors. """ provisionBucket( """ @@ -1609,23 +1610,17 @@ input ProvisionBucketInput { } type ProvisionBucketPayload { - """The access type applied""" - accessType: String! - - """The S3 bucket name that was provisioned""" - bucketName: String! - - """The S3 endpoint (null for AWS S3 default)""" - endpoint: String - - """Error message if provisioning failed""" - error: String + """The logical bucket row that was queued for reconciliation.""" + bucketId: UUID! + bucketKey: String! - """The storage provider used""" - provider: String! + """The reconciler job enqueued to provision this bucket.""" + jobId: UUID! - """Whether provisioning succeeded""" - success: Boolean! + """ + The physical bucket name already recorded, or null when reconciliation has not completed. + """ + physicalName: String } """The root query type which gives access points into the data universe.""" diff --git a/graphql/server-test/__tests__/db-scope-upload.integration.test.ts b/graphql/server-test/__tests__/db-scope-upload.integration.test.ts index 76f2983c72..afe417ea00 100644 --- a/graphql/server-test/__tests__/db-scope-upload.integration.test.ts +++ b/graphql/server-test/__tests__/db-scope-upload.integration.test.ts @@ -15,6 +15,7 @@ * pnpm test -- --testPathPattern=db-scope-upload */ +import { createS3Bucket, createS3Client } from '@constructive-io/s3-utils'; import { hashContent, putToPresignedUrl } from '@constructive-io/upload-client'; import path from 'path'; import type { PgTestClient } from 'pgsql-test'; @@ -98,6 +99,13 @@ describe('database-scope upload surface', () => { let request: supertest.Agent; let pg: PgTestClient; let teardown: () => Promise; + const s3Client = createS3Client({ + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://localhost:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin' + }); const post = ( databaseId: string, @@ -125,10 +133,19 @@ describe('database-scope upload surface', () => { }, seedAdapters )); + const result = await createS3Bucket( + s3Client, + 'database-ce551000-public-d12a972ce5d3', + { provider: 'minio' } + ); + if (!result.success) { + throw new Error('Failed to create test S3 bucket "database-ce551000-public-d12a972ce5d3"'); + } }); afterAll(async () => { if (teardown) await teardown(); + s3Client.destroy(); }); describe('mutation generation', () => { @@ -193,7 +210,7 @@ describe('database-scope upload surface', () => { ); const physicalName: string | null = stored.rows[0]?.physical_name ?? null; expect(physicalName).toBeTruthy(); - expect(physicalName).toMatch(/-public-[a-f0-9]{12}$/); + expect(physicalName).toBe('database-ce551000-public-d12a972ce5d3'); expect(bucketFromPresignedUrl(uploadUrl)).toBe(physicalName); }); diff --git a/graphql/server-test/__tests__/upload.integration.test.ts b/graphql/server-test/__tests__/upload.integration.test.ts index b7f516a669..b07a235bb5 100644 --- a/graphql/server-test/__tests__/upload.integration.test.ts +++ b/graphql/server-test/__tests__/upload.integration.test.ts @@ -4,8 +4,8 @@ * Exercises the file-centric upload pipeline: * uploadAppFile mutation -> presigned PUT URL -> PUT to S3 * - * Uses real MinIO (available in CI as minio_cdn service) and lazy bucket - * provisioning. + * Uses real MinIO (available in CI as minio_cdn service) and reconciled + * physical bucket fixtures. * * Three actors (single beforeAll, single server -- stays fast): * Alice -- baseline tenant, no RLS (wide-open schema) @@ -28,6 +28,7 @@ * pnpm test -- --testPathPattern=upload.integration */ +import { createS3Bucket, createS3Client } from '@constructive-io/s3-utils'; import { hashContent, putToPresignedUrl } from '@constructive-io/upload-client'; import path from 'path'; import type { PgTestClient } from 'pgsql-test'; @@ -99,11 +100,10 @@ const UPLOAD_APP_FILE = ` const PROVISION_BUCKET = ` mutation ProvisionBucket($input: ProvisionBucketInput!) { provisionBucket(input: $input) { - success - bucketName - accessType - provider - error + bucketId + bucketKey + physicalName + jobId } } `; @@ -280,6 +280,22 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { let request: supertest.Agent; let pg: PgTestClient; let teardown: () => Promise; + const s3Client = createS3Client({ + provider: 'minio', + region: 'us-east-1', + endpoint: 'http://localhost:9000', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin' + }); + + const ensureS3Buckets = async (bucketNames: string[]): Promise => { + for (const bucketName of bucketNames) { + const result = await createS3Bucket(s3Client, bucketName, { provider: 'minio' }); + if (!result.success) { + throw new Error(`Failed to create test S3 bucket "${bucketName}"`); + } + } + }; const postGraphQL = (payload: { query: string; @@ -338,10 +354,19 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }, seedAdapters )); + await ensureS3Buckets([ + 'app-80a2eaaf-public-c7156cb19fe3', + 'app-80a2eaaf-private-cc079c527e7d', + 'app-a1a1a1a1-public-4db6afbff1a2', + 'app-a1a1a1a1-private-a83152dee184', + 'app-fa11fa11-public-c1aac75adc22', + 'app-fa11fa11-private-c8db3baa1b20' + ]); }); afterAll(async () => { if (teardown) await teardown(); + s3Client.destroy(); }); // ========================================================================== @@ -465,12 +490,12 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { // ========================================================================== // 1b. physical_name persistence (Alice) // - // The provisioner records the exact physical S3 bucket name on the source - // bucket row at first-provision time; every later read uses that stored - // coordinate instead of recomputing it from a prefix convention. + // The reconciler records the exact physical S3 bucket name on the source + // bucket row; every later read uses that stored coordinate instead of + // recomputing it from a prefix convention. // ========================================================================== - describe('physical_name persistence (Alice)', () => { + describe('recorded physical_name usage (Alice)', () => { const aliceBucketsTable = `"${aliceSchemas[0]}".app_buckets`; const physicalNameFor = async (key: string): Promise => { @@ -485,7 +510,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const bucketFromPresignedUrl = (url: string): string => new URL(url).pathname.replace(/^\/+/, '').split('/')[0]; - it('records physical_name on the row after upload, matching the presigned URL bucket', async () => { + it('uses the reconciler-recorded physical_name for the presigned URL bucket', async () => { const fileContent = 'physical-name coordinate check'; const contentHash = await hashContent(fileContent); @@ -506,10 +531,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const stored = await physicalNameFor('public'); expect(stored).toBeTruthy(); - // Matches the resolver contract: {prefix}-{bucketKey}-{digest}, bounded - // to S3's 63-character limit. + expect(stored).toBe('app-80a2eaaf-public-c7156cb19fe3'); expect(stored).toContain('public'); - expect(stored).toMatch(/-public-[a-f0-9]{12}$/); expect(stored!.length).toBeLessThanOrEqual(63); // ...and is exactly the bucket the presigned PUT targets. expect(bucketFromPresignedUrl(payload.uploadUrl)).toBe(stored); @@ -540,6 +563,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { it('honors a preexisting custom physical_name verbatim (resolver never consulted)', async () => { const customPhysical = 'preexisting-custom-cdn-bucket'; + await ensureS3Buckets([customPhysical]); await pg.query( `INSERT INTO ${aliceBucketsTable} (key, type, is_public, physical_name) VALUES ($1, 'public', true, $2)`, @@ -570,16 +594,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }); // ========================================================================== - // 1c. Eager provisioning via the provisionBucket mutation (Alice) - // - // The explicit provisionBucket mutation must mint the SAME tenant-aware - // physical name the lazy first-upload path would (`{prefix}-{key}-{digest}`) and - // persist it on the bucket row — never the bare logical key. This is the - // regression guard for BucketProvisionerPreset being wired without a - // resolveBucketName. + // 1c. Reconciliation enqueue via the provisionBucket mutation (Alice) // ========================================================================== - describe('Eager provisioning via provisionBucket (Alice)', () => { + describe('Reconciliation enqueue via provisionBucket (Alice)', () => { const aliceBucketsTable = `"${aliceSchemas[0]}".app_buckets`; const physicalNameFor = async (key: string): Promise => { @@ -590,10 +608,6 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { return res.rows[0]?.physical_name ?? null; }; - // MinIO uses path-style URLs: http://host:9000//?... - const bucketFromPresignedUrl = (url: string): string => - new URL(url).pathname.replace(/^\/+/, '').split('/')[0]; - // Seed a fresh, never-provisioned bucket row (physical_name IS NULL). const seedBucket = async (key: string): Promise => { await pg.query( @@ -602,57 +616,24 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { ); }; - it('mints {prefix}-{key}-{digest} and records it, matching what the lazy path would mint', async () => { - // 1. Derive the naming prefix from a bucket the LAZY path provisions. - const lazyKey = 'eager-lazy'; - await seedBucket(lazyKey); - const lazyRes = await postGraphQL({ - query: UPLOAD_APP_FILE, - variables: { - input: { - bucketKey: lazyKey, - contentHash: await hashContent('eager-lazy-probe'), - contentType: 'text/plain', - size: 16, - filename: 'eager-lazy.txt' - } - } - }); - const lazyUrl = expectSuccess(lazyRes).uploadAppFile.uploadUrl; - expect(lazyUrl).toBeTruthy(); - const lazyPhysical = await physicalNameFor(lazyKey); - expect(lazyPhysical).toBeTruthy(); - // The lazy path records the exact bucket the presigned PUT targets. - expect(bucketFromPresignedUrl(lazyUrl)).toBe(lazyPhysical); - - // The shared convention: {prefix}-{key}-{digest}, bounded to S3's 63 chars. - const lazyMatch = new RegExp(`^(.+)-${lazyKey}-[a-f0-9]{12}$`).exec(lazyPhysical!); - expect(lazyMatch).not.toBeNull(); - expect(lazyPhysical!.length).toBeLessThanOrEqual(63); - const prefix = lazyMatch![1]; - - // 2. EAGER path: a fresh bucket row, provisioned via the mutation. - const eagerKey = 'eager-prov'; - await seedBucket(eagerKey); - expect(await physicalNameFor(eagerKey)).toBeNull(); + it('enqueues reconciliation and leaves the physical name untouched', async () => { + const key = 'reconcile-now'; + await seedBucket(key); const provRes = await postGraphQL({ query: PROVISION_BUCKET, - variables: { input: { bucketKey: eagerKey } } + variables: { input: { bucketKey: key } } }); const payload = expectSuccess(provRes).provisionBucket; - expect(payload.error).toBeNull(); - expect(payload.success).toBe(true); - - // Eager mints the tenant-aware name — NOT the bare logical key. - expect(payload.bucketName).toMatch(new RegExp(`^${prefix}-${eagerKey}-[a-f0-9]{12}$`)); - expect(payload.bucketName).not.toBe(eagerKey); - // ...and persists it on the row (physical_name IS NULL-guarded record). - expect(await physicalNameFor(eagerKey)).toBe(payload.bucketName); + expect(payload.bucketKey).toBe(key); + expect(payload.bucketId).toBeTruthy(); + expect(payload.physicalName).toBeNull(); + expect(payload.jobId).toBeTruthy(); + expect(await physicalNameFor(key)).toBeNull(); }); - it('does not clobber a physical_name recorded by a prior provision', async () => { - const key = 'eager-idem'; + it('can enqueue reconciliation repeatedly without changing the recorded name', async () => { + const key = 'reconcile-idem'; await seedBucket(key); const firstRes = await postGraphQL({ @@ -660,29 +641,23 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { variables: { input: { bucketKey: key } } }); const firstPayload = expectSuccess(firstRes).provisionBucket; - expect(firstPayload.success).toBe(true); - const recorded = await physicalNameFor(key); - expect(recorded).toBe(firstPayload.bucketName); + expect(firstPayload.physicalName).toBeNull(); + expect(firstPayload.jobId).toBeTruthy(); const secondRes = await postGraphQL({ query: PROVISION_BUCKET, variables: { input: { bucketKey: key } } }); const secondPayload = expectSuccess(secondRes).provisionBucket; - expect(secondPayload.success).toBe(true); - // The stored coordinate is authoritative and left untouched. - expect(await physicalNameFor(key)).toBe(recorded); + expect(secondPayload.physicalName).toBeNull(); + expect(secondPayload.jobId).toBeTruthy(); + expect(secondPayload.jobId).not.toBe(firstPayload.jobId); + expect(await physicalNameFor(key)).toBeNull(); }); }); // ========================================================================== - // 1d. Auto-provision-on-create is disabled (Alice) - // - // ConstructivePreset wires BucketProvisionerPreset with autoProvision:false, - // so creating a bucket row via the createAppBucket GraphQL mutation records - // the row WITHOUT eagerly minting an S3 bucket. Buckets are provisioned only - // lazily (first upload) or explicitly (provisionBucket) — no empty-bucket - // sprawl on every create call. + // 1d. Uploads require reconciliation (Alice) // ========================================================================== describe('Auto-provision on bucket create is disabled (Alice)', () => { @@ -696,10 +671,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { return res.rows[0]?.physical_name ?? null; }; - const bucketFromPresignedUrl = (url: string): string => - new URL(url).pathname.replace(/^\/+/, '').split('/')[0]; - - it('createAppBucket records the row without minting an S3 bucket; first upload provisions lazily', async () => { + it('createAppBucket records an unreconciled row and uploads reject it', async () => { const key = 'no-eager'; // Create the bucket ROW via the GraphQL mutation. @@ -712,11 +684,10 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { const created = expectSuccess(createRes).createAppBucket.appBucket; expect(created.key).toBe(key); - // autoProvision:false => the create hook never runs, so no S3 bucket is - // minted and nothing is recorded on the row. + // The logical row remains unreconciled until the job runs. expect(await physicalNameFor(key)).toBeNull(); - // The lazy path still provisions the bucket on first upload. + // Uploads fail while reconciliation has not recorded a physical name. const uploadRes = await postGraphQL({ query: UPLOAD_APP_FILE, variables: { @@ -729,12 +700,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { } } }); - const uploadUrl = expectSuccess(uploadRes).uploadAppFile.uploadUrl; - const physical = await physicalNameFor(key); - expect(physical).toBeTruthy(); - // Lazy mints the same tenant-aware name the presigned PUT targets. - expect(bucketFromPresignedUrl(uploadUrl)).toBe(physical); - expect(physical).toMatch(new RegExp(`-${key}-[a-f0-9]{12}$`)); + expect(uploadRes.body.errors?.[0]?.message).toContain('STORAGE_BUCKET_NOT_RECONCILED'); + expect(await physicalNameFor(key)).toBeNull(); }); }); @@ -1107,4 +1074,3 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => { }); }); }); - diff --git a/graphql/server-test/package.json b/graphql/server-test/package.json index 65b6bdc62b..e409e496c2 100644 --- a/graphql/server-test/package.json +++ b/graphql/server-test/package.json @@ -33,6 +33,7 @@ "@agentic-kit/ollama": "workspace:*", "@constructive-io/graphql-codegen": "workspace:^", "@constructive-io/graphql-query": "workspace:^", + "@constructive-io/s3-utils": "workspace:^", "@types/express": "^5.0.6", "@types/pg": "^8.20.4", "@types/supertest": "^7.2.1", diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index 2e46d83f68..c6adaf6724 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -3874,10 +3874,11 @@ based pagination. May not be used with \`last\`.", }, ], "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.", + "description": "Reconcile an S3 bucket for a logical bucket in the database. +Reads the bucket config via RLS, then enqueues the same +storage:provision_bucket job used by the INSERT trigger. This is +idempotent for an already-reconciled bucket; enqueue failures become +GraphQL errors.", "isDeprecated": false, "name": "provisionBucket", "type": { @@ -4383,15 +4384,15 @@ Omit for app-level (database-wide) storage.", { "args": [], "deprecationReason": null, - "description": "Whether provisioning succeeded", + "description": "The logical bucket row that was queued for reconciliation.", "isDeprecated": false, - "name": "success", + "name": "bucketId", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", - "name": "Boolean", + "name": "UUID", "ofType": null, }, }, @@ -4399,9 +4400,9 @@ Omit for app-level (database-wide) storage.", { "args": [], "deprecationReason": null, - "description": "The S3 bucket name that was provisioned", + "description": null, "isDeprecated": false, - "name": "bucketName", + "name": "bucketKey", "type": { "kind": "NON_NULL", "name": null, @@ -4415,59 +4416,31 @@ Omit for app-level (database-wide) storage.", { "args": [], "deprecationReason": null, - "description": "The access type applied", + "description": "The physical bucket name already recorded, or null when reconciliation has not completed.", "isDeprecated": false, - "name": "accessType", + "name": "physicalName", "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null, - }, + "kind": "SCALAR", + "name": "String", + "ofType": null, }, }, { "args": [], "deprecationReason": null, - "description": "The storage provider used", + "description": "The reconciler job enqueued to provision this bucket.", "isDeprecated": false, - "name": "provider", + "name": "jobId", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", - "name": "String", + "name": "UUID", "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": [], diff --git a/packages/bucket-provisioner/README.md b/packages/bucket-provisioner/README.md index 21cc9fcc01..4173e21e17 100644 --- a/packages/bucket-provisioner/README.md +++ b/packages/bucket-provisioner/README.md @@ -196,11 +196,6 @@ belongs to one database at one scope, and there is no global bucket namespace to prefix into. Each readable component has its own budget, so a long scope cannot crowd out the database label, and the digest covers the untruncated identity. -#### `mintPhysicalBucketName(prefix, databaseId, bucketKey)` - -The prefixed policy — `{prefix}-{bucketKey}-{digest}` — for a deployment that -names its own bucket namespace. - ### Policy Builders Standalone functions for generating S3 policy documents. diff --git a/packages/bucket-provisioner/__tests__/naming.test.ts b/packages/bucket-provisioner/__tests__/naming.test.ts index 6e1696ee8b..b9099e157f 100644 --- a/packages/bucket-provisioner/__tests__/naming.test.ts +++ b/packages/bucket-provisioner/__tests__/naming.test.ts @@ -1,60 +1,8 @@ -import { mintPhysicalBucketName, physicalBucketName } from '../src/naming'; +import { physicalBucketName } from '../src/naming'; -const PREFIX = 'test-bucket'; const DATABASE_ID = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; const BUCKET_NAME = /^[a-z0-9-]{3,63}$/; -describe('mintPhysicalBucketName', () => { - it('returns the same name for repeated calls with the same identity', () => { - const first = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'); - const second = mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'); - - expect(second).toBe(first); - }); - - it('separates the same bucket key across databases', () => { - expect( - mintPhysicalBucketName(PREFIX, DATABASE_ID, 'default'), - ).not.toBe( - mintPhysicalBucketName(PREFIX, '11111111-2222-3333-4444-555555555555', 'default'), - ); - }); - - it('separates keys that differ only past the readable budget', () => { - const shared = 'a'.repeat(40); - - expect( - mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-one`), - ).not.toBe( - mintPhysicalBucketName(PREFIX, DATABASE_ID, `${shared}-two`), - ); - }); - - it('always returns a bounded S3 bucket name without edge hyphens', () => { - const name = mintPhysicalBucketName( - 'Some_Very.Long CDN Prefix That Nobody Would Choose', - DATABASE_ID, - 'Marketing_Site/Assets — 2024'.repeat(5), - ); - - expect(name).toMatch(BUCKET_NAME); - expect(name).not.toMatch(/^-|-$/); - }); - - it('keeps a key that sanitizes to empty legal', () => { - const name = mintPhysicalBucketName(PREFIX, DATABASE_ID, '!!!'); - - expect(name).toMatch(BUCKET_NAME); - expect(name).not.toMatch(/^-|-$/); - }); - - it('falls back to the digest alone when both components sanitize away', () => { - const name = mintPhysicalBucketName('!!!', DATABASE_ID, '???'); - - expect(name).toMatch(/^[a-f0-9]{12}$/); - }); -}); - describe('physicalBucketName', () => { const identity = { scope: 'database', diff --git a/packages/bucket-provisioner/src/index.ts b/packages/bucket-provisioner/src/index.ts index 36ebbcd1cb..2b91125959 100644 --- a/packages/bucket-provisioner/src/index.ts +++ b/packages/bucket-provisioner/src/index.ts @@ -33,7 +33,7 @@ export { BucketProvisioner } from './provisioner'; // Physical naming policy export type { PhysicalBucketIdentity } from './naming'; -export { mintPhysicalBucketName, physicalBucketName } from './naming'; +export { physicalBucketName } from './naming'; // S3 client factory export { createS3Client } from './client'; diff --git a/packages/bucket-provisioner/src/naming.ts b/packages/bucket-provisioner/src/naming.ts index 6924df5d15..2e06b39c53 100644 --- a/packages/bucket-provisioner/src/naming.ts +++ b/packages/bucket-provisioner/src/naming.ts @@ -6,10 +6,6 @@ const MAX_BUCKET_NAME_LENGTH = 63; const MIN_BUCKET_NAME_LENGTH = 3; /** Hex characters of the identity digest kept as the uniqueness tail. */ const IDENTITY_DIGEST_LENGTH = 12; -/** Readable budget: how much of the name the prefix and key may each occupy. */ -const PREFIX_BUDGET = 20; -const BUCKET_KEY_BUDGET = 63 - IDENTITY_DIGEST_LENGTH - PREFIX_BUDGET - 3; - /** * Readable budgets for a name minted from a bucket's own identity. * @@ -100,10 +96,10 @@ function assembleBucketName( * * This is the policy for a platform-provisioned bucket, and it takes no prefix * because there is no global bucket namespace to prefix into: a bucket belongs - * to one database at one scope, and that is what its name should say. Callers - * derive names from here rather than composing a prefix of their own, so the - * policy can change in one place — and an already-provisioned bucket never - * consults it at all, since the row's recorded `physical_name` is authoritative. + * to one database at one scope, and that is what its name should say. The + * reconciler derives names from here rather than composing a prefix of its own, + * and consumers of a bucket row never consult this policy: the recorded + * `physical_name` is authoritative. * * The scope is part of the digested identity untruncated, so two scopes of one * database that declare the same bucket key stay distinct even when their @@ -121,24 +117,3 @@ export function physicalBucketName(identity: PhysicalBucketIdentity): string { `key "${bucketKey}" at scope "${scope}"`, ); } - -/** - * The prefixed physical-bucket naming policy: - * `{prefix}-{bucketKey}-{digest}` (e.g. `myapp-public-3f9c1a2b7e04`). - * - * For a deployment that names its own bucket namespace — a single-app - * installation, or the presigned-upload (lazy) path meeting a bucket an app - * configured. A platform-provisioned bucket uses {@link physicalBucketName} - * instead, whose components come from the bucket row rather than from - * deployment config. - */ -export function mintPhysicalBucketName(prefix: string, databaseId: string, bucketKey: string): string { - return assembleBucketName( - [ - readableComponent(prefix, PREFIX_BUDGET), - readableComponent(bucketKey, BUCKET_KEY_BUDGET), - ], - `${prefix}/${databaseId}/${bucketKey}`, - `key "${bucketKey}"`, - ); -} diff --git a/packages/errors/src/registry.ts b/packages/errors/src/registry.ts index 7c5cf70fc1..64de8fd5a1 100644 --- a/packages/errors/src/registry.ts +++ b/packages/errors/src/registry.ts @@ -14,6 +14,13 @@ export { type DefinedError,defineError } from './define'; * code is present via generation; these just refine a subset. */ export const registry = { + STORAGE_BUCKET_NOT_RECONCILED: defineError({ + code: 'STORAGE_BUCKET_NOT_RECONCILED', + class: 'public', + http: 409, + message: 'The storage bucket has not yet been reconciled.' + }), + // =========================================================================== // Auth / account (public) — copy sourced from dashboard auth-errors.ts // =========================================================================== diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7266219384..25dd9ae11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2229,6 +2229,9 @@ importers: '@constructive-io/graphql-query': specifier: workspace:^ version: link:../query/dist + '@constructive-io/s3-utils': + specifier: workspace:^ + version: link:../../uploads/s3-utils/dist '@types/express': specifier: ^5.0.6 version: 5.0.6