graphile-presigned-url-plugin 0.2.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.
package/README.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # graphile-presigned-url-plugin
2
2
 
3
+ <p align="center" width="100%">
4
+ <img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5
+ </p>
6
+
7
+ <p align="center" width="100%">
8
+ <a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9
+ <img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10
+ </a>
11
+ <a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12
+ <a href="https://www.npmjs.com/package/graphile-presigned-url-plugin"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-presigned-url-plugin%2Fpackage.json"/></a>
13
+ </p>
14
+
3
15
  Presigned URL upload plugin for PostGraphile v5.
4
16
 
5
17
  ## Features
@@ -14,14 +14,5 @@
14
14
  */
15
15
  import type { GraphileConfig } from 'graphile-config';
16
16
  import type { PresignedUrlPluginOptions } from './types';
17
- /**
18
- * Creates the downloadUrl computed field plugin.
19
- *
20
- * This is a separate plugin from the main presigned URL plugin because it
21
- * uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
22
- * The downloadUrl field needs to be added dynamically to whatever table is
23
- * the storage module's files table, which we discover at schema-build time
24
- * via the `@storageFiles` smart tag.
25
- */
26
17
  export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
27
18
  export default createDownloadUrlPlugin;
@@ -28,8 +28,38 @@ const log = new logger_1.Logger('graphile-presigned-url:download-url');
28
28
  * the storage module's files table, which we discover at schema-build time
29
29
  * via the `@storageFiles` smart tag.
30
30
  */
31
+ /**
32
+ * Resolve the S3 config from the options. If the option is a lazy getter
33
+ * function, call it (and cache the result).
34
+ */
35
+ function resolveS3(options) {
36
+ if (typeof options.s3 === 'function') {
37
+ const resolved = options.s3();
38
+ options.s3 = resolved;
39
+ return resolved;
40
+ }
41
+ return options.s3;
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
+ }
31
62
  function createDownloadUrlPlugin(options) {
32
- const { s3 } = options;
33
63
  return {
34
64
  name: 'PresignedUrlDownloadPlugin',
35
65
  version: '0.1.0',
@@ -65,34 +95,39 @@ function createDownloadUrlPlugin(options) {
65
95
  if (status !== 'ready' && status !== 'processed') {
66
96
  return null;
67
97
  }
68
- if (isPublic && s3.publicUrlPrefix) {
69
- // Public file: return direct URL
70
- return `${s3.publicUrlPrefix}/${key}`;
71
- }
72
- // 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
73
100
  let downloadUrlExpirySeconds = 3600; // fallback default
74
101
  try {
75
102
  const withPgClient = context.pgSettings
76
103
  ? context.withPgClient
77
104
  : null;
78
105
  if (withPgClient) {
79
- const config = await withPgClient(null, async (pgClient) => {
106
+ const resolved = await withPgClient(null, async (pgClient) => {
80
107
  const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
81
108
  const databaseId = dbResult.rows[0]?.id;
82
109
  if (!databaseId)
83
110
  return null;
84
- 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 };
85
115
  });
86
- if (config) {
87
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
116
+ if (resolved) {
117
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
118
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
88
119
  }
89
120
  }
90
121
  }
91
122
  catch {
92
- // 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}`;
93
128
  }
94
- // Private file: generate presigned GET URL
95
- return (0, s3_signer_1.generatePresignedGetUrl)(s3, 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);
96
131
  },
97
132
  }),
98
133
  }, 'PresignedUrlDownloadPlugin adding downloadUrl field');
@@ -14,14 +14,5 @@
14
14
  */
15
15
  import type { GraphileConfig } from 'graphile-config';
16
16
  import type { PresignedUrlPluginOptions } from './types';
17
- /**
18
- * Creates the downloadUrl computed field plugin.
19
- *
20
- * This is a separate plugin from the main presigned URL plugin because it
21
- * uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
22
- * The downloadUrl field needs to be added dynamically to whatever table is
23
- * the storage module's files table, which we discover at schema-build time
24
- * via the `@storageFiles` smart tag.
25
- */
26
17
  export declare function createDownloadUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
27
18
  export default createDownloadUrlPlugin;
@@ -25,8 +25,38 @@ const log = new Logger('graphile-presigned-url:download-url');
25
25
  * the storage module's files table, which we discover at schema-build time
