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/plugin.js CHANGED
@@ -23,6 +23,10 @@ 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 mime_bytes_1 = require("mime-bytes");
27
+ const default_bucket_1 = require("./default-bucket");
28
+ const managed_upload_1 = require("./managed-upload");
29
+ const physical_bucket_1 = require("./physical-bucket");
26
30
  const request_pg_client_1 = require("./request-pg-client");
27
31
  const s3_signer_1 = require("./s3-signer");
28
32
  const storage_module_cache_1 = require("./storage-module-cache");
@@ -71,87 +75,20 @@ async function resolveDatabaseId(pgClient) {
71
75
  });
72
76
  return result.rows[0]?.id ?? null;
73
77
  }
74
- function resolveS3(options) {
75
- if (typeof options.s3 === 'function') {
76
- const resolved = options.s3();
77
- options.s3 = resolved;
78
- return resolved;
79
- }
80
- return options.s3;
81
- }
82
- /**
83
- * Mint the physical S3 bucket name for a logical bucket's first provision.
84
- *
85
- * This is a naming *policy*, consulted exactly once per bucket — before the
86
- * physical bucket exists. Once provisioned, the recorded `physical_name` on
87
- * the row is authoritative and this function must not be consulted again.
88
- */
89
- function mintPhysicalBucketName(options, databaseId, bucketKey) {
90
- if (options.resolveBucketName) {
91
- return options.resolveBucketName(databaseId, bucketKey);
92
- }
93
- // Single-bucket deployment: the globally configured bucket is the physical bucket.
94
- return resolveS3(options).bucket;
95
- }
96
- /**
97
- * Build the S3 config for a *known* physical bucket. `physicalName` is
98
- * required — callers must resolve the coordinate (stored row value, or a
99
- * freshly provisioned name) before getting here. No name is ever recomputed.
100
- */
101
- function resolveS3ForDatabase(options, storageConfig, physicalName) {
102
- const globalS3 = resolveS3(options);
103
- const publicUrlPrefix = storageConfig.publicUrlPrefix != null
104
- ? storageConfig.publicUrlPrefix
105
- : globalS3.publicUrlPrefix;
106
- if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
107
- return globalS3;
108
- }
109
- return {
110
- ...globalS3,
111
- bucket: physicalName,
112
- ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
113
- };
114
- }
115
78
  /**
116
- * First provision of a logical bucket: mint a name, create the physical S3
117
- * bucket, and record the exact name on the source row. Returns the recorded
118
- * physical name.
119
- *
120
- * Only called when the row has no `physical_name` yet. Afterwards the stored
121
- * value is the durable coordinate: route resolution and every later read use
122
- * it verbatim; nothing is recomputed.
79
+ * Resolve the bucket an upload mutation writes into.
123
80
  *
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.
81
+ * A named `bucketKey` is the caller's override and is read directly, as before.
82
+ * An omitted one asks the database for the tenant's reserved default tag for the
83
+ * requested access, so a missing or ambiguous default raises in SQL rather than
84
+ * falling back to a server-global bucket name here.
133
85
  */
134
- async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
135
- const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
136
- if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
137
- log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
138
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
139
- (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
140
- log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
86
+ async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
87
+ if (bucketKey) {
88
+ return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
141
89
  }
142
- // Record the physical coordinate on the source row. The `physical_name IS NULL`
143
- // guard keeps this idempotent and race-safe across concurrent first uploads.
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({
147
- text: `UPDATE ${storageConfig.bucketsQualifiedName}
148
- SET physical_name = $1
149
- WHERE id = $2 AND physical_name IS NULL`,
150
- values: [s3BucketName, bucket.id],
151
- }));
152
- bucket.physical_name = s3BucketName;
153
- log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
154
- return s3BucketName;
90
+ const coordinate = await (0, default_bucket_1.resolveDefaultBucket)(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
91
+ return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
155
92
  }
