graphile-presigned-url-plugin 1.15.0 → 1.16.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/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
38
38
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
39
39
  export { PresignedUrlPreset } from './preset';
40
40
  export { type WithPgClient, withRequestPgClient } from './request-pg-client';
41
+ export { describeS3Failure, s3FailureError } from './s3-failure';
41
42
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
42
43
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
43
44
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/esm/index.js CHANGED
@@ -36,5 +36,6 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
36
36
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
37
37
  export { PresignedUrlPreset } from './preset';
38
38
  export { withRequestPgClient } from './request-pg-client';
39
+ export { describeS3Failure, s3FailureError } from './s3-failure';
39
40
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
40
41
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
@@ -68,11 +68,19 @@ export interface ManagedUploadTarget {
68
68
  * * a registered column names its storage module, and either a logical bucket
69
69
  * key or the reserved default tag for its declared publicness;
70
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.
71
+ * before the registry) falls back to the database's single global storage
72
+ * plane and the same reserved default tag. That is a *tenant* default, not
73
+ * an environment one.
73
74
  *
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.
75
+ * The fallback is by shape, not by scope name: a global plane is one with no
76
+ * entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
77
+ * whether it registered as 'database', 'platform', or 'app'. Matching the name
78
+ * instead sent every tenant whose plane registers as 'database' — i.e. every
79
+ * database-scope plane — to STORAGE_MODULE_NOT_FOUND.
80
+ *
81
+ * A database with no global plane raises, and so does one with several: there is
82
+ * nowhere unambiguously tenant-owned to put the bytes, and the deployment's
83
+ * configured bucket is not an answer.
76
84
  */
