graphile-settings 6.13.8 → 6.15.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.
@@ -19,9 +19,10 @@ import type { BucketNameResolver, EnsureBucketProvisioned, S3Config } from 'grap
19
19
  * pgpmDefaults → config file → env vars), creates an S3Client, and caches
20
20
  * the result. Same CDN config as upload-resolver.ts.
21
21
  *
22
- * NOTE: The `bucket` field here is the global fallback bucket name
23
- * (from BUCKET_NAME env var). When `resolveBucketName` is provided,
24
- * per-database bucket names take precedence for all S3 operations.
22
+ * NOTE: The `bucket` field here is only the connection's default and is never
23
+ * uploaded to. Every managed upload names its bucket explicitly, resolved from
24
+ * the tenant's logical bucket row via `resolveBucketName`; there is no
25
+ * environment-global upload bucket.
25
26
  */
26
27
  export declare function getPresignedUrlS3Config(): S3Config;
27
28
  /**
@@ -24,9 +24,10 @@ let s3Config = null;
24
24
  * pgpmDefaults → config file → env vars), creates an S3Client, and caches
25
25
  * the result. Same CDN config as upload-resolver.ts.
26
26
  *
27
- * NOTE: The `bucket` field here is the global fallback bucket name
28
- * (from BUCKET_NAME env var). When `resolveBucketName` is provided,
29
- * per-database bucket names take precedence for all S3 operations.
27
+ * NOTE: The `bucket` field here is only the connection's default and is never
28
+ * uploaded to. Every managed upload names its bucket explicitly, resolved from
29
+ * the tenant's logical bucket row via `resolveBucketName`; there is no
30
+ * environment-global upload bucket.
30
31
  */