26
26
  * via the `@storageFiles` smart tag.
27
27
  */
28
+ /**
29
+ * Resolve the S3 config from the options. If the option is a lazy getter
30
+ * function, call it (and cache the result).
31
+ */
32
+ function resolveS3(options) {
33
+ if (typeof options.s3 === 'function') {
34
+ const resolved = options.s3();
35
+ options.s3 = resolved;
36
+ return resolved;
37
+ }
38
+ return options.s3;
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
+ }
28
59
  export function createDownloadUrlPlugin(options) {
29
- const { s3 } = options;
30
60
  return {
31
61
  name: 'PresignedUrlDownloadPlugin',
32
62
  version: '0.1.0',
@@ -62,34 +92,39 @@ export function createDownloadUrlPlugin(options) {
62
92
  if (status !== 'ready' && status !== 'processed') {
63
93
  return null;
64
94
  }
65
- if (isPublic && s3.publicUrlPrefix) {
66
- // Public file: return direct URL
67
- return `${s3.publicUrlPrefix}/${key}`;
68
- }
69
- // 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
70
97
  let downloadUrlExpirySeconds = 3600; // fallback default
71
98
  try {
72
99
  const withPgClient = context.pgSettings
73
100
  ? context.withPgClient
74
101
  : null;
75
102
  if (withPgClient) {
76
- const config = await withPgClient(null, async (pgClient) => {
103
+ const resolved = await withPgClient(null, async (pgClient) => {
77
104
  const dbResult = await pgClient.query(`SELECT jwt_private.current_database_id() AS id`);
78
105
  const databaseId = dbResult.rows[0]?.id;
79
106
  if (!databaseId)
80
107
  return null;
81
- return getStorageModuleConfig(pgClient, databaseId);
108
+ const config = await getStorageModuleConfig(pgClient, databaseId);
109
+ if (!config)
110
+ return null;
111
+ return { config, databaseId };
82
112
  });
83
- if (config) {
84
- downloadUrlExpirySeconds = config.downloadUrlExpirySeconds;
113
+ if (resolved) {
114
+ downloadUrlExpirySeconds = resolved.config.downloadUrlExpirySeconds;
115
+ s3ForDb = resolveS3ForDatabase(options, resolved.config, resolved.databaseId);
85
116
  }
86
117
  }
87
118
  }
88
119
  catch {
89
- // 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}`;
90
125
  }
91
- // Private file: generate presigned GET URL
92
- return generatePresignedGetUrl(s3, key, downloadUrlExpirySeconds, filename || undefined);
126
+ // Private file: generate presigned GET URL (per-database bucket)
127
+ return generatePresignedGetUrl(s3ForDb, key, downloadUrlExpirySeconds, filename || undefined);
93
128
  },
94
129
  }),
95
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, 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) ---
@@ -52,8 +52,64 @@ async function resolveDatabaseId(pgClient) {
52
52
  return result.rows[0]?.id ?? null;
53
53
  }
54
54
  // --- Plugin factory ---
55
+ /**
56
+ * Resolve the S3 config from the options. If the option is a lazy getter
57
+ * function, call it (and cache the result). This avoids reading env vars
58
+ * or constructing an S3Client at module-import time.
59
+ */
60
+ function resolveS3(options) {
61
+ if (typeof options.s3 === 'function') {
62
+ const resolved = options.s3();
63
+ // Cache so subsequent calls don't re-evaluate
64
+ options.s3 = resolved;
65
+ return resolved;
66
+ }
67
+ return options.s3;
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
+ }
55
112
  export function createPresignedUrlPlugin(options) {
56
- const { s3 } = options;
57
113
  return extendSchema(() => ({
58
114
  typeDefs: gql `
59
115
  input RequestUploadUrlInput {
@@ -228,8 +284,11 @@ export function createPresignedUrlPlugin(options) {
228
284
  bucket.is_public,
229
285
  ]);
230
286
  const fileId = fileResult.rows[0].id;
231
- // --- Generate presigned PUT URL ---
232
- const uploadUrl = await generatePresignedPutUrl(s3, 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);
233
292
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
234
293
  // --- Track the upload request ---
235
294
  await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
@@ -295,8 +354,9 @@ export function createPresignedUrlPlugin(options) {
295
354
  success: true,
296
355
  };
297
356
  }
298
- // --- Verify file exists in S3 ---
299
- const s3Head = await headObject(s3, 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);
300
360
  if (!s3Head) {
301
361
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
302
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.
@@ -38,6 +38,10 @@ const STORAGE_MODULE_QUERY = `
38
38
  ft.name AS files_table,
39
39
  urs.schema_name AS upload_requests_schema,
40
40
  urt.name AS upload_requests_table,
41
+ sm.endpoint,
42
+ sm.public_url_prefix,
43
+ sm.provider,
44
+ sm.allowed_origins,
41
45
  sm.upload_url_expiry_seconds,
42
46
  sm.download_url_expiry_seconds,
43
47
  sm.default_max_file_size,
@@ -83,6 +87,10 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
83
87
  bucketsTableName: row.buckets_table,
84
88
  filesTableName: row.files_table,
85
89
  uploadRequestsTableName: row.upload_requests_table,
90
+ endpoint: row.endpoint,
91
+ publicUrlPrefix: row.public_url_prefix,
92
+ provider: row.provider,
93
+ allowedOrigins: row.allowed_origins,
86
94
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
87
95
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
88
96
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -153,6 +161,34 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
153
161
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
154
162
  return config;
155
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
+ }
156
192
  /**
157
193
  * Clear the storage module cache AND bucket cache.
158
194
  * Useful for testing or schema changes.
@@ -160,6 +196,7 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
160
196
  export function clearStorageModuleCache() {
161
197
  storageModuleCache.clear();
162
198
  bucketCache.clear();
199
+ provisionedBuckets.clear();
163
200
  }
164
201
  /**
165
202
  * Clear cached bucket entries for a specific database.
package/esm/types.d.ts CHANGED
@@ -31,6 +31,14 @@ export interface StorageModuleConfig {
31
31
  filesTableName: string;
32
32
  /** Upload requests table name */
33
33
  uploadRequestsTableName: string;
34
+ /** S3-compatible API endpoint URL (per-database override) */
35
+ endpoint: string | null;
36
+ /** Public URL prefix for generating download URLs (per-database override) */
37
+ publicUrlPrefix: string | null;
38
+ /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
+ provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
34
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
35
43
  uploadUrlExpirySeconds: number;
36
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -107,10 +115,57 @@ export interface S3Config {
107
115
  /** Public URL prefix for generating download URLs */
108
116
  publicUrlPrefix?: string;
109
117
  }
118
+ /**
119
+ * S3 configuration or a lazy getter that returns it on first use.
120
+ * When a function is provided, it will only be called when the first
121
+ * mutation or resolver actually needs the S3 client — avoiding eager
122
+ * env-var reads and S3Client creation at module import time.
123
+ */
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>;
110
151
  /**
111
152
  * Plugin options for the presigned URL plugin.
112
153
  */
113
154
  export interface PresignedUrlPluginOptions {
114
- /** S3 configuration */
115
- s3: S3Config;
155
+ /** S3 configuration (concrete or lazy getter) */
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;
116
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, 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.2.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",
@@ -55,8 +55,9 @@
55
55
  "postgraphile": "5.0.0"
56
56
  },
57
57
  "devDependencies": {
58
+ "@constructive-io/s3-utils": "^2.10.2",
58
59
  "@types/node": "^22.19.11",
59
60
  "makage": "^0.1.10"
60
61
  },
61
- "gitHead": "fc23b83307d007a14e54b1d0fc36614b9650a5dc"
62
+ "gitHead": "3bf7c522cf9f9d2595750ac7cea81d470b3e6c30"
62
63
  }
package/plugin.js CHANGED
@@ -56,8 +56,64 @@ async function resolveDatabaseId(pgClient) {
56
56
  return result.rows[0]?.id ?? null;
57
57
  }
58
58
  // --- Plugin factory ---
59
+ /**
60
+ * Resolve the S3 config from the options. If the option is a lazy getter
61
+ * function, call it (and cache the result). This avoids reading env vars
62
+ * or constructing an S3Client at module-import time.
63
+ */
64
+ function resolveS3(options) {
65
+ if (typeof options.s3 === 'function') {
66
+ const resolved = options.s3();
67
+ // Cache so subsequent calls don't re-evaluate
68
+ options.s3 = resolved;
69
+ return resolved;
70
+ }
71
+ return options.s3;
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
+ }
59
116
  function createPresignedUrlPlugin(options) {
60
- const { s3 } = options;
61
117
  return (0, graphile_utils_1.extendSchema)(() => ({
62
118
  typeDefs: (0, graphile_utils_1.gql) `
63
119
  input RequestUploadUrlInput {
@@ -232,8 +288,11 @@ function createPresignedUrlPlugin(options) {
232
288
  bucket.is_public,
233
289
  ]);
234
290
  const fileId = fileResult.rows[0].id;
235
- // --- Generate presigned PUT URL ---
236
- const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3, 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);
237
296
  const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
238
297
  // --- Track the upload request ---
239
298
  await pgClient.query(`INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
@@ -299,8 +358,9 @@ function createPresignedUrlPlugin(options) {
299
358
  success: true,
300
359
  };
301
360
  }
302
- // --- Verify file exists in S3 ---
303
- const s3Head = await (0, s3_signer_1.headObject)(s3, 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);
304
364
  if (!s3Head) {
305
365
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
306
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");
@@ -44,6 +46,10 @@ const STORAGE_MODULE_QUERY = `
44
46
  ft.name AS files_table,
45
47
  urs.schema_name AS upload_requests_schema,
46
48
  urt.name AS upload_requests_table,
49
+ sm.endpoint,
50
+ sm.public_url_prefix,
51
+ sm.provider,
52
+ sm.allowed_origins,
47
53
  sm.upload_url_expiry_seconds,
48
54
  sm.download_url_expiry_seconds,
49
55
  sm.default_max_file_size,
@@ -89,6 +95,10 @@ async function getStorageModuleConfig(pgClient, databaseId) {
89
95
  bucketsTableName: row.buckets_table,
90
96
  filesTableName: row.files_table,
91
97
  uploadRequestsTableName: row.upload_requests_table,
98
+ endpoint: row.endpoint,
99
+ publicUrlPrefix: row.public_url_prefix,
100
+ provider: row.provider,
101
+ allowedOrigins: row.allowed_origins,
92
102
  uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
93
103
  downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
94
104
  defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
@@ -159,6 +169,34 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
159
169
  log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
160
170
  return config;
161
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
+ }
162
200
  /**
163
201
  * Clear the storage module cache AND bucket cache.
164
202
  * Useful for testing or schema changes.
@@ -166,6 +204,7 @@ async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
166
204
  function clearStorageModuleCache() {
167
205
  storageModuleCache.clear();
168
206
  bucketCache.clear();
207
+ provisionedBuckets.clear();
169
208
  }
170
209
  /**
171
210
  * Clear cached bucket entries for a specific database.
package/types.d.ts CHANGED
@@ -31,6 +31,14 @@ export interface StorageModuleConfig {
31
31
  filesTableName: string;
32
32
  /** Upload requests table name */
33
33
  uploadRequestsTableName: string;
34
+ /** S3-compatible API endpoint URL (per-database override) */
35
+ endpoint: string | null;
36
+ /** Public URL prefix for generating download URLs (per-database override) */
37
+ publicUrlPrefix: string | null;
38
+ /** Storage provider type: 'minio', 's3', 'gcs', etc. (per-database override) */
39
+ provider: string | null;
40
+ /** CORS allowed origins (per-database override, NULL = use global fallback) */
41
+ allowedOrigins: string[] | null;
34
42
  /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
35
43
  uploadUrlExpirySeconds: number;
36
44
  /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
@@ -107,10 +115,57 @@ export interface S3Config {
107
115
  /** Public URL prefix for generating download URLs */
108
116
  publicUrlPrefix?: string;
109
117
  }
118
+ /**
119
+ * S3 configuration or a lazy getter that returns it on first use.
120
+ * When a function is provided, it will only be called when the first
121
+ * mutation or resolver actually needs the S3 client — avoiding eager
122
+ * env-var reads and S3Client creation at module import time.
123
+ */
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>;
110
151
  /**
111
152
  * Plugin options for the presigned URL plugin.
112
153
  */
113
154
  export interface PresignedUrlPluginOptions {
114
- /** S3 configuration */
115
- s3: S3Config;
155
+ /** S3 configuration (concrete or lazy getter) */
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;
116
171
  }