156
93
  // --- Plugin factory ---
157
94
  function createPresignedUrlPlugin(options) {
@@ -170,6 +107,14 @@ function createPresignedUrlPlugin(options) {
170
107
  if (!isRootMutation)
171
108
  return fields;
172
109
  const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
110
+ // The projection document is jsonb-shaped. PostGraphile registers a JSON
111
+ // scalar whenever the schema has a jsonb column, which any storage-equipped
112
+ // database does; if it is absent the payload simply omits the field rather
113
+ // than failing schema build over a field nothing can have asked for yet.
114
+ const jsonType = build.getTypeByName('JSON') ?? null;
115
+ if (!jsonType) {
116
+ log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
117
+ }
173
118
  const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
174
119
  if (bucketCodecs.length === 0)
175
120
  return fields;
@@ -203,7 +148,8 @@ function createPresignedUrlPlugin(options) {
203
148
  const InputType = new GraphQLInputObjectType({
204
149
  name: `Upload${filesTypeName}Input`,
205
150
  fields: {
206
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
151
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
152
+ 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.' },
207
153
  ...(hasOwnerId
208
154
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
209
155
  : {}),
@@ -223,6 +169,16 @@ function createPresignedUrlPlugin(options) {
223
169
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
224
170
  expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
225
171
  previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
172
+ ...(jsonType
173
+ ? {
174
+ file: {
175
+ type: jsonType,
176
+ description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
177
+ 'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
178
+ 'garbage collected while the column still references it.',
179
+ },
180
+ }
181
+ : {}),
226
182
  },
227
183
  });
228
184
  const capturedFilesCodec = filesCodec;
@@ -236,6 +192,7 @@ function createPresignedUrlPlugin(options) {
236
192
  plan(_$mutation, fieldArgs) {
237
193
  const $input = fieldArgs.getRaw('input');
238
194
  const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
195
+ const $isPublic = (0, grafast_1.access)($input, 'isPublic');
239
196
  const $contentHash = (0, grafast_1.access)($input, 'contentHash');
240
197
  const $contentType = (0, grafast_1.access)($input, 'contentType');
241
198
  const $size = (0, grafast_1.access)($input, 'size');
@@ -246,6 +203,7 @@ function createPresignedUrlPlugin(options) {
246
203
  const $pgSettings = (0, grafast_1.context)().get('pgSettings');
247
204
  const $combined = (0, grafast_1.object)({
248
205
  bucketKey: $bucketKey,
206
+ isPublic: $isPublic,
249
207
  ownerId: $ownerId,
250
208
  contentHash: $contentHash,
251
209
  contentType: $contentType,
@@ -268,16 +226,16 @@ function createPresignedUrlPlugin(options) {
268
226
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
269
227
  if (!storageConfig)
270
228
  throw new Error('STORAGE_MODULE_NOT_FOUND');
271
- // Bucket config read under the request role (RLS-gated visibility).
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));
229
+ // Bucket resolution + read under the request role (RLS-gated visibility).
230
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
273
231
  if (!bucket)
274
232
  throw new Error('BUCKET_NOT_FOUND');
275
233
  // First provision mints + records the coordinate; afterwards the
276
234
  // stored physical_name is authoritative and nothing is recomputed.
277
235
  const physicalName = bucket.physical_name === null
278
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
236
+ ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
279
237
  : bucket.physical_name;
280
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
238
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
281
239
  // File row INSERT under the request role (RLS enforced).
282
240
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
283
241
  contentHash: vals.contentHash,
@@ -309,12 +267,14 @@ function createPresignedUrlPlugin(options) {
309
267
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
310
268
  expiresAt: { type: GraphQLString },
311
269
  previousVersionId: { type: GraphQLString },
270
+ ...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
312
271
  },
313
272
  });
314
273
  const BulkInputType = new GraphQLInputObjectType({
315
274
  name: `Upload${filesTypeName}BulkInput`,
316
275
  fields: {
317
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
276
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
277
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
318
278
  ...(hasOwnerId
319
279
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
320
280
  : {}),
@@ -338,12 +298,14 @@ function createPresignedUrlPlugin(options) {
338
298
  plan(_$mutation, fieldArgs) {
339
299
  const $input = fieldArgs.getRaw('input');
340
300
  const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
301
+ const $isPublic = (0, grafast_1.access)($input, 'isPublic');
341
302
  const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
342
303
  const $files = (0, grafast_1.access)($input, 'files');
343
304
  const $withPgClient = (0, grafast_1.context)().get('withPgClient');
344
305
  const $pgSettings = (0, grafast_1.context)().get('pgSettings');
345
306
  const $combined = (0, grafast_1.object)({
346
307
  bucketKey: $bucketKey,
308
+ isPublic: $isPublic,
347
309
  ownerId: $ownerId,
348
310
  files: $files,
349
311
  withPgClient: $withPgClient,
@@ -362,8 +324,8 @@ function createPresignedUrlPlugin(options) {
362
324
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
363
325
  if (!storageConfig)
364
326
  throw new Error('STORAGE_MODULE_NOT_FOUND');
365
- // Bucket config read under the request role (RLS-gated visibility).
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));
327
+ // Bucket resolution + read under the request role (RLS-gated visibility).
328
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
367
329
  if (!bucket)
368
330
  throw new Error('BUCKET_NOT_FOUND');
369
331
  // Enforce bulk upload limits
@@ -378,9 +340,9 @@ function createPresignedUrlPlugin(options) {
378
340
  // First provision mints + records the coordinate; afterwards the
379
341
  // stored physical_name is authoritative and nothing is recomputed.
380
342
  const physicalName = bucket.physical_name === null
381
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
343
+ ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
382
344
  : bucket.physical_name;
383
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
345
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
384
346
  // File row INSERTs under the request role (RLS enforced).
385
347
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
386
348
  const results = [];
@@ -509,7 +471,7 @@ function createPresignedUrlPlugin(options) {
509
471
  log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
510
472
  return;
511
473
  }
512
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
474
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
513
475
  await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
514
476
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
515
477
  });
@@ -548,6 +510,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
548
510
  throw new Error('INVALID_FILENAME');
549
511
  }
550
512
  }
513
+ // The bytes are not here to be examined — the client PUTs them straight to S3 —
514
+ // so this checks the two claims that *are* here against each other. It is the
515
+ // cheap half of the rule: an upload declaring `image/jpeg` under the name
516
+ // `payload.html` is refused before a row exists, without reading a byte. The
517
+ // bytes themselves are checked on confirmation, before the row leaves
518
+ // `requested`.
519
+ const agreement = (0, mime_bytes_1.checkTypeAgreement)({ filename, declaredMime: contentType });
520
+ if (!agreement.ok) {
521
+ throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}`);
522
+ }
551
523
  // Validate content type against bucket's allowed_mime_types
552
524
  if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
553
525
  const allowed = bucket.allowed_mime_types;
@@ -568,6 +540,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
568
540
  if (bucket.max_file_size && size > bucket.max_file_size) {
569
541
  throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
570
542
  }
543
+ // The projection document the caller stores in an image/upload column. Built
544
+ // from the same values the files row carries, so the column and the row cannot
545
+ // disagree, and it names the files row by id — which is what stops GC from
546
+ // collecting an object a document still points at.
547
+ const projectFile = (fileId, key) => (0, managed_upload_1.buildFileProjection)({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
571
548
  // Determine S3 key
572
549
  let s3Key;
573
550
  let isCustomKey = false;
@@ -608,6 +585,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
608
585
  deduplicated: true,
609
586
  expiresAt: null,
610
587
  previousVersionId: null,
588
+ file: projectFile(existing.id, s3Key),
611
589
  };
612
590
  }
613
591
  previousVersionId = existing.id;
@@ -633,6 +611,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
633
611
  deduplicated: true,
634
612
  expiresAt: null,
635
613
  previousVersionId: null,
614
+ file: projectFile(existingFile.id, s3Key),
636
615
  };
637
616
  }
638
617
  }
@@ -673,6 +652,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
673
652
  deduplicated: false,
674
653
  expiresAt,
675
654
  previousVersionId,
655
+ file: projectFile(fileId, s3Key),
676
656
  };
677
657
  }
678
658
  exports.PresignedUrlPlugin = createPresignedUrlPlugin;
package/s3-signer.d.ts CHANGED
@@ -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/s3-signer.js CHANGED
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generatePresignedPutUrl = generatePresignedPutUrl;
4
4
  exports.generatePresignedGetUrl = generatePresignedGetUrl;
5
5
  exports.deleteS3Object = deleteS3Object;
6
+ exports.copyS3Object = copyS3Object;
7
+ exports.readObjectPrefix = readObjectPrefix;
6
8
  exports.headObject = headObject;
7
9
  const client_s3_1 = require("@aws-sdk/client-s3");
8
10
  const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
@@ -74,6 +76,66 @@ async function deleteS3Object(s3Config, key) {
74
76
  }));
75
77
  log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
76
78
  }
79
+ /**
80
+ * Copy an object within the same physical bucket, preserving its content type.
81
+ *
82
+ * Used to promote a staged upload to its content-addressed key: the bytes are
83
+ * hashed as they stream in, so the final key is only known once the stream ends.
84
+ * Server-side copy keeps that promotion off the application's wire.
85
+ *
86
+ * @param s3Config - S3 client and bucket configuration
87
+ * @param sourceKey - The staged key the bytes were written to
88
+ * @param destinationKey - The final key (the content hash)
89
+ * @param contentType - MIME type to record on the destination object
90
+ */
91
+ async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
92
+ await s3Config.client.send(new client_s3_1.CopyObjectCommand({
93
+ Bucket: s3Config.bucket,
94
+ Key: destinationKey,
95
+ CopySource: `${s3Config.bucket}/${sourceKey}`,
96
+ ContentType: contentType,
97
+ MetadataDirective: 'REPLACE',
98
+ }));
99
+ log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
100
+ }
101
+ /**
102
+ * Read the leading bytes of an object.
103
+ *
104
+ * A ranged GET, because the only reason to touch bytes the client uploaded
105
+ * directly is to see what they actually are: a magic-byte signature lives in the
106
+ * first few dozen bytes, so validating a 2GB video costs the same as validating
107
+ * an icon.
108
+ *
109
+ * Returns null when the object is not there — the presigned lane's ordinary
110
+ * "client never PUT it" case, which is an expiry rather than a failure.
111
+ *
112
+ * @param s3Config - S3 client and bucket configuration
113
+ * @param key - S3 object key
114
+ * @param byteCount - How many leading bytes to read
115
+ */
116
+ async function readObjectPrefix(s3Config, key, byteCount) {
117
+ try {
118
+ const response = await s3Config.client.send(new client_s3_1.GetObjectCommand({
119
+ Bucket: s3Config.bucket,
120
+ Key: key,
121
+ Range: `bytes=0-${byteCount - 1}`,
122
+ }));
123
+ const body = response.Body;
124
+ if (!body)
125
+ return Buffer.alloc(0);
126
+ const chunks = [];
127
+ for await (const chunk of body) {
128
+ chunks.push(Buffer.from(chunk));
129
+ }
130
+ return Buffer.concat(chunks);
131
+ }
132
+ catch (e) {
133
+ if (e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
134
+ return null;
135
+ }
136
+ throw e;
137
+ }
138
+ }
77
139
  /**
78
140
  * Check if an object exists in S3 and optionally verify its content-type.
79
141
  *
package/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.