graphile-bucket-provisioner-plugin 0.2.1 → 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/esm/plugin.js CHANGED
@@ -33,12 +33,18 @@
33
33
  import { context as grafastContext, lambda, object } from 'grafast';
34
34
  import { extendSchema, gql } from 'graphile-utils';
35
35
  import { Logger } from '@pgpmjs/logger';
36
+ import { QuoteUtils } from '@pgsql/quotes';
36
37
  import { BucketProvisioner, } from '@constructive-io/bucket-provisioner';
37
38
  const log = new Logger('graphile-bucket-provisioner:plugin');
38
- // --- Storage module query (same as presigned-url-plugin) ---
39
- const STORAGE_MODULE_QUERY = `
39
+ // --- Storage module queries ---
40
+ /**
41
+ * Resolve the app-level storage module (membership_type IS NULL).
42
+ */
43
+ const APP_STORAGE_MODULE_QUERY = `
40
44
  SELECT
41
45
  sm.id,
46
+ sm.membership_type,
47
+ sm.entity_table_id,
42
48
  bs.schema_name AS buckets_schema,
43
49
  bt.name AS buckets_table,
44
50
  sm.endpoint,
@@ -49,8 +55,56 @@ const STORAGE_MODULE_QUERY = `
49
55
  JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
50
56
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
51
57
  WHERE sm.database_id = $1
58
+ AND sm.membership_type IS NULL
52
59
  LIMIT 1
53
60
  `;
