graphile-presigned-url-plugin 1.11.4 → 1.13.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
@@ -19,9 +19,13 @@
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 { checkTypeAgreement } from 'mime-bytes';
23
+ import { resolveDefaultBucket } from './default-bucket';
24
+ import { buildFileProjection } from './managed-upload';
25
+ import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
22
26
  import { withRequestPgClient } from './request-pg-client';
23
27
  import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
24
- import { getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
28
+ import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
25
29
  const log = new Logger('graphile-presigned-url:plugin');
26
30
  // --- Protocol-level constants (not configurable) ---
27
31
  const MAX_CONTENT_HASH_LENGTH = 128;
@@ -67,87 +71,20 @@ async function resolveDatabaseId(pgClient) {
67
71
  });
68
72
  return result.rows[0]?.id ?? null;
69
73
  }
70
- function resolveS3(options) {
71
- if (typeof options.s3 === 'function') {
72
- const resolved = options.s3();
73
- options.s3 = resolved;
74
- return resolved;
75
- }
76
- return options.s3;
77
- }
78
- /**
79
- * Mint the physical S3 bucket name for a logical bucket's first provision.
80
- *
81
- * This is a naming *policy*, consulted exactly once per bucket — before the
82
- * physical bucket exists. Once provisioned, the recorded `physical_name` on
83
- * the row is authoritative and this function must not be consulted again.
84
- */
85
- function mintPhysicalBucketName(options, databaseId, bucketKey) {
86
- if (options.resolveBucketName) {
87
- return options.resolveBucketName(databaseId, bucketKey);
88
- }
89
- // Single-bucket deployment: the globally configured bucket is the physical bucket.
90
- return resolveS3(options).bucket;
91
- }
92
- /**
93
- * Build the S3 config for a *known* physical bucket. `physicalName` is
94
- * required — callers must resolve the coordinate (stored row value, or a
95
- * freshly provisioned name) before getting here. No name is ever recomputed.
96
- */
97
- function resolveS3ForDatabase(options, storageConfig, physicalName) {
98
- const globalS3 = resolveS3(options);
99
- const publicUrlPrefix = storageConfig.publicUrlPrefix != null
100
- ? storageConfig.publicUrlPrefix
101
- : globalS3.publicUrlPrefix;
102
- if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
103
- return globalS3;
104
- }
105
- return {
106
- ...globalS3,
107
- bucket: physicalName,
108
- ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
109
- };
110
- }
111
74
  /**
112
- * First provision of a logical bucket: mint a name, create the physical S3
113
- * bucket, and record the exact name on the source row. Returns the recorded
114
- * physical name.
115
- *
116
- * Only called when the row has no `physical_name` yet. Afterwards the stored
117
- * value is the durable coordinate: route resolution and every later read use
118
- * it verbatim; nothing is recomputed.
75
+ * Resolve the bucket an upload mutation writes into.
119
76
  *
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.
77
+ * A named `bucketKey` is the caller's override and is read directly, as before.
78
+ * An omitted one asks the database for the tenant's reserved default tag for the
79
+ * requested access, so a missing or ambiguous default raises in SQL rather than
80
+ * falling back to a server-global bucket name here.
129
81
  */
130
- async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
131
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
132
- if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
133
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
134
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
135
- markS3BucketProvisioned(s3BucketName);
136
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
82
+ async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
83
+ if (bucketKey) {
84
+ return getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
137
85
  }
