graphile-presigned-url-plugin 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,25 @@ function resolveS3(options) {
40
40
  }
41
41
  return options.s3;
42
42
  }
43
+ /**
44
+ * Build a per-database S3Config by overlaying storage_module overrides
45
+ * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
46
+ */
47
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
48
+ const globalS3 = resolveS3(options);
49
+ const bucket = options.resolveBucketName
50
+ ? options.resolveBucketName(databaseId)
51
+ : globalS3.bucket;
52
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
53
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
54
+ return globalS3;
55
+ }
56
+ return {
57
+ ...globalS3,
58
+ bucket,
59
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
60
+ };
61
+ }
43
62
  function createDownloadUrlPlugin(options) {
44
63
  return {
45
64
  name: 'PresignedUrlDownloadPlugin',
@@ -76,35 +95,39 @@ function createDownloadUrlPlugin(options) {
76
95
  if (status !== 'ready' && status !== 'processed') {
77
96
  return null;
78
97
  }
79
- const s3 = resolveS3(options);
80
- if (isPublic && s3.publicUrlPrefix) {
81
- // Public file: return direct URL
82
- return `${s3.publicUrlPrefix}/${key}`;
83
- }
84
- // Resolve download URL expiry from storage module config (per-database)
98
+ // Resolve per-database config (bucket, publicUrlPrefix, expiry)
99
+ let s3ForDb = resolveS3(options); // fallback to global
85
100
  let downloadUrlExpirySeconds = 3600; // fallback default
86
101
  try {
87
102
  const withPgClient = context.pgSettings
88
103
  ? context.withPgClient
89
104
  : null;
90
105
  if (withPgClient) {
91
- const config = await withPgClient(null, async (pgClient) => {
106
+ const resolved = await withPgClient(null, async (pgClient) => {
92
107
  const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
93
108
  const databaseId = dbResult.rows[0]?.id;
94
109
  if (!databaseId)
95
110
  return null;
96
- return (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
111
+ const config = await (0, storage_module_cache_1.getStorageModuleConfig)(pgClient, databaseId);
112
+ if (!config)
113
+ return null;
114
+ return { config, databaseId };
97
115
  });
98
- if (config) {
99
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
116
+ if (resolved) {
117
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
118
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
100
119
  }
101
120
  }
102
121
  }
103
122
  catch {
104
- // Fall back to default if config lookup fails
123
+ // Fall back to global config if lookup fails
124
+ }
125
+ if (isPublic && s3ForDb.publicUrlPrefix) {
126
+ // Public file: return direct CDN URL (per-database prefix)
127
+ return `${s3ForDb.publicUrlPrefix}/${key}`;
105
128
  }
106
- // Private file: generate presigned GET URL
107
- return (0, s3_signer_1.generatePresignedGetUrl)(resolveS3(options), key, downloadUrlExpirySeconds, filename || undefined);
129
+ // Private file: generate presigned GET URL (per-database bucket)
130
+ return (0, s3_signer_1.generatePresignedGetUrl)(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
108
131
  },
109
132
  }),
110
133
  }, 'PresignedUrlDownloadPlugin adding downloadUrl field');
@@ -37,6 +37,25 @@ function resolveS3(options) {
37
37
  }
38
38
  return options.s3;
39
39
  }
40
+ /**
41
+ * Build a per-database S3Config by overlaying storage_module overrides
42
+ * onto the global S3Config. Same logic as plugin.ts resolveS3ForDatabase.
43
+ */
44
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
45
+ const globalS3 = resolveS3(options);
46
+ const bucket = options.resolveBucketName
47
+ ? options.resolveBucketName(databaseId)
48
+ : globalS3.bucket;
49
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
50
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
51
+ return globalS3;
52
+ }
53
+ return {
54
+ ...globalS3,
55
+ bucket,
56
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
57
+ };
58
+ }
40
59
  export function createDownloadUrlPlugin(options) {
41
60
  return {
42
61
  name: 'PresignedUrlDownloadPlugin',
@@ -73,35 +92,39 @@ export function createDownloadUrlPlugin(options) {
73
92
  if (status !== 'ready' && status !== 'processed') {
74
93
  return null;
75
94
  }
76
- const s3 = resolveS3(options);
77
- if (isPublic && s3.publicUrlPrefix) {
78
- // Public file: return direct URL
79
- return `${s3.publicUrlPrefix}/${key}`;
80
- }
81
- // Resolve download URL expiry from storage module config (per-database)
95
+ // Resolve per-database config (bucket, publicUrlPrefix, expiry)
96
+ let s3ForDb = resolveS3(options); // fallback to global
82
97
  let downloadUrlExpirySeconds = 3600; // fallback default
83
98
  try {
84
99
  const withPgClient = context.pgSettings
85
100
  ? context.withPgClient
86
101
  : null;
87
102
  if (withPgClient) {
88
- const config = await withPgClient(null, async (pgClient) => {
103
+ const resolved = await withPgClient(null, async (pgClient) => {
89
104
  const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
90
105
  const databaseId = dbResult.rows[0]?.id;
91
106
  if (!databaseId)
92
107
  return null;
93
- return getStorageModuleConfig(pgClient, databaseId);
108
+ const config = await getStorageModuleConfig(pgClient, databaseId);
109
+ if (!config)
110
+ return null;
111
+ return { config, databaseId };
94
112
  });
95
- if (config) {
96
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
113
+ if (resolved) {
114
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
115
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
97
116
  }
98
117
  }
99
118
  }
100
119
  catch {
101
- // Fall back to default if config lookup fails
120
+ // Fall back to global config if lookup fails
121
+ }
122
+ if (isPublic && s3ForDb.publicUrlPrefix) {
123
+ // Public file: return direct CDN URL (per-database prefix)
124
+ return `${s3ForDb.publicUrlPrefix}/${key}`;
102
125
  }
103
- // Private file: generate presigned GET URL
104
- return generatePresignedGetUrl(resolveS3(options), key, downloadUrlExpirySeconds, filename || undefined);
126
+ // Private file: generate presigned GET URL (per-database bucket)
127
+ return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
105
128
  },
106
129
  }),
107
130
  }, 'PresignedUrlDownloadPlugin adding downloadUrl field');
package/esm/index.d.ts CHANGED
@@ -29,6 +29,6 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
34
+ export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
package/esm/index.js CHANGED
@@ -29,5 +29,5 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
package/esm/plugin.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { context as grafastContext, lambda, object } from 'grafast';
20
20
  import { extendSchema, gql } from 'graphile-utils';
21
21
  import { Logger } from '@pgpmjs/logger';
22
- import { getStorageModuleConfig, getBucketConfig } from './storage-module-cache';
22
+ import { getStorageModuleConfig, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
23
23
  import { generatePresignedPutUrl, headObject } from './s3-signer';
24
24
  const log = new Logger('graphile-presigned-url:plugin');
25
25
  // --- Protocol-level constants (not configurable) ---
@@ -66,6 +66,49 @@ function resolveS3(options) {
66
66
  }
67
67
  return options.s3;
68
68
  }
69
+ /**
70
+ * Build a per-database S3Config by overlaying storage_module overrides
71
+ * onto the global S3Config.
72
+ *
73
+ * - Bucket name: from resolveBucketName(databaseId) if provided, else global
74
+ * - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
75
+ * - S3 client (credentials, endpoint): always global (shared IAM key)
76
+ */
77
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
78
+ const globalS3 = resolveS3(options);
79
+ const bucket = options.resolveBucketName
80
+ ? options.resolveBucketName(databaseId)
81
+ : globalS3.bucket;
82
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
83
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
84
+ return globalS3;
85
+ }
86
+ return {
87
+ ...globalS3,
88
+ bucket,
89
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
90
+ };
91
+ }
92
+ /**
93
+ * Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
94
+ *
95
+ * Checks an in-memory Set of known-provisioned bucket names. On the first
96
+ * request for an unseen bucket, calls the `ensureBucketProvisioned` callback
97
+ * (which creates the bucket with correct CORS, policies, etc.), then marks
98
+ * it as provisioned so subsequent requests skip the check entirely.
99
+ *
100
+ * If no `ensureBucketProvisioned` callback is configured, this is a no-op.
101
+ */
102
+ async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
103
+ if (!options.ensureBucketProvisioned)
104
+ return;
105
+ if (isS3BucketProvisioned(s3BucketName))
106
+ return;
107
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
108
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
109
+ markS3BucketProvisioned(s3BucketName);
110
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
111
+ }
69
112
  export function createPresignedUrlPlugin(options) {
70
113
  return extendSchema(() => ({
71
114
  typeDefs: gql `
@@ -241,8 +284,11 @@ export function createPresignedUrlPlugin(options) {
241
284
  bucket.is_public,
242
285
  ]);
243
286
  const fileId = fileResult.rows[0].id;
244
- // --- Generate presigned PUT URL ---
245
- const uploadUrl = await generatePresignedPutUrl(resolveS3(options), s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
287
+ // --- Ensure the S3 bucket exists (lazy provisioning) ---
288
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
289
+ await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
290
+ // --- Generate presigned PUT URL (per-database bucket) ---
291
+ const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
246
292
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
247
293
  // --- Track the upload request ---
248
294
  await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
@@ -308,8 +354,9 @@ export function createPresignedUrlPlugin(options) {
308
354
  success: true,
309
355
  };
310
356
  }
311
- // --- Verify file exists in S3 ---
312
- const s3Head = await headObject(resolveS3(options), file.key, file.content_type);
357
+ // --- Verify file exists in S3 (per-database bucket) ---
358
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
359
+ const s3Head = await headObject(s3ForDb, file.key, file.content_type);
313
360
  if (!s3Head) {
314
361
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
315
362
  }
@@ -28,6 +28,14 @@ export declare function getBucketConfig(pgClient: {
28
28
  rows: unknown[];
29
29
  }>;
30
30
  }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
31
+ /**
32
+ * Check whether an S3 bucket has already been provisioned (cached).
33
+ */
34
+ export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
35
+ /**
36
+ * Mark an S3 bucket as provisioned in the in-memory cache.
37
+ */
38
+ export declare function markS3BucketProvisioned(s3BucketName: string): void;
31
39
  /**
32
40
  * Clear the storage module cache AND bucket cache.
33
41
  * Useful for testing or schema changes.
@@ -41,6 +41,7 @@ const STORAGE_MODULE_QUERY = `
41
41
  sm.endpoint,
42
42
  sm.public_url_prefix,
43
43
  sm.provider,
44
+ sm.allowed_origins,
44
45
  sm.upload_url_expiry_seconds,
45
46
  sm.download_url_expiry_seconds,
46
47
  sm.default_max_file_size,
@@ -89,6 +90,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
89
90
  endpoint: row.endpoint,
90
91
  publicUrlPrefix: row.public_url_prefix,
91
92
  provider: row.provider,
93
+ allowedOrigins: row.allowed_origins,
92
94
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
93
95
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
94
96
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -159,6 +161,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
159
161
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
160
162
  return config;
161
163
  }
164
+ // --- S3 bucket existence cache ---
165
+ /**
166
+ * In-memory set of S3 bucket names that are known to exist.
167
+ *
168
+ * Used by the lazy provisioning logic in the presigned URL plugin:
169
+ * before generating a presigned PUT URL, the plugin checks this set.
170
+ * If the bucket name is absent, it calls `ensureBucketProvisioned`
171
+ * to create the S3 bucket, then adds the name here. Subsequent
172
+ * requests for the same bucket skip the provisioning entirely.
173
+ *
174
+ * No TTL needed — S3 buckets are never deleted during normal operation.
175
+ * The set resets on server restart, which is fine because the
176
+ * provisioner's createBucket is idempotent (handles "already exists").
177
+ */
178
+ const provisionedBuckets = new Set();
179
+ /**
180
+ * Check whether an S3 bucket has already been provisioned (cached).
181
+ */
182
+ export function isS3BucketProvisioned(s3BucketName) {
183
+ return provisionedBuckets.has(s3BucketName);
184
+ }
185
+ /**
186
+ * Mark an S3 bucket as provisioned in the in-memory cache.
187
+ */
188
+ export function markS3BucketProvisioned(s3BucketName) {
189
+ provisionedBuckets.add(s3BucketName);
190
+ log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
191
+ }
162
192
  /**
163
193
  * Clear the storage module cache AND bucket cache.
164
194
  * Useful for testing or schema changes.
@@ -166,6 +196,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
166
196
  export function clearStorageModuleCache() {
167
197
  storageModuleCache.clear();
168
198
  bucketCache.clear();
199
+ provisionedBuckets.clear();
169
200
  }
170
201
  /**
171
202
  * Clear cached bucket entries for a specific database.
package/esm/types.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
37
37
  publicUrlPrefix: string | null;
38
38
  /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
39
  provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
40
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
41
43
  uploadUrlExpirySeconds: number;
42
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -120,10 +122,50 @@ export interface S3Config {
120
122
  * env-var reads and S3Client creation at module import time.
121
123
  */
122
124
  export type S3ConfigOrGetter = S3Config | (() => S3Config);
125
+ /**
126
+ * Function to derive the actual S3 bucket name for a given database.
127
+ *
128
+ * When provided, the presigned URL plugin calls this on every request
129
+ * to determine which S3 bucket to use — enabling per-database bucket
130
+ * isolation. If not provided, falls back to `s3Config.bucket` (global).
131
+ *
132
+ * @param databaseId - The metaschema database UUID
133
+ * @returns The S3 bucket name for this database
134
+ */
135
+ export type BucketNameResolver = (databaseId: string) => string;
136
+ /**
137
+ * Callback to lazily provision an S3 bucket on first use.
138
+ *
139
+ * Called by the presigned URL plugin before generating a presigned PUT URL
140
+ * when the bucket has not been seen before (tracked in an in-memory cache).
141
+ * The implementation should create and fully configure the S3 bucket
142
+ * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
143
+ * bucket already exists.
144
+ *
145
+ * @param bucketName - The S3 bucket name to provision
146
+ * @param accessType - The logical bucket type ('public', 'private', 'temp')
147
+ * @param databaseId - The metaschema database UUID
148
+ * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
149
+ */
150
+ export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
123
151
  /**
124
152
  * Plugin options for the presigned URL plugin.
125
153
  */
126
154
  export interface PresignedUrlPluginOptions {
127
155
  /** S3 configuration (concrete or lazy getter) */
128
156
  s3: S3ConfigOrGetter;
157
+ /**
158
+ * Optional function to resolve S3 bucket name per-database.
159
+ * When set, each database gets its own S3 bucket instead of sharing
160
+ * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161
+ */
162
+ resolveBucketName?: BucketNameResolver;
163
+ /**
164
+ * Optional callback to lazily provision an S3 bucket on first upload.
165
+ * When set, the plugin calls this before generating a presigned PUT URL
166
+ * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
167
+ * This enables graceful bucket creation without requiring buckets to
168
+ * exist at database provisioning time.
169
+ */
170
+ ensureBucketProvisioned?: EnsureBucketProvisioned;
129
171
  }
package/index.d.ts CHANGED
@@ -29,6 +29,6 @@
29
29
  export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
30
  export { createDownloadUrlPlugin } from './download-url-field';
31
31
  export { PresignedUrlPreset } from './preset';
32
- export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
- export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, } from './types';
34
+ export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
package/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
31
+ exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.markS3BucketProvisioned = exports.isS3BucketProvisioned = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
32
32
  var plugin_1 = require("./plugin");
33
33
  Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
34
34
  Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
@@ -41,6 +41,8 @@ Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get
41
41
  Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
42
42
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
43
43
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
44
+ Object.defineProperty(exports, "isS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.isS3BucketProvisioned; } });
45
+ Object.defineProperty(exports, "markS3BucketProvisioned", { enumerable: true, get: function () { return storage_module_cache_1.markS3BucketProvisioned; } });
44
46
  var s3_signer_1 = require("./s3-signer");
45
47
  Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
46
48
  Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -59,5 +59,5 @@
59
59
  "@types/node": "^22.19.11",
60
60
  "makage": "^0.1.10"
61
61
  },
62
- "gitHead": "fe60f7b81252eea53dce227bb581d5ae2ef0ec36"
62
+ "gitHead": "3bf7c522cf9f9d2595750ac7cea81d470b3e6c30"
63
63
  }
package/plugin.js CHANGED
@@ -70,6 +70,49 @@ function resolveS3(options) {
70
70
  }
71
71
  return options.s3;
72
72
  }
