graphile-presigned-url-plugin 0.2.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,180 @@
1
+ import { Logger } from '@pgpmjs/logger';
2
+ import { LRUCache } from 'lru-cache';
3
+ const log = new Logger('graphile-presigned-url:cache');
4
+ // --- Defaults ---
5
+ const DEFAULT_UPLOAD_URL_EXPIRY_SECONDS = 900; // 15 minutes
6
+ const DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS = 3600; // 1 hour
7
+ const DEFAULT_MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
8
+ const DEFAULT_MAX_FILENAME_LENGTH = 1024;
9
+ const DEFAULT_CACHE_TTL_SECONDS = process.env.NODE_ENV === 'development' ? 300 : 3600;
10
+ const FIVE_MINUTES_MS = 1000 * 60 * 5;
11
+ const ONE_HOUR_MS = 1000 * 60 * 60;
12
+ /**
13
+ * LRU cache for per-database StorageModuleConfig.
14
+ *
15
+ * Each PostGraphile instance serves a single database, but the presigned URL
16
+ * plugin needs to know the generated table names (buckets, files,
17
+ * upload_requests) and their schemas. This cache avoids re-querying metaschema
18
+ * on every request.
19
+ *
20
+ * Pattern: same as graphile-cache's LRU with TTL-based eviction.
21
+ */
22
+ const storageModuleCache = new LRUCache({
23
+ max: 50,
24
+ ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
25
+ updateAgeOnGet: true,
26
+ });
27
+ /**
28
+ * SQL query to resolve storage module config for a database.
29
+ *
30
+ * Joins storage_module → table → schema to get fully-qualified table names.
31
+ */
32
+ const STORAGE_MODULE_QUERY = `
33
+ SELECT
34
+ sm.id,
35
+ bs.schema_name AS buckets_schema,
36
+ bt.name AS buckets_table,
37
+ fs.schema_name AS files_schema,
38
+ ft.name AS files_table,
39
+ urs.schema_name AS upload_requests_schema,
40
+ urt.name AS upload_requests_table,
41
+ sm.upload_url_expiry_seconds,
42
+ sm.download_url_expiry_seconds,
43
+ sm.default_max_file_size,
44
+ sm.max_filename_length,
45
+ sm.cache_ttl_seconds
46
+ FROM metaschema_modules_public.storage_module sm
47
+ JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
48
+ JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
49
+ JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
50
+ JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
51
+ JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
52
+ JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
53
+ WHERE sm.database_id = $1
54
+ LIMIT 1
55
+ `;
56
+ /**
57
+ * Resolve the storage module config for a database, using the LRU cache.
58
+ *
59
+ * @param pgClient - A pg client from the Graphile context (withPgClient or pgClient)
60
+ * @param databaseId - The metaschema database UUID
61
+ * @returns StorageModuleConfig or null if no storage module is provisioned
62
+ */
63
+ export async function getStorageModuleConfig(pgClient, databaseId) {
64
+ const cacheKey = `storage:${databaseId}`;
65
+ const cached = storageModuleCache.get(cacheKey);
66
+ if (cached) {
67
+ return cached;
68
+ }
69
+ log.debug(`Cache miss for database ${databaseId}, querying metaschema...`);
70
+ const result = await pgClient.query(STORAGE_MODULE_QUERY, [databaseId]);
71
+ if (result.rows.length === 0) {
72
+ log.warn(`No storage module found for database ${databaseId}`);
73
+ return null;
74
+ }
75
+ const row = result.rows[0];
76
+ const cacheTtlSeconds = row.cache_ttl_seconds ?? DEFAULT_CACHE_TTL_SECONDS;
77
+ const config = {
78
+ id: row.id,
79
+ bucketsQualifiedName: `"${row.buckets_schema}"."${row.buckets_table}"`,
80
+ filesQualifiedName: `"${row.files_schema}"."${row.files_table}"`,
81
+ uploadRequestsQualifiedName: `"${row.upload_requests_schema}"."${row.upload_requests_table}"`,
82
+ schemaName: row.buckets_schema,
83
+ bucketsTableName: row.buckets_table,
84
+ filesTableName: row.files_table,
85
+ uploadRequestsTableName: row.upload_requests_table,
86
+ uploadUrlExpirySeconds: row.upload_url_expiry_seconds ?? DEFAULT_UPLOAD_URL_EXPIRY_SECONDS,
87
+ downloadUrlExpirySeconds: row.download_url_expiry_seconds ?? DEFAULT_DOWNLOAD_URL_EXPIRY_SECONDS,
88
+ defaultMaxFileSize: row.default_max_file_size ?? DEFAULT_MAX_FILE_SIZE,
89
+ maxFilenameLength: row.max_filename_length ?? DEFAULT_MAX_FILENAME_LENGTH,
90
+ cacheTtlSeconds,
91
+ };
92
+ storageModuleCache.set(cacheKey, config);
93
+ log.debug(`Cached storage config for database ${databaseId}: ${config.bucketsQualifiedName}`);
94
+ return config;
95
+ }
96
+ // --- Bucket metadata cache ---
97
+ /**
98
+ * LRU cache for per-database bucket metadata.
99
+ *
100
+ * Buckets are essentially static config — created once and rarely changed.
101
+ * Caching avoids a DB query on every requestUploadUrl call. The bucket
102
+ * lookup in the plugin runs under RLS, but since AuthzEntityMembership
103
+ * grants all org members access to all org buckets, and the cached data
104
+ * is just config (mime types, size limits), bypassing RLS on cache hits
105
+ * is safe. The important RLS is on the files table (INSERT/UPDATE),
106
+ * which is never cached.
107
+ *
108
+ * Keys: `bucket:${databaseId}:${bucketKey}`
109
+ * TTL: same as storage module cache (5min dev / 1hr prod)
110
+ */
111
+ const bucketCache = new LRUCache({
112
+ max: 500, // many buckets across many databases
113
+ ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
114
+ updateAgeOnGet: true,
115
+ });
116
+ /**
117
+ * Resolve bucket metadata for a given database + bucket key, using the LRU cache.
118
+ *
119
+ * On cache miss, queries the bucket table (RLS-enforced via pgSettings on
120
+ * the pgClient). On cache hit, returns the cached metadata directly.
121
+ *
122
+ * @param pgClient - A pg client from the Graphile context
123
+ * @param storageConfig - The resolved StorageModuleConfig for this database
124
+ * @param databaseId - The metaschema database UUID (used as cache key prefix)
125
+ * @param bucketKey - The bucket key (e.g., "public", "private")
126
+ * @returns BucketConfig or null if the bucket doesn't exist / isn't accessible
127
+ */
128
+ export async function getBucketConfig(pgClient, storageConfig, databaseId, bucketKey) {
129
+ const cacheKey = `bucket:${databaseId}:${bucketKey}`;
130
+ const cached = bucketCache.get(cacheKey);
131
+ if (cached) {
132
+ return cached;
133
+ }
134
+ log.debug(`Bucket cache miss for ${databaseId}:${bucketKey}, querying DB...`);
135
+ const result = await pgClient.query(`SELECT id, key, type, is_public, owner_id, allowed_mime_types, max_file_size
136
+ FROM ${storageConfig.bucketsQualifiedName}
137
+ WHERE key = $1
138
+ LIMIT 1`, [bucketKey]);
139
+ if (result.rows.length === 0) {
140
+ return null;
141
+ }
142
+ const row = result.rows[0];
143
+ const config = {
144
+ id: row.id,
145
+ key: row.key,
146
+ type: row.type,
147
+ is_public: row.is_public,
148
+ owner_id: row.owner_id,
149
+ allowed_mime_types: row.allowed_mime_types,
150
+ max_file_size: row.max_file_size,
151
+ };
152
+ bucketCache.set(cacheKey, config);
153
+ log.debug(`Cached bucket config for ${databaseId}:${bucketKey} (id=${config.id})`);
154
+ return config;
155
+ }
156
+ /**
157
+ * Clear the storage module cache AND bucket cache.
158
+ * Useful for testing or schema changes.
159
+ */
160
+ export function clearStorageModuleCache() {
161
+ storageModuleCache.clear();
162
+ bucketCache.clear();
163
+ }
164
+ /**
165
+ * Clear cached bucket entries for a specific database.
166
+ * Useful when bucket config changes are detected.
167
+ */
168
+ export function clearBucketCache(databaseId) {
169
+ if (!databaseId) {
170
+ bucketCache.clear();
171
+ return;
172
+ }
173
+ // Evict all entries for this database
174
+ const prefix = `bucket:${databaseId}:`;
175
+ for (const key of bucketCache.keys()) {
176
+ if (key.startsWith(prefix)) {
177
+ bucketCache.delete(key);
178
+ }
179
+ }
180
+ }
package/esm/types.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { S3Client } from '@aws-sdk/client-s3';
2
+ /**
3
+ * Per-bucket configuration resolved from the storage_module tables.
4
+ */
5
+ export interface BucketConfig {
6
+ id: string;
7
+ key: string;
8
+ type: 'public' | 'private' | 'temp';
9
+ is_public: boolean;
10
+ owner_id: string;
11
+ allowed_mime_types: string[] | null;
12
+ max_file_size: number | null;
13
+ }
14
+ /**
15
+ * Storage module configuration resolved from metaschema for a given database.
16
+ */
17
+ export interface StorageModuleConfig {
18
+ /** The metaschema storage_module row ID */
19
+ id: string;
20
+ /** Resolved schema.table for buckets */
21
+ bucketsQualifiedName: string;
22
+ /** Resolved schema.table for files */
23
+ filesQualifiedName: string;
24
+ /** Resolved schema.table for upload_requests */
25
+ uploadRequestsQualifiedName: string;
26
+ /** Schema name (e.g., "app_public") */
27
+ schemaName: string;
28
+ /** Buckets table name */
29
+ bucketsTableName: string;
30
+ /** Files table name */
31
+ filesTableName: string;
32
+ /** Upload requests table name */
33
+ uploadRequestsTableName: string;
34
+ /** Presigned PUT URL expiry in seconds (default: 900 = 15 min) */
35
+ uploadUrlExpirySeconds: number;
36
+ /** Presigned GET URL expiry in seconds (default: 3600 = 1 hour) */
37
+ downloadUrlExpirySeconds: number;
38
+ /** Default max file size in bytes (default: 200MB). Bucket-level max_file_size overrides this. */
39
+ defaultMaxFileSize: number;
40
+ /** Max filename length in characters (default: 1024) */
41
+ maxFilenameLength: number;
42
+ /** Cache TTL in seconds for this config entry (default: 300 dev / 3600 prod) */
43
+ cacheTtlSeconds: number;
44
+ }
45
+ /**
46
+ * Input for the requestUploadUrl mutation.
47
+ */
48
+ export interface RequestUploadUrlInput {
49
+ /** Bucket key (e.g., "public", "private") */
50
+ bucketKey: string;
51
+ /** SHA-256 content hash computed by the client */
52
+ contentHash: string;
53
+ /** MIME type of the file */
54
+ contentType: string;
55
+ /** File size in bytes */
56
+ size: number;
57
+ /** Original filename (optional, for display/Content-Disposition) */
58
+ filename?: string;
59
+ }
60
+ /**
61
+ * Result of the requestUploadUrl mutation.
62
+ */
63
+ export interface RequestUploadUrlPayload {
64
+ /** Presigned PUT URL (null if deduplicated) */
65
+ uploadUrl: string | null;
66
+ /** The file ID (existing if dedup, new if fresh upload) */
67
+ fileId: string;
68
+ /** The S3 key */
69
+ key: string;
70
+ /** Whether this file was deduplicated (already exists with same hash) */
71
+ deduplicated: boolean;
72
+ /** Presigned URL expiry time (null if deduplicated) */
73
+ expiresAt: string | null;
74
+ }
75
+ /**
76
+ * Input for the confirmUpload mutation.
77
+ */
78
+ export interface ConfirmUploadInput {
79
+ /** The file ID returned by requestUploadUrl */
80
+ fileId: string;
81
+ }
82
+ /**
83
+ * Result of the confirmUpload mutation.
84
+ */
85
+ export interface ConfirmUploadPayload {
86
+ /** The confirmed file ID */
87
+ fileId: string;
88
+ /** New file status (should be 'ready') */
89
+ status: string;
90
+ /** Whether confirmation succeeded */
91
+ success: boolean;
92
+ }
93
+ /**
94
+ * S3 configuration for the presigned URL plugin.
95
+ */
96
+ export interface S3Config {
97
+ /** S3 client instance */
98
+ client: S3Client;
99
+ /** S3 bucket name (the actual S3 bucket, not the logical bucket key) */
100
+ bucket: string;
101
+ /** S3 endpoint URL (for MinIO/custom S3) */
102
+ endpoint?: string;
103
+ /** S3 region */
104
+ region?: string;
105
+ /** Whether to use path-style URLs (required for MinIO) */
106
+ forcePathStyle?: boolean;
107
+ /** Public URL prefix for generating download URLs */
108
+ publicUrlPrefix?: string;
109
+ }
110
+ /**
111
+ * Plugin options for the presigned URL plugin.
112
+ */
113
+ export interface PresignedUrlPluginOptions {
114
+ /** S3 configuration */
115
+ s3: S3Config;
116
+ }
package/esm/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/index.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Presigned URL Plugin for PostGraphile v5
3
+ *
4
+ * Provides presigned URL upload capabilities for PostGraphile v5:
5
+ * - requestUploadUrl mutation (presigned PUT URL generation)
6
+ * - confirmUpload mutation (upload verification + status transition)
7
+ * - downloadUrl computed field (presigned GET URL / public URL)
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
12
+ * import { S3Client } from '@aws-sdk/client-s3';
13
+ *
14
+ * const s3Client = new S3Client({ region: 'us-east-1' });
15
+ *
16
+ * const preset = {
17
+ * extends: [
18
+ * PresignedUrlPreset({
19
+ * s3: {
20
+ * client: s3Client,
21
+ * bucket: 'my-uploads',
22
+ * publicUrlPrefix: 'https://cdn.example.com',
23
+ * },
24
+ * }),
25
+ * ],
26
+ * };
27
+ * ```
28
+ */
29
+ export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
30
+ export { createDownloadUrlPlugin } from './download-url-field';
31
+ export { PresignedUrlPreset } from './preset';
32
+ export { getStorageModuleConfig, getBucketConfig, clearStorageModuleCache, clearBucketCache } from './storage-module-cache';
33
+ export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
34
+ export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, ConfirmUploadInput, ConfirmUploadPayload, S3Config, PresignedUrlPluginOptions, } from './types';
package/index.js ADDED
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ /**
3
+ * Presigned URL Plugin for PostGraphile v5
4
+ *
5
+ * Provides presigned URL upload capabilities for PostGraphile v5:
6
+ * - requestUploadUrl mutation (presigned PUT URL generation)
7
+ * - confirmUpload mutation (upload verification + status transition)
8
+ * - downloadUrl computed field (presigned GET URL / public URL)
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
13
+ * import { S3Client } from '@aws-sdk/client-s3';
14
+ *
15
+ * const s3Client = new S3Client({ region: 'us-east-1' });
16
+ *
17
+ * const preset = {
18
+ * extends: [
19
+ * PresignedUrlPreset({
20
+ * s3: {
21
+ * client: s3Client,
22
+ * bucket: 'my-uploads',
23
+ * publicUrlPrefix: 'https://cdn.example.com',
24
+ * },
25
+ * }),
26
+ * ],
27
+ * };
28
+ * ```
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.headObject = exports.generatePresignedGetUrl = exports.generatePresignedPutUrl = exports.clearBucketCache = exports.clearStorageModuleCache = exports.getBucketConfig = exports.getStorageModuleConfig = exports.PresignedUrlPreset = exports.createDownloadUrlPlugin = exports.createPresignedUrlPlugin = exports.PresignedUrlPlugin = void 0;
32
+ var plugin_1 = require("./plugin");
33
+ Object.defineProperty(exports, "PresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.PresignedUrlPlugin; } });
34
+ Object.defineProperty(exports, "createPresignedUrlPlugin", { enumerable: true, get: function () { return plugin_1.createPresignedUrlPlugin; } });
35
+ var download_url_field_1 = require("./download-url-field");
36
+ Object.defineProperty(exports, "createDownloadUrlPlugin", { enumerable: true, get: function () { return download_url_field_1.createDownloadUrlPlugin; } });
37
+ var preset_1 = require("./preset");
38
+ Object.defineProperty(exports, "PresignedUrlPreset", { enumerable: true, get: function () { return preset_1.PresignedUrlPreset; } });
39
+ var storage_module_cache_1 = require("./storage-module-cache");
40
+ Object.defineProperty(exports, "getStorageModuleConfig", { enumerable: true, get: function () { return storage_module_cache_1.getStorageModuleConfig; } });
41
+ Object.defineProperty(exports, "getBucketConfig", { enumerable: true, get: function () { return storage_module_cache_1.getBucketConfig; } });
42
+ Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
43
+ Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
44
+ var s3_signer_1 = require("./s3-signer");
45
+ Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
46
+ Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
47
+ Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "graphile-presigned-url-plugin",
3
+ "version": "0.2.0",
4
+ "description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "clean": "makage clean",
13
+ "prepack": "npm run build",
14
+ "build": "makage build",
15
+ "build:dev": "makage build --dev",
16
+ "lint": "eslint . --fix",
17
+ "test": "jest --passWithNoTests",
18
+ "test:watch": "jest --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "directory": "dist"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/constructive-io/constructive"
27
+ },
28
+ "keywords": [
29
+ "postgraphile",
30
+ "graphile",
31
+ "constructive",
32
+ "plugin",
33
+ "postgres",
34
+ "graphql",
35
+ "presigned-url",
36
+ "upload",
37
+ "s3"
38
+ ],
39
+ "bugs": {
40
+ "url": "https://github.com/constructive-io/constructive/issues"
41
+ },
42
+ "dependencies": {
43
+ "@aws-sdk/client-s3": "^3.1009.0",
44
+ "@aws-sdk/s3-request-presigner": "^3.1009.0",
45
+ "@pgpmjs/logger": "^2.5.2",
46
+ "lru-cache": "^11.2.7"
47
+ },
48
+ "peerDependencies": {
49
+ "grafast": "1.0.0",
50
+ "graphile-build": "5.0.0",
51
+ "graphile-build-pg": "5.0.0",
52
+ "graphile-config": "1.0.0",
53
+ "graphile-utils": "5.0.0",
54
+ "graphql": "16.13.0",
55
+ "postgraphile": "5.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^22.19.11",
59
+ "makage": "^0.1.10"
60
+ },
61
+ "gitHead": "fc23b83307d007a14e54b1d0fc36614b9650a5dc"
62
+ }
package/plugin.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Presigned URL Plugin for PostGraphile v5
3
+ *
4
+ * Adds presigned URL upload support to PostGraphile v5:
5
+ *
6
+ * 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
7
+ * client-to-S3 upload. Checks bucket access via RLS, deduplicates by
8
+ * content hash, tracks the request in upload_requests.
9
+ *
10
+ * 2. `confirmUpload` mutation — confirms a file was uploaded to S3, verifies
11
+ * the object exists with correct content-type, transitions file status
12
+ * from 'pending' to 'ready'.
13
+ *
14
+ * 3. `downloadUrl` computed field on File types — generates presigned GET URLs
15
+ * for private files, returns public URL prefix + key for public files.
16
+ *
17
+ * Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
18
+ */
19
+ import type { GraphileConfig } from 'graphile-config';
20
+ import type { PresignedUrlPluginOptions } from './types';
21
+ export declare function createPresignedUrlPlugin(options: PresignedUrlPluginOptions): GraphileConfig.Plugin;
22
+ export declare const PresignedUrlPlugin: typeof createPresignedUrlPlugin;
23
+ export default PresignedUrlPlugin;