138
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
139
- // guard keeps this idempotent and race-safe across concurrent first uploads.
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({
143
- text: `UPDATE ${storageConfig.bucketsQualifiedName}
144
- SET physical_name = $1
145
- WHERE id = $2 AND physical_name IS NULL`,
146
- values: [s3BucketName, bucket.id],
147
- }));
148
- bucket.physical_name = s3BucketName;
149
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
150
- return s3BucketName;
86
+ const coordinate = await resolveDefaultBucket(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
87
+ return getBucketConfig(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
151
88
  }
152
89
  // --- Plugin factory ---
153
90
  export function createPresignedUrlPlugin(options) {
@@ -166,6 +103,14 @@ export function createPresignedUrlPlugin(options) {
166
103
  if (!isRootMutation)
167
104
  return fields;
168
105
  const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
106
+ // The projection document is jsonb-shaped. PostGraphile registers a JSON
107
+ // scalar whenever the schema has a jsonb column, which any storage-equipped
108
+ // database does; if it is absent the payload simply omits the field rather
109
+ // than failing schema build over a field nothing can have asked for yet.
110
+ const jsonType = build.getTypeByName('JSON') ?? null;
111
+ if (!jsonType) {
112
+ log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
113
+ }
169
114
  const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
170
115
  if (bucketCodecs.length === 0)
171
116
  return fields;
@@ -199,7 +144,8 @@ export function createPresignedUrlPlugin(options) {
199
144
  const InputType = new GraphQLInputObjectType({
200
145
  name: `Upload${filesTypeName}Input`,
201
146
  fields: {
202
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
147
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
148
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted: the public one (true) or the private one (default false). Ignored when bucketKey is given.' },
203
149
  ...(hasOwnerId
204
150
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
205
151
  : {}),
@@ -219,6 +165,16 @@ export function createPresignedUrlPlugin(options) {
219
165
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
220
166
  expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
221
167
  previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
168
+ ...(jsonType
169
+ ? {
170
+ file: {
171
+ type: jsonType,
172
+ description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
173
+ 'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
174
+ 'garbage collected while the column still references it.',
175
+ },
176
+ }
177
+ : {}),
222
178
  },
223
179
  });
224
180
  const capturedFilesCodec = filesCodec;
@@ -232,6 +188,7 @@ export function createPresignedUrlPlugin(options) {
232
188
  plan(_$mutation, fieldArgs) {
233
189
  const $input = fieldArgs.getRaw('input');
234
190
  const $bucketKey = access($input, 'bucketKey');
191
+ const $isPublic = access($input, 'isPublic');
235
192
  const $contentHash = access($input, 'contentHash');
236
193
  const $contentType = access($input, 'contentType');
237
194
  const $size = access($input, 'size');
@@ -242,6 +199,7 @@ export function createPresignedUrlPlugin(options) {
242
199
  const $pgSettings = grafastContext().get('pgSettings');
243
200
  const $combined = object({
244
201
  bucketKey: $bucketKey,
202
+ isPublic: $isPublic,
245
203
  ownerId: $ownerId,
246
204
  contentHash: $contentHash,
247
205
  contentType: $contentType,
@@ -264,8 +222,8 @@ export function createPresignedUrlPlugin(options) {
264
222
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
265
223
  if (!storageConfig)
266
224
  throw new Error('STORAGE_MODULE_NOT_FOUND');
267
- // Bucket config read under the request role (RLS-gated visibility).
268
- const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
225
+ // Bucket resolution + read under the request role (RLS-gated visibility).
226
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
269
227
  if (!bucket)
270
228
  throw new Error('BUCKET_NOT_FOUND');
271
229
  // First provision mints + records the coordinate; afterwards the
@@ -305,12 +263,14 @@ export function createPresignedUrlPlugin(options) {
305
263
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
306
264
  expiresAt: { type: GraphQLString },
307
265
  previousVersionId: { type: GraphQLString },
266
+ ...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
308
267
  },
309
268
  });
310
269
  const BulkInputType = new GraphQLInputObjectType({
311
270
  name: `Upload${filesTypeName}BulkInput`,
312
271
  fields: {
313
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
272
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
273
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
314
274
  ...(hasOwnerId
315
275
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
316
276
  : {}),
@@ -334,12 +294,14 @@ export function createPresignedUrlPlugin(options) {
334
294
  plan(_$mutation, fieldArgs) {
335
295
  const $input = fieldArgs.getRaw('input');
336
296
  const $bucketKey = access($input, 'bucketKey');
297
+ const $isPublic = access($input, 'isPublic');
337
298
  const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
338
299
  const $files = access($input, 'files');
339
300
  const $withPgClient = grafastContext().get('withPgClient');
340
301
  const $pgSettings = grafastContext().get('pgSettings');
341
302
  const $combined = object({
342
303
  bucketKey: $bucketKey,
304
+ isPublic: $isPublic,
343
305
  ownerId: $ownerId,
344
306
  files: $files,
345
307
  withPgClient: $withPgClient,
@@ -358,8 +320,8 @@ export function createPresignedUrlPlugin(options) {
358
320
  const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
359
321
  if (!storageConfig)
360
322
  throw new Error('STORAGE_MODULE_NOT_FOUND');
361
- // Bucket config read under the request role (RLS-gated visibility).
362
- const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => getBucketConfig(pgClient, storageConfig, databaseId, vals.bucketKey, vals.ownerId || undefined));
323
+ // Bucket resolution + read under the request role (RLS-gated visibility).
324
+ const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
363
325
  if (!bucket)
364
326
  throw new Error('BUCKET_NOT_FOUND');
365
327
  // Enforce bulk upload limits
@@ -544,6 +506,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
544
506
  throw new Error('INVALID_FILENAME');
545
507
  }
546
508
  }
509
+ // The bytes are not here to be examined — the client PUTs them straight to S3 —
510
+ // so this checks the two claims that *are* here against each other. It is the
511
+ // cheap half of the rule: an upload declaring `image/jpeg` under the name
512
+ // `payload.html` is refused before a row exists, without reading a byte. The
513
+ // bytes themselves are checked on confirmation, before the row leaves
514
+ // `requested`.
515
+ const agreement = checkTypeAgreement({ filename, declaredMime: contentType });
516
+ if (!agreement.ok) {
517
+ throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}`);
518
+ }
547
519
  // Validate content type against bucket's allowed_mime_types
548
520
  if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
549
521
  const allowed = bucket.allowed_mime_types;
@@ -564,6 +536,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
564
536
  if (bucket.max_file_size && size > bucket.max_file_size) {
565
537
  throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
566
538
  }
539
+ // The projection document the caller stores in an image/upload column. Built
540
+ // from the same values the files row carries, so the column and the row cannot
541
+ // disagree, and it names the files row by id — which is what stops GC from
542
+ // collecting an object a document still points at.
543
+ const projectFile = (fileId, key) => buildFileProjection({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
567
544
  // Determine S3 key
568
545
  let s3Key;
569
546
  let isCustomKey = false;
@@ -604,6 +581,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
604
581
  deduplicated: true,
605
582
  expiresAt: null,
606
583
  previousVersionId: null,
584
+ file: projectFile(existing.id, s3Key),
607
585
  };
608
586
  }
609
587
  previousVersionId = existing.id;
@@ -629,6 +607,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
629
607
  deduplicated: true,
630
608
  expiresAt: null,
631
609
  previousVersionId: null,
610
+ file: projectFile(existingFile.id, s3Key),
632
611
  };
633
612
  }
634
613
  }
@@ -669,6 +648,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
669
648
  deduplicated: false,
670
649
  expiresAt,
671
650
  previousVersionId,
651
+ file: projectFile(fileId, s3Key),
672
652
  };
673
653
  }
674
654
  export const PresignedUrlPlugin = createPresignedUrlPlugin;
@@ -36,6 +36,35 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
36
36
  * @param key - S3 object key to delete
37
37
  */
38
38
  export declare function deleteS3Object(s3Config: S3Config, key: string): Promise<void>;
39
+ /**
40
+ * Copy an object within the same physical bucket, preserving its content type.
41
+ *
42
+ * Used to promote a staged upload to its content-addressed key: the bytes are
43
+ * hashed as they stream in, so the final key is only known once the stream ends.
44
+ * Server-side copy keeps that promotion off the application's wire.
45
+ *
46
+ * @param s3Config - S3 client and bucket configuration
47
+ * @param sourceKey - The staged key the bytes were written to
48
+ * @param destinationKey - The final key (the content hash)
49
+ * @param contentType - MIME type to record on the destination object
50
+ */
51
+ export declare function copyS3Object(s3Config: S3Config, sourceKey: string, destinationKey: string, contentType: string): Promise<void>;
52
+ /**
53
+ * Read the leading bytes of an object.
54
+ *
55
+ * A ranged GET, because the only reason to touch bytes the client uploaded
56
+ * directly is to see what they actually are: a magic-byte signature lives in the
57
+ * first few dozen bytes, so validating a 2GB video costs the same as validating
58
+ * an icon.
59
+ *
60
+ * Returns null when the object is not there — the presigned lane's ordinary
61
+ * "client never PUT it" case, which is an expiry rather than a failure.
62
+ *
63
+ * @param s3Config - S3 client and bucket configuration
64
+ * @param key - S3 object key
65
+ * @param byteCount - How many leading bytes to read
66
+ */
67
+ export declare function readObjectPrefix(s3Config: S3Config, key: string, byteCount: number): Promise<Buffer | null>;
39
68
  /**
40
69
  * Check if an object exists in S3 and optionally verify its content-type.
41
70
  *
package/esm/s3-signer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
1
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
2
2
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
3
  import { Logger } from '@pgpmjs/logger';
4
4
  const log = new Logger('graphile-presigned-url:s3');
@@ -68,6 +68,66 @@ export async function deleteS3Object(s3Config, key) {
68
68
  }));
69
69
  log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
70
70
  }
71
+ /**
72
+ * Copy an object within the same physical bucket, preserving its content type.
73
+ *
74
+ * Used to promote a staged upload to its content-addressed key: the bytes are
75
+ * hashed as they stream in, so the final key is only known once the stream ends.
76
+ * Server-side copy keeps that promotion off the application's wire.
77
+ *
78
+ * @param s3Config - S3 client and bucket configuration
79
+ * @param sourceKey - The staged key the bytes were written to
80
+ * @param destinationKey - The final key (the content hash)
81
+ * @param contentType - MIME type to record on the destination object
82
+ */
83
+ export async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
84
+ await s3Config.client.send(new CopyObjectCommand({
85
+ Bucket: s3Config.bucket,
86
+ Key: destinationKey,
87
+ CopySource: `${s3Config.bucket}/${sourceKey}`,
88
+ ContentType: contentType,
89
+ MetadataDirective: 'REPLACE',
90
+ }));
91
+ log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
92
+ }
93
+ /**
94
+ * Read the leading bytes of an object.
95
+ *
96
+ * A ranged GET, because the only reason to touch bytes the client uploaded
97
+ * directly is to see what they actually are: a magic-byte signature lives in the
98
+ * first few dozen bytes, so validating a 2GB video costs the same as validating
99
+ * an icon.
100
+ *
101
+ * Returns null when the object is not there — the presigned lane's ordinary
102
+ * "client never PUT it" case, which is an expiry rather than a failure.
103
+ *
104
+ * @param s3Config - S3 client and bucket configuration
105
+ * @param key - S3 object key
106
+ * @param byteCount - How many leading bytes to read
107
+ */
108
+ export async function readObjectPrefix(s3Config, key, byteCount) {
109
+ try {
110
+ const response = await s3Config.client.send(new GetObjectCommand({
111
+ Bucket: s3Config.bucket,
112
+ Key: key,
113
+ Range: `bytes=0-${byteCount - 1}`,
114
+ }));
115
+ const body = response.Body;
116
+ if (!body)
117
+ return Buffer.alloc(0);
118
+ const chunks = [];
119
+ for await (const chunk of body) {
120
+ chunks.push(Buffer.from(chunk));
121
+ }
122
+ return Buffer.concat(chunks);
123
+ }
124
+ catch (e) {
125
+ if (e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
126
+ return null;
127
+ }
128
+ throw e;
129
+ }
130
+ }
71
131
  /**
72
132
  * Check if an object exists in S3 and optionally verify its content-type.
73
133
  *
package/esm/types.d.ts CHANGED
@@ -70,8 +70,19 @@ export interface StorageModuleConfig {
70
70
  * Input for the requestUploadUrl mutation.
71
71
  */
72
72
  export interface RequestUploadUrlInput {
73
- /** Bucket key (e.g., "public", "private") */
74
- bucketKey: string;
73
+ /**
74
+ * Logical bucket key (e.g., "public", "private").
75
+ *
76
+ * Optional: when omitted the database resolves its own default bucket for the
77
+ * requested access (see `isPublic`), so a client never has to know a tenant's
78
+ * bucket naming to upload.
79
+ */
80
+ bucketKey?: string;
81
+ /**
82
+ * Which default bucket to resolve when `bucketKey` is omitted: the public one
83
+ * (true) or the private one (default false). Ignored when `bucketKey` is given.
84
+ */
85
+ isPublic?: boolean;
75
86
  /**
76
87
  * Owner entity ID for entity-scoped uploads.
77
88
  * Omit for app-level (database-wide) storage.
@@ -111,6 +122,27 @@ export interface RequestUploadUrlPayload {
111
122
  expiresAt: string | null;
112
123
  /** ID of the previous version (set when re-uploading to an existing custom key) */
113
124
  previousVersionId: string | null;
125
+ /**
126
+ * The projection document to store in an `image`/`upload` column.
127
+ *
128
+ * Its `id` is the files row, which is what makes the column a reference the
129
+ * server can count — storage GC will not collect an object while a registered
130
+ * document column still names its file.
131
+ */
132
+ file: FileProjection;
133
+ }
134
+ /**
135
+ * The document a managed `image`/`upload` column stores. See `./managed-upload`.
136
+ */
137
+ export interface FileProjection {
138
+ id: string;
139
+ key: string;
140
+ bucket_id: string;
141
+ mime: string;
142
+ size: number;
143
+ filename?: string;
144
+ /** @deprecated Read the files row's `downloadUrl` via `id` instead. */
145
+ url?: string;
114
146
  }
115
147
  /**
116
148
  * S3 configuration for the presigned URL plugin.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The `file_ref_field` registry: which storage module and bucket a managed
3
+ * document column writes into.
4
+ *
5
+ * An `image`/`upload` column is a projection of a files row, and the decision of
6
+ * *where* those bytes live is a property of the field declaration, not of the
7
+ * request. The registry records that intent per (table, column) — a storage
8
+ * module plus either a logical bucket key, a tag selector, or nothing at all
9
+ * (meaning the reserved default tag for the declared publicness).
10
+ *
11
+ * This module answers one question — "what does a write to this column bind
12
+ * to?" — and answers it loudly: an unregistered column raises rather than
13
+ * falling back to a server-global bucket, because a silent fallback is how the
14
+ * unmanaged lane produced objects no tenant owned.
15
+ */
16
+ /**
17
+ * A field's recorded storage intent.
18
+ *
19
+ * `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
20
+ * both may be absent — resolution then uses the reserved default tag for
21
+ * `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
22
+ * is resolved per written row, inside the tenant.
23
+ */
24
+ export interface FileRefFieldBinding {
25
+ id: string;
26
+ storageModuleId: string;
27
+ bucketKey: string | null;
28
+ bucketTags: string[] | null;
29
+ isPublic: boolean | null;
30
+ enforceFk: boolean;
31
+ }
32
+ export declare class FileRefFieldNotRegisteredError extends Error {
33
+ readonly databaseId: string;
34
+ readonly schemaName: string;
35
+ readonly tableName: string;
36
+ readonly columnName: string;
37
+ constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
38
+ }
39
+ /**
40
+ * Look up the storage binding for a document column, or throw.
41
+ *
42
+ * The read runs on whichever client the caller passes. The registry is schema
43
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
44
+ * the RLS that matters is on the files table the upload eventually writes.
45
+ */
46
+ export declare function getFileRefFieldBinding(pgClient: {
47
+ query: (opts: {
48
+ text: string;
49
+ values?: unknown[];
50
+ }) => Promise<{
51
+ rows: unknown[];
52
+ }>;
53
+ }, databaseId: string, field: {
54
+ schemaName: string;
55
+ tableName: string;
56
+ columnName: string;
57
+ }): Promise<FileRefFieldBinding>;
58
+ /**
59
+ * Drop cached bindings. Used by tests and after a re-provision.
60
+ */
61
+ export declare function clearFileRefFieldCache(): void;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /**
3
+ * The `file_ref_field` registry: which storage module and bucket a managed
4
+ * document column writes into.
5
+ *
6
+ * An `image`/`upload` column is a projection of a files row, and the decision of
7
+ * *where* those bytes live is a property of the field declaration, not of the
8
+ * request. The registry records that intent per (table, column) — a storage
9
+ * module plus either a logical bucket key, a tag selector, or nothing at all
10
+ * (meaning the reserved default tag for the declared publicness).
11
+ *
12
+ * This module answers one question — "what does a write to this column bind
13
+ * to?" — and answers it loudly: an unregistered column raises rather than
14
+ * falling back to a server-global bucket, because a silent fallback is how the
15
+ * unmanaged lane produced objects no tenant owned.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.FileRefFieldNotRegisteredError = void 0;
19
+ exports.getFileRefFieldBinding = getFileRefFieldBinding;
20
+ exports.clearFileRefFieldCache = clearFileRefFieldCache;
21
+ const logger_1 = require("@pgpmjs/logger");
22
+ const lru_cache_1 = require("lru-cache");
23
+ const log = new logger_1.Logger('graphile-presigned-url:file-ref-registry');
24
+ const FIVE_MINUTES_MS = 1000 * 60 * 5;
25
+ const ONE_HOUR_MS = 1000 * 60 * 60;
26
+ /**
27
+ * Resolve the registry row for a document column.
28
+ *
29
+ * Joined through metaschema rather than keyed by name, because the registry
30
+ * records field *ids*: the physical (schema, table, column) triple is what the
31
+ * GraphQL layer knows, and metaschema is the only thing that maps one to the
32
+ * other.
33
+ */
34
+ const FILE_REF_FIELD_QUERY = `
35
+ SELECT
36
+ frf.id,
37
+ frf.storage_module_id,
38
+ frf.bucket_key,
39
+ frf.bucket_tags::text[] AS bucket_tags,
40
+ frf.is_public,
41
+ frf.enforce_fk
42
+ FROM metaschema_modules_public.file_ref_field frf
43
+ JOIN metaschema_public.field f ON f.id = frf.field_id
44
+ JOIN metaschema_public.table t ON t.id = frf.table_id
45
+ JOIN metaschema_public.schema s ON s.id = t.schema_id
46
+ WHERE frf.database_id = $1
47
+ AND s.schema_name = $2
48
+ AND t.name = $3
49
+ AND f.name = $4
50
+ LIMIT 1
51
+ `;
52
+ /**
53
+ * LRU cache of field bindings.
54
+ *
55
+ * A binding is schema, not data: it changes only when a database is
56
+ * re-provisioned, so it caches on the same terms as the storage module config
57
+ * next to it. Misses are never cached — an unregistered column is a hard error
58
+ * every time it is written, not a remembered "no".
59
+ */
60
+ const bindingCache = new lru_cache_1.LRUCache({
61
+ max: 500,
62
+ ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
63
+ updateAgeOnGet: true,
64
+ });
65
+ class FileRefFieldNotRegisteredError extends Error {
66
+ databaseId;
67
+ schemaName;
68
+ tableName;
69
+ columnName;
70
+ constructor(databaseId, schemaName, tableName, columnName) {
71
+ super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
72
+ `is not a registered file-reference field in database ${databaseId}. ` +
73
+ 'A managed upload needs the declared storage module and bucket intent; ' +
74
+ 'there is no server-global bucket to fall back to.');
75
+ this.databaseId = databaseId;
76
+ this.schemaName = schemaName;
77
+ this.tableName = tableName;
78
+ this.columnName = columnName;
79
+ this.name = 'FileRefFieldNotRegisteredError';
80
+ }
81
+ }
82
+ exports.FileRefFieldNotRegisteredError = FileRefFieldNotRegisteredError;
83
+ /**
84
+ * Look up the storage binding for a document column, or throw.
85
+ *
86
+ * The read runs on whichever client the caller passes. The registry is schema
87
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
88
+ * the RLS that matters is on the files table the upload eventually writes.
89
+ */
90
+ async function getFileRefFieldBinding(pgClient, databaseId, field) {
91
+ const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
92
+ const cached = bindingCache.get(cacheKey);
93
+ if (cached)
94
+ return cached;
95
+ const result = await pgClient.query({
96
+ text: FILE_REF_FIELD_QUERY,
97
+ values: [databaseId, field.schemaName, field.tableName, field.columnName],
98
+ });
99
+ if (result.rows.length === 0) {
100
+ throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
101
+ }
102
+ const row = result.rows[0];
103
+ const binding = {
104
+ id: row.id,
105
+ storageModuleId: row.storage_module_id,
106
+ bucketKey: row.bucket_key,
107
+ bucketTags: row.bucket_tags,
108
+ isPublic: row.is_public,
109
+ enforceFk: row.enforce_fk,
110
+ };
111
+ bindingCache.set(cacheKey, binding);
112
+ log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
113
+ `${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
114
+ return binding;
115
+ }
116
+ /**
117
+ * Drop cached bindings. Used by tests and after a re-provision.
118
+ */
119
+ function clearFileRefFieldCache() {
120
+ bindingCache.clear();
121
+ }
package/index.d.ts CHANGED
@@ -26,9 +26,17 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
+ export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
30
+ export type { ResolvedBucketCoordinate } from './default-bucket';
31
+ export { resolveDefaultBucket } from './default-bucket';
29
32
  export { createDownloadUrlPlugin } from './download-url-field';
33
+ export type { FileRefFieldBinding } from './file-ref-registry';
34
+ export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
35
+ export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
36
+ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
30
37
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
38
  export { PresignedUrlPreset } from './preset';
32
- export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
39
+ export { type WithPgClient, withRequestPgClient } from './request-pg-client';
40
+ export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
33
41
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
34
42
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';