73
+ /**
74
+ * Build a per-database S3Config by overlaying storage_module overrides
75
+ * onto the global S3Config.
76
+ *
77
+ * - Bucket name: from resolveBucketName(databaseId) if provided, else global
78
+ * - publicUrlPrefix: from storageConfig.publicUrlPrefix if set, else global
79
+ * - S3 client (credentials, endpoint): always global (shared IAM key)
80
+ */
81
+ function resolveS3ForDatabase(options, storageConfig, databaseId) {
82
+ const globalS3 = resolveS3(options);
83
+ const bucket = options.resolveBucketName
84
+ ? options.resolveBucketName(databaseId)
85
+ : globalS3.bucket;
86
+ const publicUrlPrefix = storageConfig.publicUrlPrefix ?? globalS3.publicUrlPrefix;
87
+ if (bucket === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
88
+ return globalS3;
89
+ }
90
+ return {
91
+ ...globalS3,
92
+ bucket,
93
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
94
+ };
95
+ }
96
+ /**
97
+ * Ensure the S3 bucket for a database exists, provisioning it lazily if needed.
98
+ *
99
+ * Checks an in-memory Set of known-provisioned bucket names. On the first
100
+ * request for an unseen bucket, calls the `ensureBucketProvisioned` callback
101
+ * (which creates the bucket with correct CORS, policies, etc.), then marks
102
+ * it as provisioned so subsequent requests skip the check entirely.
103
+ *
104
+ * If no `ensureBucketProvisioned` callback is configured, this is a no-op.
105
+ */
106
+ async function ensureS3BucketExists(options, s3BucketName, bucket, databaseId, allowedOrigins) {
107
+ if (!options.ensureBucketProvisioned)
108
+ return;
109
+ if ((0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName))
110
+ return;
111
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
112
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
113
+ (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
114
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
115
+ }
73
116
  function createPresignedUrlPlugin(options) {
74
117
  return (0, graphile_utils_1.extendSchema)(() => ({
75
118
  typeDefs: (0, graphile_utils_1.gql) `
@@ -245,8 +288,11 @@ function createPresignedUrlPlugin(options) {
245
288
  bucket.is_public,
246
289
  ]);
247
290
  const fileId = fileResult.rows[0].id;
248
- // --- Generate presigned PUT URL ---
249
- const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(resolveS3(options), s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
291
+ // --- Ensure the S3 bucket exists (lazy provisioning) ---
292
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
293
+ await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
294
+ // --- Generate presigned PUT URL (per-database bucket) ---
295
+ const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
250
296
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
251
297
  // --- Track the upload request ---
252
298
  await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
@@ -312,8 +358,9 @@ function createPresignedUrlPlugin(options) {
312
358
  success: true,
313
359
  };
314
360
  }
315
- // --- Verify file exists in S3 ---
316
- const s3Head = await (0, s3_signer_1.headObject)(resolveS3(options), file.key, file.content_type);
361
+ // --- Verify file exists in S3 (per-database bucket) ---
362
+ const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
363
+ const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.content_type);
317
364
  if (!s3Head) {
318
365
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
319
366
  }
@@ -28,6 +28,14 @@ export declare function getBucketConfig(pgClient: {
28
28
  rows: unknown[];
29
29
  }>;
30
30
  }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
31
+ /**
32
+ * Check whether an S3 bucket has already been provisioned (cached).
33
+ */
34
+ export declare function isS3BucketProvisioned(s3BucketName: string): boolean;
35
+ /**
36
+ * Mark an S3 bucket as provisioned in the in-memory cache.
37
+ */
38
+ export declare function markS3BucketProvisioned(s3BucketName: string): void;
31
39
  /**
32
40
  * Clear the storage module cache AND bucket cache.
33
41
  * Useful for testing or schema changes.
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getStorageModuleConfig = getStorageModuleConfig;
4
4
  exports.getBucketConfig = getBucketConfig;
5
+ exports.isS3BucketProvisioned = isS3BucketProvisioned;
6
+ exports.markS3BucketProvisioned = markS3BucketProvisioned;
5
7
  exports.clearStorageModuleCache = clearStorageModuleCache;
6
8
  exports.clearBucketCache = clearBucketCache;
7
9
  const logger_1 = require("@pgpmjs/logger");
@@ -47,6 +49,7 @@ const STORAGE_MODULE_QUERY = `
47
49
  sm.endpoint,
48
50
  sm.public_url_prefix,
49
51
  sm.provider,
52
+ sm.allowed_origins,
50
53
  sm.upload_url_expiry_seconds,
51
54
  sm.download_url_expiry_seconds,
52
55
  sm.default_max_file_size,
@@ -95,6 +98,7 @@ async function getStorageModuleConfig(pgClient, databaseId) {
95
98
  endpoint: row.endpoint,
96
99
  publicUrlPrefix: row.public_url_prefix,
97
100
  provider: row.provider,
101
+ allowedOrigins: row.allowed_origins,
98
102
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
99
103
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
100
104
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -165,6 +169,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
165
169
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
166
170
  return config;
167
171
  }
172
+ // --- S3 bucket existence cache ---
173
+ /**
174
+ * In-memory set of S3 bucket names that are known to exist.
175
+ *
176
+ * Used by the lazy provisioning logic in the presigned URL plugin:
177
+ * before generating a presigned PUT URL, the plugin checks this set.
178
+ * If the bucket name is absent, it calls `ensureBucketProvisioned`
179
+ * to create the S3 bucket, then adds the name here. Subsequent
180
+ * requests for the same bucket skip the provisioning entirely.
181
+ *
182
+ * No TTL needed — S3 buckets are never deleted during normal operation.
183
+ * The set resets on server restart, which is fine because the
184
+ * provisioner's createBucket is idempotent (handles "already exists").
185
+ */
186
+ const provisionedBuckets = new Set();
187
+ /**
188
+ * Check whether an S3 bucket has already been provisioned (cached).
189
+ */
190
+ function isS3BucketProvisioned(s3BucketName) {
191
+ return provisionedBuckets.has(s3BucketName);
192
+ }
193
+ /**
194
+ * Mark an S3 bucket as provisioned in the in-memory cache.
195
+ */
196
+ function markS3BucketProvisioned(s3BucketName) {
197
+ provisionedBuckets.add(s3BucketName);
198
+ log.debug(`Marked S3 bucket "${s3BucketName}" as provisioned`);
199
+ }
168
200
  /**
169
201
  * Clear the storage module cache AND bucket cache.
170
202
  * Useful for testing or schema changes.
@@ -172,6 +204,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
172
204
  function clearStorageModuleCache() {
173
205
  storageModuleCache.clear();
174
206
  bucketCache.clear();
207
+ provisionedBuckets.clear();
175
208
  }
176
209
  /**
177
210
  * Clear cached bucket entries for a specific database.
package/types.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface StorageModuleConfig {
37
37
  publicUrlPrefix: string | null;
38
38
  /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
39
  provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
40
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
41
43
  uploadUrlExpirySeconds: number;
42
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -120,10 +122,50 @@ export interface S3Config {
120
122
  * env-var reads and S3Client creation at module import time.
121
123
  */
122
124
  export type S3ConfigOrGetter = S3Config | (() => S3Config);
125
+ /**
126
+ * Function to derive the actual S3 bucket name for a given database.
127
+ *
128
+ * When provided, the presigned URL plugin calls this on every request
129
+ * to determine which S3 bucket to use — enabling per-database bucket
130
+ * isolation. If not provided, falls back to `s3Config.bucket` (global).
131
+ *
132
+ * @param databaseId - The metaschema database UUID
133
+ * @returns The S3 bucket name for this database
134
+ */
135
+ export type BucketNameResolver = (databaseId: string) => string;
136
+ /**
137
+ * Callback to lazily provision an S3 bucket on first use.
138
+ *
139
+ * Called by the presigned URL plugin before generating a presigned PUT URL
140
+ * when the bucket has not been seen before (tracked in an in-memory cache).
141
+ * The implementation should create and fully configure the S3 bucket
142
+ * (privacy policies, CORS, lifecycle rules, etc.) — or no-op if the
143
+ * bucket already exists.
144
+ *
145
+ * @param bucketName - The S3 bucket name to provision
146
+ * @param accessType - The logical bucket type ('public', 'private', 'temp')
147
+ * @param databaseId - The metaschema database UUID
148
+ * @param allowedOrigins - Per-database CORS origins (from storage_module), or null to use global fallback
149
+ */
150
+ export type EnsureBucketProvisioned = (bucketName: string, accessType: 'public' | 'private' | 'temp', databaseId: string, allowedOrigins: string[] | null) => Promise<void>;
123
151
  /**
124
152
  * Plugin options for the presigned URL plugin.
125
153
  */
126
154
  export interface PresignedUrlPluginOptions {
127
155
  /** S3 configuration (concrete or lazy getter) */
128
156
  s3: S3ConfigOrGetter;
157
+ /**
158
+ * Optional function to resolve S3 bucket name per-database.
159
+ * When set, each database gets its own S3 bucket instead of sharing
160
+ * the global `s3Config.bucket`. The S3 credentials (client) remain shared.
161
+ */
162
+ resolveBucketName?: BucketNameResolver;
163
+ /**
164
+ * Optional callback to lazily provision an S3 bucket on first upload.
165
+ * When set, the plugin calls this before generating a presigned PUT URL
166
+ * for any S3 bucket it hasn't seen yet (tracked in an in-memory cache).
167
+ * This enables graceful bucket creation without requiring buckets to
168
+ * exist at database provisioning time.
169
+ */
170
+ ensureBucketProvisioned?: EnsureBucketProvisioned;
129
171
  }