graphile-presigned-url-plugin 1.11.3 → 1.12.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,9 @@ 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 default_bucket_1 = require("./default-bucket");
27
+ const managed_upload_1 = require("./managed-upload");
28
+ const physical_bucket_1 = require("./physical-bucket");
26
29
  const request_pg_client_1 = require("./request-pg-client");
27
30
  const s3_signer_1 = require("./s3-signer");
28
31
  const storage_module_cache_1 = require("./storage-module-cache");
@@ -71,87 +74,20 @@ async function resolveDatabaseId(pgClient) {
71
74
  });
72
75
  return result.rows[0]?.id ?? null;
73
76
  }
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
77
  /**
83
- * Mint the physical S3 bucket name for a logical bucket's first provision.
78
+ * Resolve the bucket an upload mutation writes into.
84
79
  *
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.
80
+ * A named `bucketKey` is the caller's override and is read directly, as before.
81
+ * An omitted one asks the database for the tenant's reserved default tag for the
82
+ * requested access, so a missing or ambiguous default raises in SQL rather than
83
+ * falling back to a server-global bucket name here.
88
84
  */
89
- function mintPhysicalBucketName(options, databaseId, bucketKey) {
90
- if (options.resolveBucketName) {
91
- return options.resolveBucketName(databaseId, bucketKey);
85
+ async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
86
+ if (bucketKey) {
87
+ return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
92
88
  }
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
- /**
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.
123
- *
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.
133
- */
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`);
141
- }
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;
89
+ const coordinate = await (0, default_bucket_1.resolveDefaultBucket)(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
90
+ return (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
155
91
  }
156
92
  // --- Plugin factory ---
157
93
  function createPresignedUrlPlugin(options) {
@@ -170,6 +106,14 @@ function createPresignedUrlPlugin(options) {
170
106
  if (!isRootMutation)
171
107
  return fields;
172
108
  const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
109
+ // The projection document is jsonb-shaped. PostGraphile registers a JSON
110
+ // scalar whenever the schema has a jsonb column, which any storage-equipped
111
+ // database does; if it is absent the payload simply omits the field rather
112
+ // than failing schema build over a field nothing can have asked for yet.
113
+ const jsonType = build.getTypeByName('JSON') ?? null;
114
+ if (!jsonType) {
115
+ log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
116
+ }
173
117
  const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
174
118
  if (bucketCodecs.length === 0)
175
119
  return fields;
@@ -203,7 +147,8 @@ function createPresignedUrlPlugin(options) {
203
147
  const InputType = new GraphQLInputObjectType({
204
148
  name: `Upload${filesTypeName}Input`,
205
149
  fields: {
206
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
150
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
151
+ 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
152
  ...(hasOwnerId
208
153
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
209
154
  : {}),
@@ -223,6 +168,16 @@ function createPresignedUrlPlugin(options) {
223
168
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
224
169
  expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
225
170
  previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
171
+ ...(jsonType
172
+ ? {
173
+ file: {
174
+ type: jsonType,
175
+ description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
176
+ 'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
177
+ 'garbage collected while the column still references it.',
178
+ },
179
+ }
180
+ : {}),
226
181
  },
227
182
  });
228
183
  const capturedFilesCodec = filesCodec;
@@ -236,6 +191,7 @@ function createPresignedUrlPlugin(options) {
236
191
  plan(_$mutation, fieldArgs) {
237
192
  const $input = fieldArgs.getRaw('input');
238
193
  const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
194
+ const $isPublic = (0, grafast_1.access)($input, 'isPublic');
239
195
  const $contentHash = (0, grafast_1.access)($input, 'contentHash');
240
196
  const $contentType = (0, grafast_1.access)($input, 'contentType');
241
197
  const $size = (0, grafast_1.access)($input, 'size');
@@ -246,6 +202,7 @@ function createPresignedUrlPlugin(options) {
246
202
  const $pgSettings = (0, grafast_1.context)().get('pgSettings');
247
203
  const $combined = (0, grafast_1.object)({
248
204
  bucketKey: $bucketKey,
205
+ isPublic: $isPublic,
249
206
  ownerId: $ownerId,
250
207
  contentHash: $contentHash,
251
208
  contentType: $contentType,
@@ -268,16 +225,16 @@ function createPresignedUrlPlugin(options) {
268
225
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
269
226
  if (!storageConfig)
270
227
  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));
228
+ // Bucket resolution + read under the request role (RLS-gated visibility).
229
+ 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
230
  if (!bucket)
274
231
  throw new Error('BUCKET_NOT_FOUND');
275
232
  // First provision mints + records the coordinate; afterwards the
276
233
  // stored physical_name is authoritative and nothing is recomputed.
277
234
  const physicalName = bucket.physical_name === null
278
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
235
+ ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
279
236
  : bucket.physical_name;
280
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
237
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
281
238
  // File row INSERT under the request role (RLS enforced).
282
239
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, (txClient) => processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, {
283
240
  contentHash: vals.contentHash,
@@ -309,12 +266,14 @@ function createPresignedUrlPlugin(options) {
309
266
  deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
310
267
  expiresAt: { type: GraphQLString },
311
268
  previousVersionId: { type: GraphQLString },
269
+ ...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
312
270
  },
313
271
  });
314
272
  const BulkInputType = new GraphQLInputObjectType({
315
273
  name: `Upload${filesTypeName}BulkInput`,
316
274
  fields: {
317
- bucketKey: { type: new GraphQLNonNull(GraphQLString), description: 'Bucket key (e.g., "public", "private")' },
275
+ bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
276
+ isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
318
277
  ...(hasOwnerId
319
278
  ? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
320
279
  : {}),
@@ -338,12 +297,14 @@ function createPresignedUrlPlugin(options) {
338
297
  plan(_$mutation, fieldArgs) {
339
298
  const $input = fieldArgs.getRaw('input');
340
299
  const $bucketKey = (0, grafast_1.access)($input, 'bucketKey');
300
+ const $isPublic = (0, grafast_1.access)($input, 'isPublic');
341
301
  const $ownerId = hasOwnerId ? (0, grafast_1.access)($input, 'ownerId') : (0, grafast_1.lambda)(null, () => null);
342
302
  const $files = (0, grafast_1.access)($input, 'files');
343
303
  const $withPgClient = (0, grafast_1.context)().get('withPgClient');
344
304
  const $pgSettings = (0, grafast_1.context)().get('pgSettings');
345
305
  const $combined = (0, grafast_1.object)({
346
306
  bucketKey: $bucketKey,
307
+ isPublic: $isPublic,
347
308
  ownerId: $ownerId,
348
309
  files: $files,
349
310
  withPgClient: $withPgClient,
@@ -362,8 +323,8 @@ function createPresignedUrlPlugin(options) {
362
323
  const storageConfig = (0, storage_module_cache_1.resolveStorageConfigFromCodec)(capturedFilesCodec, allConfigs);
363
324
  if (!storageConfig)
364
325
  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));
326
+ // Bucket resolution + read under the request role (RLS-gated visibility).
327
+ 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
328
  if (!bucket)
368
329
  throw new Error('BUCKET_NOT_FOUND');
369
330
  // Enforce bulk upload limits
@@ -378,9 +339,9 @@ function createPresignedUrlPlugin(options) {
378
339
  // First provision mints + records the coordinate; afterwards the
379
340
  // stored physical_name is authoritative and nothing is recomputed.
380
341
  const physicalName = bucket.physical_name === null
381
- ? await provisionAndRecordPhysicalBucket(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
342
+ ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, vals.withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
382
343
  : bucket.physical_name;
383
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
344
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
384
345
  // File row INSERTs under the request role (RLS enforced).
385
346
  return (0, request_pg_client_1.withRequestPgClient)(vals.withPgClient, vals.pgSettings, async (txClient) => {
386
347
  const results = [];
@@ -509,7 +470,7 @@ function createPresignedUrlPlugin(options) {
509
470
  log.warn(`Bucket ${fileRow.bucket_id} has no physical_name; skipping S3 delete`);
510
471
  return;
511
472
  }
512
- const s3ForDb = resolveS3ForDatabase(options, storageConfig, physicalName);
473
+ const s3ForDb = (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName);
513
474
  await (0, s3_signer_1.deleteS3Object)(s3ForDb, fileRow.key);
514
475
  log.info(`Sync S3 delete succeeded for key=${fileRow.key}`);
515
476
  });
@@ -568,6 +529,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
568
529
  if (bucket.max_file_size && size > bucket.max_file_size) {
569
530
  throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
570
531
  }
532
+ // The projection document the caller stores in an image/upload column. Built
533
+ // from the same values the files row carries, so the column and the row cannot
534
+ // disagree, and it names the files row by id — which is what stops GC from
535
+ // collecting an object a document still points at.
536
+ const projectFile = (fileId, key) => (0, managed_upload_1.buildFileProjection)({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
571
537
  // Determine S3 key
572
538
  let s3Key;
573
539
  let isCustomKey = false;
@@ -608,6 +574,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
608
574
  deduplicated: true,
609
575
  expiresAt: null,
610
576
  previousVersionId: null,
577
+ file: projectFile(existing.id, s3Key),
611
578
  };
612
579
  }
613
580
  previousVersionId = existing.id;
@@ -633,6 +600,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
633
600
  deduplicated: true,
634
601
  expiresAt: null,
635
602
  previousVersionId: null,
603
+ file: projectFile(existingFile.id, s3Key),
636
604
  };
637
605
  }
638
606
  }
@@ -673,6 +641,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
673
641
  deduplicated: false,
674
642
  expiresAt,
675
643
  previousVersionId,
644
+ file: projectFile(fileId, s3Key),
676
645
  };
677
646
  }
678
647
  exports.PresignedUrlPlugin = createPresignedUrlPlugin;
package/s3-signer.d.ts CHANGED
@@ -36,6 +36,19 @@ 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>;
39
52
  /**
40
53
  * Check if an object exists in S3 and optionally verify its content-type.
41
54
  *
package/s3-signer.js CHANGED
@@ -3,6 +3,7 @@ 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;
6
7
  exports.headObject = headObject;
7
8
  const client_s3_1 = require("@aws-sdk/client-s3");
8
9
  const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
@@ -74,6 +75,28 @@ async function deleteS3Object(s3Config, key) {
74
75
  }));
75
76
  log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
76
77
  }
78
+ /**
79
+ * Copy an object within the same physical bucket, preserving its content type.
80
+ *
81
+ * Used to promote a staged upload to its content-addressed key: the bytes are
82
+ * hashed as they stream in, so the final key is only known once the stream ends.
83
+ * Server-side copy keeps that promotion off the application's wire.
84
+ *
85
+ * @param s3Config - S3 client and bucket configuration
86
+ * @param sourceKey - The staged key the bytes were written to
87
+ * @param destinationKey - The final key (the content hash)
88
+ * @param contentType - MIME type to record on the destination object
89
+ */
90
+ async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
91
+ await s3Config.client.send(new client_s3_1.CopyObjectCommand({
92
+ Bucket: s3Config.bucket,
93
+ Key: destinationKey,
94
+ CopySource: `${s3Config.bucket}/${sourceKey}`,
95
+ ContentType: contentType,
96
+ MetadataDirective: 'REPLACE',
97
+ }));
98
+ log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
99
+ }
77
100
  /**
78
101
  * Check if an object exists in S3 and optionally verify its content-type.
79
102
  *
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.