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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The byte-validating half of the presigned lane's confirmation.
3
+ *
4
+ * A presigned upload's bytes never pass through the server: the client PUTs them
5
+ * straight to S3, so at the moment the files row is written the only statements
6
+ * about the file are the client's own. `requested` is exactly that state — a
7
+ * claim — and the row must not reach `uploaded` until the bytes behind it have
8
+ * been looked at, because every downstream processing step (image versions,
9
+ * extraction, embeddings) fires on `uploaded` and would otherwise be handed
10
+ * whatever the client chose to upload.
11
+ *
12
+ * So confirmation answers three questions in order, and each has a distinct
13
+ * outcome on the row:
14
+ *
15
+ * 1. did the bytes arrive? no → `expired` (the client walked away)
16
+ * 2. are they the file they claim? no → `rejected` (and the object deleted)
17
+ * 3. otherwise → `uploaded`
18
+ *
19
+ * The bytes are read with a ranged GET of the leading bytes, not downloaded: a
20
+ * magic-byte signature is in the first few dozen bytes, so this costs the same
21
+ * for a 2GB video as for an icon.
22
+ *
23
+ * This module is the decision, not the transition. The worker that owns the
24
+ * `storage:confirm_upload` job applies it by calling the generated
25
+ * `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
26
+ * verdict is returned rather than executed so that the same rule can be applied
27
+ * by any transport, and tested without a database.
28
+ */
29
+ import type { S3Config } from './types';
30
+ /**
31
+ * How many leading bytes to read. Signatures are far shorter than this; the
32
+ * margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
33
+ * of an MP4) and gives charset detection enough text to work with.
34
+ */
35
+ export declare const CONFIRM_PREFIX_BYTES = 4096;
36
+ export interface ConfirmUploadInput {
37
+ s3: S3Config;
38
+ /** The object key the presigned PUT was signed for. */
39
+ key: string;
40
+ /** The MIME type the client declared when the row was created. */
41
+ declaredMime: string;
42
+ /** The filename recorded on the files row, if any. */
43
+ filename?: string | null;
44
+ }
45
+ export type ConfirmUploadVerdict = {
46
+ outcome: 'uploaded';
47
+ detectedMime: string | null;
48
+ } | {
49
+ outcome: 'rejected';
50
+ reason: string;
51
+ detectedMime: string | null;
52
+ } | {
53
+ outcome: 'expired';
54
+ reason: string;
55
+ };
56
+ /**
57
+ * Decide what should happen to a `requested` files row, from its object's bytes.
58
+ *
59
+ * A missing object is `expired` rather than `rejected`: nothing was uploaded, so
60
+ * there is nothing to reject, and the row's own retry/expiry budget governs how
61
+ * long the client has left. An empty object, by contrast, *was* written and is
62
+ * not a file.
63
+ */
64
+ export declare function confirmUploadedBytes(input: ConfirmUploadInput): Promise<ConfirmUploadVerdict>;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * The byte-validating half of the presigned lane's confirmation.
4
+ *
5
+ * A presigned upload's bytes never pass through the server: the client PUTs them
6
+ * straight to S3, so at the moment the files row is written the only statements
7
+ * about the file are the client's own. `requested` is exactly that state — a
8
+ * claim — and the row must not reach `uploaded` until the bytes behind it have
9
+ * been looked at, because every downstream processing step (image versions,
10
+ * extraction, embeddings) fires on `uploaded` and would otherwise be handed
11
+ * whatever the client chose to upload.
12
+ *
13
+ * So confirmation answers three questions in order, and each has a distinct
14
+ * outcome on the row:
15
+ *
16
+ * 1. did the bytes arrive? no → `expired` (the client walked away)
17
+ * 2. are they the file they claim? no → `rejected` (and the object deleted)
18
+ * 3. otherwise → `uploaded`
19
+ *
20
+ * The bytes are read with a ranged GET of the leading bytes, not downloaded: a
21
+ * magic-byte signature is in the first few dozen bytes, so this costs the same
22
+ * for a 2GB video as for an icon.
23
+ *
24
+ * This module is the decision, not the transition. The worker that owns the
25
+ * `storage:confirm_upload` job applies it by calling the generated
26
+ * `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
27
+ * verdict is returned rather than executed so that the same rule can be applied
28
+ * by any transport, and tested without a database.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.CONFIRM_PREFIX_BYTES = void 0;
32
+ exports.confirmUploadedBytes = confirmUploadedBytes;
33
+ const mime_bytes_1 = require("mime-bytes");
34
+ const mime_bytes_2 = require("mime-bytes");
35
+ const s3_signer_1 = require("./s3-signer");
36
+ /**
37
+ * How many leading bytes to read. Signatures are far shorter than this; the
38
+ * margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
39
+ * of an MP4) and gives charset detection enough text to work with.
40
+ */
41
+ exports.CONFIRM_PREFIX_BYTES = 4096;
42
+ /**
43
+ * Decide what should happen to a `requested` files row, from its object's bytes.
44
+ *
45
+ * A missing object is `expired` rather than `rejected`: nothing was uploaded, so
46
+ * there is nothing to reject, and the row's own retry/expiry budget governs how
47
+ * long the client has left. An empty object, by contrast, *was* written and is
48
+ * not a file.
49
+ */
50
+ async function confirmUploadedBytes(input) {
51
+ const { s3, key, declaredMime, filename } = input;
52
+ const prefix = await (0, s3_signer_1.readObjectPrefix)(s3, key, exports.CONFIRM_PREFIX_BYTES);
53
+ if (prefix === null) {
54
+ return { outcome: 'expired', reason: `no object at key ${key}: the upload never arrived` };
55
+ }
56
+ if (prefix.length === 0) {
57
+ return {
58
+ outcome: 'rejected',
59
+ reason: `object at key ${key} is empty; an upload must carry at least one byte`,
60
+ detectedMime: null,
61
+ };
62
+ }
63
+ const detected = await (0, mime_bytes_1.detectFromBuffer)(prefix);
64
+ const detectedMime = detected?.mimeType ?? null;
65
+ const agreement = (0, mime_bytes_2.checkTypeAgreement)({ filename, declaredMime, detectedMime });
66
+ if (!agreement.ok) {
67
+ return { outcome: 'rejected', reason: agreement.violation.message, detectedMime };
68
+ }
69
+ return { outcome: 'uploaded', detectedMime };
70
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Server-side bucket resolution.
3
+ *
4
+ * Which bucket a write lands in belongs to the database, never to the client and
5
+ * never to the server's environment: a client-chosen key means a different
6
+ * bucket per tenant, and an env-level bucket name means storage that belongs to
7
+ * no tenant at all. `function_resolution.resolve_default_bucket` is the one
8
+ * place that answers it — a logical key when the field declares one, otherwise
9
+ * the reserved default tag for the requested access ('default' / 'default-public').
10
+ *
11
+ * Zero matches and several matches both raise inside SQL, so there is nothing to
12
+ * guess here: this module only carries the question in and the coordinate out.
13
+ */
14
+ /**
15
+ * The resolved bucket coordinate.
16
+ *
17
+ * `physicalName` is the recorded S3 bucket name, or null when the logical
18
+ * bucket has never been provisioned — the caller mints and records it then.
19
+ */
20
+ export interface ResolvedBucketCoordinate {
21
+ bucketId: string;
22
+ resolvedKey: string;
23
+ bucketType: 'public' | 'private' | 'temp';
24
+ physicalName: string | null;
25
+ }
26
+ /**
27
+ * Resolve the bucket a write should land in.
28
+ *
29
+ * @param scope - The storage module's scope ('app' for database-wide storage)
30
+ * @param entityId - The owning entity row for an entity-scoped module, else null
31
+ * @param publicAccess - Which reserved default tag to use when no key is named,
32
+ * and an assertion on the named bucket's type when one is
33
+ * @param bucketKey - The field's declared logical key, or null for the default
34
+ */
35
+ export declare function resolveDefaultBucket(pgClient: {
36
+ query: (opts: {
37
+ text: string;
38
+ values?: unknown[];
39
+ }) => Promise<{
40
+ rows: unknown[];
41
+ }>;
42
+ }, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ /**
3
+ * Server-side bucket resolution.
4
+ *
5
+ * Which bucket a write lands in belongs to the database, never to the client and
6
+ * never to the server's environment: a client-chosen key means a different
7
+ * bucket per tenant, and an env-level bucket name means storage that belongs to
8
+ * no tenant at all. `function_resolution.resolve_default_bucket` is the one
9
+ * place that answers it — a logical key when the field declares one, otherwise
10
+ * the reserved default tag for the requested access ('default' / 'default-public').
11
+ *
12
+ * Zero matches and several matches both raise inside SQL, so there is nothing to
13
+ * guess here: this module only carries the question in and the coordinate out.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.resolveDefaultBucket = resolveDefaultBucket;
17
+ const logger_1 = require("@pgpmjs/logger");
18
+ const log = new logger_1.Logger('graphile-presigned-url:default-bucket');
19
+ const RESOLVE_DEFAULT_BUCKET_QUERY = `
20
+ SELECT bucket_id, resolved_key, bucket_type, physical_name
21
+ FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
22
+ `;
23
+ /**
24
+ * Resolve the bucket a write should land in.
25
+ *
26
+ * @param scope - The storage module's scope ('app' for database-wide storage)
27
+ * @param entityId - The owning entity row for an entity-scoped module, else null
28
+ * @param publicAccess - Which reserved default tag to use when no key is named,
29
+ * and an assertion on the named bucket's type when one is
30
+ * @param bucketKey - The field's declared logical key, or null for the default
31
+ */
32
+ async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
33
+ const result = await pgClient.query({
34
+ text: RESOLVE_DEFAULT_BUCKET_QUERY,
35
+ values: [databaseId, scope, entityId, publicAccess, bucketKey],
36
+ });
37
+ const row = result.rows[0];
38
+ if (!row) {
39
+ // resolve_default_bucket raises on zero and on several matches, so an empty
40
+ // result means the function did not run as declared rather than "no bucket".
41
+ throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
42
+ `database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
43
+ }
44
+ log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
45
+ return {
46
+ bucketId: row.bucket_id,
47
+ resolvedKey: row.resolved_key,
48
+ bucketType: row.bucket_type,
49
+ physicalName: row.physical_name,
50
+ };
51
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The byte-validating half of the presigned lane's confirmation.
3
+ *
4
+ * A presigned upload's bytes never pass through the server: the client PUTs them
5
+ * straight to S3, so at the moment the files row is written the only statements
6
+ * about the file are the client's own. `requested` is exactly that state — a
7
+ * claim — and the row must not reach `uploaded` until the bytes behind it have
8
+ * been looked at, because every downstream processing step (image versions,
9
+ * extraction, embeddings) fires on `uploaded` and would otherwise be handed
10
+ * whatever the client chose to upload.
11
+ *
12
+ * So confirmation answers three questions in order, and each has a distinct
13
+ * outcome on the row:
14
+ *
15
+ * 1. did the bytes arrive? no → `expired` (the client walked away)
16
+ * 2. are they the file they claim? no → `rejected` (and the object deleted)
17
+ * 3. otherwise → `uploaded`
18
+ *
19
+ * The bytes are read with a ranged GET of the leading bytes, not downloaded: a
20
+ * magic-byte signature is in the first few dozen bytes, so this costs the same
21
+ * for a 2GB video as for an icon.
22
+ *
23
+ * This module is the decision, not the transition. The worker that owns the
24
+ * `storage:confirm_upload` job applies it by calling the generated
25
+ * `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
26
+ * verdict is returned rather than executed so that the same rule can be applied
27
+ * by any transport, and tested without a database.
28
+ */
29
+ import type { S3Config } from './types';
30
+ /**
31
+ * How many leading bytes to read. Signatures are far shorter than this; the
32
+ * margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
33
+ * of an MP4) and gives charset detection enough text to work with.
34
+ */
35
+ export declare const CONFIRM_PREFIX_BYTES = 4096;
36
+ export interface ConfirmUploadInput {
37
+ s3: S3Config;
38
+ /** The object key the presigned PUT was signed for. */
39
+ key: string;
40
+ /** The MIME type the client declared when the row was created. */
41
+ declaredMime: string;
42
+ /** The filename recorded on the files row, if any. */
43
+ filename?: string | null;
44
+ }
45
+ export type ConfirmUploadVerdict = {
46
+ outcome: 'uploaded';
47
+ detectedMime: string | null;
48
+ } | {
49
+ outcome: 'rejected';
50
+ reason: string;
51
+ detectedMime: string | null;
52
+ } | {
53
+ outcome: 'expired';
54
+ reason: string;
55
+ };
56
+ /**
57
+ * Decide what should happen to a `requested` files row, from its object's bytes.
58
+ *
59
+ * A missing object is `expired` rather than `rejected`: nothing was uploaded, so
60
+ * there is nothing to reject, and the row's own retry/expiry budget governs how
61
+ * long the client has left. An empty object, by contrast, *was* written and is
62
+ * not a file.
63
+ */
64
+ export declare function confirmUploadedBytes(input: ConfirmUploadInput): Promise<ConfirmUploadVerdict>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The byte-validating half of the presigned lane's confirmation.
3
+ *
4
+ * A presigned upload's bytes never pass through the server: the client PUTs them
5
+ * straight to S3, so at the moment the files row is written the only statements
6
+ * about the file are the client's own. `requested` is exactly that state — a
7
+ * claim — and the row must not reach `uploaded` until the bytes behind it have
8
+ * been looked at, because every downstream processing step (image versions,
9
+ * extraction, embeddings) fires on `uploaded` and would otherwise be handed
10
+ * whatever the client chose to upload.
11
+ *
12
+ * So confirmation answers three questions in order, and each has a distinct
13
+ * outcome on the row:
14
+ *
15
+ * 1. did the bytes arrive? no → `expired` (the client walked away)
16
+ * 2. are they the file they claim? no → `rejected` (and the object deleted)
17
+ * 3. otherwise → `uploaded`
18
+ *
19
+ * The bytes are read with a ranged GET of the leading bytes, not downloaded: a
20
+ * magic-byte signature is in the first few dozen bytes, so this costs the same
21
+ * for a 2GB video as for an icon.
22
+ *
23
+ * This module is the decision, not the transition. The worker that owns the
24
+ * `storage:confirm_upload` job applies it by calling the generated
25
+ * `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
26
+ * verdict is returned rather than executed so that the same rule can be applied
27
+ * by any transport, and tested without a database.
28
+ */
29
+ import { detectFromBuffer } from 'mime-bytes';
30
+ import { checkTypeAgreement } from 'mime-bytes';
31
+ import { readObjectPrefix } from './s3-signer';
32
+ /**
33
+ * How many leading bytes to read. Signatures are far shorter than this; the
34
+ * margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
35
+ * of an MP4) and gives charset detection enough text to work with.
36
+ */
37
+ export const CONFIRM_PREFIX_BYTES = 4096;
38
+ /**
39
+ * Decide what should happen to a `requested` files row, from its object's bytes.
40
+ *
41
+ * A missing object is `expired` rather than `rejected`: nothing was uploaded, so
42
+ * there is nothing to reject, and the row's own retry/expiry budget governs how
43
+ * long the client has left. An empty object, by contrast, *was* written and is
44
+ * not a file.
45
+ */
46
+ export async function confirmUploadedBytes(input) {
47
+ const { s3, key, declaredMime, filename } = input;
48
+ const prefix = await readObjectPrefix(s3, key, CONFIRM_PREFIX_BYTES);
49
+ if (prefix === null) {
50
+ return { outcome: 'expired', reason: `no object at key ${key}: the upload never arrived` };
51
+ }
52
+ if (prefix.length === 0) {
53
+ return {
54
+ outcome: 'rejected',
55
+ reason: `object at key ${key} is empty; an upload must carry at least one byte`,
56
+ detectedMime: null,
57
+ };
58
+ }
59
+ const detected = await detectFromBuffer(prefix);
60
+ const detectedMime = detected?.mimeType ?? null;
61
+ const agreement = checkTypeAgreement({ filename, declaredMime, detectedMime });
62
+ if (!agreement.ok) {
63
+ return { outcome: 'rejected', reason: agreement.violation.message, detectedMime };
64
+ }
65
+ return { outcome: 'uploaded', detectedMime };
66
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Server-side bucket resolution.
3
+ *
4
+ * Which bucket a write lands in belongs to the database, never to the client and
5
+ * never to the server's environment: a client-chosen key means a different
6
+ * bucket per tenant, and an env-level bucket name means storage that belongs to
7
+ * no tenant at all. `function_resolution.resolve_default_bucket` is the one
8
+ * place that answers it — a logical key when the field declares one, otherwise
9
+ * the reserved default tag for the requested access ('default' / 'default-public').
10
+ *
11
+ * Zero matches and several matches both raise inside SQL, so there is nothing to
12
+ * guess here: this module only carries the question in and the coordinate out.
13
+ */
14
+ /**
15
+ * The resolved bucket coordinate.
16
+ *
17
+ * `physicalName` is the recorded S3 bucket name, or null when the logical
18
+ * bucket has never been provisioned — the caller mints and records it then.
19
+ */
20
+ export interface ResolvedBucketCoordinate {
21
+ bucketId: string;
22
+ resolvedKey: string;
23
+ bucketType: 'public' | 'private' | 'temp';
24
+ physicalName: string | null;
25
+ }
26
+ /**
27
+ * Resolve the bucket a write should land in.
28
+ *
29
+ * @param scope - The storage module's scope ('app' for database-wide storage)
30
+ * @param entityId - The owning entity row for an entity-scoped module, else null
31
+ * @param publicAccess - Which reserved default tag to use when no key is named,
32
+ * and an assertion on the named bucket's type when one is
33
+ * @param bucketKey - The field's declared logical key, or null for the default
34
+ */
35
+ export declare function resolveDefaultBucket(pgClient: {
36
+ query: (opts: {
37
+ text: string;
38
+ values?: unknown[];
39
+ }) => Promise<{
40
+ rows: unknown[];
41
+ }>;
42
+ }, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Server-side bucket resolution.
3
+ *
4
+ * Which bucket a write lands in belongs to the database, never to the client and
5
+ * never to the server's environment: a client-chosen key means a different
6
+ * bucket per tenant, and an env-level bucket name means storage that belongs to
7
+ * no tenant at all. `function_resolution.resolve_default_bucket` is the one
8
+ * place that answers it — a logical key when the field declares one, otherwise
9
+ * the reserved default tag for the requested access ('default' / 'default-public').
10
+ *
11
+ * Zero matches and several matches both raise inside SQL, so there is nothing to
12
+ * guess here: this module only carries the question in and the coordinate out.
13
+ */
14
+ import { Logger } from '@pgpmjs/logger';
15
+ const log = new Logger('graphile-presigned-url:default-bucket');
16
+ const RESOLVE_DEFAULT_BUCKET_QUERY = `
17
+ SELECT bucket_id, resolved_key, bucket_type, physical_name
18
+ FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
19
+ `;
20
+ /**
21
+ * Resolve the bucket a write should land in.
22
+ *
23
+ * @param scope - The storage module's scope ('app' for database-wide storage)
24
+ * @param entityId - The owning entity row for an entity-scoped module, else null
25
+ * @param publicAccess - Which reserved default tag to use when no key is named,
26
+ * and an assertion on the named bucket's type when one is
27
+ * @param bucketKey - The field's declared logical key, or null for the default
28
+ */
29
+ export async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
30
+ const result = await pgClient.query({
31
+ text: RESOLVE_DEFAULT_BUCKET_QUERY,
32
+ values: [databaseId, scope, entityId, publicAccess, bucketKey],
33
+ });
34
+ const row = result.rows[0];
35
+ if (!row) {
36
+ // resolve_default_bucket raises on zero and on several matches, so an empty
37
+ // result means the function did not run as declared rather than "no bucket".
38
+ throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
39
+ `database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
40
+ }
41
+ log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
42
+ return {
43
+ bucketId: row.bucket_id,
44
+ resolvedKey: row.resolved_key,
45
+ bucketType: row.bucket_type,
46
+ physicalName: row.physical_name,
47
+ };
48
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The `file_ref_field` registry: which storage module and bucket a managed
3
+ * document column writes into.
4
+ *
5
+ * An `image`/`upload` column is a projection of a files row, and the decision of
6
+ * *where* those bytes live is a property of the field declaration, not of the
7
+ * request. The registry records that intent per (table, column) — a storage
8
+ * module plus either a logical bucket key, a tag selector, or nothing at all
9
+ * (meaning the reserved default tag for the declared publicness).
10
+ *
11
+ * This module answers one question — "what does a write to this column bind
12
+ * to?" — and answers it loudly: an unregistered column raises rather than
13
+ * falling back to a server-global bucket, because a silent fallback is how the
14
+ * unmanaged lane produced objects no tenant owned.
15
+ */
16
+ /**
17
+ * A field's recorded storage intent.
18
+ *
19
+ * `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
20
+ * both may be absent — resolution then uses the reserved default tag for
21
+ * `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
22
+ * is resolved per written row, inside the tenant.
23
+ */
24
+ export interface FileRefFieldBinding {
25
+ id: string;
26
+ storageModuleId: string;
27
+ bucketKey: string | null;
28
+ bucketTags: string[] | null;
29
+ isPublic: boolean | null;
30
+ enforceFk: boolean;
31
+ }
32
+ export declare class FileRefFieldNotRegisteredError extends Error {
33
+ readonly databaseId: string;
34
+ readonly schemaName: string;
35
+ readonly tableName: string;
36
+ readonly columnName: string;
37
+ constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
38
+ }
39
+ /**
40
+ * Look up the storage binding for a document column, or throw.
41
+ *
42
+ * The read runs on whichever client the caller passes. The registry is schema
43
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
44
+ * the RLS that matters is on the files table the upload eventually writes.
45
+ */
46
+ export declare function getFileRefFieldBinding(pgClient: {
47
+ query: (opts: {
48
+ text: string;
49
+ values?: unknown[];
50
+ }) => Promise<{
51
+ rows: unknown[];
52
+ }>;
53
+ }, databaseId: string, field: {
54
+ schemaName: string;
55
+ tableName: string;
56
+ columnName: string;
57
+ }): Promise<FileRefFieldBinding>;
58
+ /**
59
+ * Drop cached bindings. Used by tests and after a re-provision.
60
+ */
61
+ export declare function clearFileRefFieldCache(): void;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The `file_ref_field` registry: which storage module and bucket a managed
3
+ * document column writes into.
4
+ *
5
+ * An `image`/`upload` column is a projection of a files row, and the decision of
6
+ * *where* those bytes live is a property of the field declaration, not of the
7
+ * request. The registry records that intent per (table, column) — a storage
8
+ * module plus either a logical bucket key, a tag selector, or nothing at all
9
+ * (meaning the reserved default tag for the declared publicness).
10
+ *
11
+ * This module answers one question — "what does a write to this column bind
12
+ * to?" — and answers it loudly: an unregistered column raises rather than
13
+ * falling back to a server-global bucket, because a silent fallback is how the
14
+ * unmanaged lane produced objects no tenant owned.
15
+ */
16
+ import { Logger } from '@pgpmjs/logger';
17
+ import { LRUCache } from 'lru-cache';
18
+ const log = new Logger('graphile-presigned-url:file-ref-registry');
19
+ const FIVE_MINUTES_MS = 1000 * 60 * 5;
20
+ const ONE_HOUR_MS = 1000 * 60 * 60;
21
+ /**
22
+ * Resolve the registry row for a document column.
23
+ *
24
+ * Joined through metaschema rather than keyed by name, because the registry
25
+ * records field *ids*: the physical (schema, table, column) triple is what the
26
+ * GraphQL layer knows, and metaschema is the only thing that maps one to the
27
+ * other.
28
+ */
29
+ const FILE_REF_FIELD_QUERY = `
30
+ SELECT
31
+ frf.id,
32
+ frf.storage_module_id,
33
+ frf.bucket_key,
34
+ frf.bucket_tags::text[] AS bucket_tags,
35
+ frf.is_public,
36
+ frf.enforce_fk
37
+ FROM metaschema_modules_public.file_ref_field frf
38
+ JOIN metaschema_public.field f ON f.id = frf.field_id
39
+ JOIN metaschema_public.table t ON t.id = frf.table_id
40
+ JOIN metaschema_public.schema s ON s.id = t.schema_id
41
+ WHERE frf.database_id = $1
42
+ AND s.schema_name = $2
43
+ AND t.name = $3
44
+ AND f.name = $4
45
+ LIMIT 1
46
+ `;
47
+ /**
48
+ * LRU cache of field bindings.
49
+ *
50
+ * A binding is schema, not data: it changes only when a database is
51
+ * re-provisioned, so it caches on the same terms as the storage module config
52
+ * next to it. Misses are never cached — an unregistered column is a hard error
53
+ * every time it is written, not a remembered "no".
54
+ */
55
+ const bindingCache = new LRUCache({
56
+ max: 500,
57
+ ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
58
+ updateAgeOnGet: true,
59
+ });
60
+ export class FileRefFieldNotRegisteredError extends Error {
61
+ databaseId;
62
+ schemaName;
63
+ tableName;
64
+ columnName;
65
+ constructor(databaseId, schemaName, tableName, columnName) {
66
+ super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
67
+ `is not a registered file-reference field in database ${databaseId}. ` +
68
+ 'A managed upload needs the declared storage module and bucket intent; ' +
69
+ 'there is no server-global bucket to fall back to.');
70
+ this.databaseId = databaseId;
71
+ this.schemaName = schemaName;
72
+ this.tableName = tableName;
73
+ this.columnName = columnName;
74
+ this.name = 'FileRefFieldNotRegisteredError';
75
+ }
76
+ }
77
+ /**
78
+ * Look up the storage binding for a document column, or throw.
79
+ *
80
+ * The read runs on whichever client the caller passes. The registry is schema
81
+ * metadata rather than tenant rows, so callers resolve it in the system lane —
82
+ * the RLS that matters is on the files table the upload eventually writes.
83
+ */
84
+ export async function getFileRefFieldBinding(pgClient, databaseId, field) {
85
+ const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
86
+ const cached = bindingCache.get(cacheKey);
87
+ if (cached)
88
+ return cached;
89
+ const result = await pgClient.query({
90
+ text: FILE_REF_FIELD_QUERY,
91
+ values: [databaseId, field.schemaName, field.tableName, field.columnName],
92
+ });
93
+ if (result.rows.length === 0) {
94
+ throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
95
+ }
96
+ const row = result.rows[0];
97
+ const binding = {
98
+ id: row.id,
99
+ storageModuleId: row.storage_module_id,
100
+ bucketKey: row.bucket_key,
101
+ bucketTags: row.bucket_tags,
102
+ isPublic: row.is_public,
103
+ enforceFk: row.enforce_fk,
104
+ };
105
+ bindingCache.set(cacheKey, binding);
106
+ log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
107
+ `${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
108
+ return binding;
109
+ }
110
+ /**
111
+ * Drop cached bindings. Used by tests and after a re-provision.
112
+ */
113
+ export function clearFileRefFieldCache() {
114
+ bindingCache.clear();
115
+ }
package/esm/index.d.ts CHANGED
@@ -26,9 +26,17 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
+ export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
30
+ export type { ResolvedBucketCoordinate } from './default-bucket';
31
+ export { resolveDefaultBucket } from './default-bucket';
29
32
  export { createDownloadUrlPlugin } from './download-url-field';
33
+ export type { FileRefFieldBinding } from './file-ref-registry';
34
+ export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
35
+ export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
36
+ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
30
37
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
38
  export { PresignedUrlPreset } from './preset';
32
- export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
39
+ export { type WithPgClient, withRequestPgClient } from './request-pg-client';
40
+ export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
33
41
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
34
42
  export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
package/esm/index.js CHANGED
@@ -26,8 +26,14 @@
26
26
  * };
27
27
  * ```
28
28
  */
29
+ export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, } from './confirm-upload';
30
+ export { resolveDefaultBucket } from './default-bucket';
29
31
  export { createDownloadUrlPlugin } from './download-url-field';
32
+ export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
33
+ export { assertUploadAllowedByBucket, buildFileProjection, finalizeStagedUpload, resolveManagedUploadTarget, } from './managed-upload';
34
+ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
30
35
  export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
31
36
  export { PresignedUrlPreset } from './preset';
32
- export { deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
37
+ export { withRequestPgClient } from './request-pg-client';
38
+ export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
33
39
  export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';