graphile-bucket-provisioner-plugin 1.13.0 → 1.14.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/esm/plugin.js +114 -64
- package/package.json +2 -2
- package/plugin.js +114 -64
package/esm/plugin.js
CHANGED
|
@@ -38,9 +38,12 @@ import { extendSchema, gql } from 'graphile-utils';
|
|
|
38
38
|
const log = new Logger('graphile-bucket-provisioner:plugin');
|
|
39
39
|
// --- Storage module queries ---
|
|
40
40
|
/**
|
|
41
|
-
* Resolve the
|
|
41
|
+
* Resolve the storage module whose buckets table is the one being addressed.
|
|
42
|
+
*
|
|
43
|
+
* The buckets table's identity (schema + table, from the codec being mutated or
|
|
44
|
+
* the module row) is the fact that names the plane — never a scope literal.
|
|
42
45
|
*/
|
|
43
|
-
const
|
|
46
|
+
const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
|
|
44
47
|
SELECT
|
|
45
48
|
sm.id,
|
|
46
49
|
sm.scope,
|
|
@@ -55,11 +58,12 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
55
58
|
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
|
|
56
59
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
57
60
|
WHERE sm.database_id = $1
|
|
58
|
-
AND
|
|
59
|
-
|
|
61
|
+
AND bs.schema_name = $2
|
|
62
|
+
AND bt.name = $3
|
|
60
63
|
`;
|
|
61
64
|
/**
|
|
62
|
-
* Resolve ALL storage modules for a database (for ownerId-based
|
|
65
|
+
* Resolve ALL storage modules for a database (for bucket-key and ownerId-based
|
|
66
|
+
* resolution in the explicit provisionBucket mutation).
|
|
63
67
|
*/
|
|
64
68
|
const ALL_STORAGE_MODULES_QUERY = `
|
|
65
69
|
SELECT
|
|
@@ -93,28 +97,82 @@ function runQuery(pgClient, text, values) {
|
|
|
93
97
|
return pgClient.query(values === undefined ? { text } : { text, values });
|
|
94
98
|
}
|
|
95
99
|
/**
|
|
96
|
-
* Resolve the storage module
|
|
97
|
-
*
|
|
98
|
-
*
|
|
100
|
+
* Resolve the storage module whose buckets table is the one being addressed.
|
|
101
|
+
*
|
|
102
|
+
* This is the resolution the auto-provision/CORS hooks use: the codec being
|
|
103
|
+
* mutated names its table, and the table names its module. A `@storageBuckets`
|
|
104
|
+
* table with no module row (or with several) is a provisioning bug and throws.
|
|
99
105
|
*/
|
|
100
|
-
async function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
async function resolveStorageModuleByBucketsTable(pgClient, databaseId, schemaName, tableName) {
|
|
107
|
+
const result = await runQuery(pgClient, STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY, [
|
|
108
|
+
databaseId,
|
|
109
|
+
schemaName,
|
|
110
|
+
tableName,
|
|
111
|
+
]);
|
|
112
|
+
const rows = result.rows;
|
|
113
|
+
if (rows.length === 0) {
|
|
114
|
+
throw new Error(`STORAGE_MODULE_NOT_FOUND: no storage module in database ${databaseId} records ` +
|
|
115
|
+
`${schemaName}.${tableName} as its buckets table`);
|
|
116
|
+
}
|
|
117
|
+
if (rows.length > 1) {
|
|
118
|
+
throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${rows.length} storage modules in database ${databaseId} record ` +
|
|
119
|
+
`${schemaName}.${tableName} as their buckets table`);
|
|
105
120
|
}
|
|
106
|
-
|
|
121
|
+
return rows[0];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The explicit provisionBucket mutation's resolution: find the plane that
|
|
125
|
+
* actually holds the named bucket row.
|
|
126
|
+
*
|
|
127
|
+
* With an ownerId, the owning entity row names the plane (probed through each
|
|
128
|
+
* entity-keyed module's recorded entity table). Without one, every
|
|
129
|
+
* non-entity-keyed plane's buckets table is probed for the key under RLS —
|
|
130
|
+
* exactly one plane may hold it; several is ambiguous and throws rather than
|
|
131
|
+
* silently picking.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
|
|
107
134
|
const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
|
|
108
135
|
const modules = result.rows;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
if (modules.length === 0) {
|
|
137
|
+
throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
|
|
138
|
+
}
|
|
139
|
+
if (ownerId) {
|
|
140
|
+
const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
|
|
141
|
+
for (const mod of entityModules) {
|
|
142
|
+
const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
|
|
143
|
+
const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
|
|
144
|
+
if (probe.rows.length === 0)
|
|
145
|
+
continue;
|
|
146
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
147
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
148
|
+
FROM ${bucketsTable}
|
|
149
|
+
WHERE key = $1 AND owner_id = $2
|
|
150
|
+
LIMIT 1`, [bucketKey, ownerId]);
|
|
151
|
+
if (bucketResult.rows.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
return { storageModule: mod, bucket: bucketResult.rows[0] };
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const matches = [];
|
|
158
|
+
for (const mod of modules.filter((m) => m.entity_table_id === null)) {
|
|
159
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
160
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
161
|
+
FROM ${bucketsTable}
|
|
162
|
+
WHERE key = $1
|
|
163
|
+
LIMIT 1`, [bucketKey]);
|
|
164
|
+
if (bucketResult.rows.length > 0) {
|
|
165
|
+
matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
|
|
115
166
|
}
|
|
116
167
|
}
|
|
117
|
-
|
|
168
|
+
if (matches.length === 0)
|
|
169
|
+
return null;
|
|
170
|
+
if (matches.length > 1) {
|
|
171
|
+
const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
|
|
172
|
+
throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
|
|
173
|
+
`(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
|
|
174
|
+
}
|
|
175
|
+
return matches[0];
|
|
118
176
|
}
|
|
119
177
|
/**
|
|
120
178
|
* Normalize the recorded physical coordinate at the DB boundary.
|
|
@@ -207,10 +265,8 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
|
|
|
207
265
|
* Core provisioning logic shared by both the explicit mutation and the
|
|
208
266
|
* auto-provisioning hook.
|
|
209
267
|
*/
|
|
210
|
-
async function provisionBucketForRow(
|
|
268
|
+
async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
211
269
|
const accessType = bucketType;
|
|
212
|
-
// Read storage module config to check for endpoint/provider/CORS overrides
|
|
213
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
214
270
|
// Resolve CORS origins using the 3-tier hierarchy
|
|
215
271
|
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
216
272
|
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
@@ -230,9 +286,8 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
|
|
|
230
286
|
/**
|
|
231
287
|
* Update CORS on an existing S3 bucket when allowed_origins changes.
|
|
232
288
|
*/
|
|
233
|
-
async function updateBucketCors(
|
|
289
|
+
async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
234
290
|
const accessType = bucketType;
|
|
235
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
236
291
|
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
237
292
|
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
238
293
|
log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
|
|
@@ -324,29 +379,14 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
324
379
|
if (!databaseId) {
|
|
325
380
|
throw new Error('DATABASE_NOT_FOUND');
|
|
326
381
|
}
|
|
327
|
-
// Resolve
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
|
|
332
|
-
: 'STORAGE_MODULE_NOT_PROVISIONED');
|
|
333
|
-
}
|
|
334
|
-
// Look up the bucket row (RLS enforced via pgSettings)
|
|
335
|
-
const hasOwner = ownerId && storageModule.scope !== 'app';
|
|
336
|
-
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
337
|
-
const bucketResult = await runQuery(pgClient, hasOwner
|
|
338
|
-
? `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
339
|
-
FROM ${bucketsTable}
|
|
340
|
-
WHERE key = $1 AND owner_id = $2
|
|
341
|
-
LIMIT 1`
|
|
342
|
-
: `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
343
|
-
FROM ${bucketsTable}
|
|
344
|
-
WHERE key = $1
|
|
345
|
-
LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
|
|
346
|
-
if (bucketResult.rows.length === 0) {
|
|
382
|
+
// Resolve the plane that actually holds the named bucket row
|
|
383
|
+
// (RLS enforced via pgSettings) — never a guessed scope.
|
|
384
|
+
const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
|
|
385
|
+
if (!resolution) {
|
|
347
386
|
throw new Error('BUCKET_NOT_FOUND');
|
|
348
387
|
}
|
|
349
|
-
const bucket =
|
|
388
|
+
const { storageModule, bucket } = resolution;
|
|
389
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
350
390
|
// First provision mints a name; afterwards the stored coordinate
|
|
351
391
|
// is authoritative and the naming hook is never consulted again.
|
|
352
392
|
const recorded = storedPhysicalName(bucket);
|
|
@@ -354,7 +394,7 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
354
394
|
? resolveBucketName(bucket.key, databaseId, options)
|
|
355
395
|
: recorded;
|
|
356
396
|
try {
|
|
357
|
-
const result = await provisionBucketForRow(
|
|
397
|
+
const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
|
|
358
398
|
// Record the exact provisioned name on the source row.
|
|
359
399
|
await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
|
|
360
400
|
return {
|
|
@@ -427,6 +467,10 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
427
467
|
return field;
|
|
428
468
|
}
|
|
429
469
|
log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
|
|
470
|
+
// The codec being mutated names the buckets table — the hook always
|
|
471
|
+
// operates on the plane that table belongs to, never a guessed scope.
|
|
472
|
+
const codecSchemaName = pgCodec.extensions?.pg?.schemaName;
|
|
473
|
+
const codecTableName = pgCodec.extensions?.pg?.name;
|
|
430
474
|
const defaultResolver = (obj) => obj[fieldName];
|
|
431
475
|
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
432
476
|
return {
|
|
@@ -450,23 +494,29 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
450
494
|
`could not extract key/type from mutation input`);
|
|
451
495
|
return result;
|
|
452
496
|
}
|
|
497
|
+
if (!codecSchemaName || !codecTableName) {
|
|
498
|
+
throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
|
|
499
|
+
}
|
|
453
500
|
await withPgClient(pgSettings, async (pgClient) => {
|
|
454
501
|
const databaseId = await resolveDatabaseId(pgClient);
|
|
455
502
|
if (!databaseId) {
|
|
456
503
|
log.warn('Auto-provision skipped: could not resolve database_id');
|
|
457
504
|
return;
|
|
458
505
|
}
|
|
506
|
+
// The mutated table names its module — the plane being written
|
|
507
|
+
// is the plane that gets provisioned.
|
|
508
|
+
const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
|
|
459
509
|
// Newly-created row has no stored coordinate yet — mint on first provision.
|
|
460
|
-
const result = await provisionBucketForRow(
|
|
510
|
+
const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
|
|
461
511
|
// Record the provisioned name on the just-created row.
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
512
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
513
|
+
const ownerId = bucketInput.ownerId ?? bucketInput.owner_id ?? null;
|
|
514
|
+
const idResult = await runQuery(pgClient, ownerId
|
|
515
|
+
? `SELECT id FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1`
|
|
516
|
+
: `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, ownerId ? [bucketInput.key, ownerId] : [bucketInput.key]);
|
|
517
|
+
const bucketId = idResult.rows[0]?.id;
|
|
518
|
+
if (bucketId)
|
|
519
|
+
await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
|
|
470
520
|
});
|
|
471
521
|
}
|
|
472
522
|
else {
|
|
@@ -477,18 +527,18 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
477
527
|
// allowed_origins not being changed, nothing to do
|
|
478
528
|
return result;
|
|
479
529
|
}
|
|
530
|
+
if (!codecSchemaName || !codecTableName) {
|
|
531
|
+
throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
|
|
532
|
+
}
|
|
480
533
|
await withPgClient(pgSettings, async (pgClient) => {
|
|
481
534
|
const databaseId = await resolveDatabaseId(pgClient);
|
|
482
535
|
if (!databaseId) {
|
|
483
536
|
log.warn('CORS update skipped: could not resolve database_id');
|
|
484
537
|
return;
|
|
485
538
|
}
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
log.warn('CORS update skipped: storage module not provisioned');
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
539
|
+
// The mutated table names its module — CORS applies to the
|
|
540
|
+
// plane whose row was updated.
|
|
541
|
+
const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
|
|
492
542
|
// We need the bucket key — it may come from input or patch
|
|
493
543
|
// For updates, PostGraphile uses nodeId or the row's PK, so
|
|
494
544
|
// we read the bucket from the patch's key or from the nodeId
|
|
@@ -513,7 +563,7 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
513
563
|
// never provisioned there is nothing to update yet, so mint the
|
|
514
564
|
// conventional name the first provision would use.
|
|
515
565
|
const recorded = storedPhysicalName(bucket);
|
|
516
|
-
await updateBucketCors(
|
|
566
|
+
await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
|
|
517
567
|
? resolveBucketName(bucket.key, databaseId, options)
|
|
518
568
|
: recorded);
|
|
519
569
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-bucket-provisioner-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Bucket provisioning plugin for PostGraphile v5 — auto-provisions S3 buckets on bucket table mutations",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -57,5 +57,5 @@
|
|
|
57
57
|
"@types/node": "^22.19.11",
|
|
58
58
|
"makage": "^0.3.0"
|
|
59
59
|
},
|
|
60
|
-
"gitHead": "
|
|
60
|
+
"gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
|
|
61
61
|
}
|
package/plugin.js
CHANGED
|
@@ -42,9 +42,12 @@ const graphile_utils_1 = require("graphile-utils");
|
|
|
42
42
|
const log = new logger_1.Logger('graphile-bucket-provisioner:plugin');
|
|
43
43
|
// --- Storage module queries ---
|
|
44
44
|
/**
|
|
45
|
-
* Resolve the
|
|
45
|
+
* Resolve the storage module whose buckets table is the one being addressed.
|
|
46
|
+
*
|
|
47
|
+
* The buckets table's identity (schema + table, from the codec being mutated or
|
|
48
|
+
* the module row) is the fact that names the plane — never a scope literal.
|
|
46
49
|
*/
|
|
47
|
-
const
|
|
50
|
+
const STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY = `
|
|
48
51
|
SELECT
|
|
49
52
|
sm.id,
|
|
50
53
|
sm.scope,
|
|
@@ -59,11 +62,12 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
59
62
|
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
|
|
60
63
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
61
64
|
WHERE sm.database_id = $1
|
|
62
|
-
AND
|
|
63
|
-
|
|
65
|
+
AND bs.schema_name = $2
|
|
66
|
+
AND bt.name = $3
|
|
64
67
|
`;
|
|
65
68
|
/**
|
|
66
|
-
* Resolve ALL storage modules for a database (for ownerId-based
|
|
69
|
+
* Resolve ALL storage modules for a database (for bucket-key and ownerId-based
|
|
70
|
+
* resolution in the explicit provisionBucket mutation).
|
|
67
71
|
*/
|
|
68
72
|
const ALL_STORAGE_MODULES_QUERY = `
|
|
69
73
|
SELECT
|
|
@@ -97,28 +101,82 @@ function runQuery(pgClient, text, values) {
|
|
|
97
101
|
return pgClient.query(values === undefined ? { text } : { text, values });
|
|
98
102
|
}
|
|
99
103
|
/**
|
|
100
|
-
* Resolve the storage module
|
|
101
|
-
*
|
|
102
|
-
*
|
|
104
|
+
* Resolve the storage module whose buckets table is the one being addressed.
|
|
105
|
+
*
|
|
106
|
+
* This is the resolution the auto-provision/CORS hooks use: the codec being
|
|
107
|
+
* mutated names its table, and the table names its module. A `@storageBuckets`
|
|
108
|
+
* table with no module row (or with several) is a provisioning bug and throws.
|
|
103
109
|
*/
|
|
104
|
-
async function
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
async function resolveStorageModuleByBucketsTable(pgClient, databaseId, schemaName, tableName) {
|
|
111
|
+
const result = await runQuery(pgClient, STORAGE_MODULE_BY_BUCKETS_TABLE_QUERY, [
|
|
112
|
+
databaseId,
|
|
113
|
+
schemaName,
|
|
114
|
+
tableName,
|
|
115
|
+
]);
|
|
116
|
+
const rows = result.rows;
|
|
117
|
+
if (rows.length === 0) {
|
|
118
|
+
throw new Error(`STORAGE_MODULE_NOT_FOUND: no storage module in database ${databaseId} records ` +
|
|
119
|
+
`${schemaName}.${tableName} as its buckets table`);
|
|
120
|
+
}
|
|
121
|
+
if (rows.length > 1) {
|
|
122
|
+
throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${rows.length} storage modules in database ${databaseId} record ` +
|
|
123
|
+
`${schemaName}.${tableName} as their buckets table`);
|
|
109
124
|
}
|
|
110
|
-
|
|
125
|
+
return rows[0];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The explicit provisionBucket mutation's resolution: find the plane that
|
|
129
|
+
* actually holds the named bucket row.
|
|
130
|
+
*
|
|
131
|
+
* With an ownerId, the owning entity row names the plane (probed through each
|
|
132
|
+
* entity-keyed module's recorded entity table). Without one, every
|
|
133
|
+
* non-entity-keyed plane's buckets table is probed for the key under RLS —
|
|
134
|
+
* exactly one plane may hold it; several is ambiguous and throws rather than
|
|
135
|
+
* silently picking.
|
|
136
|
+
*/
|
|
137
|
+
async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
|
|
111
138
|
const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
|
|
112
139
|
const modules = result.rows;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
140
|
+
if (modules.length === 0) {
|
|
141
|
+
throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
|
|
142
|
+
}
|
|
143
|
+
if (ownerId) {
|
|
144
|
+
const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
|
|
145
|
+
for (const mod of entityModules) {
|
|
146
|
+
const entityTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
|
|
147
|
+
const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
|
|
148
|
+
if (probe.rows.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
151
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
152
|
+
FROM ${bucketsTable}
|
|
153
|
+
WHERE key = $1 AND owner_id = $2
|
|
154
|
+
LIMIT 1`, [bucketKey, ownerId]);
|
|
155
|
+
if (bucketResult.rows.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
return { storageModule: mod, bucket: bucketResult.rows[0] };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const matches = [];
|
|
162
|
+
for (const mod of modules.filter((m) => m.entity_table_id === null)) {
|
|
163
|
+
const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
164
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
165
|
+
FROM ${bucketsTable}
|
|
166
|
+
WHERE key = $1
|
|
167
|
+
LIMIT 1`, [bucketKey]);
|
|
168
|
+
if (bucketResult.rows.length > 0) {
|
|
169
|
+
matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
|
|
119
170
|
}
|
|
120
171
|
}
|
|
121
|
-
|
|
172
|
+
if (matches.length === 0)
|
|
173
|
+
return null;
|
|
174
|
+
if (matches.length > 1) {
|
|
175
|
+
const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
|
|
176
|
+
throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
|
|
177
|
+
`(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
|
|
178
|
+
}
|
|
179
|
+
return matches[0];
|
|
122
180
|
}
|
|
123
181
|
/**
|
|
124
182
|
* Normalize the recorded physical coordinate at the DB boundary.
|
|
@@ -211,10 +269,8 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
|
|
|
211
269
|
* Core provisioning logic shared by both the explicit mutation and the
|
|
212
270
|
* auto-provisioning hook.
|
|
213
271
|
*/
|
|
214
|
-
async function provisionBucketForRow(
|
|
272
|
+
async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
215
273
|
const accessType = bucketType;
|
|
216
|
-
// Read storage module config to check for endpoint/provider/CORS overrides
|
|
217
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
218
274
|
// Resolve CORS origins using the 3-tier hierarchy
|
|
219
275
|
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
220
276
|
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
@@ -234,9 +290,8 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
|
|
|
234
290
|
/**
|
|
235
291
|
* Update CORS on an existing S3 bucket when allowed_origins changes.
|
|
236
292
|
*/
|
|
237
|
-
async function updateBucketCors(
|
|
293
|
+
async function updateBucketCors(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
238
294
|
const accessType = bucketType;
|
|
239
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
240
295
|
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
241
296
|
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
242
297
|
log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
|
|
@@ -328,29 +383,14 @@ function createBucketProvisionerPlugin(options) {
|
|
|
328
383
|
if (!databaseId) {
|
|
329
384
|
throw new Error('DATABASE_NOT_FOUND');
|
|
330
385
|
}
|
|
331
|
-
// Resolve
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
|
|
336
|
-
: 'STORAGE_MODULE_NOT_PROVISIONED');
|
|
337
|
-
}
|
|
338
|
-
// Look up the bucket row (RLS enforced via pgSettings)
|
|
339
|
-
const hasOwner = ownerId && storageModule.scope !== 'app';
|
|
340
|
-
const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
341
|
-
const bucketResult = await runQuery(pgClient, hasOwner
|
|
342
|
-
? `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
343
|
-
FROM ${bucketsTable}
|
|
344
|
-
WHERE key = $1 AND owner_id = $2
|
|
345
|
-
LIMIT 1`
|
|
346
|
-
: `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
347
|
-
FROM ${bucketsTable}
|
|
348
|
-
WHERE key = $1
|
|
349
|
-
LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
|
|
350
|
-
if (bucketResult.rows.length === 0) {
|
|
386
|
+
// Resolve the plane that actually holds the named bucket row
|
|
387
|
+
// (RLS enforced via pgSettings) — never a guessed scope.
|
|
388
|
+
const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
|
|
389
|
+
if (!resolution) {
|
|
351
390
|
throw new Error('BUCKET_NOT_FOUND');
|
|
352
391
|
}
|
|
353
|
-
const bucket =
|
|
392
|
+
const { storageModule, bucket } = resolution;
|
|
393
|
+
const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
354
394
|
// First provision mints a name; afterwards the stored coordinate
|
|
355
395
|
// is authoritative and the naming hook is never consulted again.
|
|
356
396
|
const recorded = storedPhysicalName(bucket);
|
|
@@ -358,7 +398,7 @@ function createBucketProvisionerPlugin(options) {
|
|
|
358
398
|
? resolveBucketName(bucket.key, databaseId, options)
|
|
359
399
|
: recorded;
|
|
360
400
|
try {
|
|
361
|
-
const result = await provisionBucketForRow(
|
|
401
|
+
const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
|
|
362
402
|
// Record the exact provisioned name on the source row.
|
|
363
403
|
await recordPhysicalName(withPgClient, bucketsTable, bucket.id, result.bucketName);
|
|
364
404
|
return {
|
|
@@ -431,6 +471,10 @@ function createBucketProvisionerPlugin(options) {
|
|
|
431
471
|
return field;
|
|
432
472
|
}
|
|
433
473
|
log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
|
|
474
|
+
// The codec being mutated names the buckets table — the hook always
|
|
475
|
+
// operates on the plane that table belongs to, never a guessed scope.
|
|
476
|
+
const codecSchemaName = pgCodec.extensions?.pg?.schemaName;
|
|
477
|
+
const codecTableName = pgCodec.extensions?.pg?.name;
|
|
434
478
|
const defaultResolver = (obj) => obj[fieldName];
|
|
435
479
|
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
436
480
|
return {
|
|
@@ -454,23 +498,29 @@ function createBucketProvisionerPlugin(options) {
|
|
|
454
498
|
`could not extract key/type from mutation input`);
|
|
455
499
|
return result;
|
|
456
500
|
}
|
|
501
|
+
if (!codecSchemaName || !codecTableName) {
|
|
502
|
+
throw new Error(`Auto-provision failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
|
|
503
|
+
}
|
|
457
504
|
await withPgClient(pgSettings, async (pgClient) => {
|
|
458
505
|
const databaseId = await resolveDatabaseId(pgClient);
|
|
459
506
|
if (!databaseId) {
|
|
460
507
|
log.warn('Auto-provision skipped: could not resolve database_id');
|
|
461
508
|
return;
|
|
462
509
|
}
|
|
510
|
+
// The mutated table names its module — the plane being written
|
|
511
|
+
// is the plane that gets provisioned.
|
|
512
|
+
const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
|
|
463
513
|
// Newly-created row has no stored coordinate yet — mint on first provision.
|
|
464
|
-
const result = await provisionBucketForRow(
|
|
514
|
+
const result = await provisionBucketForRow(storageModule, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
|
|
465
515
|
// Record the provisioned name on the just-created row.
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
516
|
+
const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
517
|
+
const ownerId = bucketInput.ownerId ?? bucketInput.owner_id ?? null;
|
|
518
|
+
const idResult = await runQuery(pgClient, ownerId
|
|
519
|
+
? `SELECT id FROM ${bucketsTable} WHERE key = $1 AND owner_id = $2 LIMIT 1`
|
|
520
|
+
: `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, ownerId ? [bucketInput.key, ownerId] : [bucketInput.key]);
|
|
521
|
+
const bucketId = idResult.rows[0]?.id;
|
|
522
|
+
if (bucketId)
|
|
523
|
+
await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
|
|
474
524
|
});
|
|
475
525
|
}
|
|
476
526
|
else {
|
|
@@ -481,18 +531,18 @@ function createBucketProvisionerPlugin(options) {
|
|
|
481
531
|
// allowed_origins not being changed, nothing to do
|
|
482
532
|
return result;
|
|
483
533
|
}
|
|
534
|
+
if (!codecSchemaName || !codecTableName) {
|
|
535
|
+
throw new Error(`CORS update failed for "${fieldName}": codec ${pgCodec.name} carries no pg schema/table identity`);
|
|
536
|
+
}
|
|
484
537
|
await withPgClient(pgSettings, async (pgClient) => {
|
|
485
538
|
const databaseId = await resolveDatabaseId(pgClient);
|
|
486
539
|
if (!databaseId) {
|
|
487
540
|
log.warn('CORS update skipped: could not resolve database_id');
|
|
488
541
|
return;
|
|
489
542
|
}
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
log.warn('CORS update skipped: storage module not provisioned');
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
543
|
+
// The mutated table names its module — CORS applies to the
|
|
544
|
+
// plane whose row was updated.
|
|
545
|
+
const storageModule = await resolveStorageModuleByBucketsTable(pgClient, databaseId, codecSchemaName, codecTableName);
|
|
496
546
|
// We need the bucket key — it may come from input or patch
|
|
497
547
|
// For updates, PostGraphile uses nodeId or the row's PK, so
|
|
498
548
|
// we read the bucket from the patch's key or from the nodeId
|
|
@@ -517,7 +567,7 @@ function createBucketProvisionerPlugin(options) {
|
|
|
517
567
|
// never provisioned there is nothing to update yet, so mint the
|
|
518
568
|
// conventional name the first provision would use.
|
|
519
569
|
const recorded = storedPhysicalName(bucket);
|
|
520
|
-
await updateBucketCors(
|
|
570
|
+
await updateBucketCors(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
|
|
521
571
|
? resolveBucketName(bucket.key, databaseId, options)
|
|
522
572
|
: recorded);
|
|
523
573
|
});
|