graphile-presigned-url-plugin 1.10.0 → 1.11.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.
@@ -24,6 +24,7 @@ exports.createDownloadUrlPlugin = createDownloadUrlPlugin;
24
24
  require("graphile-build");
25
25
  const logger_1 = require("@pgpmjs/logger");
26
26
  const grafast_1 = require("grafast");
27
+ const request_pg_client_1 = require("./request-pg-client");
27
28
  const s3_signer_1 = require("./s3-signer");
28
29
  const storage_module_cache_1 = require("./storage-module-cache");
29
30
  const log = new logger_1.Logger('graphile-presigned-url:download-url');
@@ -115,7 +116,7 @@ function createDownloadUrlPlugin(options) {
115
116
  let downloadUrlExpirySeconds = 3600;
116
117
  try {
117
118
  if (withPgClient && pgSettings) {
118
- const databaseId = await withPgClient(pgSettings, async (pgClient) => {
119
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
119
120
  const dbResult = await pgClient.query({
120
121
  text: `SELECT jwt_private.current_database_id() AS id`,
121
122
  });
@@ -127,7 +128,7 @@ function createDownloadUrlPlugin(options) {
127
128
  ? (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId)))
128
129
  : null;
129
130
  const resolved = config && bucketId
130
- ? await withPgClient(pgSettings, async (pgClient) => {
131
+ ? await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
131
132
  // Look up the stored physical coordinate for scoped S3 resolution
132
133
  const bucketResult = await pgClient.query({
133
134
  text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
@@ -21,6 +21,7 @@
21
21
  import 'graphile-build';
22
22
  import { Logger } from '@pgpmjs/logger';
23
23
  import { context as grafastContext, lambda, object } from 'grafast';
24
+ import { withRequestPgClient } from './request-pg-client';
24
25
  import { generatePresignedGetUrl } from './s3-signer';
25
26
  import { loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
26
27
  const log = new Logger('graphile-presigned-url:download-url');
@@ -112,7 +113,7 @@ export function createDownloadUrlPlugin(options) {
112
113
  let downloadUrlExpirySeconds = 3600;
113
114
  try {
114
115
  if (withPgClient && pgSettings) {
115
- const databaseId = await withPgClient(pgSettings, async (pgClient) => {
116
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
116
117
  const dbResult = await pgClient.query({
117
118
  text: `SELECT jwt_private.current_database_id() AS id`,
118
119
  });
@@ -124,7 +125,7 @@ export function createDownloadUrlPlugin(options) {
124
125
  ? resolveStorageConfigFromCodec(capturedCodec, await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId)))
125
126
  : null;
126
127
  const resolved = config && bucketId
127
- ? await withPgClient(pgSettings, async (pgClient) => {
128
+ ? await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
128
129
  // Look up the stored physical coordinate for scoped S3 resolution
129
130
  const bucketResult = await pgClient.query({
130
131
  text: `SELECT key, physical_name FROM ${config.bucketsQualifiedName} WHERE id = $1 LIMIT 1`,
package/esm/plugin.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import 'graphile-build';
20
20
  import { Logger } from '@pgpmjs/logger';
21
21
  import { access, context as grafastContext, lambda, object } from 'grafast';
22
+ import { withRequestPgClient } from './request-pg-client';
22
23
  import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
23
24
  import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
24
25
  const log = new Logger('graphile-presigned-url:plugin');
@@ -116,10 +117,15 @@ function resolveS3ForDatabase(options, storageConfig, physicalName) {
116
117
  * value is the durable coordinate: route resolution and every later read use
117
118
  * it verbatim; nothing is recomputed.
118
119
  *
119
- * The record write runs in the system lane (`withPgClient(null, ...)`) — it is
120
- * server bookkeeping, not request data, and anonymous request roles cannot
121
- * UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
122
- * so subsequent reads observe the recorded name without a DB round-trip.
120
+ * The record write runs in the system lane (privileged role, so it bypasses the
121
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
122
+ * bookkeeping, not request data. It still carries the tenant `database_id`
123
+ * claim, because the buckets table's catalog-sync trigger calls
124
+ * `jwt_private.current_database_id()` and would otherwise raise
125
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
126
+ * write's transaction without switching off the privileged role.
127
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
128
+ * the recorded name without a DB round-trip.
123
129
  */
124
130
  async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
125
131
  const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
@@ -131,7 +137,9 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
131
137
  }
132
138
  // Record the physical coordinate on the source row. The `physical_name IS NULL`
133
139
  // guard keeps this idempotent and race-safe across concurrent first uploads.
134
- await withPgClient(null, (client) => client.query({
140
+ // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
141
+ // write runs under the resolved database claim (privileged role preserved).
142
+ await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
135
143
  text: `UPDATE ${storageConfig.bucketsQualifiedName}
136
144
  SET physical_name = $1
137
145
  WHERE id = $2 AND physical_name IS NULL`,
@@ -244,7 +252,10 @@ export function createPresignedUrlPlugin(options) {
244
252
  pgSettings: $pgSettings,
245
253
  });
246
254
  return lambda($combined, async (vals) => {
247
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
255
+ // Request-lane reads/writes run under the request role's pgSettings
256
+ // inside an explicit transaction so the jwt claims stay applied
257
+ // across every statement (see withRequestPgClient).
258
+ const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
248
259
  if (!databaseId)
249
260
  throw new Error('DATABASE_NOT_FOUND');
250
261
  // Module registration is server config, not user data:
@@ -254,7 +265,7 @@ export function createPresignedUrlPlugin(options) {
254
265
  if (!storageConfig)
255
266
  throw new Error('STORAGE_MODULE_NOT_FOUND');
256
267
  // Bucket config read under the request role (RLS-gated visibility).
257
- const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
268
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
258
269
  if (!bucket)
259
270
  throw new Error('BUCKET_NOT_FOUND');
260
271
  // First provision mints + records the coordinate; afterwards the
@@ -264,13 +275,13 @@ export function createPresignedUrlPlugin(options) {
264
275
  : bucket.physical_name;
265
276
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
266
277
  // File row INSERT under the request role (RLS enforced).
267
- return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
278
+ return withRequestPgClient(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
268
279
  contentHash: vals.contentHash,
269
280
  contentType: vals.contentType,
270
281
  size: vals.size,
271
282
  filename: vals.filename,
272
283
  key: vals.customKey,
273
- })));
284
+ }));
274
285
  });
275
286
  },
276
287
  });
@@ -335,7 +346,10 @@ export function createPresignedUrlPlugin(options) {
335
346
  pgSettings: $pgSettings,
336
347
  });
337
348
  return lambda($combined, async (vals) => {
338
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
349
+ // Request-lane reads/writes run under the request role's pgSettings
350
+ // inside an explicit transaction so the jwt claims stay applied
351
+ // across every statement (see withRequestPgClient).
352
+ const databaseId = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
339
353
  if (!databaseId)
340
354
  throw new Error('DATABASE_NOT_FOUND');
341
355
  // Module registration is server config, not user data:
@@ -345,7 +359,7 @@ export function createPresignedUrlPlugin(options) {
345
359
  if (!storageConfig)
346
360
  throw new Error('STORAGE_MODULE_NOT_FOUND');
347
361
  // Bucket config read under the request role (RLS-gated visibility).
348
- const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
362
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
349
363
  if (!bucket)
350
364
  throw new Error('BUCKET_NOT_FOUND');
351
365
  // Enforce bulk upload limits
@@ -364,7 +378,7 @@ export function createPresignedUrlPlugin(options) {
364
378
  : bucket.physical_name;
365
379
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
366
380
  // File row INSERTs under the request role (RLS enforced).
367
- return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
381
+ return withRequestPgClient(vals.withPgClient, vals.pgSettings, async (txClient) => {
368
382
  const results = [];
369
383
  for (const file of filesArray) {
370
384
  results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
@@ -376,7 +390,7 @@ export function createPresignedUrlPlugin(options) {
376
390
  }));
377
391
  }
378
392
  return { files: results };
379
- }));
393
+ });
380
394
  });
381
395
  },
382
396
  });
@@ -421,7 +435,7 @@ export function createPresignedUrlPlugin(options) {
421
435
  const pgSettings = graphqlContext.pgSettings;
422
436
  if (withPgClient) {
423
437
  try {
424
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
438
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
425
439
  // Module registration is server config, not user data:
426
440
  // resolve it without the request role's pgSettings.
427
441
  const allConfigs = databaseId
@@ -429,7 +443,7 @@ export function createPresignedUrlPlugin(options) {
429
443
  : [];
430
444
  const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
431
445
  if (storageConfig) {
432
- await withPgClient(pgSettings, async (pgClient) => {
446
+ await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
433
447
  // Read the file row (RLS enforced)
434
448
  const result = await pgClient.query({
435
449
  text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
@@ -454,7 +468,7 @@ export function createPresignedUrlPlugin(options) {
454
468
  const pgSettings = graphqlContext.pgSettings;
455
469
  if (withPgClient) {
456
470
  try {
457
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
471
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
458
472
  // Module registration is server config, not user data:
459
473
  // resolve it without the request role's pgSettings.
460
474
  const allConfigs = databaseId
@@ -462,7 +476,7 @@ export function createPresignedUrlPlugin(options) {
462
476
  : [];
463
477
  const storageConfig = resolveStorageConfigFromCodec(capturedCodec, allConfigs);
464
478
  if (storageConfig)
465
- await withPgClient(pgSettings, async (pgClient) => {
479
+ await withRequestPgClient(withPgClient, pgSettings, async (pgClient) => {
466
480
  // Check refcount: any other file with the same key in this bucket?
467
481
  const refResult = await pgClient.query({
468
482
  text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ export interface RequestPgClient {
20
+ query(opts: {
21
+ text: string;
22
+ values?: unknown[];
23
+ }): Promise<{
24
+ rows: Array<Record<string, unknown>>;
25
+ }>;
26
+ withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
27
+ }
28
+ export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
29
+ export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ async function applyRequestSettings(tx, pgSettings) {
20
+ if (!pgSettings)
21
+ return;
22
+ const entries = Object.entries(pgSettings)
23
+ .filter(([, value]) => value != null)
24
+ .map(([key, value]) => [key, String(value)]);
25
+ if (entries.length === 0)
26
+ return;
27
+ await tx.query({
28
+ text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
29
+ values: [JSON.stringify(entries)],
30
+ });
31
+ }
32
+ export function withRequestPgClient(withPgClient, pgSettings, cb) {
33
+ return withPgClient(null, (client) => client.withTransaction(async (tx) => {
34
+ await applyRequestSettings(tx, pgSettings);
35
+ return cb(tx);
36
+ }));
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
5
5
  "author": "Constructive <developers@constructive.io>",
6
6
  "homepage": "https://github.com/constructive-io/constructive",
@@ -42,7 +42,7 @@
42
42
  "dependencies": {
43
43
  "@aws-sdk/client-s3": "^3.1052.0",
44
44
  "@aws-sdk/s3-request-presigner": "^3.1052.0",
45
- "@pgpmjs/logger": "^2.23.3",
45
+ "@pgpmjs/logger": "^2.24.0",
46
46
  "@pgsql/quotes": "^18.2.0",
47
47
  "lru-cache": "^11.2.7"
48
48
  },
@@ -56,9 +56,9 @@
56
56
  "postgraphile": "5.0.3"
57
57
  },
58
58
  "devDependencies": {
59
- "@constructive-io/s3-utils": "^2.29.3",
59
+ "@constructive-io/s3-utils": "^2.30.0",
60
60
  "@types/node": "^22.19.11",
61
61
  "makage": "^0.3.0"
62
62
  },
63
- "gitHead": "c438821362ceaf70b2a20d74ecf25ad4191c1748"
63
+ "gitHead": "d508453a32a2f1d70ff6916871046615226e6f33"
64
64
  }
package/plugin.js CHANGED
@@ -23,6 +23,7 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
23
23
  require("graphile-build");
24
24
  const logger_1 = require("@pgpmjs/logger");
25
25
  const grafast_1 = require("grafast");
26
+ const request_pg_client_1 = require("./request-pg-client");
26
27
  const s3_signer_1 = require("./s3-signer");
27
28
  const storage_module_cache_1 = require("./storage-module-cache");
28
29
  const log = new logger_1.Logger('graphile-presigned-url:plugin');
@@ -120,10 +121,15 @@ function resolveS3ForDatabase(options, storageConfig, physicalName) {
120
121
  * value is the durable coordinate: route resolution and every later read use
121
122
  * it verbatim; nothing is recomputed.
122
123
  *
123
- * The record write runs in the system lane (`withPgClient(null, ...)`) — it is
124
- * server bookkeeping, not request data, and anonymous request roles cannot
125
- * UPDATE bucket rows under RLS. `bucket` (the cached config) is mutated in place
126
- * so subsequent reads observe the recorded name without a DB round-trip.
124
+ * The record write runs in the system lane (privileged role, so it bypasses the
125
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
126
+ * bookkeeping, not request data. It still carries the tenant `database_id`
127
+ * claim, because the buckets table's catalog-sync trigger calls
128
+ * `jwt_private.current_database_id()` and would otherwise raise
129
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
130
+ * write's transaction without switching off the privileged role.
131
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
132
+ * the recorded name without a DB round-trip.
127
133
  */
128
134
  async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
129
135
  const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
@@ -135,7 +141,9 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
135
141
  }
136
142
  // Record the physical coordinate on the source row. The `physical_name IS NULL`
137
143
  // guard keeps this idempotent and race-safe across concurrent first uploads.
138
- await withPgClient(null, (client) => client.query({
144
+ // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
145
+ // write runs under the resolved database claim (privileged role preserved).
146
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
139
147
  text: `UPDATE ${storageConfig.bucketsQualifiedName}
140
148
  SET physical_name = $1
141
149
  WHERE id = $2 AND physical_name IS NULL`,
@@ -248,7 +256,10 @@ function createPresignedUrlPlugin(options) {
248
256
  pgSettings: $pgSettings,
249
257
  });
250
258
  return (0, grafast_1.lambda)($combined, async (vals) => {
251
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
259
+ // Request-lane reads/writes run under the request role's pgSettings
260
+ // inside an explicit transaction so the jwt claims stay applied
261
+ // across every statement (see withRequestPgClient).
262
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
252
263
  if (!databaseId)
253
264
  throw new Error('DATABASE_NOT_FOUND');
254
265
  // Module registration is server config, not user data:
@@ -258,7 +269,7 @@ function createPresignedUrlPlugin(options) {
258
269
  if (!storageConfig)
259
270
  throw new Error('STORAGE_MODULE_NOT_FOUND');
260
271
  // Bucket config read under the request role (RLS-gated visibility).
261
- const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
272
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
262
273
  if (!bucket)
263
274
  throw new Error('BUCKET_NOT_FOUND');
264
275
  // First provision mints + records the coordinate; afterwards the
@@ -268,13 +279,13 @@ function createPresignedUrlPlugin(options) {
268
279
  : bucket.physical_name;
269
280
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
270
281
  // File row INSERT under the request role (RLS enforced).
271
- return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction((txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
282
+ return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
272
283
  contentHash: vals.contentHash,
273
284
  contentType: vals.contentType,
274
285
  size: vals.size,
275
286
  filename: vals.filename,
276
287
  key: vals.customKey,
277
- })));
288
+ }));
278
289
  });
279
290
  },
280
291
  });
@@ -339,7 +350,10 @@ function createPresignedUrlPlugin(options) {
339
350
  pgSettings: $pgSettings,
340
351
  });
341
352
  return (0, grafast_1.lambda)($combined, async (vals) => {
342
- const databaseId = await vals.withPgClient(vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
353
+ // Request-lane reads/writes run under the request role's pgSettings
354
+ // inside an explicit transaction so the jwt claims stay applied
355
+ // across every statement (see withRequestPgClient).
356
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveDatabaseId(pgClient));
343
357
  if (!databaseId)
344
358
  throw new Error('DATABASE_NOT_FOUND');
345
359
  // Module registration is server config, not user data:
@@ -349,7 +363,7 @@ function createPresignedUrlPlugin(options) {
349
363
  if (!storageConfig)
350
364
  throw new Error('STORAGE_MODULE_NOT_FOUND');
351
365
  // Bucket config read under the request role (RLS-gated visibility).
352
- const bucket = await vals.withPgClient(vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
366
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
353
367
  if (!bucket)
354
368
  throw new Error('BUCKET_NOT_FOUND');
355
369
  // Enforce bulk upload limits
@@ -368,7 +382,7 @@ function createPresignedUrlPlugin(options) {
368
382
  : bucket.physical_name;
369
383
  const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
370
384
  // File row INSERTs under the request role (RLS enforced).
371
- return vals.withPgClient(vals.pgSettings, (pgClient) => pgClient.withTransaction(async (txClient) => {
385
+ return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
372
386
  const results = [];
373
387
  for (const file of filesArray) {
374
388
  results.push(await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
@@ -380,7 +394,7 @@ function createPresignedUrlPlugin(options) {
380
394
  }));
381
395
  }
382
396
  return { files: results };
383
- }));
397
+ });
384
398
  });
385
399
  },
386
400
  });
@@ -425,7 +439,7 @@ function createPresignedUrlPlugin(options) {
425
439
  const pgSettings = graphqlContext.pgSettings;
426
440
  if (withPgClient) {
427
441
  try {
428
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
442
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
429
443
  // Module registration is server config, not user data:
430
444
  // resolve it without the request role's pgSettings.
431
445
  const allConfigs = databaseId
@@ -433,7 +447,7 @@ function createPresignedUrlPlugin(options) {
433
447
  : [];
434
448
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
435
449
  if (storageConfig) {
436
- await withPgClient(pgSettings, async (pgClient) => {
450
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
437
451
  // Read the file row (RLS enforced)
438
452
  const result = await pgClient.query({
439
453
  text: `SELECT key, bucket_id FROM ${storageConfig.filesQualifiedName} WHERE id = $1 LIMIT 1`,
@@ -458,7 +472,7 @@ function createPresignedUrlPlugin(options) {
458
472
  const pgSettings = graphqlContext.pgSettings;
459
473
  if (withPgClient) {
460
474
  try {
461
- const databaseId = await withPgClient(pgSettings, (pgClient) => resolveDatabaseId(pgClient));
475
+ const databaseId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
462
476
  // Module registration is server config, not user data:
463
477
  // resolve it without the request role's pgSettings.
464
478
  const allConfigs = databaseId
@@ -466,7 +480,7 @@ function createPresignedUrlPlugin(options) {
466
480
  : [];
467
481
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedCodec, allConfigs);
468
482
  if (storageConfig)
469
- await withPgClient(pgSettings, async (pgClient) => {
483
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
470
484
  // Check refcount: any other file with the same key in this bucket?
471
485
  const refResult = await pgClient.query({
472
486
  text: `SELECT COUNT(*)::int AS ref_count FROM ${storageConfig.filesQualifiedName} WHERE key = $1 AND bucket_id = $2`,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
3
+ *
4
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
5
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
6
+ * more than one statement WITHOUT an explicit surrounding transaction, each
7
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
8
+ * setting applied for one statement is already gone by the next. The request
9
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
10
+ * statements, and `jwt_private.current_database_id()` raises
11
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
12
+ *
13
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
14
+ * opens a single explicit transaction, and applies the request settings inside
15
+ * it — so every statement in `cb` observes the same role and jwt claims. It
16
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
17
+ * manual, multi-statement RLS work.
18
+ */
19
+ export interface RequestPgClient {
20
+ query(opts: {
21
+ text: string;
22
+ values?: unknown[];
23
+ }): Promise<{
24
+ rows: Array<Record<string, unknown>>;
25
+ }>;
26
+ withTransaction<T>(cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
27
+ }
28
+ export type WithPgClient = <T>(pgSettings: Record<string, string> | null, cb: (client: RequestPgClient) => Promise<T>) => Promise<T>;
29
+ export declare function withRequestPgClient<T>(withPgClient: WithPgClient, pgSettings: Record<string, string> | null, cb: (tx: RequestPgClient) => Promise<T>): Promise<T>;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * Run a callback under the request's pgSettings inside ONE explicit transaction.
4
+ *
5
+ * The grafast context's `withPgClient(pgSettings, cb)` applies pgSettings as
6
+ * transaction-LOCAL `set_config(key, value, is_local => true)`. When `cb` issues
7
+ * more than one statement WITHOUT an explicit surrounding transaction, each
8
+ * statement runs in its own implicit (autocommit) transaction, so a LOCAL
9
+ * setting applied for one statement is already gone by the next. The request
10
+ * role's jwt claims (notably `jwt.claims.database_id`) then vanish between
11
+ * statements, and `jwt_private.current_database_id()` raises
12
+ * DATABASE_CLAIM_REQUIRED even though the request carried the claim.
13
+ *
14
+ * This helper acquires the client without settings (`withPgClient(null, ...)`),
15
+ * opens a single explicit transaction, and applies the request settings inside
16
+ * it — so every statement in `cb` observes the same role and jwt claims. It
17
+ * mirrors the `pg-query-context` pattern used elsewhere in the codebase for
18
+ * manual, multi-statement RLS work.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.withRequestPgClient = withRequestPgClient;
22
+ async function applyRequestSettings(tx, pgSettings) {
23
+ if (!pgSettings)
24
+ return;
25
+ const entries = Object.entries(pgSettings)
26
+ .filter(([, value]) => value != null)
27
+ .map(([key, value]) => [key, String(value)]);
28
+ if (entries.length === 0)
29
+ return;
30
+ await tx.query({
31
+ text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
32
+ values: [JSON.stringify(entries)],
33
+ });
34
+ }
35
+ function withRequestPgClient(withPgClient, pgSettings, cb) {
36
+ return withPgClient(null, (client) => client.withTransaction(async (tx) => {
37
+ await applyRequestSettings(tx, pgSettings);
38
+ return cb(tx);
39
+ }));
40
+ }