61
+ /**
62
+ * Resolve ALL storage modules for a database (for ownerId-based resolution).
63
+ */
64
+ const ALL_STORAGE_MODULES_QUERY = `
65
+ SELECT
66
+ sm.id,
67
+ sm.membership_type,
68
+ sm.entity_table_id,
69
+ bs.schema_name AS buckets_schema,
70
+ bt.name AS buckets_table,
71
+ sm.endpoint,
72
+ sm.public_url_prefix,
73
+ sm.provider,
74
+ sm.allowed_origins,
75
+ es.schema_name AS entity_schema,
76
+ et.name AS entity_table
77
+ FROM metaschema_modules_public.storage_module sm
78
+ JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
79
+ JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
80
+ LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
81
+ LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
82
+ WHERE sm.database_id = $1
83
+ `;
84
+ /**
85
+ * Resolve the storage module for a given scope.
86
+ * If ownerId is provided, probes entity tables to find the matching module.
87
+ * Otherwise, returns the app-level module.
88
+ */
89
+ async function resolveStorageModule(pgClient, databaseId, ownerId) {
90
+ if (!ownerId) {
91
+ // App-level resolution
92
+ const result = await pgClient.query(APP_STORAGE_MODULE_QUERY, [databaseId]);
93
+ return result.rows[0] ?? null;
94
+ }
95
+ // Entity-scoped: load all modules and probe entity tables
96
+ const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]);
97
+ const modules = result.rows;
98
+ const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
99
+ for (const mod of entityModules) {
100
+ const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
101
+ const probe = await pgClient.query(`SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
102
+ if (probe.rows.length > 0) {
103
+ return mod;
104
+ }
105
+ }
106
+ return null;
107
+ }
54
108
  // --- Helpers ---
55
109
  /**
56
110
  * Resolve the connection config from the options. If the option is a lazy
@@ -124,8 +178,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
124
178
  const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
125
179
  const accessType = bucketType;
126
180
  // Read storage module config to check for endpoint/provider/CORS overrides
127
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
128
- const storageModule = smResult.rows[0] ?? null;
181
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
129
182
  // Resolve CORS origins using the 3-tier hierarchy
130
183
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
131
184
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
@@ -148,8 +201,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
148
201
  async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
149
202
  const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
150
203
  const accessType = bucketType;
151
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
152
- const storageModule = smResult.rows[0] ?? null;
204
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
153
205
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
154
206
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
155
207
  log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
@@ -185,6 +237,11 @@ export function createBucketProvisionerPlugin(options) {
185
237
  input ProvisionBucketInput {
186
238
  """The logical bucket key (e.g., "public", "private")"""
187
239
  bucketKey: String!
240
+ """
241
+ Owner entity ID for entity-scoped bucket provisioning.
242
+ Omit for app-level (database-wide) storage.
243
+ """
244
+ ownerId: UUID
188
245
  }
189
246
 
190
247
  type ProvisionBucketPayload {
@@ -226,7 +283,7 @@ export function createBucketProvisionerPlugin(options) {
226
283
  pgSettings: $pgSettings,
227
284
  });
228
285
  return lambda($combined, async ({ input, withPgClient, pgSettings }) => {
229
- const { bucketKey } = input;
286
+ const { bucketKey, ownerId } = input;
230
287
  if (!bucketKey || typeof bucketKey !== 'string') {
231
288
  throw new Error('INVALID_BUCKET_KEY');
232
289
  }
@@ -236,17 +293,25 @@ export function createBucketProvisionerPlugin(options) {
236
293
  if (!databaseId) {
237
294
  throw new Error('DATABASE_NOT_FOUND');
238
295
  }
239
- // Read storage module config
240
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
241
- if (smResult.rows.length === 0) {
242
- throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
296
+ // Resolve storage module (app-level or entity-scoped via ownerId)
297
+ const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
298
+ if (!storageModule) {
299
+ throw new Error(ownerId
300
+ ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
301
+ : 'STORAGE_MODULE_NOT_PROVISIONED');
243
302
  }
244
- const storageModule = smResult.rows[0];
245
303
  // Look up the bucket row (RLS enforced via pgSettings)
246
- const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
247
- FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
248
- WHERE key = $1
249
- LIMIT 1`, [bucketKey]);
304
+ const hasOwner = ownerId && storageModule.membership_type !== null;
305
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
306
+ const bucketResult = await pgClient.query(hasOwner
307
+ ? `SELECT id, key, type, is_public, allowed_origins
308
+ FROM ${bucketsTable}
309
+ WHERE key = $1 AND owner_id = $2
310
+ LIMIT 1`
311
+ : `SELECT id, key, type, is_public, allowed_origins
312
+ FROM ${bucketsTable}
313
+ WHERE key = $1
314
+ LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
250
315
  if (bucketResult.rows.length === 0) {
251
316
  throw new Error('BUCKET_NOT_FOUND');
252
317
  }
@@ -369,13 +434,12 @@ export function createBucketProvisionerPlugin(options) {
369
434
  log.warn('CORS update skipped: could not resolve database_id');
370
435
  return;
371
436
  }
372
- // Read the updated bucket row to get full state
373
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
374
- if (smResult.rows.length === 0) {
437
+ // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
438
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
439
+ if (!storageModule) {
375
440
  log.warn('CORS update skipped: storage module not provisioned');
376
441
  return;
377
442
  }
378
- const storageModule = smResult.rows[0];
379
443
  // We need the bucket key — it may come from input or patch
380
444
  // For updates, PostGraphile uses nodeId or the row's PK, so
381
445
  // we read the bucket from the patch's key or from the nodeId
@@ -386,8 +450,9 @@ export function createBucketProvisionerPlugin(options) {
386
450
  return;
387
451
  }
388
452
  // Read the full bucket row (post-update) to get type + origins
453
+ const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
389
454
  const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
390
- FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
455
+ FROM ${bucketsTable}
391
456
  WHERE key = $1
392
457
  LIMIT 1`, [patchKey]);
393
458
  if (bucketResult.rows.length === 0) {
package/esm/types.d.ts CHANGED
@@ -70,6 +70,11 @@ export interface BucketProvisionerPluginOptions {
70
70
  export interface ProvisionBucketInput {
71
71
  /** The logical bucket key (e.g., "public", "private") */
72
72
  bucketKey: string;
73
+ /**
74
+ * Owner entity ID for entity-scoped bucket provisioning.
75
+ * Omit for app-level (database-wide) storage.
76
+ */
77
+ ownerId?: string;
73
78
  }
74
79
  /**
75
80
  * Result of the provisionBucket mutation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-bucket-provisioner-plugin",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -40,8 +40,9 @@
40
40
  "url": "https://github.com/constructive-io/constructive/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@constructive-io/bucket-provisioner": "^0.2.1",
44
- "@pgpmjs/logger": "^2.5.2"
43
+ "@constructive-io/bucket-provisioner": "^0.3.0",
44
+ "@pgpmjs/logger": "^2.6.0",
45
+ "@pgsql/quotes": "^17.1.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "grafast": "1.0.0",
@@ -56,5 +57,5 @@
56
57
  "@types/node": "^22.19.11",
57
58
  "makage": "^0.3.0"
58
59
  },
59
- "gitHead": "79cd3e66871804a22c672c7ca2fa5e2105d4b368"
60
+ "gitHead": "28734dd71a973b2fe296e8240c8f86c568b4292f"
60
61
  }
package/plugin.js CHANGED
@@ -37,12 +37,18 @@ exports.createBucketProvisionerPlugin = createBucketProvisionerPlugin;
37
37
  const grafast_1 = require("grafast");
38
38
  const graphile_utils_1 = require("graphile-utils");
39
39
  const logger_1 = require("@pgpmjs/logger");
40
+ const quotes_1 = require("@pgsql/quotes");
40
41
  const bucket_provisioner_1 = require("@constructive-io/bucket-provisioner");
41
42
  const log = new logger_1.Logger('graphile-bucket-provisioner:plugin');
42
- // --- Storage module query (same as presigned-url-plugin) ---
43
- const STORAGE_MODULE_QUERY = `
43
+ // --- Storage module queries ---
44
+ /**
45
+ * Resolve the app-level storage module (membership_type IS NULL).
46
+ */
47
+ const APP_STORAGE_MODULE_QUERY = `
44
48
  SELECT
45
49
  sm.id,
50
+ sm.membership_type,
51
+ sm.entity_table_id,
46
52
  bs.schema_name AS buckets_schema,
47
53
  bt.name AS buckets_table,
48
54
  sm.endpoint,
@@ -53,8 +59,56 @@ const STORAGE_MODULE_QUERY = `
53
59
  JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
54
60
  JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
55
61
  WHERE sm.database_id = $1
62
+ AND sm.membership_type IS NULL
56
63
  LIMIT 1
57
64
  `;
65
+ /**
66
+ * Resolve ALL storage modules for a database (for ownerId-based resolution).
67
+ */
68
+ const ALL_STORAGE_MODULES_QUERY = `
69
+ SELECT
70
+ sm.id,
71
+ sm.membership_type,
72
+ sm.entity_table_id,
73
+ bs.schema_name AS buckets_schema,
74
+ bt.name AS buckets_table,
75
+ sm.endpoint,
76
+ sm.public_url_prefix,
77
+ sm.provider,
78
+ sm.allowed_origins,
79
+ es.schema_name AS entity_schema,
80
+ et.name AS entity_table
81
+ FROM metaschema_modules_public.storage_module sm
82
+ JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
83
+ JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
84
+ LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
85
+ LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
86
+ WHERE sm.database_id = $1
87
+ `;
88
+ /**
89
+ * Resolve the storage module for a given scope.
90
+ * If ownerId is provided, probes entity tables to find the matching module.
91
+ * Otherwise, returns the app-level module.
92
+ */
93
+ async function resolveStorageModule(pgClient, databaseId, ownerId) {
94
+ if (!ownerId) {
95
+ // App-level resolution
96
+ const result = await pgClient.query(APP_STORAGE_MODULE_QUERY, [databaseId]);
97
+ return result.rows[0] ?? null;
98
+ }
99
+ // Entity-scoped: load all modules and probe entity tables
100
+ const result = await pgClient.query(ALL_STORAGE_MODULES_QUERY, [databaseId]);
101
+ const modules = result.rows;
102
+ const entityModules = modules.filter((m) => m.entity_schema && m.entity_table);
103
+ for (const mod of entityModules) {
104
+ const entityTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
105
+ const probe = await pgClient.query(`SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
106
+ if (probe.rows.length > 0) {
107
+ return mod;
108
+ }
109
+ }
110
+ return null;
111
+ }
58
112
  // --- Helpers ---
59
113
  /**
60
114
  * Resolve the connection config from the options. If the option is a lazy
@@ -128,8 +182,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
128
182
  const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
129
183
  const accessType = bucketType;
130
184
  // Read storage module config to check for endpoint/provider/CORS overrides
131
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
132
- const storageModule = smResult.rows[0] ?? null;
185
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
133
186
  // Resolve CORS origins using the 3-tier hierarchy
134
187
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
135
188
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
@@ -152,8 +205,7 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
152
205
  async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options) {
153
206
  const s3BucketName = resolveBucketName(bucketKey, databaseId, options);
154
207
  const accessType = bucketType;
155
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
156
- const storageModule = smResult.rows[0] ?? null;
208
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
157
209
  const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
158
210
  const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
159
211
  log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
@@ -189,6 +241,11 @@ function createBucketProvisionerPlugin(options) {
189
241
  input ProvisionBucketInput {
190
242
  """The logical bucket key (e.g., "public", "private")"""
191
243
  bucketKey: String!
244
+ """
245
+ Owner entity ID for entity-scoped bucket provisioning.
246
+ Omit for app-level (database-wide) storage.
247
+ """
248
+ ownerId: UUID
192
249
  }
193
250
 
194
251
  type ProvisionBucketPayload {
@@ -230,7 +287,7 @@ function createBucketProvisionerPlugin(options) {
230
287
  pgSettings: $pgSettings,
231
288
  });
232
289
  return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
233
- const { bucketKey } = input;
290
+ const { bucketKey, ownerId } = input;
234
291
  if (!bucketKey || typeof bucketKey !== 'string') {
235
292
  throw new Error('INVALID_BUCKET_KEY');
236
293
  }
@@ -240,17 +297,25 @@ function createBucketProvisionerPlugin(options) {
240
297
  if (!databaseId) {
241
298
  throw new Error('DATABASE_NOT_FOUND');
242
299
  }
243
- // Read storage module config
244
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
245
- if (smResult.rows.length === 0) {
246
- throw new Error('STORAGE_MODULE_NOT_PROVISIONED');
300
+ // Resolve storage module (app-level or entity-scoped via ownerId)
301
+ const storageModule = await resolveStorageModule(pgClient, databaseId, ownerId);
302
+ if (!storageModule) {
303
+ throw new Error(ownerId
304
+ ? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
305
+ : 'STORAGE_MODULE_NOT_PROVISIONED');
247
306
  }
248
- const storageModule = smResult.rows[0];
249
307
  // Look up the bucket row (RLS enforced via pgSettings)
250
- const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
251
- FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
252
- WHERE key = $1
253
- LIMIT 1`, [bucketKey]);
308
+ const hasOwner = ownerId && storageModule.membership_type !== null;
309
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
310
+ const bucketResult = await pgClient.query(hasOwner
311
+ ? `SELECT id, key, type, is_public, allowed_origins
312
+ FROM ${bucketsTable}
313
+ WHERE key = $1 AND owner_id = $2
314
+ LIMIT 1`
315
+ : `SELECT id, key, type, is_public, allowed_origins
316
+ FROM ${bucketsTable}
317
+ WHERE key = $1
318
+ LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
254
319
  if (bucketResult.rows.length === 0) {
255
320
  throw new Error('BUCKET_NOT_FOUND');
256
321
  }
@@ -373,13 +438,12 @@ function createBucketProvisionerPlugin(options) {
373
438
  log.warn('CORS update skipped: could not resolve database_id');
374
439
  return;
375
440
  }
376
- // Read the updated bucket row to get full state
377
- const smResult = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
378
- if (smResult.rows.length === 0) {
441
+ // Read the storage module config (app-level; auto-hook doesn't have ownerId context)
442
+ const storageModule = await resolveStorageModule(pgClient, databaseId);
443
+ if (!storageModule) {
379
444
  log.warn('CORS update skipped: storage module not provisioned');
380
445
  return;
381
446
  }
382
- const storageModule = smResult.rows[0];
383
447
  // We need the bucket key — it may come from input or patch
384
448
  // For updates, PostGraphile uses nodeId or the row's PK, so
385
449
  // we read the bucket from the patch's key or from the nodeId
@@ -390,8 +454,9 @@ function createBucketProvisionerPlugin(options) {
390
454
  return;
391
455
  }
392
456
  // Read the full bucket row (post-update) to get type + origins
457
+ const bucketsTable = quotes_1.QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
393
458
  const bucketResult = await pgClient.query(`SELECT id, key, type, is_public, allowed_origins
394
- FROM "${storageModule.buckets_schema}"."${storageModule.buckets_table}"
459
+ FROM ${bucketsTable}
395
460
  WHERE key = $1
396
461
  LIMIT 1`, [patchKey]);
397
462
  if (bucketResult.rows.length === 0) {
package/types.d.ts CHANGED
@@ -70,6 +70,11 @@ export interface BucketProvisionerPluginOptions {
70
70
  export interface ProvisionBucketInput {
71
71
  /** The logical bucket key (e.g., "public", "private") */
72
72
  bucketKey: string;
73
+ /**
74
+ * Owner entity ID for entity-scoped bucket provisioning.
75
+ * Omit for app-level (database-wide) storage.
76
+ */
77
+ ownerId?: string;
73
78
  }
74
79
  /**
75
80
  * Result of the provisionBucket mutation.