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)