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/index.js CHANGED
@@ -28,19 +28,42 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.createDownloadUrlPlugin = void 0;
31
+ exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
32
+ var confirm_upload_1 = require("./confirm-upload");
33
+ Object.defineProperty(exports, "CONFIRM_PREFIX_BYTES", { enumerable: true, get: function () { return confirm_upload_1.CONFIRM_PREFIX_BYTES; } });
34
+ Object.defineProperty(exports, "confirmUploadedBytes", { enumerable: true, get: function () { return confirm_upload_1.confirmUploadedBytes; } });
35
+ var default_bucket_1 = require("./default-bucket");
36
+ Object.defineProperty(exports, "resolveDefaultBucket", { enumerable: true, get: function () { return default_bucket_1.resolveDefaultBucket; } });
32
37
  var download_url_field_1 = require("./download-url-field");
33
38
  Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
39
+ var file_ref_registry_1 = require("./file-ref-registry");
40
+ Object.defineProperty(exports, "clearFileRefFieldCache", { enumerable: true, get: function () { return file_ref_registry_1.clearFileRefFieldCache; } });
41
+ Object.defineProperty(exports, "FileRefFieldNotRegisteredError", { enumerable: true, get: function () { return file_ref_registry_1.FileRefFieldNotRegisteredError; } });
42
+ Object.defineProperty(exports, "getFileRefFieldBinding", { enumerable: true, get: function () { return file_ref_registry_1.getFileRefFieldBinding; } });
43
+ var managed_upload_1 = require("./managed-upload");
44
+ Object.defineProperty(exports, "assertUploadAllowedByBucket", { enumerable: true, get: function () { return managed_upload_1.assertUploadAllowedByBucket; } });
45
+ Object.defineProperty(exports, "buildFileProjection", { enumerable: true, get: function () { return managed_upload_1.buildFileProjection; } });
46
+ Object.defineProperty(exports, "finalizeStagedUpload", { enumerable: true, get: function () { return managed_upload_1.finalizeStagedUpload; } });
47
+ Object.defineProperty(exports, "resolveManagedUploadTarget", { enumerable: true, get: function () { return managed_upload_1.resolveManagedUploadTarget; } });
48
+ var physical_bucket_1 = require("./physical-bucket");
49
+ Object.defineProperty(exports, "mintPhysicalBucketName", { enumerable: true, get: function () { return physical_bucket_1.mintPhysicalBucketName; } });
50
+ Object.defineProperty(exports, "provisionAndRecordPhysicalBucket", { enumerable: true, get: function () { return physical_bucket_1.provisionAndRecordPhysicalBucket; } });
51
+ Object.defineProperty(exports, "resolveS3", { enumerable: true, get: function () { return physical_bucket_1.resolveS3; } });
52
+ Object.defineProperty(exports, "resolveS3ForDatabase", { enumerable: true, get: function () { return physical_bucket_1.resolveS3ForDatabase; } });
34
53
  var plugin_1 = require("./plugin");
35
54
  Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
36
55
  Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
37
56
  var preset_1 = require("./preset");
38
57
  Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
58
+ var request_pg_client_1 = require("./request-pg-client");
59
+ Object.defineProperty(exports, "withRequestPgClient", { enumerable: true, get: function () { return request_pg_client_1.withRequestPgClient; } });
39
60
  var s3_signer_1 = require("./s3-signer");
61
+ Object.defineProperty(exports, "copyS3Object", { enumerable: true, get: function () { return s3_signer_1.copyS3Object; } });
40
62
  Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
41
63
  Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
42
64
  Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
43
65
  Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
66
+ Object.defineProperty(exports, "readObjectPrefix", { enumerable: true, get: function () { return s3_signer_1.readObjectPrefix; } });
44
67
  var storage_module_cache_1 = require("./storage-module-cache");
45
68
  Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
46
69
  Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The managed upload lifecycle, shared by both transports.
