graphile-presigned-url-plugin 0.5.0 → 0.6.1

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/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, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
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, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, 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, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
22
+ import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, 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) ---
@@ -117,6 +117,13 @@ export function createPresignedUrlPlugin(options) {
117
117
  input RequestUploadUrlInput {
118
118
  """Bucket key (e.g., "public", "private")"""
119
119
  bucketKey: String!
120
+ """
121
+ Owner entity ID for entity-scoped uploads.
122
+ Omit for app-level (database-wide) storage.
123
+ When provided, resolves the storage module for the entity type
124
+ that owns this entity instance (e.g., a data room ID, team ID).
125
+ """
126
+ ownerId: UUID
120
127
  """SHA-256 content hash computed by the client (hex-encoded, 64 chars)"""
121
128
  contentHash: String!
122
129
  """MIME type of the file (e.g., "image/png")"""
@@ -188,7 +195,7 @@ export function createPresignedUrlPlugin(options) {
188
195
  });
189
196
  return lambda($combined, async ({ input, withPgClient, pgSettings }) => {
190
197
  // --- Input validation ---
191
- const { bucketKey, contentHash, contentType, size, filename } = input;
198
+ const { bucketKey, ownerId, contentHash, contentType, size, filename } = input;
192
199
  if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
193
200
  throw new Error('INVALID_BUCKET_KEY');
194
201
  }
@@ -208,9 +215,14 @@ export function createPresignedUrlPlugin(options) {
208
215
  if (!databaseId) {
209
216
  throw new Error('DATABASE_NOT_FOUND');
210
217
  }
211
- const storageConfig = await getStorageModuleConfig(txClient, databaseId);
218
+ // --- Resolve storage module (app-level or entity-scoped) ---
219
+ const storageConfig = ownerId
220
+ ? await getStorageModuleConfigForOwner(txClient, databaseId, ownerId)
221
+ : await getStorageModuleConfig(txClient, databaseId);
212
222
  if (!storageConfig) {
213
- throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
223
+ throw new Error(ownerId
224
+ ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
225
+ : 'STORAGE_MODULE_NOT_PROVISIONED');
214
226
  }
215
227
  // --- Validate size against storage module default (bucket override checked below) ---
216
228
  if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
@@ -222,7 +234,7 @@ export function createPresignedUrlPlugin(options) {
222
234
  }
223
235
  }
224
236
  // --- Look up the bucket (cached; first miss queries via RLS) ---
225
- const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey);
237
+ const bucket = await getBucketConfig(txClient, storageConfig, databaseId, bucketKey, ownerId);
226
238
  if (!bucket) {
227
239
  throw new Error('BUCKET_NOT_FOUND');
228
240
  }
@@ -247,15 +259,15 @@ export function createPresignedUrlPlugin(options) {
247
259
  throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
248
260
  }
249
261
  const s3Key = buildS3Key(contentHash);
250
- // --- Dedup check: look for existing file with same content_hash in this bucket ---
262
+ // --- Dedup check: look for existing file with same key (content hash) in this bucket ---
251
263
  const dedupResult = await txClient.query({
252
264
  text: `SELECT id, status
253
265
  FROM ${storageConfig.filesQualifiedName}
254
- WHERE content_hash = $1
266
+ WHERE key = $1
255
267
  AND bucket_id = $2
256
268
  AND status IN ('ready', 'processed')
257
269
  LIMIT 1`,
258
- values: [contentHash, bucket.id],
270
+ values: [s3Key, bucket.id],
259
271
  });
260
272
  if (dedupResult.rows.length > 0) {
261
273
  const existingFile = dedupResult.rows[0];
@@ -276,21 +288,36 @@ export function createPresignedUrlPlugin(options) {
276
288
  };
277
289
  }
278
290
  // --- Create file record (status=pending) ---
291
+ // For app-level storage (no owner_id column), omit owner_id from the INSERT.
292
+ const hasOwnerColumn = storageConfig.membershipType !== null;
279
293
  const fileResult = await txClient.query({
280
- text: `INSERT INTO ${storageConfig.filesQualifiedName}
281
- (bucket_id, key, content_type, content_hash, size, filename, owner_id, is_public, status)
282
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
283
- RETURNING id`,
284
- values: [
285
- bucket.id,
286
- s3Key,
287
- contentType,
288
- contentHash,
289
- size,
290
- filename || null,
291
- bucket.owner_id,
292
- bucket.is_public,
293
- ],
294
+ text: hasOwnerColumn
295
+ ? `INSERT INTO ${storageConfig.filesQualifiedName}
296
+ (bucket_id, key, mime_type, size, filename, owner_id, is_public, status)
297
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
298
+ RETURNING id`
299
+ : `INSERT INTO ${storageConfig.filesQualifiedName}
300
+ (bucket_id, key, mime_type, size, filename, is_public, status)
301
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending')
302
+ RETURNING id`,
303
+ values: hasOwnerColumn
304
+ ? [
305
+ bucket.id,
306
+ s3Key,
307
+ contentType,
308
+ size,
309
+ filename || null,
310
+ bucket.owner_id,
311
+ bucket.is_public,
312
+ ]
313
+ : [
314
+ bucket.id,
315
+ s3Key,
316
+ contentType,
317
+ size,
318
+ filename || null,
319
+ bucket.is_public,
320
+ ],
294
321
  });
295
322
  const fileId = fileResult.rows[0].id;
296
323
  // --- Ensure the S3 bucket exists (lazy provisioning) ---
@@ -333,27 +360,16 @@ export function createPresignedUrlPlugin(options) {
333
360
  }
334
361
  return withPgClient(pgSettings, async (pgClient) => {
335
362
  return pgClient.withTransaction(async (txClient) => {
336
- // --- Resolve storage module config ---
363
+ // --- Resolve storage module by file ID (probes all file tables) ---
337
364
  const databaseId = await resolveDatabaseId(txClient);
338
365
  if (!databaseId) {
339
366
  throw new Error('DATABASE_NOT_FOUND');
340
367
  }
341
- const storageConfig = await getStorageModuleConfig(txClient, databaseId);
342
- if (!storageConfig) {
343
- throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
344
- }
345
- // --- Look up the file (RLS enforced) ---
346
- const fileResult = await txClient.query({
347
- text: `SELECT id, key, content_type, status, bucket_id
348
- FROM ${storageConfig.filesQualifiedName}
349
- WHERE id = $1
350
- LIMIT 1`,
351
- values: [fileId],
352
- });
353
- if (fileResult.rows.length === 0) {
368
+ const resolved = await resolveStorageModuleByFileId(txClient, databaseId, fileId);
369
+ if (!resolved) {
354
370
  throw new Error('FILE_NOT_FOUND');
355
371
  }
356
- const file = fileResult.rows[0];
372
+ const { storageConfig, file } = resolved;
357
373
  if (file.status !== 'pending') {
358
374
  // File is already confirmed or processed — idempotent success
359
375
  return {
@@ -364,12 +380,12 @@ export function createPresignedUrlPlugin(options) {
364
380
  }
365
381
  // --- Verify file exists in S3 (per-database bucket) ---
366
382
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
367
- const s3Head = await headObject(s3ForDb, file.key, file.content_type);
383
+ const s3Head = await headObject(s3ForDb, file.key, file.mime_type);
368
384
  if (!s3Head) {
369
385
  throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
370
386
  }
371
387
  // --- Content-type verification ---
372
- if (s3Head.contentType && s3Head.contentType !== file.content_type) {
388
+ if (s3Head.contentType && s3Head.contentType !== file.mime_type) {
373
389
  // Mark upload_request as rejected
374
390
  await txClient.query({
375
391
  text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
@@ -377,7 +393,7 @@ export function createPresignedUrlPlugin(options) {
377
393
  WHERE file_id = $1 AND status = 'issued'`,
378
394
  values: [fileId],
379
395
  });
380
- throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.content_type}, got ${s3Head.contentType}`);
396
+ throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.mime_type}, got ${s3Head.contentType}`);
381
397
  }
382
398
  // --- Transition file to 'ready' ---
383
399
  await txClient.query({
@@ -1,6 +1,9 @@
1
1
  import type { StorageModuleConfig, BucketConfig } from './types';
2
2
  /**
3
- * Resolve the storage module config for a database, using the LRU cache.
3
+ * Resolve the app-level storage module config for a database, using the LRU cache.
4
+ *
5
+ * This is the default path when no ownerId is provided. It returns the
6
+ * storage module with membership_type IS NULL (app-level / database-wide).
4
7
  *
5
8
  * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
6
9
  * @param databaseId - The metaschema database UUID
@@ -14,6 +17,57 @@ export declare function getStorageModuleConfig(pgClient: {
14
17
  rows: unknown[];
15
18
  }>;
16
19
  }, databaseId: string): Promise<StorageModuleConfig | null>;
20
+ /**
21
+ * Resolve the storage module config for a specific owner entity.
22
+ *
23
+ * When ownerId is provided, this function:
24
+ * 1. Loads ALL storage modules for the database (cached)
25
+ * 2. Finds which entity-scoped module contains the ownerId in its entity table
26
+ * 3. Returns that module's config
27
+ *
28
+ * This is the core of Option C — the ownerId tells us which scope to use.
29
+ *
30
+ * @param pgClient - A pg client from the Graphile context
31
+ * @param databaseId - The metaschema database UUID
32
+ * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
33
+ * @returns StorageModuleConfig or null if no matching module found
34
+ */
35
+ export declare function getStorageModuleConfigForOwner(pgClient: {
36
+ query: (opts: {
37
+ text: string;
38
+ values?: unknown[];
39
+ }) => Promise<{
40
+ rows: unknown[];
41
+ }>;
42
+ }, databaseId: string, ownerId: string): Promise<StorageModuleConfig | null>;
43
+ /**
44
+ * Resolve the storage module that owns a specific file by probing all file tables.
45
+ *
46
+ * Used by confirmUpload when only a fileId (UUID) is available.
47
+ * Since UUIDs are globally unique, exactly one table will contain the file.
48
+ *
49
+ * @param pgClient - A pg client from the Graphile context
50
+ * @param databaseId - The metaschema database UUID
51
+ * @param fileId - The file UUID to look up
52
+ * @returns Object with the storage config and file row, or null if not found
53
+ */
54
+ export declare function resolveStorageModuleByFileId(pgClient: {
55
+ query: (opts: {
56
+ text: string;
57
+ values?: unknown[];
58
+ }) => Promise<{
59
+ rows: unknown[];
60
+ }>;
61
+ }, databaseId: string, fileId: string): Promise<{
62
+ storageConfig: StorageModuleConfig;
63
+ file: {
64
+ id: string;
65
+ key: string;
66
+ mime_type: string;
67
+ status: string;
68
+ bucket_id: string;
69
+ };
70
+ } | null>;
17
71
  /**
18
72
  * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
19
73
  *
@@ -21,9 +75,10 @@ export declare function getStorageModuleConfig(pgClient: {
21
75
  * the pgClient). On cache hit, returns the cached metadata directly.
22
76
  *
23
77
  * @param pgClient - A pg client from the Graphile context
24
- * @param storageConfig - The resolved StorageModuleConfig for this database
78
+ * @param storageConfig - The resolved StorageModuleConfig for this database/scope
25
79
  * @param databaseId - The metaschema database UUID (used as cache key prefix)
26
80
  * @param bucketKey - The bucket key (e.g., "public", "private")
81
+ * @param ownerId - Optional owner entity ID for entity-scoped bucket lookup
27
82
  * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
28
83
  */
29
84
  export declare function getBucketConfig(pgClient: {
@@ -33,7 +88,7 @@ export declare function getBucketConfig(pgClient: {
33
88
  }) => Promise<{
34
89
  rows: unknown[];
35
90
  }>;
36
- }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string): Promise<BucketConfig | null>;
91
+ }, storageConfig: StorageModuleConfig, databaseId: string, bucketKey: string, ownerId?: string): Promise<BucketConfig | null>;
37
92
  /**
38
93
  * Check whether an S3 bucket has already been provisioned (cached).
39
94
  */
@@ -1,5 +1,6 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
2
  import { LRUCache } from 'lru-cache';
3
+ import { QuoteUtils } from '@pgsql/quotes';
3
4
  const log = new Logger('graphile-presigned-url:cache');
4
5
  // --- Defaults ---
5
6
  const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
@@ -25,13 +26,18 @@ const storageModuleCache = new LRUCache({
25
26
  updateAgeOnGet: true,
26
27
  });
27
28
  /**
28
- * SQL query to resolve storage module config for a database.
29
+ * SQL query to resolve the app-level storage module config for a database.
29
30
  *
30
31
  * Joins storage_module → table → schema to get fully-qualified table names.
32
+ * Filters to app-level (membership_type IS NULL) by default.
33
+ *
34
+ * Requires the multi-scope schema (membership_type column on storage_module).
31
35
  */
32
- const STORAGE_MODULE_QUERY = `
36
+ const APP_STORAGE_MODULE_QUERY = `
33
37
  SELECT
34
38
  sm.id,
39
+ sm.membership_type,
40
+ sm.entity_table_id,
35
41
  bs.schema_name AS buckets_schema,
36
42
  bt.name AS buckets_table,
37
43
  fs.schema_name AS files_schema,
@@ -46,7 +52,9 @@ const STORAGE_MODULE_QUERY = `
46
52
  sm.download_url_expiry_seconds,
47
53
  sm.default_max_file_size,
48
54
  sm.max_filename_length,
49
- sm.cache_ttl_seconds
55
+ sm.cache_ttl_seconds,
56
+ NULL AS entity_schema,
57
+ NULL AS entity_table
50
58
  FROM metaschema_modules_public.storage_module sm
51
59
  JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
52
60
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
@@ -55,38 +63,67 @@ const STORAGE_MODULE_QUERY = `
55
63
  JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
56
64
  JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
57
65
  WHERE sm.database_id = $1
66
+ AND sm.membership_type IS NULL
58
67
  LIMIT 1
59
68
  `;
60
69
  /**
61
- * Resolve the storage module config for a database, using the LRU cache.
70
+ * SQL query to resolve ALL storage modules for a database (app-level + entity-scoped).
62
71
  *
63
- * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
64
- * @param databaseId - The metaschema database UUID
65
- * @returns StorageModuleConfig or null if no storage module is provisioned
72
+ * Returns all storage modules with their entity table names for ownerId resolution.
73
+ * Requires the multi-scope schema.
66
74
  */
67
- export async function getStorageModuleConfig(pgClient, databaseId) {
68
- const cacheKey = `storage:${databaseId}`;
69
- const cached = storageModuleCache.get(cacheKey);
70
- if (cached) {
71
- return cached;
72
- }
73
- log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
74
- const result = await pgClient.query({ text: STORAGE_MODULE_QUERY, values: [databaseId] });
75
- if (result.rows.length === 0) {
76
- log.warn(`No storage module found for database ${databaseId}`);
77
- return null;
78
- }
79
- const row = result.rows[0];
75
+ const ALL_STORAGE_MODULES_QUERY = `
76
+ SELECT
77
+ sm.id,
78
+ sm.membership_type,
79
+ sm.entity_table_id,
80
+ bs.schema_name AS buckets_schema,
81
+ bt.name AS buckets_table,
82
+ fs.schema_name AS files_schema,
83
+ ft.name AS files_table,
84
+ urs.schema_name AS upload_requests_schema,
85
+ urt.name AS upload_requests_table,
86
+ sm.endpoint,
87
+ sm.public_url_prefix,
88
+ sm.provider,
89
+ sm.allowed_origins,
90
+ sm.upload_url_expiry_seconds,
91
+ sm.download_url_expiry_seconds,
92
+ sm.default_max_file_size,
93
+ sm.max_filename_length,
94
+ sm.cache_ttl_seconds,
95
+ es.schema_name AS entity_schema,
96
+ et.name AS entity_table
97
+ FROM metaschema_modules_public.storage_module sm
98
+ JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
99
+ JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
100
+ JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
101
+ JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
102
+ JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
103
+ JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
104
+ LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
105
+ LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
106
+ WHERE sm.database_id = $1
107
+ `;
108
+ /**
109
+ * Build a StorageModuleConfig from a raw DB row.
110
+ */
111
+ function buildConfig(row) {
80
112
  const cacheTtlSeconds = row.cache_ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS;
81
- const config = {
113
+ return {
82
114
  id: row.id,
83
- bucketsQualifiedName: `"${row.buckets_schema}"."${row.buckets_table}"`,
84
- filesQualifiedName: `"${row.files_schema}"."${row.files_table}"`,
85
- uploadRequestsQualifiedName: `"${row.upload_requests_schema}"."${row.upload_requests_table}"`,
115
+ bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
116
+ filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
117
+ uploadRequestsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.upload_requests_schema, row.upload_requests_table),
86
118
  schemaName: row.buckets_schema,
87
119
  bucketsTableName: row.buckets_table,
88
120
  filesTableName: row.files_table,
89
121
  uploadRequestsTableName: row.upload_requests_table,
122
+ membershipType: row.membership_type,
123
+ entityTableId: row.entity_table_id,
124
+ entityQualifiedName: row.entity_schema && row.entity_table
125
+ ? QuoteUtils.quoteQualifiedIdentifier(row.entity_schema, row.entity_table)
126
+ : null,
90
127
  endpoint: row.endpoint,
91
128
  publicUrlPrefix: row.public_url_prefix,
92
129
  provider: row.provider,
@@ -97,10 +134,129 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
97
134
  maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
98
135
  cacheTtlSeconds,
99
136
  };
137
+ }
138
+ /**
139
+ * Resolve the app-level storage module config for a database, using the LRU cache.
140
+ *
141
+ * This is the default path when no ownerId is provided. It returns the
142
+ * storage module with membership_type IS NULL (app-level / database-wide).
143
+ *
144
+ * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
145
+ * @param databaseId - The metaschema database UUID
146
+ * @returns StorageModuleConfig or null if no storage module is provisioned
147
+ */
148
+ export async function getStorageModuleConfig(pgClient, databaseId) {
149
+ const cacheKey = `storage:${databaseId}:app`;
150
+ const cached = storageModuleCache.get(cacheKey);
151
+ if (cached) {
152
+ return cached;
153
+ }
154
+ log.debug(`Cache miss for app-level storage in database ${databaseId}, querying metaschema...`);
155
+ const result = await pgClient.query({ text: APP_STORAGE_MODULE_QUERY, values: [databaseId] });
156
+ if (result.rows.length === 0) {
157
+ log.warn(`No app-level storage module found for database ${databaseId}`);
158
+ return null;
159
+ }
160
+ const config = buildConfig(result.rows[0]);
100
161
  storageModuleCache.set(cacheKey, config);
101
- log.debug(`Cached storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
162
+ log.debug(`Cached app-level storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
102
163
  return config;
103
164
  }
165
+ /**
166
+ * Resolve the storage module config for a specific owner entity.
167
+ *
168
+ * When ownerId is provided, this function:
169
+ * 1. Loads ALL storage modules for the database (cached)
170
+ * 2. Finds which entity-scoped module contains the ownerId in its entity table
171
+ * 3. Returns that module's config
172
+ *
173
+ * This is the core of Option C — the ownerId tells us which scope to use.
174
+ *
175
+ * @param pgClient - A pg client from the Graphile context
176
+ * @param databaseId - The metaschema database UUID
177
+ * @param ownerId - The entity instance UUID (e.g., a data room ID, team ID)
178
+ * @returns StorageModuleConfig or null if no matching module found
179
+ */
180
+ export async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
181
+ // Check if we already have a cached mapping for this ownerId
182
+ const ownerCacheKey = `storage:${databaseId}:owner:${ownerId}`;
183
+ const cachedOwner = storageModuleCache.get(ownerCacheKey);
184
+ if (cachedOwner) {
185
+ return cachedOwner;
186
+ }
187
+ // Load all storage modules for this database
188
+ const allModulesCacheKey = `storage:${databaseId}:all`;
189
+ let allConfigs;
190
+ const cachedAll = storageModuleCache.get(allModulesCacheKey);
191
+ if (cachedAll) {
192
+ // We stored a sentinel; re-derive from individual caches
193
+ // Actually, let's just query fresh — this is the cache-miss path
194
+ allConfigs = [];
195
+ }
196
+ else {
197
+ allConfigs = [];
198
+ }
199
+ if (allConfigs.length === 0) {
200
+ log.debug(`Loading all storage modules for database ${databaseId} to resolve ownerId ${ownerId}`);
201
+ const result = await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] });
202
+ allConfigs = result.rows.map(buildConfig);
203
+ // Cache each individual config by its membership type
204
+ for (const config of allConfigs) {
205
+ const key = config.membershipType === null
206
+ ? `storage:${databaseId}:app`
207
+ : `storage:${databaseId}:mt:${config.membershipType}`;
208
+ storageModuleCache.set(key, config);
209
+ }
210
+ }
211
+ // Find entity-scoped modules and probe their entity tables for the ownerId
212
+ const entityModules = allConfigs.filter((c) => c.entityQualifiedName !== null);
213
+ for (const mod of entityModules) {
214
+ const probeResult = await pgClient.query({
215
+ text: `SELECT 1 FROM ${mod.entityQualifiedName} WHERE id = $1 LIMIT 1`,
216
+ values: [ownerId],
217
+ });
218
+ if (probeResult.rows.length > 0) {
219
+ // Found the matching module — cache the ownerId→module mapping
220
+ storageModuleCache.set(ownerCacheKey, mod);
221
+ log.debug(`Resolved ownerId ${ownerId} to storage module ${mod.id} ` +
222
+ `(membershipType=${mod.membershipType}, table=${mod.bucketsQualifiedName})`);
223
+ return mod;
224
+ }
225
+ }
226
+ log.warn(`No entity-scoped storage module found for ownerId ${ownerId} in database ${databaseId}`);
227
+ return null;
228
+ }
229
+ /**
230
+ * Resolve the storage module that owns a specific file by probing all file tables.
231
+ *
232
+ * Used by confirmUpload when only a fileId (UUID) is available.
233
+ * Since UUIDs are globally unique, exactly one table will contain the file.
234
+ *
235
+ * @param pgClient - A pg client from the Graphile context
236
+ * @param databaseId - The metaschema database UUID
237
+ * @param fileId - The file UUID to look up
238
+ * @returns Object with the storage config and file row, or null if not found
239
+ */
240
+ export async function resolveStorageModuleByFileId(pgClient, databaseId, fileId) {
241
+ // Load all storage modules for this database
242
+ log.debug(`Resolving file ${fileId} across all storage modules for database ${databaseId}`);
243
+ const allConfigs = (await pgClient.query({ text: ALL_STORAGE_MODULES_QUERY, values: [databaseId] })).rows.map((row) => buildConfig(row));
244
+ // Probe each module's files table for the fileId
245
+ for (const config of allConfigs) {
246
+ const fileResult = await pgClient.query({
247
+ text: `SELECT id, key, mime_type, status, bucket_id
248
+ FROM ${config.filesQualifiedName}
249
+ WHERE id = $1
250
+ LIMIT 1`,
251
+ values: [fileId],
252
+ });
253
+ if (fileResult.rows.length > 0) {
254
+ const file = fileResult.rows[0];
255
+ return { storageConfig: config, file };
256
+ }
257
+ }
258
+ return null;
259
+ }
104
260
  // --- Bucket metadata cache ---
105
261
  /**
106
262
  * LRU cache for per-database bucket metadata.
@@ -113,7 +269,7 @@ export async function getStorageModuleConfig(pgClient, databaseId) {
113
269
  * is safe. The important RLS is on the files table (INSERT/UPDATE),
114
270
  * which is never cached.
115
271
  *
116
- * Keys: `bucket:${databaseId}:${bucketKey}`
272
+ * Keys: `bucket:${databaseId}:${storageModuleId}:${bucketKey}`
117
273
  * TTL: same as storage module cache (5min dev / 1hr prod)
118
274
  */
119
275
  const bucketCache = new LRUCache({
@@ -128,24 +284,33 @@ const bucketCache = new LRUCache({
128
284
  * the pgClient). On cache hit, returns the cached metadata directly.
129
285
  *
130
286
  * @param pgClient - A pg client from the Graphile context
131
- * @param storageConfig - The resolved StorageModuleConfig for this database
287
+ * @param storageConfig - The resolved StorageModuleConfig for this database/scope
132
288
  * @param databaseId - The metaschema database UUID (used as cache key prefix)
133
289
  * @param bucketKey - The bucket key (e.g., "public", "private")
290
+ * @param ownerId - Optional owner entity ID for entity-scoped bucket lookup
134
291
  * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
135
292
  */
136
- export async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
137
- const cacheKey = `bucket:${databaseId}:${bucketKey}`;
293
+ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, ownerId) {
294
+ const cacheKey = `bucket:${databaseId}:${storageConfig.id}:${bucketKey}${ownerId ? `:${ownerId}` : ''}`;
138
295
  const cached = bucketCache.get(cacheKey);
139
296
  if (cached) {
140
297
  return cached;
141
298
  }
142
- log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
299
+ log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}${ownerId ? ` (owner=${ownerId})` : ''}, querying DB...`);
300
+ // Entity-scoped buckets use (owner_id, key) composite lookup;
301
+ // app-level buckets just use key.
302
+ const hasOwner = ownerId && storageConfig.membershipType !== null;
143
303
  const result = await pgClient.query({
144
- text: `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
145
- FROM ${storageConfig.bucketsQualifiedName}
146
- WHERE key = $1
147
- LIMIT 1`,
148
- values: [bucketKey],
304
+ text: hasOwner
305
+ ? `SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
306
+ FROM ${storageConfig.bucketsQualifiedName}
307
+ WHERE key = $1 AND owner_id = $2
308
+ LIMIT 1`
309
+ : `SELECT id, key, type, is_public, ${storageConfig.membershipType !== null ? 'owner_id,' : ''} allowed_mime_types, max_file_size
310
+ FROM ${storageConfig.bucketsQualifiedName}
311
+ WHERE key = $1
312
+ LIMIT 1`,
313
+ values: hasOwner ? [bucketKey, ownerId] : [bucketKey],
149
314
  });
150
315
  if (result.rows.length === 0) {
151
316
  return null;
@@ -156,12 +321,12 @@ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucke
156
321
  key: row.key,
157
322
  type: row.type,
158
323
  is_public: row.is_public,
159
- owner_id: row.owner_id,
324
+ owner_id: row.owner_id ?? null,
160
325
  allowed_mime_types: row.allowed_mime_types,
161
326
  max_file_size: row.max_file_size,
162
327
  };
163
328
  bucketCache.set(cacheKey, config);
164
- log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
329
+ log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id}, scope=${storageConfig.membershipType ?? 'app'})`);
165
330
  return config;
166
331
  }
167
332
  // --- S3 bucket existence cache ---
package/esm/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface BucketConfig {
7
7
  key: string;
8
8
  type: 'public' | 'private' | 'temp';
9
9
  is_public: boolean;
10
- owner_id: string;
10
+ owner_id: string | null;
11
11
  allowed_mime_types: string[] | null;
12
12
  max_file_size: number | null;
13
13
  }
@@ -31,6 +31,12 @@ export interface StorageModuleConfig {
31
31
  filesTableName: string;
32
32
  /** Upload requests table name */
33
33
  uploadRequestsTableName: string;
34
+ /** Membership type (NULL for app-level, non-NULL for entity-scoped) */
35
+ membershipType: number | null;
36
+ /** Entity table ID for entity-scoped storage (NULL for app-level) */
37
+ entityTableId: string | null;
38
+ /** Qualified entity table name for ownerId lookups (NULL for app-level) */
39
+ entityQualifiedName: string | null;
34
40
  /** S3-compatible API endpoint URL (per-database override) */
35
41
  endpoint: string | null;
36
42
  /** Public URL prefix for generating download URLs (per-database override) */
@@ -56,6 +62,13 @@ export interface StorageModuleConfig {
56
62
  export interface RequestUploadUrlInput {
57
63
  /** Bucket key (e.g., "public", "private") */
58
64
  bucketKey: string;
65
+ /**
66
+ * Owner entity ID for entity-scoped uploads.
67
+ * Omit for app-level (database-wide) storage.
68
+ * When provided, resolves the storage module for the entity type
69
+ * that owns this entity instance (e.g., a data room ID, team ID).
70
+ */
71
+ ownerId?: string;
59
72
  /** SHA-256 content hash computed by the client */
60
73
  contentHash: string;
61
74
  /** MIME type of the file */
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, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
32
+ export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
33
33
  export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
34
  export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';