diff --git a/graphile/graphile-bucket-provisioner-plugin/README.md b/graphile/graphile-bucket-provisioner-plugin/README.md index bbcd597e18..83110bca90 100644 --- a/graphile/graphile-bucket-provisioner-plugin/README.md +++ b/graphile/graphile-bucket-provisioner-plugin/README.md @@ -12,19 +12,16 @@

-PostGraphile v5 plugin that automatically provisions S3-compatible buckets when bucket rows are created in the database. Wraps bucket creation mutations to call [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner) after the database row is inserted. +PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through a GraphQL mutation using [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner). ## Features -- **Auto-provisioning hook** — Wraps `create*` mutations on tables tagged with `@storageBuckets` to automatically provision S3 buckets after row creation -- **CORS update hook** — Wraps `update*` mutations to detect `allowed_origins` changes and re-apply CORS rules to the S3 bucket +- **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 -- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket - **Per-database overrides** — Reads `endpoint`, `provider`, `public_url_prefix`, and `allowed_origins` from the `storage_module` table for multi-tenant setups - **Lazy S3 config** — Connection config can be a function (evaluated once, cached) to avoid eager env-var reads at import time -- **Graceful error handling** — Provisioning and CORS update failures are logged but never fail the mutation (admin can retry via `provisionBucket`) -- **Custom bucket naming** — Supports prefix-based naming or a fully custom `resolveBucketName` function +- **Deployment-controlled naming** — Requires a `resolveBucketName` policy for tenant-aware physical bucket names ## Installation @@ -120,10 +117,8 @@ Creates the plugin instance. Returns a `GraphileConfig.Plugin`. |--------|------|-------------| | `connection` | `StorageConnectionConfig \| () => StorageConnectionConfig` | S3 connection config (static or lazy getter) | | `allowedOrigins` | `string[]` | CORS allowed origins for bucket configuration | -| `bucketNamePrefix` | `string?` | Prefix for S3 bucket names (e.g., `"myapp"` → `"myapp-public"`) | -| `resolveBucketName` | `(bucketKey, databaseId) => string` | Custom bucket name resolver (takes precedence over prefix) | +| `resolveBucketName` | `(databaseId, bucketKey) => string` | Deployment policy for deriving a tenant-aware physical bucket name | | `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) | -| `autoProvision` | `boolean?` | Enable auto-provisioning hook on create mutations (default: `true`) | ### `BucketProvisionerPreset(options)` diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts index aaf8001fbf..5d48364b27 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/plugin.test.ts @@ -1,27 +1,14 @@ /** - * Tests for the bucket provisioner plugin. - * - * Covers: - * - provisionBucket mutation (explicit provisioning) - * - Auto-provisioning hook on bucket create mutations - * - CORS update hook on bucket update mutations - * - CORS resolution hierarchy (bucket > storage_module > plugin) - * - Wildcard CORS handling (['*']) - * - Error handling and graceful degradation - * - Connection config resolution (lazy getter, static) - * - Bucket name resolution (prefix, custom resolver) - * - Storage module config reading + * Tests for the explicit bucket provisioning mutation. */ -// Mock @constructive-io/bucket-provisioner before any imports const mockProvision = jest.fn(); -const mockUpdateCors = jest.fn(); const mockBucketProvisionerConstructor = jest.fn(); jest.mock('@constructive-io/bucket-provisioner', () => ({ BucketProvisioner: jest.fn().mockImplementation((opts: any) => { mockBucketProvisionerConstructor(opts); - return { provision: mockProvision, updateCors: mockUpdateCors }; + return { provision: mockProvision }; }), })); @@ -34,7 +21,6 @@ jest.mock('@pgpmjs/logger', () => ({ })), })); -// Mock grafast let capturedLambdaCallback: Function | null = null; jest.mock('grafast', () => ({ context: jest.fn(() => ({ @@ -47,26 +33,16 @@ jest.mock('grafast', () => ({ object: jest.fn((obj: any) => obj), })); -// Mock graphile-utils -// The extendSchema mock must invoke the plan function so that `lambda` gets -// called and capturedLambdaCallback is set. const mockGetRaw = jest.fn(() => 'mock-input'); jest.mock('graphile-utils', () => ({ extendSchema: jest.fn((factory: any) => { const schema = factory(); - // Invoke the provisionBucket plan to trigger the lambda mock, - // which captures the callback into capturedLambdaCallback. if (schema.plans?.Mutation?.provisionBucket) { - schema.plans.Mutation.provisionBucket( - null, - { getRaw: mockGetRaw }, - ); + schema.plans.Mutation.provisionBucket(null, { getRaw: mockGetRaw }); } return { name: 'ExtendSchemaPlugin', - schema: { - hooks: {}, - }, + schema: { hooks: {} }, _typeDefs: schema.typeDefs, _plans: schema.plans, }; @@ -77,8 +53,6 @@ jest.mock('graphile-utils', () => ({ import { createBucketProvisionerPlugin } from '../src/plugin'; import type { BucketProvisionerPluginOptions } from '../src/types'; -// --- Test helpers --- - function createDefaultOptions( overrides: Partial = {}, ): BucketProvisionerPluginOptions { @@ -91,6 +65,7 @@ function createDefaultOptions( secretAccessKey: 'minioadmin', }, allowedOrigins: ['https://app.example.com'], + resolveBucketName: (databaseId, bucketKey) => `tenant-${databaseId}-${bucketKey}`, ...overrides, }; } @@ -122,36 +97,30 @@ function createMockPgClient(overrides: Record = {}) { type: 'public', is_public: true, allowed_origins: null, + physical_name: null, }], }, }; - // The grafast `withPgClient` client takes the `{ text, values }` object form - // (mirrored here), not node-pg's positional `(text, params)` args. 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); - } + if (sql.includes(key)) return Promise.resolve(value); } return Promise.resolve({ rows: [] }); }), }; } -// --- Tests --- - describe('createBucketProvisionerPlugin', () => { beforeEach(() => { jest.clearAllMocks(); mockProvision.mockReset(); mockBucketProvisionerConstructor.mockReset(); capturedLambdaCallback = null; - mockProvision.mockResolvedValue({ - bucketName: 'public', + bucketName: 'tenant-db-uuid-123-public', accessType: 'public', endpoint: 'http://minio:9000', provider: 'minio', @@ -164,1047 +133,223 @@ describe('createBucketProvisionerPlugin', () => { }); }); - describe('plugin structure', () => { - it('returns a plugin object with name and schema hooks', () => { - const plugin = createBucketProvisionerPlugin(createDefaultOptions()); - - expect(plugin).toBeDefined(); - expect(plugin.name).toBe('BucketProvisionerPlugin'); - expect(plugin.version).toBe('0.1.0'); - expect(plugin.schema).toBeDefined(); - expect(plugin.schema!.hooks).toBeDefined(); - }); - - it('includes GraphQLObjectType_fields_field hook when autoProvision is true', () => { - const plugin = createBucketProvisionerPlugin(createDefaultOptions()); + it('returns a mutation-only plugin', () => { + const plugin = createBucketProvisionerPlugin(createDefaultOptions()); - expect(plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBeDefined(); - expect(typeof plugin.schema!.hooks!.GraphQLObjectType_fields_field).toBe('function'); - }); - - it('does not include fields_field hook when autoProvision is false', () => { - const plugin = createBucketProvisionerPlugin( - createDefaultOptions({ autoProvision: false }), - ); - - // When autoProvision is false, the plugin is just the extendSchema result - // which doesn't have the GraphQLObjectType_fields_field hook - const hooks = plugin.schema?.hooks ?? {}; - expect(hooks.GraphQLObjectType_fields_field).toBeUndefined(); - }); - - it('sets after dependencies for correct hook ordering', () => { - const plugin = createBucketProvisionerPlugin(createDefaultOptions()); - - expect(plugin.after).toContain('PgAttributesPlugin'); - expect(plugin.after).toContain('PgMutationCreatePlugin'); - }); + expect(plugin).toBeDefined(); + expect(plugin.name).toBe('ExtendSchemaPlugin'); + expect(plugin.schema).toBeDefined(); + expect(plugin.schema!.hooks).toEqual({}); }); - describe('provisionBucket mutation (via lambda callback)', () => { - it('provisions a public bucket successfully', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((settings: any, callback: any) => - callback(pgClient), - ); - - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }); - - expect(result.success).toBe(true); - expect(result.bucketName).toBe('public'); - expect(result.accessType).toBe('public'); - expect(result.provider).toBe('minio'); - expect(result.error).toBeNull(); - }); - - it('provisions a private bucket', async () => { - const privateBucketOverrides = { - app_public: { - rows: [{ - id: 'bucket-uuid-private', - key: 'private', - type: 'private', - is_public: false, - }], - }, - }; - - mockProvision.mockResolvedValue({ - bucketName: 'private', - accessType: 'private', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: true, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient(privateBucketOverrides); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'private' }, - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }); - - expect(result.success).toBe(true); - expect(result.bucketName).toBe('private'); - expect(result.accessType).toBe('private'); - }); - - it('uses bucketNamePrefix when set', async () => { - createBucketProvisionerPlugin( - createDefaultOptions({ bucketNamePrefix: 'myapp' }), - ); - - mockProvision.mockResolvedValue({ - bucketName: 'myapp-public', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }); - - // The provision call should have the prefixed name - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'myapp-public' }), - ); - expect(result.success).toBe(true); - }); - - it('uses custom resolveBucketName when provided', async () => { - const customResolver = jest.fn( - (bucketKey: string, databaseId: string) => `org-${databaseId}-${bucketKey}`, - ); - - createBucketProvisionerPlugin( - createDefaultOptions({ resolveBucketName: customResolver }), - ); - - mockProvision.mockResolvedValue({ - bucketName: 'org-db-uuid-123-public', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }); - - expect(customResolver).toHaveBeenCalledWith('public', 'db-uuid-123'); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'org-db-uuid-123-public' }), - ); - }); - - it('throws INVALID_BUCKET_KEY for empty key', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - await expect( - capturedLambdaCallback!({ - input: { bucketKey: '' }, - withPgClient: jest.fn(), - pgSettings: {}, - }), - ).rejects.toThrow('INVALID_BUCKET_KEY'); - }); - - it('throws DATABASE_NOT_FOUND when database_id is null', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - 'jwt_private.current_database_id': { rows: [{ id: null }] }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await expect( - capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }), - ).rejects.toThrow('DATABASE_NOT_FOUND'); - }); - - it('throws STORAGE_MODULE_NOT_PROVISIONED when no storage module exists', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { rows: [] }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + it('provisions a public bucket successfully', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + 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_MODULE_NOT_PROVISIONED'); + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, }); - it('throws BUCKET_NOT_FOUND when bucket does not exist', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); + 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' }), + ); + }); - const pgClient = createMockPgClient({ - app_public: { rows: [] }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + 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 expect( - capturedLambdaCallback!({ - input: { bucketKey: 'nonexistent' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }), - ).rejects.toThrow('BUCKET_NOT_FOUND'); + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, }); - it('returns error payload when provisioning fails', async () => { - mockProvision.mockRejectedValue(new Error('S3 connection refused')); - - createBucketProvisionerPlugin(createDefaultOptions()); + 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' }), + ); + }); - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + 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)); - const result = await capturedLambdaCallback!({ + await expect( + capturedLambdaCallback!({ input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('S3 connection refused'); - expect(result.bucketName).toBe('public'); - }); - - it('records physical_name on the bucket row after successful provisioning', 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); - - const update = pgClient.query.mock.calls.find( - (c: any[]) => c[0]?.text?.includes('SET physical_name'), - ); - expect(update).toBeDefined(); - // Guarded so a re-provision never clobbers an already-recorded coordinate. - expect(update![0].text).toContain('physical_name IS NULL'); - // Records the exact name returned by the provisioner against the row id. - expect(update![0].values).toEqual(['public', 'bucket-uuid-789']); - }); - - it('provisions the stored physical_name verbatim when already recorded', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - 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', - }], - }, - }); - 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: { role: 'admin' }, - }); + }), + ).rejects.toThrow('STORAGE_BUCKET_NAME_POLICY_MISSING'); + expect(mockProvision).not.toHaveBeenCalled(); + }); - expect(result.success).toBe(true); - // The stored coordinate is provisioned as-is; no prefix/resolver name is minted. - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'preexisting-cdn-bucket' }), - ); + 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: [], }); - - it('does not record physical_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), - ); - - const result = await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }); - - expect(result.success).toBe(false); - const update = pgClient.query.mock.calls.find( - (c: any[]) => c[0]?.text?.includes('SET physical_name'), - ); - expect(update).toBeUndefined(); + createBucketProvisionerPlugin(createDefaultOptions()); + const pgClient = createMockPgClient({ + app_public: { + rows: [{ + id: 'bucket-uuid-private', + key: 'private', + type: 'private', + is_public: false, + allowed_origins: null, + physical_name: null, + }], + }, }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - it('applies per-database endpoint override from storage module', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - 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', - entity_schema: null, - entity_table: null, - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }); - - // Check that the provisioner was created with the overridden endpoint - expect(mockBucketProvisionerConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - connection: expect.objectContaining({ - endpoint: 'http://custom-minio:9000', - provider: 'minio', - }), - }), - ); + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'private' }, + withPgClient: mockWithPgClient, + pgSettings: {}, }); - it('passes versioning option to provision call', async () => { - createBucketProvisionerPlugin( - createDefaultOptions({ versioning: true }), - ); + expect(result.success).toBe(true); + expect(result.accessType).toBe('private'); + }); - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + it('throws INVALID_BUCKET_KEY for an empty key', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, - withPgClient: mockWithPgClient, + await expect( + capturedLambdaCallback!({ + input: { bucketKey: '' }, + withPgClient: jest.fn(), pgSettings: {}, - }); + }), + ).rejects.toThrow('INVALID_BUCKET_KEY'); + }); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ versioning: true }), - ); + 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)); - it('passes publicUrlPrefix from storage module to provision call', async () => { - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - scope: 'app', - entity_table_id: null, - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: 'https://cdn.example.com', - provider: null, - entity_schema: null, - entity_table: null, - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ + await expect( + capturedLambdaCallback!({ input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, - }); - - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ - publicUrlPrefix: 'https://cdn.example.com', - }), - ); - }); + }), + ).rejects.toThrow('DATABASE_NOT_FOUND'); }); - describe('connection config resolution', () => { - it('resolves static connection config', () => { - const options = createDefaultOptions(); - createBucketProvisionerPlugin(options); - - // The connection should remain as-is (static object) - expect(typeof options.connection).toBe('object'); + it('throws STORAGE_MODULE_NOT_PROVISIONED when no storage modules exist', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); + const pgClient = createMockPgClient({ + 'metaschema_modules_public.storage_module': { rows: [] }, }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - it('resolves lazy getter connection config on first use', async () => { - const connectionConfig = { - provider: 'minio' as const, - region: 'us-east-1', - endpoint: 'http://minio:9000', - accessKeyId: 'minioadmin', - secretAccessKey: 'minioadmin', - }; - const getter = jest.fn(() => connectionConfig); - - const options = createDefaultOptions({ connection: getter }); - createBucketProvisionerPlugin(options); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ + await expect( + capturedLambdaCallback!({ input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, - }); + }), + ).rejects.toThrow('STORAGE_MODULE_NOT_PROVISIONED'); + }); - expect(getter).toHaveBeenCalledTimes(1); + 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)); - // Second call should use cached value - await capturedLambdaCallback!({ - input: { bucketKey: 'public' }, + await expect( + capturedLambdaCallback!({ + input: { bucketKey: 'missing' }, withPgClient: mockWithPgClient, pgSettings: {}, - }); - - // Still only 1 call because it was cached - expect(getter).toHaveBeenCalledTimes(1); - }); + }), + ).rejects.toThrow('BUCKET_NOT_FOUND'); }); - describe('auto-provisioning hook (GraphQLObjectType_fields_field)', () => { - function getFieldsFieldHook(options?: Partial) { - const plugin = createBucketProvisionerPlugin(createDefaultOptions(options)); - return plugin.schema!.hooks!.GraphQLObjectType_fields_field as Function; - } - - it('skips non-mutation fields', () => { - const hook = getFieldsFieldHook(); - const field = { resolve: jest.fn() }; - const build = {}; - const context = { - scope: { - isRootMutation: false, - fieldName: 'buckets', - pgCodec: { name: 'Bucket', attributes: {} }, - }, - }; - - const result = hook(field, build, context); - expect(result).toBe(field); - }); - - it('skips when pgCodec is missing', () => { - const hook = getFieldsFieldHook(); - const field = { resolve: jest.fn() }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: null as any, - }, - }; - - const result = hook(field, build, context); - expect(result).toBe(field); - }); - - it('skips when pgCodec has no @storageBuckets tag', () => { - const hook = getFieldsFieldHook(); - const field = { resolve: jest.fn() }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {} }, - extensions: { tags: {} }, - }, - }, - }; - - const result = hook(field, build, context); - expect(result).toBe(field); - }); - - it('skips delete mutations (only wraps create and update)', () => { - const hook = getFieldsFieldHook(); - const field = { resolve: jest.fn() }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'deleteBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const result = hook(field, build, context); - expect(result).toBe(field); - }); - - it('wraps update mutations on @storageBuckets-tagged tables', () => { - const hook = getFieldsFieldHook(); - const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'updated' } }); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'updateBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {}, allowed_origins: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const result = hook(field, build, context); - expect(result).not.toBe(field); - expect(result.resolve).toBeDefined(); - expect(typeof result.resolve).toBe('function'); - }); - - it('wraps create mutations on @storageBuckets-tagged tables', () => { - const hook = getFieldsFieldHook(); - const originalResolve = jest.fn().mockResolvedValue({ data: { id: 'new-bucket' } }); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const result = hook(field, build, context); - - expect(result).not.toBe(field); - expect(result.resolve).toBeDefined(); - expect(typeof result.resolve).toBe('function'); - }); - - it('calls original resolver first then provisions', async () => { - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'new-bucket' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - const graphqlContext = { - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }; - - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', type: 'public' } } }, - graphqlContext, - {}, - ); - - // Original resolver should be called - expect(originalResolve).toHaveBeenCalled(); - // The mutation result should be returned - expect(result).toBe(mutationResult); - // Provisioning should have been called - expect(mockProvision).toHaveBeenCalled(); - }); - - it('returns mutation result even if provisioning fails', async () => { - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'new-bucket' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - mockProvision.mockRejectedValue(new Error('S3 connection refused')); - - const wrapped = hook(field, build, context); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - const graphqlContext = { - withPgClient: mockWithPgClient, - pgSettings: { role: 'admin' }, - }; - - // Should NOT throw — provisioning errors are logged, not thrown - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', type: 'public' } } }, - graphqlContext, - {}, - ); - - expect(result).toBe(mutationResult); - }); - - it('skips provisioning when key/type not in mutation input', async () => { - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'new-bucket' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const result = await wrapped.resolve( - null, - { input: { bucket: { name: 'test' } } }, // Missing key and type - { withPgClient: jest.fn(), pgSettings: {} }, - {}, - ); - - // Should still return the mutation result - expect(result).toBe(mutationResult); - // Should NOT call provision - expect(mockProvision).not.toHaveBeenCalled(); - }); - - it('skips provisioning when withPgClient not in context', async () => { - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'new-bucket' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', type: 'public' } } }, - { pgSettings: {} }, // No withPgClient - {}, - ); - - expect(result).toBe(mutationResult); - expect(mockProvision).not.toHaveBeenCalled(); - }); - - it('uses default resolver when field has no resolve', () => { - const hook = getFieldsFieldHook(); - const field = {}; // No resolve function - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'createBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - expect(wrapped.resolve).toBeDefined(); - }); - - it('update mutation skips CORS update when allowed_origins not in input', async () => { - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'updated' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'updateBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {}, allowed_origins: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', type: 'public' } } }, // No allowed_origins - { withPgClient: jest.fn(), pgSettings: {} }, - {}, - ); - - expect(result).toBe(mutationResult); - expect(mockUpdateCors).not.toHaveBeenCalled(); - }); - - it('update mutation calls updateCors when allowed_origins is in input', async () => { - mockUpdateCors.mockResolvedValue([]); - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'updated' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'updateBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {}, allowed_origins: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: ['https://new-origin.example.com'], - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', allowed_origins: ['https://new-origin.example.com'] } } }, - { withPgClient: mockWithPgClient, pgSettings: {} }, - {}, - ); + 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)); - expect(result).toBe(mutationResult); - expect(mockUpdateCors).toHaveBeenCalledWith( - expect.objectContaining({ - bucketName: 'public', - accessType: 'public', - allowedOrigins: ['https://new-origin.example.com'], - }), - ); + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: {}, }); - it('update mutation returns result even if CORS update fails', async () => { - mockUpdateCors.mockRejectedValue(new Error('S3 CORS update failed')); - const hook = getFieldsFieldHook(); - const mutationResult = { data: { id: 'updated' } }; - const originalResolve = jest.fn().mockResolvedValue(mutationResult); - const field = { resolve: originalResolve }; - const build = {}; - const context = { - scope: { - isRootMutation: true, - fieldName: 'updateBucket', - pgCodec: { - name: 'Bucket', - attributes: { key: {}, type: {}, allowed_origins: {} }, - extensions: { - tags: { storageBuckets: true }, - pg: { schemaName: 'app_public', name: 'buckets' }, - }, - }, - }, - }; - - const wrapped = hook(field, build, context); - - const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-789', - key: 'public', - type: 'public', - is_public: true, - allowed_origins: ['https://bad-origin.com'], - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + expect(result.success).toBe(false); + expect(result.error).toBe('S3 connection refused'); + expect(result.bucketName).toBe('tenant-db-uuid-123-public'); + }); - // Should NOT throw - const result = await wrapped.resolve( - null, - { input: { bucket: { key: 'public', allowed_origins: ['https://bad-origin.com'] } } }, - { withPgClient: mockWithPgClient, pgSettings: {} }, - {}, - ); + 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)); - expect(result).toBe(mutationResult); + await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, + withPgClient: mockWithPgClient, + pgSettings: { role: 'admin' }, }); - }); -}); -describe('CORS resolution hierarchy', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockProvision.mockReset(); - mockUpdateCors.mockReset(); - mockBucketProvisionerConstructor.mockReset(); - capturedLambdaCallback = null; + 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('uses bucket-level allowed_origins when set', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'cdn-assets', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - + it('provisions the stored physical name verbatim', async () => { createBucketProvisionerPlugin(createDefaultOptions()); - const pgClient = createMockPgClient({ app_public: { rows: [{ - id: 'bucket-uuid-cdn', - key: 'cdn-assets', + id: 'bucket-uuid-789', + key: 'public', type: 'public', is_public: true, - allowed_origins: ['*'], - }], - }, - '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: ['https://db-default.example.com'], - entity_schema: null, - entity_table: null, + allowed_origins: null, + physical_name: 'preexisting-cdn-bucket', }], }, }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ - input: { bucketKey: 'cdn-assets' }, - withPgClient: mockWithPgClient, - pgSettings: {}, - }); - - // Bucket-level ['*'] should take precedence over storage_module and plugin defaults - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ - bucketName: 'cdn-assets', - allowedOrigins: ['*'], - }), - ); - }); - - it('falls back to storage_module allowed_origins when bucket has none', async () => { mockProvision.mockResolvedValue({ - bucketName: 'uploads', + bucketName: 'preexisting-cdn-bucket', accessType: 'public', endpoint: 'http://minio:9000', provider: 'minio', @@ -1215,82 +360,46 @@ describe('CORS resolution hierarchy', () => { corsRules: [], lifecycleRules: [], }); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-uploads', - key: 'uploads', - type: 'public', - is_public: true, - allowed_origins: null, // No bucket-level override - }], - }, - 'metaschema_modules_public.storage_module': { - rows: [{ - id: 'sm-uuid-456', - scope: 'app', - entity_table_id: null, - buckets_schema: 'app_public', - buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, - allowed_origins: ['https://db-default.example.com'], - entity_schema: null, - entity_table: null, - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - - await capturedLambdaCallback!({ - input: { bucketKey: 'uploads' }, + const result = await capturedLambdaCallback!({ + input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); - // Should fall back to storage_module level + expect(result.success).toBe(true); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ - bucketName: 'uploads', - allowedOrigins: ['https://db-default.example.com'], - }), + expect.objectContaining({ bucketName: 'preexisting-cdn-bucket' }), + ); + 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'); }); - it('falls back to plugin config allowedOrigins when both bucket and storage_module are null', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'docs', - accessType: 'private', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: true, - versioning: false, - corsRules: [], - lifecycleRules: [], + 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: {}, }); - createBucketProvisionerPlugin(createDefaultOptions({ - allowedOrigins: ['https://plugin-default.example.com'], - })); + const update = pgClient.query.mock.calls.find( + (call: any[]) => call[0]?.text?.includes('SET physical_name'), + ); + expect(update).toBeUndefined(); + }); + it('applies storage-module endpoint and public URL overrides', async () => { + createBucketProvisionerPlugin(createDefaultOptions()); const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-docs', - key: 'docs', - type: 'private', - is_public: false, - allowed_origins: null, - }], - }, 'metaschema_modules_public.storage_module': { rows: [{ id: 'sm-uuid-456', @@ -1298,183 +407,77 @@ describe('CORS resolution hierarchy', () => { entity_table_id: null, buckets_schema: 'app_public', buckets_table: 'buckets', - endpoint: null, - public_url_prefix: null, - provider: null, + endpoint: 'http://custom-minio:9000', + public_url_prefix: 'https://cdn.example.com', + provider: 'minio', allowed_origins: null, entity_schema: null, entity_table: null, }], }, }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + const mockWithPgClient = jest.fn((_settings: any, callback: any) => callback(pgClient)); await capturedLambdaCallback!({ - input: { bucketKey: 'docs' }, + input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); - // Should fall back to plugin config - expect(mockProvision).toHaveBeenCalledWith( + expect(mockBucketProvisionerConstructor).toHaveBeenCalledWith( expect.objectContaining({ - bucketName: 'docs', - allowedOrigins: ['https://plugin-default.example.com'], + connection: expect.objectContaining({ + endpoint: 'http://custom-minio:9000', + provider: 'minio', + }), }), ); + expect(mockProvision).toHaveBeenCalledWith( + expect.objectContaining({ publicUrlPrefix: 'https://cdn.example.com' }), + ); }); - it('wildcard CORS (["*"]) passes through correctly for CDN buckets', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'cdn-public', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: 'https://cdn.example.com', - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - createBucketProvisionerPlugin(createDefaultOptions()); - - const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid-cdn', - key: 'cdn-public', - type: 'public', - is_public: true, - allowed_origins: ['*'], - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + 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: 'cdn-public' }, + input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ - allowedOrigins: ['*'], - }), + expect.objectContaining({ versioning: true }), ); }); -}); -describe('bucket name resolution', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockProvision.mockReset(); - mockUpdateCors.mockReset(); - mockBucketProvisionerConstructor.mockReset(); - capturedLambdaCallback = null; - }); - - it('uses plain bucket key when no prefix or resolver', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'private', - accessType: 'private', - endpoint: 'http://minio:9000', - provider: 'minio', + it('caches a lazy connection getter', async () => { + const connection = { + provider: 'minio' as const, region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: true, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - createBucketProvisionerPlugin({ - connection: { - provider: 'minio', - region: 'us-east-1', - endpoint: 'http://minio:9000', - accessKeyId: 'test', - secretAccessKey: 'test', - }, - allowedOrigins: ['https://app.example.com'], - }); - - const pgClient = createMockPgClient({ - app_public: { - rows: [{ - id: 'bucket-uuid', - key: 'private', - type: 'private', - is_public: false, - }], - }, - }); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); + 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: 'private' }, + input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); - - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'private' }), - ); - }); - - it('resolveBucketName takes precedence over bucketNamePrefix', async () => { - mockProvision.mockResolvedValue({ - bucketName: 'custom-public', - accessType: 'public', - endpoint: 'http://minio:9000', - provider: 'minio', - region: 'us-east-1', - publicUrlPrefix: null, - blockPublicAccess: false, - versioning: false, - corsRules: [], - lifecycleRules: [], - }); - - const customResolver = jest.fn( - (bucketKey: string) => `custom-${bucketKey}`, - ); - - createBucketProvisionerPlugin({ - connection: { - provider: 'minio', - region: 'us-east-1', - endpoint: 'http://minio:9000', - accessKeyId: 'test', - secretAccessKey: 'test', - }, - allowedOrigins: ['https://app.example.com'], - bucketNamePrefix: 'should-be-ignored', - resolveBucketName: customResolver, - }); - - const pgClient = createMockPgClient(); - const mockWithPgClient = jest.fn((_settings: any, callback: any) => - callback(pgClient), - ); - await capturedLambdaCallback!({ input: { bucketKey: 'public' }, withPgClient: mockWithPgClient, pgSettings: {}, }); - expect(customResolver).toHaveBeenCalled(); - expect(mockProvision).toHaveBeenCalledWith( - expect.objectContaining({ bucketName: 'custom-public' }), - ); + expect(getter).toHaveBeenCalledTimes(1); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts index 0a833529b6..1500b907af 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/preset.test.ts @@ -67,7 +67,6 @@ describe('BucketProvisionerPreset', () => { secretAccessKey: 'secret', }, allowedOrigins: ['https://app.example.com'], - bucketNamePrefix: 'myapp', versioning: true, }); diff --git a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts index 96a4ba1c88..8fd78d139d 100644 --- a/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts +++ b/graphile/graphile-bucket-provisioner-plugin/__tests__/types.test.ts @@ -57,16 +57,12 @@ describe('BucketProvisionerPluginOptions', () => { secretAccessKey: 'secret', }, allowedOrigins: ['https://app.example.com', 'http://localhost:3000'], - bucketNamePrefix: 'myapp', - resolveBucketName: (key, dbId) => `${dbId}-${key}`, + resolveBucketName: (dbId, key) => `${dbId}-${key}`, versioning: true, - autoProvision: false, }; - expect(options.bucketNamePrefix).toBe('myapp'); expect(options.resolveBucketName).toBeDefined(); expect(options.versioning).toBe(true); - expect(options.autoProvision).toBe(false); }); }); @@ -98,12 +94,12 @@ describe('ConnectionConfigOrGetter', () => { }); describe('BucketNameResolver', () => { - it('takes bucketKey and databaseId and returns a string', () => { - const resolver: BucketNameResolver = (bucketKey, databaseId) => + it('takes databaseId and bucketKey and returns a string', () => { + const resolver: BucketNameResolver = (databaseId, bucketKey) => `org-${databaseId}-${bucketKey}`; - expect(resolver('public', 'db-123')).toBe('org-db-123-public'); - expect(resolver('private', 'db-456')).toBe('org-db-456-private'); + expect(resolver('db-123', 'public')).toBe('org-db-123-public'); + expect(resolver('db-456', 'private')).toBe('org-db-456-private'); }); }); diff --git a/graphile/graphile-bucket-provisioner-plugin/package.json b/graphile/graphile-bucket-provisioner-plugin/package.json index 1e34dd8322..6defb15624 100644 --- a/graphile/graphile-bucket-provisioner-plugin/package.json +++ b/graphile/graphile-bucket-provisioner-plugin/package.json @@ -1,7 +1,7 @@ { "name": "graphile-bucket-provisioner-plugin", "version": "1.14.0", - "description": "Bucket provisioning plugin for PostGraphile v5 — auto-provisions S3 buckets on bucket table mutations", + "description": "Bucket provisioning plugin for PostGraphile v5 — explicitly provisions S3 buckets via a GraphQL mutation", "author": "Constructive ", "homepage": "https://github.com/constructive-io/constructive", "license": "MIT", @@ -42,7 +42,8 @@ "dependencies": { "@constructive-io/bucket-provisioner": "workspace:^", "@pgpmjs/logger": "workspace:^", - "@pgsql/quotes": "^18.2.4" + "@pgsql/quotes": "^18.2.4", + "graphile-storage-registry": "workspace:^" }, "peerDependencies": { "grafast": "^1.1.1", diff --git a/graphile/graphile-bucket-provisioner-plugin/src/index.ts b/graphile/graphile-bucket-provisioner-plugin/src/index.ts index 77919ee720..6541939a94 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/index.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/index.ts @@ -1,13 +1,7 @@ /** * Bucket Provisioner Plugin for PostGraphile v5 * - * Provides automatic S3 bucket provisioning for PostGraphile v5. - * When bucket rows are created via GraphQL mutations, this plugin - * automatically provisions the corresponding S3 bucket with the - * correct privacy policies, CORS rules, and lifecycle settings. - * - * Also provides an explicit `provisionBucket` mutation for manual - * provisioning or re-provisioning of S3 buckets. + * Provides an explicit `provisionBucket` mutation for PostGraphile v5. * * @example * ```typescript @@ -31,7 +25,6 @@ * BucketProvisionerPreset({ * connection: getConnection, // pass function ref, NOT getConnection() * allowedOrigins: ['https://app.example.com'], - * bucketNamePrefix: 'myapp', * }), * ], * }; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts index be9e853ceb..03e435bcd5 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/plugin.ts @@ -7,21 +7,7 @@ * logical bucket row in the database. Reads the bucket config via RLS, * then calls BucketProvisioner to create and configure the S3 bucket. * - * 2. Auto-provisioning hook — wraps `create*` mutations on tables tagged - * with `@storageBuckets` to automatically provision the S3 bucket after - * the database row is created. - * - * 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables - * to detect changes to `allowed_origins` and re-apply CORS rules to the - * S3 bucket. - * - * CORS resolution hierarchy (most specific wins): - * 1. Bucket-level `allowed_origins` column (per-bucket override) - * 2. Storage-module-level `allowed_origins` column (per-database default) - * 3. Plugin config `allowedOrigins` (global fallback) - * Supports `['*']` for open/CDN mode (wildcard CORS). - * - * Both pathways use `@constructive-io/bucket-provisioner` for the actual + * This plugin uses `@constructive-io/bucket-provisioner` for the actual * S3 operations (bucket creation, Block Public Access, CORS, policies, * versioning, lifecycle rules). * @@ -39,6 +25,7 @@ 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 { @@ -49,31 +36,6 @@ const log = new Logger('graphile-bucket-provisioner:plugin'); // --- Storage module queries --- -/** - * Resolve the storage module whose buckets table is the one being addressed. - * - * The buckets table's identity (schema + table, from the codec being mutated or - * the module row) is the fact that names the plane — never a scope literal. - */ -const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = ` - SELECT - sm.id, - sm.scope, - sm.entity_table_id, - bs.schema_name AS buckets_schema, - bt.name AS buckets_table, - sm.endpoint, - sm.public_url_prefix, - sm.provider, - sm.allowed_origins - FROM metaschema_modules_public.storage_module sm - JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id - JOIN metaschema_public.schema bs ON bs.id = bt.schema_id - WHERE sm.database_id = $1 - AND bs.schema_name = $2 - AND bt.name = $3 -`; - /** * Resolve ALL storage modules for a database (for bucket-key and ownerId-based * resolution in the explicit provisionBucket mutation). @@ -129,40 +91,6 @@ function runQuery( return pgClient.query(values === undefined ? { text } : { text, values }); } -/** - * Resolve the storage module whose buckets table is the one being addressed. - * - * This is the resolution the auto-provision/CORS hooks use: the codec being - * mutated names its table, and the table names its module. A `@storageBuckets` - * table with no module row (or with several) is a provisioning bug and throws. - */ -async function resolveStorageModuleByBucketsTable( - pgClient: any, - databaseId: string, - schemaName: string, - tableName: string, -): Promise { - const result = await runQuery(pgClient, STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY, [ - databaseId, - schemaName, - tableName, - ]); - const rows = result.rows as StorageModuleRow[]; - if (rows.length === 0) { - throw new Error( - `STORAGE_MODULE_NOT_FOUND: no storage module in database ${databaseId} records ` + - `${schemaName}.${tableName} as its buckets table`, - ); - } - if (rows.length > 1) { - throw new Error( - `STORAGE_MODULE_AMBIGUOUS: ${rows.length} storage modules in database ${databaseId} record ` + - `${schemaName}.${tableName} as their buckets table`, - ); - } - return rows[0]; -} - /** * The explicit provisionBucket mutation's resolution: find the plane that * actually holds the named bucket row. @@ -262,29 +190,6 @@ function storedPhysicalName(row: Pick): string | nul return row.physical_name == null ? null : row.physical_name; } -/** - * Record the physical S3 bucket name on the source bucket row. - * - * Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping, - * RLS-independent. Idempotent via the `physical_name IS NULL` guard so a - * re-provision never clobbers an already-recorded coordinate. - */ -async function recordPhysicalName( - withPgClient: (pgSettings: null, cb: (client: any) => Promise) => Promise, - bucketsTable: string, - bucketId: string, - physicalName: string, -): Promise { - await withPgClient(null, (client: any) => - runQuery( - client, - `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, - [physicalName, bucketId], - ), - ); - log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`); -} - // --- Helpers --- /** @@ -307,17 +212,19 @@ function resolveConnection( * Resolve the S3 bucket name from a logical bucket key. */ function resolveBucketName( - bucketKey: string, databaseId: string, + bucketKey: string, options: BucketProvisionerPluginOptions, ): string { - if (options.resolveBucketName) { - return options.resolveBucketName(bucketKey, databaseId); - } - if (options.bucketNamePrefix) { - return `${options.bucketNamePrefix}-${bucketKey}`; + 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 bucketKey; + return options.resolveBucketName(databaseId, bucketKey); } /** @@ -375,8 +282,7 @@ function buildProvisioner( } /** - * Core provisioning logic shared by both the explicit mutation and the - * auto-provisioning hook. + * Core provisioning logic for the explicit mutation. */ async function provisionBucketForRow( storageModule: StorageModuleRow, @@ -419,64 +325,22 @@ async function provisionBucketForRow( return result; } -/** - * Update CORS on an existing S3 bucket when allowed_origins changes. - */ -async function updateBucketCors( - storageModule: StorageModuleRow, - databaseId: string, - bucketKey: string, - bucketType: string, - bucketAllowedOrigins: string[] | null | undefined, - options: BucketProvisionerPluginOptions, - s3BucketName: string, -): Promise { - const accessType = bucketType as 'public' | 'private' | 'temp'; - - const effectiveOrigins = resolveAllowedOrigins( - bucketAllowedOrigins, - storageModule?.allowed_origins, - options.allowedOrigins, - ); - - const provisioner = buildProvisioner(options, storageModule, effectiveOrigins); - - log.info( - `Updating CORS on S3 bucket "${s3BucketName}" ` + - `(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`, - ); - - await provisioner.updateCors({ - bucketName: s3BucketName, - accessType, - allowedOrigins: effectiveOrigins, - }); - - log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`); -} - // --- Plugin factory --- /** * Creates the bucket provisioner plugin. * - * This plugin provides two provisioning pathways: + * This plugin provides one provisioning 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. * - * 2. **Auto-provisioning hook** — When `autoProvision` is true (default), - * wraps `create*` mutation resolvers on tables tagged with `@storageBuckets` - * to automatically provision the S3 bucket after the row is created. - * * @param options - Plugin configuration (S3 credentials, CORS origins, naming) */ export function createBucketProvisionerPlugin( options: BucketProvisionerPluginOptions, ): GraphileConfig.Plugin { - const autoProvision = options.autoProvision ?? true; - // The extendSchema plugin adds the explicit provisionBucket mutation const mutationPlugin = extendSchema(() => ({ typeDefs: gql` @@ -556,7 +420,7 @@ export function createBucketProvisionerPlugin( // is authoritative and the naming hook is never consulted again. const recorded = storedPhysicalName(bucket); const s3BucketName = recorded === null - ? resolveBucketName(bucket.key, databaseId, options) + ? resolveBucketName(databaseId, bucket.key, options) : recorded; try { @@ -571,7 +435,15 @@ export function createBucketProvisionerPlugin( ); // Record the exact provisioned name on the source row. - await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName); + 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, @@ -599,236 +471,7 @@ export function createBucketProvisionerPlugin( }, })); - // If autoProvision is disabled, return only the mutation plugin - if (!autoProvision) { - return mutationPlugin; - } - - // Build a composite plugin that includes both the mutation and the hook - return { - ...mutationPlugin, - name: 'BucketProvisionerPlugin', - version: '0.1.0', - description: - 'Auto-provisions S3 buckets when bucket rows are created, ' + - 'updates CORS when allowed_origins changes on update, ' + - 'and provides a provisionBucket mutation for explicit provisioning', - after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'], - - schema: { - ...mutationPlugin.schema, - hooks: { - ...((mutationPlugin.schema as any)?.hooks ?? {}), - - /** - * Wrap create and update mutation resolvers on tables tagged with @storageBuckets. - * - * - create*: After the row is created, provision the S3 bucket. - * - update*: After the row is updated, re-apply CORS if allowed_origins changed. - * - * If provisioning/CORS update fails, the DB row still exists (the mutation - * already committed), and the error is logged. Admin can retry via provisionBucket. - */ - GraphQLObjectType_fields_field(field: any, build: any, context: any) { - const { - scope: { isRootMutation, fieldName, pgCodec }, - } = context; - - // Only wrap root mutation fields - if (!isRootMutation || !pgCodec || !pgCodec.attributes) { - return field; - } - - // Check for @storageBuckets smart tag - const tags = pgCodec.extensions?.tags; - if (!tags?.storageBuckets) { - return field; - } - - const isCreate = fieldName.startsWith('create'); - const isUpdate = fieldName.startsWith('update'); - - // Only wrap create and update mutations (not delete) - if (!isCreate && !isUpdate) { - return field; - } - - log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`); - - // The codec being mutated names the buckets table — the hook always - // operates on the plane that table belongs to, never a guessed scope. - const codecSchemaName = pgCodec.extensions?.pg?.schemaName as string | undefined; - const codecTableName = pgCodec.extensions?.pg?.name as string | undefined; - - const defaultResolver = (obj: any) => obj[fieldName]; - const { resolve: oldResolve = defaultResolver, ...rest } = field; - - return { - ...rest, - async resolve(source: any, args: any, graphqlContext: any, info: any) { - // Call the original resolver first (creates/updates the DB row) - const result = await oldResolve(source, args, graphqlContext, info); - - try { - const inputKey = Object.keys(args.input || {}).find( - (k) => k !== 'clientMutationId', - ); - const bucketInput = inputKey ? args.input[inputKey] : null; - - const withPgClient = graphqlContext.withPgClient; - const pgSettings = graphqlContext.pgSettings; - - if (!withPgClient) { - log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`); - return result; - } - - if (isCreate) { - // --- CREATE: full provisioning --- - if (!bucketInput?.key || !bucketInput?.type) { - log.warn( - `Auto-provision skipped for "${fieldName}": ` + - `could not extract key/type from mutation input`, - ); - return result; - } - - if (!codecSchemaName || !codecTableName) { - throw new Error( - `Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`, - ); - } - - await withPgClient(pgSettings, async (pgClient: any) => { - const databaseId = await resolveDatabaseId(pgClient); - if (!databaseId) { - log.warn('Auto-provision skipped: could not resolve database_id'); - return; - } - - // The mutated table names its module — the plane being written - // is the plane that gets provisioned. - const storageModule = await resolveStorageModuleByBucketsTable( - pgClient, databaseId, codecSchemaName, codecTableName, - ); - - // Newly-created row has no stored coordinate yet — mint on first provision. - const result = await provisionBucketForRow( - storageModule, - databaseId, - bucketInput.key, - bucketInput.type, - bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, - options, - resolveBucketName(bucketInput.key, databaseId, options), - ); - - // Record the provisioned name on the just-created row. - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); - const ownerId = bucketInput.ownerId ?? bucketInput.owner_id ?? null; - const idResult = await runQuery( - pgClient, - ownerId - ? `SELECT id FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1` - : `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, - ownerId ? [bucketInput.key, ownerId] : [bucketInput.key], - ); - const bucketId = idResult.rows[0]?.id; - if (bucketId) await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName); - }); - } else { - // --- UPDATE: re-apply CORS if allowed_origins is in the patch --- - const hasOriginsUpdate = bucketInput && - ('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput); - - if (!hasOriginsUpdate) { - // allowed_origins not being changed, nothing to do - return result; - } - - if (!codecSchemaName || !codecTableName) { - throw new Error( - `CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`, - ); - } - - await withPgClient(pgSettings, async (pgClient: any) => { - const databaseId = await resolveDatabaseId(pgClient); - if (!databaseId) { - log.warn('CORS update skipped: could not resolve database_id'); - return; - } - - // The mutated table names its module — CORS applies to the - // plane whose row was updated. - const storageModule = await resolveStorageModuleByBucketsTable( - pgClient, databaseId, codecSchemaName, codecTableName, - ); - - // We need the bucket key — it may come from input or patch - // For updates, PostGraphile uses nodeId or the row's PK, so - // we read the bucket from the patch's key or from the nodeId - const patchKey = bucketInput?.key; - if (!patchKey) { - log.warn( - `CORS update skipped for "${fieldName}": ` + - `could not determine bucket key from mutation input`, - ); - return; - } - - // Read the full bucket row (post-update) to get type + origins - const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table); - const bucketResult = await runQuery( - pgClient, - `SELECT id, key, type, is_public, allowed_origins, physical_name - FROM ${bucketsTable} - WHERE key = $1 - LIMIT 1`, - [patchKey], - ); - - if (bucketResult.rows.length === 0) { - log.warn(`CORS update skipped: bucket "${patchKey}" not found`); - return; - } - - const bucket = bucketResult.rows[0] as BucketRow; - - // CORS applies to the recorded physical bucket; if the row was - // never provisioned there is nothing to update yet, so mint the - // conventional name the first provision would use. - const recorded = storedPhysicalName(bucket); - - await updateBucketCors( - storageModule, - databaseId, - bucket.key, - bucket.type, - bucket.allowed_origins, - options, - recorded === null - ? resolveBucketName(bucket.key, databaseId, options) - : recorded, - ); - }); - } - } catch (err: any) { - log.error( - `${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` + - (isCreate - ? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.` - : `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`), - ); - } - - return result; - }, - }; - }, - }, - }, - }; + return mutationPlugin; } export const BucketProvisionerPlugin = createBucketProvisionerPlugin; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts index c5e9384082..446c447bad 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/preset.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/preset.ts @@ -35,7 +35,6 @@ import type { BucketProvisionerPluginOptions } from './types'; * BucketProvisionerPreset({ * connection: getConnection, // pass function ref, NOT getConnection() * allowedOrigins: ['https://app.example.com'], - * bucketNamePrefix: 'myapp', * }), * ], * }; diff --git a/graphile/graphile-bucket-provisioner-plugin/src/types.ts b/graphile/graphile-bucket-provisioner-plugin/src/types.ts index 45a5c172ba..5761b5052c 100644 --- a/graphile/graphile-bucket-provisioner-plugin/src/types.ts +++ b/graphile/graphile-bucket-provisioner-plugin/src/types.ts @@ -1,9 +1,5 @@ /** * Types for the bucket provisioner plugin. - * - * Defines plugin options, connection configuration, and provisioning result - * types used by the Graphile plugin to auto-provision S3 buckets when - * bucket rows are created via GraphQL mutations. */ import type { @@ -30,11 +26,11 @@ export type ConnectionConfigOrGetter = /** * Function to derive the actual S3 bucket name from a logical bucket key. * - * @param bucketKey - The logical bucket key from the database (e.g., "public", "private") * @param databaseId - The metaschema database UUID + * @param bucketKey - The logical bucket key from the database (e.g., "public", "private") * @returns The S3 bucket name to create/configure */ -export type BucketNameResolver = (bucketKey: string, databaseId: string) => string; +export type BucketNameResolver = (databaseId: string, bucketKey: string) => string; /** * Plugin options for the bucket provisioner plugin. @@ -53,16 +49,9 @@ export interface BucketProvisionerPluginOptions { */ allowedOrigins: string[]; - /** - * Optional prefix for S3 bucket names. - * When set, the S3 bucket name becomes `{prefix}-{bucketKey}`. - * Example: prefix "myapp" + key "public" → S3 bucket "myapp-public" - */ - bucketNamePrefix?: string; - /** * Optional custom function to derive S3 bucket names from logical bucket keys. - * Takes precedence over `bucketNamePrefix` when provided. + * Naming is a deployment policy and must be supplied by the caller. */ resolveBucketName?: BucketNameResolver; @@ -71,16 +60,6 @@ export interface BucketProvisionerPluginOptions { * Default: false */ versioning?: boolean; - - /** - * Whether to auto-provision S3 buckets when bucket rows are created - * via GraphQL mutations. When true, the plugin wraps create mutations - * on tables tagged with `@storageBuckets` to trigger provisioning - * after the mutation succeeds. - * - * Default: true - */ - autoProvision?: boolean; } /** diff --git a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts index 73b4434de5..cf04907ee0 100644 --- a/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts +++ b/graphile/graphile-presigned-url-plugin/src/physical-bucket.ts @@ -10,6 +10,7 @@ */ import { Logger } from '@pgpmjs/logger'; +import { recordPhysicalName } from 'graphile-storage-registry'; import { type WithPgClient, withRequestPgClient } from './request-pg-client'; import { s3FailureError } from './s3-failure'; @@ -140,13 +141,16 @@ export async function provisionAndRecordPhysicalBucket( // 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) => - client.query({ - text: `UPDATE ${storageConfig.bucketsQualifiedName} - SET physical_name = $1 - WHERE id = $2 AND physical_name IS NULL`, - values: [s3BucketName, bucket.id], - }), + 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}`); diff --git a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts index 6fcb4cd6a9..e9ba0d31b5 100644 --- a/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts +++ b/graphile/graphile-settings/__tests__/constructive-preset-bucket-wiring.test.ts @@ -56,24 +56,19 @@ describe('ConstructivePreset bucket-provisioner wiring', () => { createConstructivePreset(); const { resolveBucketName } = captured.bucketProvisionerOptions; - // provisioner plugin signature: (bucketKey, databaseId) - expect(resolveBucketName('public', DATABASE_ID)).toMatch( + // Both plugins use the signature: (databaseId, bucketKey) + expect(resolveBucketName(DATABASE_ID, 'public')).toMatch( new RegExp(`^${PREFIX}-public-[a-f0-9]{12}$`), ); - expect(resolveBucketName('private', DATABASE_ID)).toMatch( + 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('public', DATABASE_ID)).not.toBe( - resolveBucketName('public', '11111111-2222-3333-4444-555555555555'), + expect(resolveBucketName(DATABASE_ID, 'public')).not.toBe( + resolveBucketName('11111111-2222-3333-4444-555555555555', 'public'), ); }); - it('disables auto-provision-on-create so buckets are minted lazily / explicitly', () => { - createConstructivePreset(); - expect(captured.bucketProvisionerOptions.autoProvision).toBe(false); - }); - 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 1ae9521894..4c148388b6 100644 --- a/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts +++ b/graphile/graphile-settings/__tests__/presigned-url-resolver.test.ts @@ -6,8 +6,7 @@ * pair — `{prefix}-{bucketKey}-{digest}` — so a bucket's physical coordinate is * identical regardless of which path first provisions it. * - * The two plugins declare their resolver with opposite argument order, so the - * equality below also guards against re-introducing an argument-order bug. The + * 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. @@ -41,17 +40,11 @@ describe('bucket-name resolvers', () => { expect(resolve(DATABASE_ID, 'private')).toMatch(/^test-bucket-private-[a-f0-9]{12}$/); }); - it('provisioner resolver mints the identical name despite opposite arg order', async () => { - const { createBucketNameResolver, createProvisionerBucketNameResolver } = - await loadResolverModule({ bucketName: PREFIX }); - - const presigned = createBucketNameResolver(); - const provisioner = createProvisionerBucketNameResolver(); + it('mints the identical name used by the bucket provisioner', async () => { + const { createBucketNameResolver } = await loadResolverModule({ bucketName: PREFIX }); + const resolve = createBucketNameResolver(); - for (const key of ['public', 'private', 'temp', 'custom-cdn']) { - // presigned: (databaseId, bucketKey) — provisioner: (bucketKey, databaseId) - expect(provisioner(key, DATABASE_ID)).toBe(presigned(DATABASE_ID, key)); - } + expect(resolve(DATABASE_ID, 'public')).toBe(resolve(DATABASE_ID, 'public')); }); it('names are stable across calls and resolver instances', async () => { @@ -95,15 +88,8 @@ describe('bucket-name resolvers', () => { expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); }); - it('provisioner resolver throws (no default bucket name) when the prefix is missing', async () => { - const { createProvisionerBucketNameResolver } = await loadResolverModule({}); - expect(() => createProvisionerBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); - }); - - it('both resolvers throw when CDN config is entirely absent', async () => { - const { createBucketNameResolver, createProvisionerBucketNameResolver } = - await loadResolverModule(undefined); + it('throws when CDN config is entirely absent', async () => { + const { createBucketNameResolver } = await loadResolverModule(undefined); expect(() => createBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); - expect(() => createProvisionerBucketNameResolver()).toThrow(/CDN_BUCKET_NAME/); }); }); diff --git a/graphile/graphile-settings/src/presets/constructive-preset.ts b/graphile/graphile-settings/src/presets/constructive-preset.ts index fdaf48fcd9..fcd08fd78a 100644 --- a/graphile/graphile-settings/src/presets/constructive-preset.ts +++ b/graphile/graphile-settings/src/presets/constructive-preset.ts @@ -26,7 +26,7 @@ import { PgTypeMappingsPreset, RequiredInputPreset } from '../plugins'; -import { createBucketNameResolver, createEnsureBucketProvisioned, createProvisionerBucketNameResolver, getAllowedOrigins,getPresignedUrlS3Config } from '../presigned-url-resolver'; +import { createBucketNameResolver, createEnsureBucketProvisioned, getAllowedOrigins,getPresignedUrlS3Config } from '../presigned-url-resolver'; import { constructiveUploadFieldDefinitions } from '../upload-resolver'; /** @@ -207,16 +207,7 @@ export function createConstructivePreset( BucketProvisionerPreset({ connection: getBucketProvisionerConnection, allowedOrigins: getAllowedOrigins(), - // Same tenant-aware naming policy as the presigned (lazy) path, so the - // eager provisionBucket mutation mints the identical physical name - // (`{prefix}-{bucketKey}-{databaseId}`) instead of falling back to the - // bare logical bucket key. - resolveBucketName: createProvisionerBucketNameResolver(), - // S3 buckets are provisioned lazily (on first upload) or explicitly via - // the provisionBucket mutation. Disable the auto-provision-on-create - // hook so a createBucket GraphQL mutation records the row without - // eagerly minting an S3 bucket that may never receive an upload. - autoProvision: false + resolveBucketName: createBucketNameResolver() }) ); } diff --git a/graphile/graphile-settings/src/presigned-url-resolver.ts b/graphile/graphile-settings/src/presigned-url-resolver.ts index 4e1728fd1a..821cb0156c 100644 --- a/graphile/graphile-settings/src/presigned-url-resolver.ts +++ b/graphile/graphile-settings/src/presigned-url-resolver.ts @@ -15,7 +15,6 @@ import { BucketProvisioner, mintPhysicalBucketName } from '@constructive-io/buck import { getEnvOptions } from '@constructive-io/graphql-env'; import { createS3Client } from '@constructive-io/s3-utils'; import { Logger } from '@pgpmjs/logger'; -import type { BucketNameResolver as ProvisionerBucketNameResolver } from 'graphile-bucket-provisioner-plugin'; import type { BucketNameResolver, EnsureBucketProvisioned,S3Config } from 'graphile-presigned-url-plugin'; import { getBucketProvisionerConnection } from './bucket-provisioner-resolver'; @@ -124,21 +123,6 @@ export function createBucketNameResolver(): BucketNameResolver { mintPhysicalBucketName(prefix, databaseId, bucketKey); } -/** - * Create the bucket name resolver for the bucket provisioner plugin - * (argument order: `(bucketKey, databaseId)`). - * - * Produces the exact same physical name as createBucketNameResolver() - * (`{prefix}-{bucketKey}-{digest}`) so the eager `provisionBucket` - * mutation mints the identical tenant-aware name that the lazy first-upload - * path would. Throws on a missing prefix — no default bucket name. - */ -export function createProvisionerBucketNameResolver(): ProvisionerBucketNameResolver { - const prefix = getBucketNamePrefix(); - return (bucketKey: string, databaseId: string): string => - mintPhysicalBucketName(prefix, databaseId, bucketKey); -} - /** * Resolve CORS allowed origins from the env/config system. * diff --git a/graphile/graphile-storage-registry/src/index.ts b/graphile/graphile-storage-registry/src/index.ts index 8dccc73e65..a2844b342c 100644 --- a/graphile/graphile-storage-registry/src/index.ts +++ b/graphile/graphile-storage-registry/src/index.ts @@ -8,3 +8,4 @@ export type { StoragePlanePair, } from './pairing'; export { discoverStoragePlanes, pairStoragePlane } from './pairing'; +export { recordPhysicalName } from './physical-bucket'; diff --git a/graphile/graphile-storage-registry/src/physical-bucket.ts b/graphile/graphile-storage-registry/src/physical-bucket.ts new file mode 100644 index 0000000000..8c53b8a92b --- /dev/null +++ b/graphile/graphile-storage-registry/src/physical-bucket.ts @@ -0,0 +1,19 @@ +/** + * Record the authoritative physical bucket coordinate on a bucket row. + * + * The caller supplies the query runner so each storage lane can preserve its + * own database-client and claims behavior. + */ +export async function recordPhysicalName( + query: (query: { text: string; values: unknown[] }) => Promise, + bucketsQualifiedName: string, + bucketId: string, + physicalName: string, +): Promise { + await query({ + text: `UPDATE ${bucketsQualifiedName} + SET physical_name = $1 + WHERE id = $2 AND physical_name IS NULL`, + values: [physicalName, bucketId], + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 510489b068..7266219384 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,6 +435,9 @@ importers: graphile-config: specifier: 1.1.0 version: 1.1.0 + graphile-storage-registry: + specifier: workspace:^ + version: link:../graphile-storage-registry/dist graphile-utils: specifier: 5.0.3 version: 5.0.3(@dataplan/pg@1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build-pg@5.1.3(@dataplan/pg@1.1.1(@dataplan/json@1.0.1(grafast@1.1.2(graphql@16.13.0)))(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0))(grafast@1.1.2(graphql@16.13.0))(graphile-build@5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(pg-sql2@5.0.1)(pg@8.21.0)(tamedevil@0.1.1))(graphile-build@5.1.1(grafast@1.1.2(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0))(graphile-config@1.1.0)(graphql@16.13.0)(tamedevil@0.1.1)