77
85
  export declare function resolveManagedUploadTarget(args: {
78
86
  options: PresignedUrlPluginOptions;
@@ -55,11 +55,19 @@ export function buildFileProjection(file, bucket, s3) {
55
55
  * * a registered column names its storage module, and either a logical bucket
56
56
  * key or the reserved default tag for its declared publicness;
57
57
  * * an unregistered column (a bare `image`/`upload` on a database provisioned
58
- * before the registry) falls back to the app-scope module and the same
59
- * reserved default tag. That is a *tenant* default, not an environment one.
58
+ * before the registry) falls back to the database's single global storage
59
+ * plane and the same reserved default tag. That is a *tenant* default, not
60
+ * an environment one.
60
61
  *
61
- * A database with no storage module raises: there is nowhere tenant-owned to put
62
- * the bytes, and the deployment's configured bucket is not an answer.
62
+ * The fallback is by shape, not by scope name: a global plane is one with no
63
+ * entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
64
+ * whether it registered as 'database', 'platform', or 'app'. Matching the name
65
+ * instead sent every tenant whose plane registers as 'database' — i.e. every
66
+ * database-scope plane — to STORAGE_MODULE_NOT_FOUND.
67
+ *
68
+ * A database with no global plane raises, and so does one with several: there is
69
+ * nowhere unambiguously tenant-owned to put the bytes, and the deployment's
70
+ * configured bucket is not an answer.
63
71
  */
64
72
  export async function resolveManagedUploadTarget(args) {
65
73
  const { options, withPgClient, pgSettings, databaseId, field, defaultPublicAccess } = args;
@@ -71,7 +79,7 @@ export async function resolveManagedUploadTarget(args) {
71
79
  }
72
80
  catch (err) {
73
81
  // An unregistered column is a legitimate state (it predates the registry)
74
- // and falls back to the tenant's app-scope default below. Any other
82
+ // and falls back to the tenant's global plane below. Any other
75
83
  // failure — a broken connection, a missing registry table — is not.
76
84
  if (err?.name === 'FileRefFieldNotRegisteredError')
77
85
  return null;
@@ -79,15 +87,24 @@ export async function resolveManagedUploadTarget(args) {
79
87
  }
80
88
  });
81
89
  const allConfigs = await withPgClient(null, (pgClient) => loadAllStorageModules(pgClient, databaseId));
90
+ const globalConfigs = allConfigs.filter((c) => c.entityTableId === null);
91
+ if (!binding && globalConfigs.length > 1) {
92
+ // Several global planes and no registry row to disambiguate: picking one is
93
+ // picking a tenant's bucket at random. Name them and refuse.
94
+ throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
95
+ `unregistered upload column and database ${databaseId} has ${globalConfigs.length} global ` +
96
+ `storage planes (scopes: ${globalConfigs.map((c) => c.scope).join(', ')}); register the column ` +
97
+ 'so it names the plane it writes to');
98
+ }
82
99
  const storageConfig = binding
83
100
  ? allConfigs.find((c) => c.id === binding.storageModuleId)
84
- : allConfigs.find((c) => c.scope === 'app');
101
+ : globalConfigs[0];
85
102
  if (!storageConfig) {
86
103
  throw new Error(binding
87
104
  ? `STORAGE_MODULE_NOT_FOUND: file_ref_field ${binding.id} names storage module ` +
88
105
  `${binding.storageModuleId}, which database ${databaseId} does not have`
89
106
  : `STORAGE_MODULE_NOT_FOUND: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
90
- `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
107
+ `unregistered upload column and database ${databaseId} has no global storage plane to ` +
91
108
  'default to; there is no environment bucket to fall back to');
92
109
  }
93
110
  if (storageConfig.entityTableId !== null) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { Logger } from '@pgpmjs/logger';
12
12
  import { withRequestPgClient } from './request-pg-client';
13
+ import { s3FailureError } from './s3-failure';
13
14
  import { isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
14
15
  const log = new Logger('graphile-presigned-url:physical-bucket');
15
16
  /**
@@ -91,7 +92,15 @@ export async function provisionAndRecordPhysicalBucket(options, withPgClient, st
91
92
  const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
92
93
  if (options.ensureBucketProvisioned && !isS3BucketProvisioned(s3BucketName)) {
93
94
  log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
94
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
95
+ try {
96
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
97
+ }
98
+ catch (err) {
99
+ // The first upload to a bucket is where an unreachable object store is
100
+ // discovered, and the transport's own message is routinely empty: name the
101
+ // endpoint it could not reach so the response says what is misconfigured.
102
+ throw s3FailureError('BUCKET_PROVISION_FAILED', { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId }, err);
103
+ }
95
104
  markS3BucketProvisioned(s3BucketName);
96
105
  log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
97
106
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Turning an S3 transport failure into a reason a caller can act on.
3
+ *
4
+ * The AWS SDK routinely fails with an **empty** `message`: an unreachable
5
+ * endpoint arrives as an `AggregateError` whose own message is `''` and whose
6
+ * per-address `errors` hold the `ECONNREFUSED`, and a hung socket arrives as a
7
+ * bare wrapper around its `cause`. Anything that reports `err.message` verbatim
8
+ * therefore hands the client a blank reason — which is how a server signing
9
+ * against the wrong endpoint (a missing `CDN_ENDPOINT`, so the library default
10
+ * `http://localhost:9000`, i.e. the pod's own loopback) presents as an upload
11
+ * that fails with nothing to diagnose.
12
+ *
13
+ * So a failure is described by walking to where the words actually are, and
14
+ * re-thrown naming the coordinates it was talking to, with the original kept as
15
+ * `cause` for the server log.
16
+ */
17
+ /**
18
+ * Describe a thrown S3 error in one line, including the nested errors an
19
+ * `AggregateError` (or a `cause` chain) hides its actual reason in.
20
+ */
21
+ export declare function describeS3Failure(err: unknown, depth?: number): string;
22
+ /**
23
+ * Wrap a failed S3 call as an error whose message carries the operation, the
24
+ * coordinates it used, and the underlying reason — with the original as `cause`.
25
+ *
26
+ * `context` is rendered as `key=value` pairs in the order given; entries with no
27
+ * value are dropped, so a connection without an explicit endpoint (real AWS)
28
+ * does not print an empty one.
29
+ */
30
+ export declare function s3FailureError(operation: string, context: Record<string, string | number | undefined | null>, err: unknown): Error;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Turning an S3 transport failure into a reason a caller can act on.
3
+ *
4
+ * The AWS SDK routinely fails with an **empty** `message`: an unreachable
5
+ * endpoint arrives as an `AggregateError` whose own message is `''` and whose
6
+ * per-address `errors` hold the `ECONNREFUSED`, and a hung socket arrives as a
7
+ * bare wrapper around its `cause`. Anything that reports `err.message` verbatim
8
+ * therefore hands the client a blank reason — which is how a server signing
9
+ * against the wrong endpoint (a missing `CDN_ENDPOINT`, so the library default
10
+ * `http://localhost:9000`, i.e. the pod's own loopback) presents as an upload
11
+ * that fails with nothing to diagnose.
12
+ *
13
+ * So a failure is described by walking to where the words actually are, and
14
+ * re-thrown naming the coordinates it was talking to, with the original kept as
15
+ * `cause` for the server log.
16
+ */
17
+ /** How far to walk `errors` / `cause` before the chain stops being informative. */
18
+ const MAX_DEPTH = 4;
19
+ /**
20
+ * Describe a thrown S3 error in one line, including the nested errors an
21
+ * `AggregateError` (or a `cause` chain) hides its actual reason in.
22
+ */
23
+ export function describeS3Failure(err, depth = 0) {
24
+ if (err === null || err === undefined)
25
+ return 'unknown error';
26
+ if (typeof err !== 'object')
27
+ return String(err);
28
+ const e = err;
29
+ const name = typeof e.name === 'string' && e.name !== 'Error' ? e.name : '';
30
+ const message = typeof e.message === 'string' ? e.message : '';
31
+ const head = message.length > 0 ? [name, message].filter(Boolean).join(': ') : name;
32
+ const status = e.$metadata?.httpStatusCode ? `HTTP ${e.$metadata.httpStatusCode}` : '';
33
+ let detail = '';
34
+ if (depth < MAX_DEPTH) {
35
+ const nested = Array.isArray(e.errors) ? e.errors : e.cause !== undefined ? [e.cause] : [];
36
+ const described = nested
37
+ .map((inner) => describeS3Failure(inner, depth + 1))
38
+ .filter((text) => text.length > 0 && text !== 'unknown error');
39
+ if (described.length > 0)
40
+ detail = `(${described.join('; ')})`;
41
+ }
42
+ const described = [head, status, detail].filter((part) => part.length > 0).join(' ');
43
+ return described.length > 0 ? described : 'unknown error';
44
+ }
45
+ /**
46
+ * Wrap a failed S3 call as an error whose message carries the operation, the
47
+ * coordinates it used, and the underlying reason — with the original as `cause`.
48
+ *
49
+ * `context` is rendered as `key=value` pairs in the order given; entries with no
50
+ * value are dropped, so a connection without an explicit endpoint (real AWS)
51
+ * does not print an empty one.
52
+ */
53
+ export function s3FailureError(operation, context, err) {
54
+ const coordinates = Object.entries(context)
55
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
56
+ .map(([key, value]) => `${key}=${value}`)
57
+ .join(' ');
58
+ const prefix = coordinates.length > 0 ? `${operation}: ${coordinates}` : operation;
59
+ return new Error(`${prefix}: ${describeS3Failure(err)}`, { cause: err });
60
+ }
package/esm/s3-signer.js CHANGED
@@ -1,6 +1,7 @@
1
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
+ import { s3FailureError } from './s3-failure';
4
5
  const log = new Logger('graphile-presigned-url:s3');
5
6
  /**
6
7
  * Generate a presigned PUT URL for uploading a file to S3.
@@ -23,7 +24,13 @@ export async function generatePresignedPutUrl(s3Config, key, contentType, conten
23
24
  ContentType: contentType,
24
25
  ContentLength: contentLength,
25
26
  });
26
- const url = await getSignedUrl(s3Config.client, command, { expiresIn });
27
+ let url;
28
+ try {
29
+ url = await getSignedUrl(s3Config.client, command, { expiresIn });
30
+ }
31
+ catch (err) {
32
+ throw s3FailureError('PRESIGN_PUT_FAILED', { endpoint: s3Config.endpoint, bucket: s3Config.bucket, key, contentType }, err);
33
+ }
27
34
  log.debug(`Generated presigned PUT URL for key=${key}, contentType=${contentType}, expires=${expiresIn}s`);
28
35
  return url;
29
36
  }
@@ -49,7 +56,13 @@ export async function generatePresignedGetUrl(s3Config, key, expiresIn = 3600, f
49
56
  params.ResponseContentDisposition = `attachment; filename="${sanitized}"`;
50
57
  }
51
58
  const command = new GetObjectCommand(params);
52
- const url = await getSignedUrl(s3Config.client, command, { expiresIn });
59
+ let url;
60
+ try {
61
+ url = await getSignedUrl(s3Config.client, command, { expiresIn });
62
+ }
63
+ catch (err) {
64
+ throw s3FailureError('PRESIGN_GET_FAILED', { endpoint: s3Config.endpoint, bucket: s3Config.bucket, key }, err);
65
+ }
53
66
  log.debug(`Generated presigned GET URL for key=${key}, expires=${expiresIn}s`);
54
67
  return url;
55
68
  }
package/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
38
38
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
39
39
  export { PresignedUrlPreset } from './preset';
40
40
  export { type WithPgClient, withRequestPgClient } from './request-pg-client';
41
+ export { describeS3Failure, s3FailureError } from './s3-failure';
41
42
  export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
42
43
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
43
44
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = 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.validateCustomKey = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
31
+ exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.s3FailureError = exports.describeS3Failure = 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.validateCustomKey = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
32
32
  var confirm_upload_1 = require("./confirm-upload");
33
33
  Object.defineProperty(exports, "CONFIRM_PREFIX_BYTES", { enumerable: true, get: function () { return confirm_upload_1.CONFIRM_PREFIX_BYTES; } });
34
34
  Object.defineProperty(exports, "confirmUploadedBytes", { enumerable: true, get: function () { return confirm_upload_1.confirmUploadedBytes; } });
@@ -59,6 +59,9 @@ var preset_1 = require("./preset");
59
59
  Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
60
60
  var request_pg_client_1 = require("./request-pg-client");
61
61
  Object.defineProperty(exports, "withRequestPgClient", { enumerable: true, get: function () { return request_pg_client_1.withRequestPgClient; } });
62
+ var s3_failure_1 = require("./s3-failure");
63
+ Object.defineProperty(exports, "describeS3Failure", { enumerable: true, get: function () { return s3_failure_1.describeS3Failure; } });
64
+ Object.defineProperty(exports, "s3FailureError", { enumerable: true, get: function () { return s3_failure_1.s3FailureError; } });
62
65
  var s3_signer_1 = require("./s3-signer");
63
66
  Object.defineProperty(exports, "copyS3Object", { enumerable: true, get: function () { return s3_signer_1.copyS3Object; } });
64
67
  Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: function () { return s3_signer_1.deleteS3Object; } });
@@ -68,11 +68,19 @@ export interface ManagedUploadTarget {
68
68
  * * a registered column names its storage module, and either a logical bucket
69
69
  * key or the reserved default tag for its declared publicness;
70
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.
71
+ * before the registry) falls back to the database's single global storage
72
+ * plane and the same reserved default tag. That is a *tenant* default, not
73
+ * an environment one.
73
74
  *
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.
75
+ * The fallback is by shape, not by scope name: a global plane is one with no
76
+ * entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
77
+ * whether it registered as 'database', 'platform', or 'app'. Matching the name
78
+ * instead sent every tenant whose plane registers as 'database' — i.e. every
79
+ * database-scope plane — to STORAGE_MODULE_NOT_FOUND.
80
+ *
81
+ * A database with no global plane raises, and so does one with several: there is
82
+ * nowhere unambiguously tenant-owned to put the bytes, and the deployment's
83
+ * configured bucket is not an answer.
76
84
  */
77
85
  export declare function resolveManagedUploadTarget(args: {
78
86
  options: PresignedUrlPluginOptions;
package/managed-upload.js CHANGED
@@ -61,11 +61,19 @@ function buildFileProjection(file, bucket, s3) {
61
61
  * * a registered column names its storage module, and either a logical bucket
62
62
  * key or the reserved default tag for its declared publicness;
63
63
  * * an unregistered column (a bare `image`/`upload` on a database provisioned
64
- * before the registry) falls back to the app-scope module and the same
65
- * reserved default tag. That is a *tenant* default, not an environment one.
64
+ * before the registry) falls back to the database's single global storage
65
+ * plane and the same reserved default tag. That is a *tenant* default, not
66
+ * an environment one.
66
67
  *
67
- * A database with no storage module raises: there is nowhere tenant-owned to put
68
- * the bytes, and the deployment's configured bucket is not an answer.
68
+ * The fallback is by shape, not by scope name: a global plane is one with no
69
+ * entity key (`entity_table_id IS NULL`), which is what a database-wide plane is
70
+ * whether it registered as 'database', 'platform', or 'app'. Matching the name
71
+ * instead sent every tenant whose plane registers as 'database' — i.e. every
72
+ * database-scope plane — to STORAGE_MODULE_NOT_FOUND.
73
+ *
74
+ * A database with no global plane raises, and so does one with several: there is
75
+ * nowhere unambiguously tenant-owned to put the bytes, and the deployment's
76
+ * configured bucket is not an answer.
69
77
  */
70
78
  async function resolveManagedUploadTarget(args) {
71
79
  const { options, withPgClient, pgSettings, databaseId, field, defaultPublicAccess } = args;
@@ -77,7 +85,7 @@ async function resolveManagedUploadTarget(args) {
77
85
  }
78
86
  catch (err) {
79
87
  // An unregistered column is a legitimate state (it predates the registry)
80
- // and falls back to the tenant's app-scope default below. Any other
88
+ // and falls back to the tenant's global plane below. Any other
81
89
  // failure — a broken connection, a missing registry table — is not.
82
90
  if (err?.name === 'FileRefFieldNotRegisteredError')
83
91
  return null;
@@ -85,15 +93,24 @@ async function resolveManagedUploadTarget(args) {
85
93
  }
86
94
  });
87
95
  const allConfigs = await withPgClient(null, (pgClient) => (0, storage_module_cache_1.loadAllStorageModules)(pgClient, databaseId));
96
+ const globalConfigs = allConfigs.filter((c) => c.entityTableId === null);
97
+ if (!binding && globalConfigs.length > 1) {
98
+ // Several global planes and no registry row to disambiguate: picking one is
99
+ // picking a tenant's bucket at random. Name them and refuse.
100
+ throw new Error(`STORAGE_MODULE_AMBIGUOUS: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
101
+ `unregistered upload column and database ${databaseId} has ${globalConfigs.length} global ` +
102
+ `storage planes (scopes: ${globalConfigs.map((c) => c.scope).join(', ')}); register the column ` +
103
+ 'so it names the plane it writes to');
104
+ }
88
105
  const storageConfig = binding
89
106
  ? allConfigs.find((c) => c.id === binding.storageModuleId)
90
- : allConfigs.find((c) => c.scope === 'app');
107
+ : globalConfigs[0];
91
108
  if (!storageConfig) {
92
109
  throw new Error(binding
93
110
  ? `STORAGE_MODULE_NOT_FOUND: file_ref_field ${binding.id} names storage module ` +
94
111
  `${binding.storageModuleId}, which database ${databaseId} does not have`
95
112
  : `STORAGE_MODULE_NOT_FOUND: ${field.schemaName}.${field.tableName}.${field.columnName} is an ` +
96
- `unregistered upload column and database ${databaseId} has no app-scope storage module to ` +
113
+ `unregistered upload column and database ${databaseId} has no global storage plane to ` +
97
114
  'default to; there is no environment bucket to fall back to');
98
115
  }
99
116
  if (storageConfig.entityTableId !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-presigned-url-plugin",
3
- "version": "1.15.0",
3
+ "version": "1.16.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",
@@ -62,5 +62,5 @@
62
62
  "@types/node": "^22.19.11",
63
63
  "makage": "^0.3.0"
64
64
  },
65
- "gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
65
+ "gitHead": "df6b6613c844b505813e5d02d3a4afd918b74eab"
66
66
  }
@@ -16,6 +16,7 @@ exports.resolveS3ForDatabase = resolveS3ForDatabase;
16
16
  exports.provisionAndRecordPhysicalBucket = provisionAndRecordPhysicalBucket;
17
17
  const logger_1 = require("@pgpmjs/logger");
18
18
  const request_pg_client_1 = require("./request-pg-client");
19
+ const s3_failure_1 = require("./s3-failure");
19
20
  const storage_module_cache_1 = require("./storage-module-cache");
20
21
  const log = new logger_1.Logger('graphile-presigned-url:physical-bucket');
21
22
  /**
@@ -97,7 +98,15 @@ async function provisionAndRecordPhysicalBucket(options, withPgClient, storageCo
97
98
  const s3BucketName = mintPhysicalBucketName(options, databaseId, bucket.key);
98
99
  if (options.ensureBucketProvisioned && !(0, storage_module_cache_1.isS3BucketProvisioned)(s3BucketName)) {
99
100
  log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
100
- await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
101
+ try {
102
+ await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
103
+ }
104
+ catch (err) {
105
+ // The first upload to a bucket is where an unreachable object store is
106
+ // discovered, and the transport's own message is routinely empty: name the
107
+ // endpoint it could not reach so the response says what is misconfigured.
108
+ throw (0, s3_failure_1.s3FailureError)('BUCKET_PROVISION_FAILED', { endpoint: resolveS3(options).endpoint, bucket: s3BucketName, databaseId }, err);
109
+ }
101
110
  (0, storage_module_cache_1.markS3BucketProvisioned)(s3BucketName);
102
111
  log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
103
112
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Turning an S3 transport failure into a reason a caller can act on.
3
+ *
4
+ * The AWS SDK routinely fails with an **empty** `message`: an unreachable
5
+ * endpoint arrives as an `AggregateError` whose own message is `''` and whose
6
+ * per-address `errors` hold the `ECONNREFUSED`, and a hung socket arrives as a
7
+ * bare wrapper around its `cause`. Anything that reports `err.message` verbatim
8
+ * therefore hands the client a blank reason — which is how a server signing
9
+ * against the wrong endpoint (a missing `CDN_ENDPOINT`, so the library default
10
+ * `http://localhost:9000`, i.e. the pod's own loopback) presents as an upload
11
+ * that fails with nothing to diagnose.
12
+ *
13
+ * So a failure is described by walking to where the words actually are, and
14
+ * re-thrown naming the coordinates it was talking to, with the original kept as
15
+ * `cause` for the server log.
16
+ */
17
+ /**
18
+ * Describe a thrown S3 error in one line, including the nested errors an
19
+ * `AggregateError` (or a `cause` chain) hides its actual reason in.
20
+ */
21
+ export declare function describeS3Failure(err: unknown, depth?: number): string;
22
+ /**
23
+ * Wrap a failed S3 call as an error whose message carries the operation, the
24
+ * coordinates it used, and the underlying reason — with the original as `cause`.
25
+ *
26
+ * `context` is rendered as `key=value` pairs in the order given; entries with no
27
+ * value are dropped, so a connection without an explicit endpoint (real AWS)
28
+ * does not print an empty one.
29
+ */
30
+ export declare function s3FailureError(operation: string, context: Record<string, string | number | undefined | null>, err: unknown): Error;
package/s3-failure.js ADDED
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Turning an S3 transport failure into a reason a caller can act on.
4
+ *
5
+ * The AWS SDK routinely fails with an **empty** `message`: an unreachable
6
+ * endpoint arrives as an `AggregateError` whose own message is `''` and whose
7
+ * per-address `errors` hold the `ECONNREFUSED`, and a hung socket arrives as a
8
+ * bare wrapper around its `cause`. Anything that reports `err.message` verbatim
9
+ * therefore hands the client a blank reason — which is how a server signing
10
+ * against the wrong endpoint (a missing `CDN_ENDPOINT`, so the library default
11
+ * `http://localhost:9000`, i.e. the pod's own loopback) presents as an upload
12
+ * that fails with nothing to diagnose.
13
+ *
14
+ * So a failure is described by walking to where the words actually are, and
15
+ * re-thrown naming the coordinates it was talking to, with the original kept as
16
+ * `cause` for the server log.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.describeS3Failure = describeS3Failure;
20
+ exports.s3FailureError = s3FailureError;
21
+ /** How far to walk `errors` / `cause` before the chain stops being informative. */
22
+ const MAX_DEPTH = 4;
23
+ /**
24
+ * Describe a thrown S3 error in one line, including the nested errors an
25
+ * `AggregateError` (or a `cause` chain) hides its actual reason in.
26
+ */
27
+ function describeS3Failure(err, depth = 0) {
28
+ if (err === null || err === undefined)
29
+ return 'unknown error';
30
+ if (typeof err !== 'object')
31
+ return String(err);
32
+ const e = err;
33
+ const name = typeof e.name === 'string' && e.name !== 'Error' ? e.name : '';
34
+ const message = typeof e.message === 'string' ? e.message : '';
35
+ const head = message.length > 0 ? [name, message].filter(Boolean).join(': ') : name;
36
+ const status = e.$metadata?.httpStatusCode ? `HTTP ${e.$metadata.httpStatusCode}` : '';
37
+ let detail = '';
38
+ if (depth < MAX_DEPTH) {
39
+ const nested = Array.isArray(e.errors) ? e.errors : e.cause !== undefined ? [e.cause] : [];
40
+ const described = nested
41
+ .map((inner) => describeS3Failure(inner, depth + 1))
42
+ .filter((text) => text.length > 0 && text !== 'unknown error');
43
+ if (described.length > 0)
44
+ detail = `(${described.join('; ')})`;
45
+ }
46
+ const described = [head, status, detail].filter((part) => part.length > 0).join(' ');
47
+ return described.length > 0 ? described : 'unknown error';
48
+ }
49
+ /**
50
+ * Wrap a failed S3 call as an error whose message carries the operation, the
51
+ * coordinates it used, and the underlying reason — with the original as `cause`.
52
+ *
53
+ * `context` is rendered as `key=value` pairs in the order given; entries with no
54
+ * value are dropped, so a connection without an explicit endpoint (real AWS)
55
+ * does not print an empty one.
56
+ */
57
+ function s3FailureError(operation, context, err) {
58
+ const coordinates = Object.entries(context)
59
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
60
+ .map(([key, value]) => `${key}=${value}`)
61
+ .join(' ');
62
+ const prefix = coordinates.length > 0 ? `${operation}: ${coordinates}` : operation;
63
+ return new Error(`${prefix}: ${describeS3Failure(err)}`, { cause: err });
64
+ }
package/s3-signer.js CHANGED
@@ -9,6 +9,7 @@ exports.headObject = headObject;
9
9
  const client_s3_1 = require("@aws-sdk/client-s3");
10
10
  const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
11
11
  const logger_1 = require("@pgpmjs/logger");
12
+ const s3_failure_1 = require("./s3-failure");
12
13
  const log = new logger_1.Logger('graphile-presigned-url:s3');
13
14
  /**
14
15
  * Generate a presigned PUT URL for uploading a file to S3.
@@ -31,7 +32,13 @@ async function generatePresignedPutUrl(s3Config, key, contentType, contentLength
31
32
  ContentType: contentType,
32
33
  ContentLength: contentLength,
33
34
  });
34
- const url = await (0, s3_request_presigner_1.getSignedUrl)(s3Config.client, command, { expiresIn });
35
+ let url;
36
+ try {
37
+ url = await (0, s3_request_presigner_1.getSignedUrl)(s3Config.client, command, { expiresIn });
38
+ }
39
+ catch (err) {
40
+ throw (0, s3_failure_1.s3FailureError)('PRESIGN_PUT_FAILED', { endpoint: s3Config.endpoint, bucket: s3Config.bucket, key, contentType }, err);
41
+ }
35
42
  log.debug(`Generated presigned PUT URL for key=${key}, contentType=${contentType}, expires=${expiresIn}s`);
36
43
  return url;
37
44
  }
@@ -57,7 +64,13 @@ async function generatePresignedGetUrl(s3Config, key, expiresIn = 3600, filename
57
64
  params.ResponseContentDisposition = `attachment; filename="${sanitized}"`;
58
65
  }
59
66
  const command = new client_s3_1.GetObjectCommand(params);
60
- const url = await (0, s3_request_presigner_1.getSignedUrl)(s3Config.client, command, { expiresIn });
67
+ let url;
68
+ try {
69
+ url = await (0, s3_request_presigner_1.getSignedUrl)(s3Config.client, command, { expiresIn });
70
+ }
71
+ catch (err) {
72
+ throw (0, s3_failure_1.s3FailureError)('PRESIGN_GET_FAILED', { endpoint: s3Config.endpoint, bucket: s3Config.bucket, key }, err);
73
+ }
61
74
  log.debug(`Generated presigned GET URL for key=${key}, expires=${expiresIn}s`);
62
75
  return url;
63
76
  }