31
32
  export function getPresignedUrlS3Config() {
32
33
  if (s3Config)
@@ -1,15 +1,24 @@
1
1
  /**
2
- * Upload resolver for the Constructive upload plugin.
2
+ * Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
3
3
  *
4
- * Reads CDN/S3/MinIO configuration from environment variables (via getEnvOptions)
5
- * and streams uploaded files to the configured storage backend.
4
+ * This is the streaming transport into the *managed* storage lane: bytes arrive
5
+ * on the mutation, and the file they carry gets the same treatment a presigned
6
+ * upload gets — a bucket resolved inside the tenant, a content-addressed key, a
7
+ * files row, and a projection document naming that row.
6
8
  *
7
- * Lazily initializes the S3 streamer on first upload to avoid requiring
8
- * env vars at module load time.
9
+ * It used to be a second storage model: stream to `BUCKET_NAME` under a random
10
+ * key, hand back a URL, record nothing. Objects written that way belonged to no
11
+ * database, could not be deduplicated, listed, or access-controlled, and storage
12
+ * GC could not see that a document still pointed at them. There is no
13
+ * environment bucket in this path any more; `cdn.*` supplies S3 credentials and
14
+ * an endpoint only.
9
15
  *
10
- * ENV VARS:
16
+ * Compatibility: `image`/`upload` columns still receive `url` alongside the new
17
+ * `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
18
+ * working while they migrate to `id` + the files row's late-bound `downloadUrl`.
19
+ *
20
+ * ENV VARS (S3 connection only):
11
21
  * BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
12
- * BUCKET_NAME - bucket name (default: 'test-bucket')
13
22
  * AWS_REGION - AWS region (default: 'us-east-1')
14
23
  * AWS_ACCESS_KEY - access key (default: 'minioadmin')
15
24
  * AWS_SECRET_KEY - secret key (default: 'minioadmin')
@@ -1,15 +1,24 @@
1
1
  /**
2
- * Upload resolver for the Constructive upload plugin.
2
+ * Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
3
3
  *
4
- * Reads CDN/S3/MinIO configuration from environment variables (via getEnvOptions)
5
- * and streams uploaded files to the configured storage backend.
4
+ * This is the streaming transport into the *managed* storage lane: bytes arrive
5
+ * on the mutation, and the file they carry gets the same treatment a presigned
6
+ * upload gets — a bucket resolved inside the tenant, a content-addressed key, a
7
+ * files row, and a projection document naming that row.
6
8
  *
7
- * Lazily initializes the S3 streamer on first upload to avoid requiring
8
- * env vars at module load time.
9
+ * It used to be a second storage model: stream to `BUCKET_NAME` under a random
10
+ * key, hand back a URL, record nothing. Objects written that way belonged to no
11
+ * database, could not be deduplicated, listed, or access-controlled, and storage
12
+ * GC could not see that a document still pointed at them. There is no
13
+ * environment bucket in this path any more; `cdn.*` supplies S3 credentials and
14
+ * an endpoint only.
9
15
  *
10
- * ENV VARS:
16
+ * Compatibility: `image`/`upload` columns still receive `url` alongside the new
17
+ * `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
18
+ * working while they migrate to `id` + the files row's late-bound `downloadUrl`.
19
+ *
20
+ * ENV VARS (S3 connection only):
11
21
  * BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
12
- * BUCKET_NAME - bucket name (default: 'test-bucket')
13
22
  * AWS_REGION - AWS region (default: 'us-east-1')
14
23
  * AWS_ACCESS_KEY - access key (default: 'minioadmin')
15
24
  * AWS_SECRET_KEY - secret key (default: 'minioadmin')
@@ -17,100 +26,215 @@
17
26
  */
18
27
  import { getEnvOptions } from '@constructive-io/graphql-env';
19
28
  import Streamer from '@constructive-io/s3-streamer';
20
- import uploadNames from '@constructive-io/upload-names';
21
29
  import { Logger } from '@pgpmjs/logger';
22
- import { randomBytes } from 'crypto';
30
+ import { createHash, randomUUID } from 'crypto';
31
+ import { finalizeStagedUpload, resolveManagedUploadTarget, withRequestPgClient, } from 'graphile-presigned-url-plugin';
32
+ import { checkTypeAgreement } from 'mime-bytes';
33
+ import { Transform } from 'stream';
34
+ import { createBucketNameResolver, createEnsureBucketProvisioned, getPresignedUrlS3Config, } from './presigned-url-resolver';
23
35
  const log = new Logger('upload-resolver');
24
36
  const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
25
37
  let streamer = null;
26
- let bucketName;
38
+ /**
39
+ * The S3 streamer, built from the CDN connection settings.
40
+ *
41
+ * Deliberately constructed with no `defaultBucket`: every upload names the
42
+ * bucket it resolved, and a default here would be an environment-owned bucket
43
+ * standing in for a tenant's.
44
+ */
27
45
  function getStreamer() {
28
46
  if (streamer)
29
47
  return streamer;
30
- const opts = getEnvOptions();
31
- const cdn = opts.cdn || {};
32
- const provider = cdn.provider || 'minio';
33
- bucketName = cdn.bucketName || 'test-bucket';
34
- const awsRegion = cdn.awsRegion || 'us-east-1';
35
- const awsAccessKey = cdn.awsAccessKey || 'minioadmin';
36
- const awsSecretKey = cdn.awsSecretKey || 'minioadmin';
37
- const endpoint = cdn.endpoint || 'http://localhost:9000';
38
- if (process.env.NODE_ENV === 'production') {
39
- if (!cdn.awsAccessKey || !cdn.awsSecretKey) {
40
- log.warn('[upload-resolver] WARNING: Using default credentials in production.');
41
- }
48
+ const { cdn = {} } = getEnvOptions();
49
+ if (process.env.NODE_ENV === 'production' && (!cdn.awsAccessKey || !cdn.awsSecretKey)) {
50
+ log.warn('[upload-resolver] WARNING: Using default credentials in production.');
42
51
  }
43
- log.info(`[upload-resolver] Initializing: provider=${provider} bucket=${bucketName}`);
52
+ const provider = cdn.provider || 'minio';
53
+ log.info(`[upload-resolver] Initializing: provider=${provider}`);
44
54
  streamer = new Streamer({
45
- defaultBucket: bucketName,
46
- awsRegion,
47
- awsSecretKey,
48
- awsAccessKey,
49
- endpoint,
50
55
  provider,
56
+ awsRegion: cdn.awsRegion || 'us-east-1',
57
+ awsAccessKey: cdn.awsAccessKey || 'minioadmin',
58
+ awsSecretKey: cdn.awsSecretKey || 'minioadmin',
59
+ endpoint: cdn.endpoint || 'http://localhost:9000',
51
60
  });
52
61
  return streamer;
53
62
  }
54
63
  /**
55
- * Generates a randomized storage key from a filename.
56
- * Format: {random10chars}-{sanitized-filename}
64
+ * The upload lane's view of the presigned plugin's options: the same S3
65
+ * connection, physical-name policy, and provisioning hook the presigned lane
66
+ * uses, so both transports resolve identical coordinates for a bucket.
67
+ *
68
+ * Built on first upload rather than at import time — `createBucketNameResolver`
69
+ * throws on a missing name prefix, and that must surface as a failed upload, not
70
+ * as a server that will not boot.
57
71
  */
58
- function generateKey(filename) {
59
- const rand = randomBytes(12).toString('hex');
60
- return `${rand}-${uploadNames(filename)}`;
72
+ let managedOptions = null;
73
+ function getManagedOptions() {
74
+ if (!managedOptions) {
75
+ managedOptions = {
76
+ s3: getPresignedUrlS3Config,
77
+ resolveBucketName: createBucketNameResolver(),
78
+ ensureBucketProvisioned: createEnsureBucketProvisioned(),
79
+ };
80
+ }
81
+ return managedOptions;
82
+ }
83
+ /** A staging key: transient, and never what the object ends up under. */
84
+ function stagingKey() {
85
+ return `.staging/${randomUUID()}`;
86
+ }
87
+ async function resolveDatabaseId(pgClient) {
88
+ const result = await pgClient.query({ text: `SELECT jwt_private.current_database_id() AS id` });
89
+ return result.rows[0]?.id ?? null;
61
90
  }
62
91
  /**
63
- * Upload resolver that streams files to S3/MinIO.
92
+ * Which default bucket an *unregistered* column resolves to.
64
93
  *
65
- * Returns different shapes based on the column's type hint:
66
- * - 'image' / 'upload' → { filename, mime, url } (for jsonb domain columns)
67
- * - 'attachment' / default → url string (for text domain columns)
68
- *
69
- * MIME validation happens before persistence: content type is detected from
70
- * stream bytes, validated against smart-tag/type rules, and only then uploaded.
94
+ * Columns written by the pre-managed resolver held a directly-embedded URL, so
95
+ * their readers assume a publicly addressable object; resolving them to the
96
+ * private default would break every page rendering one. A registered column
97
+ * states its own intent and this is not consulted.
71
98
  */
72
- async function uploadResolver(upload, _args, _context, info) {
73
- const { tags, type } = info.uploadPlugin;
74
- const s3 = getStreamer();
75
- const { filename } = upload;
76
- const key = generateKey(filename);
77
- // MIME type validation from smart tags
78
- const typ = type || tags?.type;
99
+ const LEGACY_DEFAULT_PUBLIC_ACCESS = true;
100
+ /** The mime allowlist for a column, from its smart tags or its type. */
101
+ function allowedMimeTypes(tags, typ) {
79
102
  const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i;
80
- const mim = tags?.mime
81
- ? String(tags.mime)
103
+ if (tags?.mime) {
104
+ return String(tags.mime)
82
105
  .trim()
83
106
  .split(',')
84
107
  .map((a) => a.trim())
85
- .filter((m) => VALID_MIME.test(m))
86
- : typ === 'image'
87
- ? DEFAULT_IMAGE_MIME_TYPES
88
- : [];
108
+ .filter((m) => VALID_MIME.test(m));
109
+ }
110
+ return typ === 'image' ? DEFAULT_IMAGE_MIME_TYPES : [];
111
+ }
112
+ /**
113
+ * Hash and measure bytes as they stream past, without buffering them.
114
+ *
115
+ * The final key is the content hash, which is only known once the last byte has
116
+ * gone by — so the object is staged first and promoted after. Nothing is held in
117
+ * memory: a 2GB upload streams through this the same as a 2KB one.
118
+ */
119
+ function hashingPassThrough() {
120
+ const hash = createHash('sha256');
121
+ let bytes = 0;
122
+ const stream = new Transform({
123
+ transform(chunk, _encoding, callback) {
124
+ hash.update(chunk);
125
+ bytes += chunk.length;
126
+ callback(null, chunk);
127
+ },
128
+ });
129
+ return Object.assign(stream, {
130
+ digest: () => hash.digest('hex'),
131
+ bytes: () => bytes,
132
+ });
133
+ }
134
+ /**
135
+ * Stream an upload into managed storage and return the value the column stores.
136
+ *
137
+ * Shape by column type hint:
138
+ * * `image` / `upload` (jsonb domains) → the projection document, including a
139
+ * compatibility `url` for public buckets.
140
+ * * `attachment` (text domain) → the object's public URL. A text column cannot
141
+ * hold a projection, so the files row is still authoritative but the column
142
+ * itself carries no id; the row keeps the object alive. A private bucket
143
+ * raises rather than storing an expiring presigned URL in a column.
144
+ */
145
+ async function uploadResolver(upload, _args, context, info) {
146
+ const { tags, type, field } = info.uploadPlugin;
147
+ const typ = type || tags?.type;
148
+ const withPgClient = context?.withPgClient;
149
+ const pgSettings = context?.pgSettings ?? null;
150
+ if (!withPgClient) {
151
+ throw new Error('UPLOAD_NO_PG_CLIENT: a managed upload resolves its bucket in the database, so the ' +
152
+ 'GraphQL context must carry withPgClient');
153
+ }
154
+ if (!field) {
155
+ throw new Error('UPLOAD_FIELD_UNKNOWN: the upload plugin did not report which column is being written, ' +
156
+ 'so the storage module and bucket backing it cannot be resolved');
157
+ }
158
+ const databaseId = await withRequestPgClient(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
159
+ if (!databaseId)
160
+ throw new Error('DATABASE_NOT_FOUND');
161
+ const target = await resolveManagedUploadTarget({
162
+ options: getManagedOptions(),
163
+ withPgClient,
164
+ pgSettings,
165
+ databaseId,
166
+ field: field,
167
+ defaultPublicAccess: LEGACY_DEFAULT_PUBLIC_ACCESS,
168
+ });
169
+ if (typ === 'attachment' && !target.bucket.is_public) {
170
+ throw new Error('ATTACHMENT_BUCKET_NOT_PUBLIC: an attachment column stores a plain URL, and the resolved ' +
171
+ `bucket "${target.bucket.key}" is private, whose only URLs expire. Use an upload column, ` +
172
+ 'which stores the file id and resolves a fresh download URL on read.');
173
+ }
174
+ const s3 = getStreamer();
175
+ const { filename } = upload;
176
+ // Validate before persisting: content type comes from the leading bytes, not
177
+ // from the client's claim about them.
89
178
  const detected = await s3.detectContentType({
90
179
  readStream: upload.createReadStream(),
91
180
  filename,
92
181
  });
93
- const detectedContentType = detected.contentType;
94
- if (mim.length && !mim.includes(detectedContentType)) {
182
+ // The three claims must agree, and disagreement is rejection rather than
183
+ // relabelling: an `.jpg` carrying HTML is served to a browser as an image and
184
+ // executed as a script. Compared against `magic.type` — what the bytes say —
185
+ // not `contentType`, which the detector may have refined using the very
186
+ // extension under suspicion.
187
+ const agreement = checkTypeAgreement({
188
+ filename,
189
+ declaredMime: upload.mimetype,
190
+ detectedMime: detected.magic?.type,
191
+ });
192
+ if (!agreement.ok) {
193
+ detected.stream.destroy();
194
+ throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}. The upload was rejected rather than ` +
195
+ 'stored under a type it is not.');
196
+ }
197
+ const allowed = allowedMimeTypes(tags, typ);
198
+ if (allowed.length && !allowed.includes(detected.contentType)) {
95
199
  detected.stream.destroy();
96
200
  throw new Error('UPLOAD_MIMETYPE');
97
201
  }
98
- const result = await s3.uploadWithContentType({
99
- readStream: detected.stream,
100
- contentType: detectedContentType,
202
+ const staged = stagingKey();
203
+ const hashing = hashingPassThrough();
204
+ const uploadResult = await s3.uploadWithContentType({
205
+ readStream: detected.stream.pipe(hashing),
206
+ contentType: detected.contentType,
101
207
  magic: detected.magic,
102
- key,
103
- bucket: bucketName,
208
+ key: staged,
209
+ bucket: target.physicalName,
210
+ });
211
+ // Owns the staged key from here: it either promotes it into a files row or
212
+ // removes it, so a failed upload leaves nothing behind in S3.
213
+ const { projection } = await finalizeStagedUpload({
214
+ target,
215
+ withPgClient,
216
+ pgSettings,
217
+ staged: {
218
+ stagingKey: staged,
219
+ contentHash: hashing.digest(),
220
+ contentType: uploadResult.contentType,
221
+ size: hashing.bytes(),
222
+ filename,
223
+ },
104
224
  });
105
- const url = result.upload.Location;
106
- const { contentType } = result;
107
225
  switch (typ) {
108
226
  case 'image':
109
227
  case 'upload':
110
- return { filename, mime: contentType, url };
228
+ // `filename` and `mime` were in the pre-managed shape and stay in it;
229
+ // `url` is populated for public buckets and deprecated in favour of `id`.
230
+ return { ...projection, filename, mime: uploadResult.contentType };
111
231
  case 'attachment':
112
232
  default:
113
- return url;
233
+ if (!projection.url) {
234
+ throw new Error(`ATTACHMENT_NO_PUBLIC_URL: bucket "${target.bucket.key}" has no public URL prefix ` +
235
+ 'configured, so there is no durable URL to store in a text column');
236
+ }
237
+ return projection.url;
114
238
  }
115
239
  }
116
240
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graphile-settings",
3
- "version": "6.13.8",
3
+ "version": "6.15.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "graphile settings",
6
6
  "main": "index.js",
@@ -33,7 +33,7 @@
33
33
  "@constructive-io/bucket-provisioner": "^0.24.0",
34
34
  "@constructive-io/graphql-env": "^3.29.3",
35
35
  "@constructive-io/graphql-types": "^3.28.3",
36
- "@constructive-io/s3-streamer": "^2.39.3",
36
+ "@constructive-io/s3-streamer": "^2.40.1",
37
37
  "@constructive-io/s3-utils": "^2.30.0",
38
38
  "@constructive-io/upload-names": "^2.29.0",
39
39
  "@dataplan/json": "1.0.1",
@@ -44,33 +44,34 @@
44
44
  "@pgsql/quotes": "^18.2.4",
45
45
  "cors": "^2.8.6",
46
46
  "express": "^5.2.1",
47
- "grafast": "1.1.1",
47
+ "grafast": "1.1.2",
48
48
  "grafserv": "1.0.1",
49
49
  "graphile-bucket-provisioner-plugin": "1.12.4",
50
50
  "graphile-build": "5.1.1",
51
51
  "graphile-build-pg": "5.1.3",
52
- "graphile-bulk-mutations": "^1.11.7",
52
+ "graphile-bulk-mutations": "^1.11.8",
53
53
  "graphile-config": "1.1.0",
54
- "graphile-connection-filter": "^2.11.7",
55
- "graphile-history": "^1.3.7",
56
- "graphile-i18n": "^2.11.7",
57
- "graphile-llm": "^1.11.7",
58
- "graphile-ltree": "^2.11.7",
54
+ "graphile-connection-filter": "^2.11.8",
55
+ "graphile-history": "^1.3.8",
56
+ "graphile-i18n": "^2.11.8",
57
+ "graphile-llm": "^1.11.8",
58
+ "graphile-ltree": "^2.11.8",
59
59
  "graphile-meta": "^1.7.1",
60
- "graphile-pg-aggregates": "^2.11.7",
61
- "graphile-postgis": "^3.11.7",
62
- "graphile-presigned-url-plugin": "^1.11.4",
60
+ "graphile-pg-aggregates": "^2.11.8",
61
+ "graphile-postgis": "^3.11.8",
62
+ "graphile-presigned-url-plugin": "^1.13.0",
63
63
  "graphile-realtime-subscriptions": "^1.10.5",
64
- "graphile-search": "^2.11.7",
65
- "graphile-upload-plugin": "^2.24.2",
64
+ "graphile-search": "^2.11.8",
65
+ "graphile-upload-plugin": "^2.25.0",
66
66
  "graphile-utils": "5.0.3",
67
67
  "graphql": "16.13.0",
68
68
  "inflekt": "^0.8.1",
69
69
  "lru-cache": "^11.2.7",
70
+ "mime-bytes": "^0.31.0",
70
71
  "pg": "^8.21.0",
71
72
  "pg-query-context": "^2.29.1",
72
73
  "pg-sql2": "5.0.1",
73
- "postgraphile": "5.1.3",
74
+ "postgraphile": "5.1.4",
74
75
  "request-ip": "^3.3.0",
75
76
  "tamedevil": "0.1.1"
76
77
  },
@@ -79,7 +80,7 @@
79
80
  "@types/express": "^5.0.6",
80
81
  "@types/pg": "^8.20.4",
81
82
  "@types/request-ip": "^0.0.41",
82
- "graphile-test": "^5.11.7",
83
+ "graphile-test": "^5.11.8",
83
84
  "makage": "^0.3.0",
84
85
  "nodemon": "^3.1.14",
85
86
  "ts-node": "^10.9.2"
@@ -91,5 +92,5 @@
91
92
  "constructive",
92
93
  "graphql"
93
94
  ],
94
- "gitHead": "d6afee66ac3400e78864b9e259cdd6ab9184b251"
95
+ "gitHead": "d0d796b7eb0d16c8c19405a49c0ca62ce3df117b"
95
96
  }
@@ -19,9 +19,10 @@ import type { BucketNameResolver, EnsureBucketProvisioned, S3Config } from 'grap
19
19
  * pgpmDefaults → config file → env vars), creates an S3Client, and caches
20
20
  * the result. Same CDN config as upload-resolver.ts.
21
21
  *
22
- * NOTE: The `bucket` field here is the global fallback bucket name
23
- * (from BUCKET_NAME env var). When `resolveBucketName` is provided,
24
- * per-database bucket names take precedence for all S3 operations.
22
+ * NOTE: The `bucket` field here is only the connection's default and is never
23
+ * uploaded to. Every managed upload names its bucket explicitly, resolved from
24
+ * the tenant's logical bucket row via `resolveBucketName`; there is no
25
+ * environment-global upload bucket.
25
26
  */
26
27
  export declare function getPresignedUrlS3Config(): S3Config;
27
28
  /**
@@ -31,9 +31,10 @@ let s3Config = null;
31
31
  * pgpmDefaults → config file → env vars), creates an S3Client, and caches
32
32
  * the result. Same CDN config as upload-resolver.ts.
33
33
  *
34
- * NOTE: The `bucket` field here is the global fallback bucket name
35
- * (from BUCKET_NAME env var). When `resolveBucketName` is provided,
36
- * per-database bucket names take precedence for all S3 operations.
34
+ * NOTE: The `bucket` field here is only the connection's default and is never
35
+ * uploaded to. Every managed upload names its bucket explicitly, resolved from
36
+ * the tenant's logical bucket row via `resolveBucketName`; there is no
37
+ * environment-global upload bucket.
37
38
  */
38
39
  function getPresignedUrlS3Config() {
39
40
  if (s3Config)
@@ -1,15 +1,24 @@
1
1
  /**
2
- * Upload resolver for the Constructive upload plugin.
2
+ * Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
3
3
  *
4
- * Reads CDN/S3/MinIO configuration from environment variables (via getEnvOptions)
5
- * and streams uploaded files to the configured storage backend.
4
+ * This is the streaming transport into the *managed* storage lane: bytes arrive
5
+ * on the mutation, and the file they carry gets the same treatment a presigned
6
+ * upload gets — a bucket resolved inside the tenant, a content-addressed key, a
7
+ * files row, and a projection document naming that row.
6
8
  *
7
- * Lazily initializes the S3 streamer on first upload to avoid requiring
8
- * env vars at module load time.
9
+ * It used to be a second storage model: stream to `BUCKET_NAME` under a random
10
+ * key, hand back a URL, record nothing. Objects written that way belonged to no
11
+ * database, could not be deduplicated, listed, or access-controlled, and storage
12
+ * GC could not see that a document still pointed at them. There is no
13
+ * environment bucket in this path any more; `cdn.*` supplies S3 credentials and
14
+ * an endpoint only.
9
15
  *
10
- * ENV VARS:
16
+ * Compatibility: `image`/`upload` columns still receive `url` alongside the new
17
+ * `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
18
+ * working while they migrate to `id` + the files row's late-bound `downloadUrl`.
19
+ *
20
+ * ENV VARS (S3 connection only):
11
21
  * BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
12
- * BUCKET_NAME - bucket name (default: 'test-bucket')
13
22
  * AWS_REGION - AWS region (default: 'us-east-1')
14
23
  * AWS_ACCESS_KEY - access key (default: 'minioadmin')
15
24
  * AWS_SECRET_KEY - secret key (default: 'minioadmin')
@@ -1,16 +1,25 @@
1
1
  "use strict";
2
2
  /**
3
- * Upload resolver for the Constructive upload plugin.
3
+ * Upload resolver for the Constructive upload plugin (multipart `Upload` scalar).
4
4
  *
5
- * Reads CDN/S3/MinIO configuration from environment variables (via getEnvOptions)
6
- * and streams uploaded files to the configured storage backend.
5
+ * This is the streaming transport into the *managed* storage lane: bytes arrive
6
+ * on the mutation, and the file they carry gets the same treatment a presigned
7
+ * upload gets — a bucket resolved inside the tenant, a content-addressed key, a
8
+ * files row, and a projection document naming that row.
7
9
  *
8
- * Lazily initializes the S3 streamer on first upload to avoid requiring
9
- * env vars at module load time.
10
+ * It used to be a second storage model: stream to `BUCKET_NAME` under a random
11
+ * key, hand back a URL, record nothing. Objects written that way belonged to no
12
+ * database, could not be deduplicated, listed, or access-controlled, and storage
13
+ * GC could not see that a document still pointed at them. There is no
14
+ * environment bucket in this path any more; `cdn.*` supplies S3 credentials and
15
+ * an endpoint only.
10
16
  *
11
- * ENV VARS:
17
+ * Compatibility: `image`/`upload` columns still receive `url` alongside the new
18
+ * `id`/`key`/`bucket_id`/`size` fields, so existing readers of `photo.url` keep
19
+ * working while they migrate to `id` + the files row's late-bound `downloadUrl`.
20
+ *
21
+ * ENV VARS (S3 connection only):
12
22
  * BUCKET_PROVIDER - 'minio' | 's3' (default: 'minio')
13
- * BUCKET_NAME - bucket name (default: 'test-bucket')
14
23
  * AWS_REGION - AWS region (default: 'us-east-1')
15
24
  * AWS_ACCESS_KEY - access key (default: 'minioadmin')
16
25
  * AWS_SECRET_KEY - secret key (default: 'minioadmin')
@@ -23,100 +32,215 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
32
  exports.constructiveUploadFieldDefinitions = void 0;
24
33
  const graphql_env_1 = require("@constructive-io/graphql-env");
25
34
  const s3_streamer_1 = __importDefault(require("@constructive-io/s3-streamer"));
26
- const upload_names_1 = __importDefault(require("@constructive-io/upload-names"));
27
35
  const logger_1 = require("@pgpmjs/logger");
28
36
  const crypto_1 = require("crypto");
37
+ const graphile_presigned_url_plugin_1 = require("graphile-presigned-url-plugin");
38
+ const mime_bytes_1 = require("mime-bytes");
39
+ const stream_1 = require("stream");
40
+ const presigned_url_resolver_1 = require("./presigned-url-resolver");
29
41
  const log = new logger_1.Logger('upload-resolver');
30
42
  const DEFAULT_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
31
43
  let streamer = null;
32
- let bucketName;
44
+ /**
45
+ * The S3 streamer, built from the CDN connection settings.
46
+ *
47
+ * Deliberately constructed with no `defaultBucket`: every upload names the
48
+ * bucket it resolved, and a default here would be an environment-owned bucket
49
+ * standing in for a tenant's.
50
+ */
33
51
  function getStreamer() {
34
52
  if (streamer)
35
53
  return streamer;
36
- const opts = (0, graphql_env_1.getEnvOptions)();
37
- const cdn = opts.cdn || {};
38
- const provider = cdn.provider || 'minio';
39
- bucketName = cdn.bucketName || 'test-bucket';
40
- const awsRegion = cdn.awsRegion || 'us-east-1';
41
- const awsAccessKey = cdn.awsAccessKey || 'minioadmin';
42
- const awsSecretKey = cdn.awsSecretKey || 'minioadmin';
43
- const endpoint = cdn.endpoint || 'http://localhost:9000';
44
- if (process.env.NODE_ENV === 'production') {
45
- if (!cdn.awsAccessKey || !cdn.awsSecretKey) {
46
- log.warn('[upload-resolver] WARNING: Using default credentials in production.');
47
- }
54
+ const { cdn = {} } = (0, graphql_env_1.getEnvOptions)();
55
+ if (process.env.NODE_ENV === 'production' && (!cdn.awsAccessKey || !cdn.awsSecretKey)) {
56
+ log.warn('[upload-resolver] WARNING: Using default credentials in production.');
48
57
  }
49
- log.info(`[upload-resolver] Initializing: provider=${provider} bucket=${bucketName}`);
58
+ const provider = cdn.provider || 'minio';
59
+ log.info(`[upload-resolver] Initializing: provider=${provider}`);
50
60
  streamer = new s3_streamer_1.default({
51
- defaultBucket: bucketName,
52
- awsRegion,
53
- awsSecretKey,
54
- awsAccessKey,
55
- endpoint,
56
61
  provider,
62
+ awsRegion: cdn.awsRegion || 'us-east-1',
63
+ awsAccessKey: cdn.awsAccessKey || 'minioadmin',
64
+ awsSecretKey: cdn.awsSecretKey || 'minioadmin',
65
+ endpoint: cdn.endpoint || 'http://localhost:9000',
57
66
  });
58
67
  return streamer;
59
68
  }
60
69
  /**
61
- * Generates a randomized storage key from a filename.
62
- * Format: {random10chars}-{sanitized-filename}
70
+ * The upload lane's view of the presigned plugin's options: the same S3
71
+ * connection, physical-name policy, and provisioning hook the presigned lane
72
+ * uses, so both transports resolve identical coordinates for a bucket.
73
+ *
74
+ * Built on first upload rather than at import time — `createBucketNameResolver`
75
+ * throws on a missing name prefix, and that must surface as a failed upload, not
76
+ * as a server that will not boot.
63
77
  */
64
- function generateKey(filename) {
65
- const rand = (0, crypto_1.randomBytes)(12).toString('hex');
66
- return `${rand}-${(0, upload_names_1.default)(filename)}`;
78
+ let managedOptions = null;
79
+ function getManagedOptions() {
80
+ if (!managedOptions) {
81
+ managedOptions = {
82
+ s3: presigned_url_resolver_1.getPresignedUrlS3Config,
83
+ resolveBucketName: (0, presigned_url_resolver_1.createBucketNameResolver)(),
84
+ ensureBucketProvisioned: (0, presigned_url_resolver_1.createEnsureBucketProvisioned)(),
85
+ };
86
+ }
87
+ return managedOptions;
88
+ }
89
+ /** A staging key: transient, and never what the object ends up under. */
90
+ function stagingKey() {
91
+ return `.staging/${(0, crypto_1.randomUUID)()}`;
92
+ }
93
+ async function resolveDatabaseId(pgClient) {
94
+ const result = await pgClient.query({ text: `SELECT jwt_private.current_database_id() AS id` });
95
+ return result.rows[0]?.id ?? null;
67
96
  }
68
97
  /**
69
- * Upload resolver that streams files to S3/MinIO.
98
+ * Which default bucket an *unregistered* column resolves to.
70
99
  *
71
- * Returns different shapes based on the column's type hint:
72
- * - 'image' / 'upload' → { filename, mime, url } (for jsonb domain columns)
73
- * - 'attachment' / default → url string (for text domain columns)
74
- *
75
- * MIME validation happens before persistence: content type is detected from
76
- * stream bytes, validated against smart-tag/type rules, and only then uploaded.
100
+ * Columns written by the pre-managed resolver held a directly-embedded URL, so
101
+ * their readers assume a publicly addressable object; resolving them to the
102
+ * private default would break every page rendering one. A registered column
103
+ * states its own intent and this is not consulted.
77
104
  */
78
- async function uploadResolver(upload, _args, _context, info) {
79
- const { tags, type } = info.uploadPlugin;
80
- const s3 = getStreamer();
81
- const { filename } = upload;
82
- const key = generateKey(filename);
83
- // MIME type validation from smart tags
84
- const typ = type || tags?.type;
105
+ const LEGACY_DEFAULT_PUBLIC_ACCESS = true;
106
+ /** The mime allowlist for a column, from its smart tags or its type. */
107
+ function allowedMimeTypes(tags, typ) {
85
108
  const VALID_MIME = /^[a-z]+\/[a-z0-9][a-z0-9!#$&\-.^_+]*$/i;
86
- const mim = tags?.mime
87
- ? String(tags.mime)
109
+ if (tags?.mime) {
110
+ return String(tags.mime)
88
111
  .trim()
89
112
  .split(',')
90
113
  .map((a) => a.trim())
91
- .filter((m) => VALID_MIME.test(m))
92
- : typ === 'image'
93
- ? DEFAULT_IMAGE_MIME_TYPES
94
- : [];
114
+ .filter((m) => VALID_MIME.test(m));
115
+ }
116
+ return typ === 'image' ? DEFAULT_IMAGE_MIME_TYPES : [];
117
+ }
118
+ /**
119
+ * Hash and measure bytes as they stream past, without buffering them.
120
+ *
121
+ * The final key is the content hash, which is only known once the last byte has
122
+ * gone by — so the object is staged first and promoted after. Nothing is held in
123
+ * memory: a 2GB upload streams through this the same as a 2KB one.
124
+ */
125
+ function hashingPassThrough() {
126
+ const hash = (0, crypto_1.createHash)('sha256');
127
+ let bytes = 0;
128
+ const stream = new stream_1.Transform({
129
+ transform(chunk, _encoding, callback) {
130
+ hash.update(chunk);
131
+ bytes += chunk.length;
132
+ callback(null, chunk);
133
+ },
134
+ });
135
+ return Object.assign(stream, {
136
+ digest: () => hash.digest('hex'),
137
+ bytes: () => bytes,
138
+ });
139
+ }
140
+ /**
141
+ * Stream an upload into managed storage and return the value the column stores.
142
+ *
143
+ * Shape by column type hint:
144
+ * * `image` / `upload` (jsonb domains) → the projection document, including a
145
+ * compatibility `url` for public buckets.
146
+ * * `attachment` (text domain) → the object's public URL. A text column cannot
147
+ * hold a projection, so the files row is still authoritative but the column
148
+ * itself carries no id; the row keeps the object alive. A private bucket
149
+ * raises rather than storing an expiring presigned URL in a column.
150
+ */
151
+ async function uploadResolver(upload, _args, context, info) {
152
+ const { tags, type, field } = info.uploadPlugin;
153
+ const typ = type || tags?.type;
154
+ const withPgClient = context?.withPgClient;
155
+ const pgSettings = context?.pgSettings ?? null;
156
+ if (!withPgClient) {
157
+ throw new Error('UPLOAD_NO_PG_CLIENT: a managed upload resolves its bucket in the database, so the ' +
158
+ 'GraphQL context must carry withPgClient');
159
+ }
160
+ if (!field) {
161
+ throw new Error('UPLOAD_FIELD_UNKNOWN: the upload plugin did not report which column is being written, ' +
162
+ 'so the storage module and bucket backing it cannot be resolved');
163
+ }
164
+ const databaseId = await (0, graphile_presigned_url_plugin_1.withRequestPgClient)(withPgClient, pgSettings, (pgClient) => resolveDatabaseId(pgClient));
165
+ if (!databaseId)
166
+ throw new Error('DATABASE_NOT_FOUND');
167
+ const target = await (0, graphile_presigned_url_plugin_1.resolveManagedUploadTarget)({
168
+ options: getManagedOptions(),
169
+ withPgClient,
170
+ pgSettings,
171
+ databaseId,
172
+ field: field,
173
+ defaultPublicAccess: LEGACY_DEFAULT_PUBLIC_ACCESS,
174
+ });
175
+ if (typ === 'attachment' && !target.bucket.is_public) {
176
+ throw new Error('ATTACHMENT_BUCKET_NOT_PUBLIC: an attachment column stores a plain URL, and the resolved ' +
177
+ `bucket "${target.bucket.key}" is private, whose only URLs expire. Use an upload column, ` +
178
+ 'which stores the file id and resolves a fresh download URL on read.');
179
+ }
180
+ const s3 = getStreamer();
181
+ const { filename } = upload;
182
+ // Validate before persisting: content type comes from the leading bytes, not
183
+ // from the client's claim about them.
95
184
  const detected = await s3.detectContentType({
96
185
  readStream: upload.createReadStream(),
97
186
  filename,
98
187
  });
99
- const detectedContentType = detected.contentType;
100
- if (mim.length && !mim.includes(detectedContentType)) {
188
+ // The three claims must agree, and disagreement is rejection rather than
189
+ // relabelling: an `.jpg` carrying HTML is served to a browser as an image and
190
+ // executed as a script. Compared against `magic.type` — what the bytes say —
191
+ // not `contentType`, which the detector may have refined using the very
192
+ // extension under suspicion.
193
+ const agreement = (0, mime_bytes_1.checkTypeAgreement)({
194
+ filename,
195
+ declaredMime: upload.mimetype,
196
+ detectedMime: detected.magic?.type,
197
+ });
198
+ if (!agreement.ok) {
199
+ detected.stream.destroy();
200
+ throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}. The upload was rejected rather than ` +
201
+ 'stored under a type it is not.');
202
+ }
203
+ const allowed = allowedMimeTypes(tags, typ);
204
+ if (allowed.length && !allowed.includes(detected.contentType)) {
101
205
  detected.stream.destroy();
102
206
  throw new Error('UPLOAD_MIMETYPE');
103
207
  }
104
- const result = await s3.uploadWithContentType({
105
- readStream: detected.stream,
106
- contentType: detectedContentType,
208
+ const staged = stagingKey();
209
+ const hashing = hashingPassThrough();
210
+ const uploadResult = await s3.uploadWithContentType({
211
+ readStream: detected.stream.pipe(hashing),
212
+ contentType: detected.contentType,
107
213
  magic: detected.magic,
108
- key,
109
- bucket: bucketName,
214
+ key: staged,
215
+ bucket: target.physicalName,
216
+ });
217
+ // Owns the staged key from here: it either promotes it into a files row or
218
+ // removes it, so a failed upload leaves nothing behind in S3.
219
+ const { projection } = await (0, graphile_presigned_url_plugin_1.finalizeStagedUpload)({
220
+ target,
221
+ withPgClient,
222
+ pgSettings,
223
+ staged: {
224
+ stagingKey: staged,
225
+ contentHash: hashing.digest(),
226
+ contentType: uploadResult.contentType,
227
+ size: hashing.bytes(),
228
+ filename,
229
+ },
110
230
  });
111
- const url = result.upload.Location;
112
- const { contentType } = result;
113
231
  switch (typ) {
114
232
  case 'image':
115
233
  case 'upload':
116
- return { filename, mime: contentType, url };
234
+ // `filename` and `mime` were in the pre-managed shape and stay in it;
235
+ // `url` is populated for public buckets and deprecated in favour of `id`.
236
+ return { ...projection, filename, mime: uploadResult.contentType };
117
237
  case 'attachment':
118
238
  default:
119
- return url;
239
+ if (!projection.url) {
240
+ throw new Error(`ATTACHMENT_NO_PUBLIC_URL: bucket "${target.bucket.key}" has no public URL prefix ` +
241
+ 'configured, so there is no durable URL to store in a text column');
242
+ }
243
+ return projection.url;
120
244
  }
121
245
  }
122
246
  /**