3
+ *
4
+ * Multipart-through-GraphQL and presigned two-step are transports for one
5
+ * lifecycle, not two data models: either way an object gets a files row, a
6
+ * server-chosen key, a tenant-resolved bucket, and a projection document that
7
+ * carries the files row's id. The only difference is who moves the bytes.
8
+ *
9
+ * This module owns the parts that are the same:
10
+ * * `resolveManagedUploadTarget` — from a document column to a concrete
11
+ * (storage module, bucket, physical bucket, S3 config).
12
+ * * `finalizeStagedUpload` — from bytes already in S3 under a staging key to a
13
+ * files row and a projection document, deduplicating on content hash.
14
+ * * `buildFileProjection` — the document shape the column stores.
15
+ *
16
+ * Nothing here bakes a presigned URL into a row: `url` is populated only for a
17
+ * public bucket, where it is a stable CDN address rather than a credential with
18
+ * an expiry.
19
+ */
20
+ import { type FileRefFieldBinding } from './file-ref-registry';
21
+ import { type WithPgClient } from './request-pg-client';
22
+ import type { BucketConfig, FileProjection, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
23
+ /**
24
+ * The document a managed `image`/`upload` column stores.
25
+ *
26
+ * `id` is the files row — the load-bearing field: it is what makes the column a
27
+ * projection rather than a second, unmanaged copy of the truth, and it is what
28
+ * storage GC counts before collecting an object.
29
+ *
30
+ * `url` is retained for existing readers of the pre-managed shape and is set
31
+ * only for public buckets. Prefer `id` plus the files row's `downloadUrl`, which
32
+ * is late-bound and works for private buckets too.
33
+ */
34
+ export type { FileProjection } from './types';
35
+ /**
36
+ * Build the projection document for a files row.
37
+ *
38
+ * A public bucket has a stable address, so `url` is a real, durable value there.
39
+ * A private bucket has no such address — only presigned, expiring ones — so the
40
+ * field is omitted rather than filled with a URL that dies in an hour.
41
+ */
42
+ export declare function buildFileProjection(file: {
43
+ id: string;
44
+ key: string;
45
+ bucketId: string;
46
+ mime: string;
47
+ size: number;
48
+ filename?: string | null;
49
+ }, bucket: {
50
+ is_public: boolean;
51
+ }, s3: S3Config): FileProjection;
52
+ /**
53
+ * Everything a managed upload needs before bytes move.
54
+ */
55
+ export interface ManagedUploadTarget {
56
+ databaseId: string;
57
+ storageConfig: StorageModuleConfig;
58
+ bucket: BucketConfig;
59
+ physicalName: string;
60
+ s3: S3Config;
61
+ /** The registry row, or null when the column predates registration. */
62
+ binding: FileRefFieldBinding | null;
63
+ }
64
+ /**
65
+ * Resolve where a write to a document column lands.
66
+ *
67
+ * Two routes, one rule — the bucket is always resolved inside the tenant:
68
+ * * a registered column names its storage module, and either a logical bucket
69
+ * key or the reserved default tag for its declared publicness;
70
+ * * an unregistered column (a bare `image`/`upload` on a database provisioned
71
+ * before the registry) falls back to the app-scope module and the same
72
+ * reserved default tag. That is a *tenant* default, not an environment one.
73
+ *
74
+ * A database with no storage module raises: there is nowhere tenant-owned to put
75
+ * the bytes, and the deployment's configured bucket is not an answer.
76
+ */
77
+ export declare function resolveManagedUploadTarget(args: {
78
+ options: PresignedUrlPluginOptions;
79
+ withPgClient: WithPgClient;
80
+ pgSettings: Record<string, string> | null;
81
+ databaseId: string;
82
+ field: {
83
+ schemaName: string;
84
+ tableName: string;
85
+ columnName: string;
86
+ };
87
+ /** Publicness to use when the column is unregistered. */
88
+ defaultPublicAccess: boolean;
89
+ }): Promise<ManagedUploadTarget>;
90
+ /**
91
+ * Validate an upload against the resolved bucket's rules.
92
+ *
93
+ * The same rules the presigned lane enforces — a transport must not be a way
94
+ * around a bucket's mime allowlist or size cap.
95
+ */
96
+ export declare function assertUploadAllowedByBucket(target: ManagedUploadTarget, contentType: string, size: number): void;
97
+ /**
98
+ * Turn bytes already staged in S3 into a files row and a projection document.
99
+ *
100
+ * The content hash is only known once the stream has been read, so a streaming
101
+ * transport writes to a staging key first and promotes here:
102
+ *
103
+ * * hash already present in this bucket → drop the staged object, reuse the
104
+ * existing files row. Dedup is a property of the object, so it holds no
105
+ * matter which transport wrote it first.
106
+ * * otherwise → server-side copy to the content-addressed key, drop the staged
107
+ * object, insert the files row.
108
+ *
109
+ * The row is inserted *after* the bytes land, so the confirm-upload job the
110
+ * insert trigger enqueues finds the object and completes the
111
+ * `requested → uploaded` transition without any extra wiring here.
112
+ *
113
+ * Every failure path leaves S3 as it found it. Bytes written by this call and
114
+ * not reachable through a files row would be invisible to storage GC, which
115
+ * collects objects by walking rows — so an object is only left behind once the
116
+ * row naming it exists.
117
+ */
118
+ export declare function finalizeStagedUpload(args: {
119
+ target: ManagedUploadTarget;
120
+ withPgClient: WithPgClient;
121
+ pgSettings: Record<string, string> | null;
122
+ staged: {
123
+ stagingKey: string;
124
+ contentHash: string;
125
+ contentType: string;
126
+ size: number;
127
+ filename?: string | null;
128
+ };
129
+ }): Promise<{
130
+ projection: FileProjection;
131
+ deduplicated: boolean;
132
+ }>;
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ /**
3
+ * The managed upload lifecycle, shared by both transports.
4
+ *
5
+ * Multipart-through-GraphQL and presigned two-step are transports for one
6
+ * lifecycle, not two data models: either way an object gets a files row, a
7
+ * server-chosen key, a tenant-resolved bucket, and a projection document that
8
+ * carries the files row's id. The only difference is who moves the bytes.
9
+ *
10
+ * This module owns the parts that are the same:
11
+ * * `resolveManagedUploadTarget` — from a document column to a concrete
12
+ * (storage module, bucket, physical bucket, S3 config).
13
+ * * `finalizeStagedUpload` — from bytes already in S3 under a staging key to a
14
+ * files row and a projection document, deduplicating on content hash.
15
+ * * `buildFileProjection` — the document shape the column stores.
16
+ *
17
+ * Nothing here bakes a presigned URL into a row: `url` is populated only for a
18
+ * public bucket, where it is a stable CDN address rather than a credential with
19
+ * an expiry.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.buildFileProjection = buildFileProjection;
23
+ exports.resolveManagedUploadTarget = resolveManagedUploadTarget;
24
+ exports.assertUploadAllowedByBucket = assertUploadAllowedByBucket;
25
+ exports.finalizeStagedUpload = finalizeStagedUpload;
26
+ const logger_1 = require("@pgpmjs/logger");
27
+ const default_bucket_1 = require("./default-bucket");
28
+ const file_ref_registry_1 = require("./file-ref-registry");
29
+ const physical_bucket_1 = require("./physical-bucket");
30
+ const request_pg_client_1 = require("./request-pg-client");
31
+ const s3_signer_1 = require("./s3-signer");
32
+ const storage_module_cache_1 = require("./storage-module-cache");
33
+ const log = new logger_1.Logger('graphile-presigned-url:managed-upload');
34
+ /**
35
+ * Build the projection document for a files row.
36
+ *
37
+ * A public bucket has a stable address, so `url` is a real, durable value there.
38
+ * A private bucket has no such address — only presigned, expiring ones — so the
39
+ * field is omitted rather than filled with a URL that dies in an hour.
40
+ */
41
+ function buildFileProjection(file, bucket, s3) {
42
+ const projection = {
43
+ id: file.id,
44
+ key: file.key,
45
+ bucket_id: file.bucketId,
46
+ mime: file.mime,
47
+ size: file.size,
48
+ };
49
+ if (file.filename)
50
+ projection.filename = file.filename;
51
+ if (bucket.is_public && s3.publicUrlPrefix) {
52
+ projection.url = `${s3.publicUrlPrefix.replace(/\/$/, '')}/${file.key}`;
53
+ }
54
+ return projection;
55
+ }
56
+ /**
57
+ * Resolve where a write to a document column lands.
58
+ *
59
+ * Two routes, one rule — the bucket is always resolved inside the tenant:
60
+ * * a registered column names its storage module, and either a logical bucket
61
+ * key or the reserved default tag for its declared publicness;
62
+ * * an unregistered column (a bare `image`/`upload` on a database provisioned
63
+ * before the registry) falls back to the app-scope module and the same
64
+ * reserved default tag. That is a *tenant* default, not an environment one.
65
+ *
66
+ * A database with no storage module raises: there is nowhere tenant-owned to put
67
+ * the bytes, and the deployment's configured bucket is not an answer.
68
+ */
69
+ async function resolveManagedUploadTarget(args) {
70
+ const { options, withPgClient, pgSettings, databaseId, field, defaultPublicAccess } = args;
71
+ // Registry and module registration are schema metadata, not tenant rows:
72
+ // read them in the system lane, like every other config read here.
73
+ const binding = await withPgClient(null, async (pgClient) => {
74
+ try {
75
+ return await (0, file_ref_registry_1.getFileRefFieldBinding)(pgClient, databaseId, field);
76
+ }
77
+ catch (err) {
78
+ // An unregistered column is a legitimate state (it predates the registry)
79
+ // and falls back to the tenant's app-scope default below. Any other
80
+ // failure — a broken connection, a missing registry table — is not.
81
+ if (err?.name === 'FileRefFieldNotRegisteredError')
82
+ return null;
83
+ throw err;
84
+ }
85
+ });
86
+ const allConfigs = await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId));
87
+ const storageConfig = binding
88
+ ? allConfigs.find((c) => c.id === binding.storageModuleId)
89
+ : allConfigs.find((c) => c.scope === 'app');
90
+ if (!storageConfig) {
91
+ throw new Error(binding
92
+ ? `STORAGE_MODULE_NOT_FOUND: file_ref_field ${binding.id} names storage module ` +
93
+ `${binding.storageModuleId}, which database ${databaseId} does not have`
94
+ : `STORAGE_MODULE_NOT_FOUND: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
95
+ `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
96
+ 'default to; there is no environment bucket to fall back to');
97
+ }
98
+ if (storageConfig.scope !== 'app') {
99
+ // An entity-scoped module resolves its bucket per owning row, and a
100
+ // multipart column write does not carry one. Refuse rather than write a
101
+ // tenant's file into whichever bucket happened to resolve.
102
+ throw new Error(`STORAGE_SCOPE_UNSUPPORTED: ${field.schemaName}.${field.tableName}.${field.columnName} binds to ` +
103
+ `'${storageConfig.scope}'-scoped storage, which resolves its bucket per owner row. ` +
104
+ 'Use the presigned upload mutation, which takes an ownerId.');
105
+ }
106
+ const publicAccess = binding?.isPublic ?? defaultPublicAccess;
107
+ // Bucket resolution and the bucket read run under the request role: what the
108
+ // caller may store into is exactly what RLS lets them see.
109
+ const coordinate = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => (0, default_bucket_1.resolveDefaultBucket)(pgClient, databaseId, storageConfig.scope, null, publicAccess, binding?.bucketKey ?? null));
110
+ const bucket = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => (0, storage_module_cache_1.getBucketConfig)(pgClient, storageConfig, databaseId, coordinate.resolvedKey));
111
+ if (!bucket) {
112
+ throw new Error(`BUCKET_NOT_FOUND: bucket "${coordinate.resolvedKey}" resolved for ` +
113
+ `${field.schemaName}.${field.tableName}.${field.columnName} is not readable`);
114
+ }
115
+ if (bucket.allow_custom_keys) {
116
+ // A path-keyed bucket (e.g. a static site's) is addressed by the keys its
117
+ // publisher chose; this lane can only mint content-hash keys, which would
118
+ // pollute it with unreachable objects. Path-keyed uploads go through the
119
+ // presigned lane, which accepts an explicit `key`.
120
+ throw new Error(`BUCKET_PATH_KEYED: bucket "${bucket.key}" allows custom keys and is addressed by path; ` +
121
+ 'the multipart upload lane only writes content-addressed keys. Use the presigned upload ' +
122
+ 'mutation with an explicit key.');
123
+ }
124
+ const physicalName = bucket.physical_name === null
125
+ ? await (0, physical_bucket_1.provisionAndRecordPhysicalBucket)(options, withPgClient, storageConfig, databaseId, bucket, storageConfig.allowedOrigins)
126
+ : bucket.physical_name;
127
+ return {
128
+ databaseId,
129
+ storageConfig,
130
+ bucket,
131
+ physicalName,
132
+ s3: (0, physical_bucket_1.resolveS3ForDatabase)(options, storageConfig, physicalName),
133
+ binding,
134
+ };
135
+ }
136
+ /**
137
+ * Validate an upload against the resolved bucket's rules.
138
+ *
139
+ * The same rules the presigned lane enforces — a transport must not be a way
140
+ * around a bucket's mime allowlist or size cap.
141
+ */
142
+ function assertUploadAllowedByBucket(target, contentType, size) {
143
+ const { bucket, storageConfig } = target;
144
+ if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
145
+ const isAllowed = bucket.allowed_mime_types.some((pattern) => {
146
+ if (pattern === '*/*')
147
+ return true;
148
+ if (pattern.endsWith('/*'))
149
+ return contentType.startsWith(pattern.slice(0, -1));
150
+ return contentType === pattern;
151
+ });
152
+ if (!isAllowed) {
153
+ throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`);
154
+ }
155
+ }
156
+ const maxSize = bucket.max_file_size ?? storageConfig.defaultMaxFileSize;
157
+ if (size > maxSize) {
158
+ throw new Error(`FILE_TOO_LARGE: ${size} bytes exceeds the ${maxSize} byte limit`);
159
+ }
160
+ if (size <= 0) {
161
+ throw new Error('INVALID_FILE_SIZE: an upload must carry at least one byte');
162
+ }
163
+ }
164
+ /** Delete an object we are abandoning, without masking the failure in progress. */
165
+ async function bestEffortDelete(s3, key) {
166
+ try {
167
+ await (0, s3_signer_1.deleteS3Object)(s3, key);
168
+ }
169
+ catch (err) {
170
+ log.warn(`Failed to clean up abandoned object ${key}: ${err}`);
171
+ }
172
+ }
173
+ /**
174
+ * Turn bytes already staged in S3 into a files row and a projection document.
175
+ *
176
+ * The content hash is only known once the stream has been read, so a streaming
177
+ * transport writes to a staging key first and promotes here:
178
+ *
179
+ * * hash already present in this bucket → drop the staged object, reuse the
180
+ * existing files row. Dedup is a property of the object, so it holds no
181
+ * matter which transport wrote it first.
182
+ * * otherwise → server-side copy to the content-addressed key, drop the staged
183
+ * object, insert the files row.
184
+ *
185
+ * The row is inserted *after* the bytes land, so the confirm-upload job the
186
+ * insert trigger enqueues finds the object and completes the
187
+ * `requested → uploaded` transition without any extra wiring here.
188
+ *
189
+ * Every failure path leaves S3 as it found it. Bytes written by this call and
190
+ * not reachable through a files row would be invisible to storage GC, which
191
+ * collects objects by walking rows — so an object is only left behind once the
192
+ * row naming it exists.
193
+ */
194
+ async function finalizeStagedUpload(args) {
195
+ const { target, withPgClient, pgSettings, staged } = args;
196
+ const { storageConfig, bucket, s3 } = target;
197
+ assertUploadAllowedByBucket(target, staged.contentType, staged.size);
198
+ const finalKey = staged.contentHash;
199
+ const existing = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
200
+ const result = await pgClient.query({
201
+ text: `SELECT id, key, mime_type, size, filename
202
+ FROM ${storageConfig.filesQualifiedName}
203
+ WHERE content_hash = $1 AND bucket_id = $2
204
+ LIMIT 1`,
205
+ values: [staged.contentHash, bucket.id],
206
+ });
207
+ return result.rows[0];
208
+ });
209
+ if (existing) {
210
+ log.info(`Dedup hit: file ${existing.id} already carries hash ${staged.contentHash}`);
211
+ await (0, s3_signer_1.deleteS3Object)(s3, staged.stagingKey);
212
+ return {
213
+ projection: buildFileProjection({
214
+ id: existing.id,
215
+ key: existing.key,
216
+ bucketId: bucket.id,
217
+ mime: existing.mime_type,
218
+ size: Number(existing.size),
219
+ filename: existing.filename,
220
+ }, bucket, s3),
221
+ deduplicated: true,
222
+ };
223
+ }
224
+ await (0, s3_signer_1.copyS3Object)(s3, staged.stagingKey, finalKey, staged.contentType);
225
+ let fileId;
226
+ try {
227
+ fileId = await (0, request_pg_client_1.withRequestPgClient)(withPgClient, pgSettings, async (pgClient) => {
228
+ const result = await pgClient.query({
229
+ text: `INSERT INTO ${storageConfig.filesQualifiedName}
230
+ (bucket_id, key, content_hash, mime_type, size, filename, is_public)
231
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
232
+ RETURNING id`,
233
+ values: [
234
+ bucket.id,
235
+ finalKey,
236
+ staged.contentHash,
237
+ staged.contentType,
238
+ staged.size,
239
+ staged.filename ?? null,
240
+ bucket.is_public,
241
+ ],
242
+ });
243
+ return result.rows[0].id;
244
+ });
245
+ }
246
+ catch (err) {
247
+ // No row names either key, so both are unreachable to GC. Dropping the
248
+ // promoted copy is safe precisely because the dedup probe above found no row
249
+ // on this hash: nothing else in this bucket is entitled to those bytes.
250
+ // Cleanup must never replace the failure that caused it.
251
+ await bestEffortDelete(s3, finalKey);
252
+ await bestEffortDelete(s3, staged.stagingKey);
253
+ throw err;
254
+ }
255
+ await (0, s3_signer_1.deleteS3Object)(s3, staged.stagingKey);
256
+ log.info(`Managed upload created file ${fileId} at ${bucket.key}/${finalKey}`);
257
+ return {
258
+ projection: buildFileProjection({
259
+ id: fileId,
260
+ key: finalKey,
261
+ bucketId: bucket.id,
262
+ mime: staged.contentType,
263
+ size: staged.size,
264
+ filename: staged.filename,
265
+ }, bucket, s3),
266
+ deduplicated: false,
267
+ };
268
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.11.4",
3
+ "version": "1.13.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",
@@ -44,7 +44,8 @@
44
44
  "@aws-sdk/s3-request-presigner": "^3.1052.0",
45
45
  "@pgpmjs/logger": "^2.24.1",
46
46
  "@pgsql/quotes": "^18.2.4",
47
- "lru-cache": "^11.2.7"
47
+ "lru-cache": "^11.2.7",
48
+ "mime-bytes": "^0.31.0"
48
49
  },
49
50
  "peerDependencies": {
50
51
  "grafast": "^1.1.1",
@@ -60,5 +61,5 @@
60
61
  "@types/node": "^22.19.11",
61
62
  "makage": "^0.3.0"
62
63
  },
63
- "gitHead": "d6afee66ac3400e78864b9e259cdd6ab9184b251"
64
+ "gitHead": "d0d796b7eb0d16c8c19405a49c0ca62ce3df117b"
64
65
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Physical bucket coordinates: minting a name once, recording it, and building
3
+ * an S3 config against a *known* name.
4
+ *
5
+ * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
6
+ * mapping is recorded on the bucket row the first time it is provisioned, and
7
+ * from then on the recorded value is the only coordinate anything reads — no
8
+ * name is ever recomputed from a prefix convention, and there is no
9
+ * environment-level bucket standing in for a tenant's.
10
+ */
11
+ import { type WithPgClient } from './request-pg-client';
12
+ import type { BucketConfig, PresignedUrlPluginOptions, S3Config, StorageModuleConfig } from './types';
13
+ /**
14
+ * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
15
+ * a lazy getter on first use.
16
+ *
17
+ * `s3.bucket` on the result is the deployment's *default* physical bucket. It is
18
+ * a connection default only — never a tenant's bucket. Every upload path
19
+ * resolves its physical bucket from the tenant's bucket row.
20
+ */
21
+ export declare function resolveS3(options: PresignedUrlPluginOptions): S3Config;
22
+ /**
23
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
24
+ *
25
+ * This is a naming *policy*, consulted exactly once per bucket — before the
26
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on the
27
+ * row is authoritative and this function must not be consulted again.
28
+ *
29
+ * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
30
+ * name is not a tenant's storage, and silently minting one is how objects ended
31
+ * up in a bucket no database owned. A deployment that wants per-tenant buckets
32
+ * must supply the policy.
33
+ */
34
+ export declare function mintPhysicalBucketName(options: PresignedUrlPluginOptions, databaseId: string, bucketKey: string): string;
35
+ /**
36
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
37
+ * required — callers must resolve the coordinate (stored row value, or a
38
+ * freshly provisioned name) before getting here. No name is ever recomputed.
39
+ */
40
+ export declare function resolveS3ForDatabase(options: PresignedUrlPluginOptions, storageConfig: StorageModuleConfig, physicalName: string): S3Config;
41
+ /**
42
+ * First provision of a logical bucket: mint a name, create the physical S3
43
+ * bucket, and record the exact name on the source row. Returns the recorded
44
+ * physical name.
45
+ *
46
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
47
+ * value is the durable coordinate: route resolution and every later read use
48
+ * it verbatim; nothing is recomputed.
49
+ *
50
+ * The record write runs in the system lane (privileged role, so it bypasses the
51
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
52
+ * bookkeeping, not request data. It still carries the tenant `database_id`
53
+ * claim, because the buckets table's catalog-sync trigger calls
54
+ * `jwt_private.current_database_id()` and would otherwise raise
55
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
56
+ * write's transaction without switching off the privileged role.
57
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
58
+ * the recorded name without a DB round-trip.
59
+ */
60
+ export declare function provisionAndRecordPhysicalBucket(options: PresignedUrlPluginOptions, withPgClient: WithPgClient, storageConfig: StorageModuleConfig, databaseId: string, bucket: BucketConfig, allowedOrigins: string[] | null): Promise<string>;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * Physical bucket coordinates: minting a name once, recording it, and building
4
+ * an S3 config against a *known* name.
5
+ *
6
+ * A logical bucket belongs to a tenant; a physical bucket is an S3 name. The
7
+ * mapping is recorded on the bucket row the first time it is provisioned, and
8
+ * from then on the recorded value is the only coordinate anything reads — no
9
+ * name is ever recomputed from a prefix convention, and there is no
10
+ * environment-level bucket standing in for a tenant's.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.resolveS3 = resolveS3;
14
+ exports.mintPhysicalBucketName = mintPhysicalBucketName;
15
+ exports.resolveS3ForDatabase = resolveS3ForDatabase;
16
+ exports.provisionAndRecordPhysicalBucket = provisionAndRecordPhysicalBucket;
17
+ const logger_1 = require("@pgpmjs/logger");
18
+ const request_pg_client_1 = require("./request-pg-client");
19
+ const storage_module_cache_1 = require("./storage-module-cache");
20
+ const log = new logger_1.Logger('graphile-presigned-url:physical-bucket');
21
+ /**
22
+ * Resolve the plugin's S3 connection (credentials, endpoint, region), memoizing
23
+ * a lazy getter on first use.
24
+ *
25
+ * `s3.bucket` on the result is the deployment's *default* physical bucket. It is
26
+ * a connection default only — never a tenant's bucket. Every upload path
27
+ * resolves its physical bucket from the tenant's bucket row.
28
+ */
29
+ function resolveS3(options) {
30
+ if (typeof options.s3 === 'function') {
31
+ const resolved = options.s3();
32
+ options.s3 = resolved;
33
+ return resolved;
34
+ }
35
+ return options.s3;
36
+ }
37
+ /**
38
+ * Mint the physical S3 bucket name for a logical bucket's first provision.
39
+ *
40
+ * This is a naming *policy*, consulted exactly once per bucket — before the
41
+ * physical bucket exists. Once provisioned, the recorded `physical_name` on the
42
+ * row is authoritative and this function must not be consulted again.
43
+ *
44
+ * There is no fallback to the configured `s3.bucket`: a deployment-wide bucket
45
+ * name is not a tenant's storage, and silently minting one is how objects ended
46
+ * up in a bucket no database owned. A deployment that wants per-tenant buckets
47
+ * must supply the policy.
48
+ */
49
+ function mintPhysicalBucketName(options, databaseId, bucketKey) {
50
+ if (!options.resolveBucketName) {
51
+ throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
52
+ `no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
53
+ 'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
54
+ 'connection default and is never a tenant bucket.');
55
+ }
56
+ return options.resolveBucketName(databaseId, bucketKey);
57
+ }
58
+ /**
59
+ * Build the S3 config for a *known* physical bucket. `physicalName` is
60
+ * required — callers must resolve the coordinate (stored row value, or a
61
+ * freshly provisioned name) before getting here. No name is ever recomputed.
62
+ */
63
+ function resolveS3ForDatabase(options, storageConfig, physicalName) {
64
+ const globalS3 = resolveS3(options);
65
+ const publicUrlPrefix = storageConfig.publicUrlPrefix != null
66
+ ? storageConfig.publicUrlPrefix
67
+ : globalS3.publicUrlPrefix;
68
+ if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
69
+ return globalS3;
70
+ }
71
+ return {
72
+ ...globalS3,
73
+ bucket: physicalName,
74
+ ...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
75
+ };
76
+ }
77
+ /**
78
+ * First provision of a logical bucket: mint a name, create the physical S3
79
+ * bucket, and record the exact name on the source row. Returns the recorded
80
+ * physical name.
81
+ *
82
+ * Only called when the row has no `physical_name` yet. Afterwards the stored
83
+ * value is the durable coordinate: route resolution and every later read use
84
+ * it verbatim; nothing is recomputed.
85
+ *
86
+ * The record write runs in the system lane (privileged role, so it bypasses the
87
+ * RLS that stops request roles from UPDATE-ing bucket rows) — it is server
88
+ * bookkeeping, not request data. It still carries the tenant `database_id`
89
+ * claim, because the buckets table's catalog-sync trigger calls
90
+ * `jwt_private.current_database_id()` and would otherwise raise
91
+ * DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
92
+ * write's transaction without switching off the privileged role.
93
+ * `bucket` (the cached config) is mutated in place so subsequent reads observe
94
+ * the recorded name without a DB round-trip.
95
+ */
96
+ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageConfig, databaseId, bucket, allowedOrigins) {
97
+ const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
98
+ if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
99
+ log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
100
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
101
+ (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
102
+ log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
103
+ }
104
+ // Record the physical coordinate on the source row. The `physical_name IS NULL`
105
+ // guard keeps this idempotent and race-safe across concurrent first uploads.
106
+ // The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
107
+ // write runs under the resolved database claim (privileged role preserved).
108
+ await (0, request_pg_client_1.withRequestPgClient)(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
109
+ text: `UPDATE ${storageConfig.bucketsQualifiedName}
110
+ SET physical_name = $1
111
+ WHERE id = $2 AND physical_name IS NULL`,
112
+ values: [s3BucketName, bucket.id],
113
+ }));
114
+ bucket.physical_name = s3BucketName;
115
+ log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
116
+ return s3BucketName;
117
+ }