graphile-bucket-provisioner-plugin 1.13.0 → 1.14.1
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/README.md +4 -9
- package/esm/index.d.ts +1 -8
- package/esm/index.js +1 -8
- package/esm/plugin.d.ts +2 -20
- package/esm/plugin.js +72 -273
- package/esm/preset.d.ts +0 -1
- package/esm/preset.js +0 -1
- package/esm/types.d.ts +3 -22
- package/esm/types.js +0 -4
- package/index.d.ts +1 -8
- package/index.js +1 -8
- package/package.json +6 -5
- package/plugin.d.ts +2 -20
- package/plugin.js +72 -273
- package/preset.d.ts +0 -1
- package/preset.js +0 -1
- package/types.d.ts +3 -22
- package/types.js +0 -4
package/README.md
CHANGED
|
@@ -12,19 +12,16 @@
|
|
|
12
12
|
<a href="https://www.npmjs.com/package/graphile-bucket-provisioner-plugin"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-bucket-provisioner-plugin%2Fpackage.json"/></a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
-
PostGraphile v5 plugin that
|
|
15
|
+
PostGraphile v5 plugin that explicitly provisions S3-compatible buckets through a GraphQL mutation using [`@constructive-io/bucket-provisioner`](../packages/bucket-provisioner).
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
|
-
- **
|
|
20
|
-
- **CORS update hook** — Wraps `update*` mutations to detect `allowed_origins` changes and re-apply CORS rules to the S3 bucket
|
|
19
|
+
- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket
|
|
21
20
|
- **3-tier CORS resolution** — Bucket-level `allowed_origins` → storage module-level `allowed_origins` → plugin config `allowedOrigins`
|
|
22
21
|
- **Wildcard CORS** — Set `allowed_origins = ['*']` on a bucket for fully open CDN/public deployments
|
|
23
|
-
- **Explicit `provisionBucket` mutation** — GraphQL mutation for manual/retry provisioning of any bucket
|
|
24
22
|
- **Per-database overrides** — Reads `endpoint`, `provider`, `public_url_prefix`, and `allowed_origins` from the `storage_module` table for multi-tenant setups
|
|
25
23
|
- **Lazy S3 config** — Connection config can be a function (evaluated once, cached) to avoid eager env-var reads at import time
|
|
26
|
-
- **
|
|
27
|
-
- **Custom bucket naming** — Supports prefix-based naming or a fully custom `resolveBucketName` function
|
|
24
|
+
- **Deployment-controlled naming** — Requires a `resolveBucketName` policy for tenant-aware physical bucket names
|
|
28
25
|
|
|
29
26
|
## Installation
|
|
30
27
|
|
|
@@ -120,10 +117,8 @@ Creates the plugin instance. Returns a `GraphileConfig.Plugin`.
|
|
|
120
117
|
|--------|------|-------------|
|
|
121
118
|
| `connection` | `StorageConnectionConfig \| () => StorageConnectionConfig` | S3 connection config (static or lazy getter) |
|
|
122
119
|
| `allowedOrigins` | `string[]` | CORS allowed origins for bucket configuration |
|
|
123
|
-
| `
|
|
124
|
-
| `resolveBucketName` | `(bucketKey, databaseId) => string` | Custom bucket name resolver (takes precedence over prefix) |
|
|
120
|
+
| `resolveBucketName` | `(databaseId, bucketKey) => string` | Deployment policy for deriving a tenant-aware physical bucket name |
|
|
125
121
|
| `versioning` | `boolean?` | Enable S3 versioning on provisioned buckets (default: `false`) |
|
|
126
|
-
| `autoProvision` | `boolean?` | Enable auto-provisioning hook on create mutations (default: `true`) |
|
|
127
122
|
|
|
128
123
|
### `BucketProvisionerPreset(options)`
|
|
129
124
|
|
package/esm/index.d.ts
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bucket Provisioner Plugin for PostGraphile v5
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
5
|
-
* When bucket rows are created via GraphQL mutations, this plugin
|
|
6
|
-
* automatically provisions the corresponding S3 bucket with the
|
|
7
|
-
* correct privacy policies, CORS rules, and lifecycle settings.
|
|
8
|
-
*
|
|
9
|
-
* Also provides an explicit `provisionBucket` mutation for manual
|
|
10
|
-
* provisioning or re-provisioning of S3 buckets.
|
|
4
|
+
* Provides an explicit `provisionBucket` mutation for PostGraphile v5.
|
|
11
5
|
*
|
|
12
6
|
* @example
|
|
13
7
|
* ```typescript
|
|
@@ -31,7 +25,6 @@
|
|
|
31
25
|
* BucketProvisionerPreset({
|
|
32
26
|
* connection: getConnection, // pass function ref, NOT getConnection()
|
|
33
27
|
* allowedOrigins: ['https://app.example.com'],
|
|
34
|
-
* bucketNamePrefix: 'myapp',
|
|
35
28
|
* }),
|
|
36
29
|
* ],
|
|
37
30
|
* };
|
package/esm/index.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bucket Provisioner Plugin for PostGraphile v5
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
5
|
-
* When bucket rows are created via GraphQL mutations, this plugin
|
|
6
|
-
* automatically provisions the corresponding S3 bucket with the
|
|
7
|
-
* correct privacy policies, CORS rules, and lifecycle settings.
|
|
8
|
-
*
|
|
9
|
-
* Also provides an explicit `provisionBucket` mutation for manual
|
|
10
|
-
* provisioning or re-provisioning of S3 buckets.
|
|
4
|
+
* Provides an explicit `provisionBucket` mutation for PostGraphile v5.
|
|
11
5
|
*
|
|
12
6
|
* @example
|
|
13
7
|
* ```typescript
|
|
@@ -31,7 +25,6 @@
|
|
|
31
25
|
* BucketProvisionerPreset({
|
|
32
26
|
* connection: getConnection, // pass function ref, NOT getConnection()
|
|
33
27
|
* allowedOrigins: ['https://app.example.com'],
|
|
34
|
-
* bucketNamePrefix: 'myapp',
|
|
35
28
|
* }),
|
|
36
29
|
* ],
|
|
37
30
|
* };
|
package/esm/plugin.d.ts
CHANGED
|
@@ -7,21 +7,7 @@
|
|
|
7
7
|
* logical bucket row in the database. Reads the bucket config via RLS,
|
|
8
8
|
* then calls BucketProvisioner to create and configure the S3 bucket.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* with `@storageBuckets` to automatically provision the S3 bucket after
|
|
12
|
-
* the database row is created.
|
|
13
|
-
*
|
|
14
|
-
* 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
|
|
15
|
-
* to detect changes to `allowed_origins` and re-apply CORS rules to the
|
|
16
|
-
* S3 bucket.
|
|
17
|
-
*
|
|
18
|
-
* CORS resolution hierarchy (most specific wins):
|
|
19
|
-
* 1. Bucket-level `allowed_origins` column (per-bucket override)
|
|
20
|
-
* 2. Storage-module-level `allowed_origins` column (per-database default)
|
|
21
|
-
* 3. Plugin config `allowedOrigins` (global fallback)
|
|
22
|
-
* Supports `['*']` for open/CDN mode (wildcard CORS).
|
|
23
|
-
*
|
|
24
|
-
* Both pathways use `@constructive-io/bucket-provisioner` for the actual
|
|
10
|
+
* This plugin uses `@constructive-io/bucket-provisioner` for the actual
|
|
25
11
|
* S3 operations (bucket creation, Block Public Access, CORS, policies,
|
|
26
12
|
* versioning, lifecycle rules).
|
|
27
13
|
*
|
|
@@ -35,16 +21,12 @@ import type { BucketProvisionerPluginOptions } from './types';
|
|
|
35
21
|
/**
|
|
36
22
|
* Creates the bucket provisioner plugin.
|
|
37
23
|
*
|
|
38
|
-
* This plugin provides
|
|
24
|
+
* This plugin provides one provisioning pathway:
|
|
39
25
|
*
|
|
40
26
|
* 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
|
|
41
27
|
* bucket key to provision (or re-provision) the S3 bucket. Protected
|
|
42
28
|
* by RLS on the buckets table.
|
|
43
29
|
*
|
|
44
|
-
* 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
|
|
45
|
-
* wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
|
|
46
|
-
* to automatically provision the S3 bucket after the row is created.
|
|
47
|
-
*
|
|
48
30
|
* @param options - Plugin configuration (S3 credentials, CORS origins, naming)
|
|
49
31
|
*/
|
|
50
32
|
export declare function createBucketProvisionerPlugin(options: BucketProvisionerPluginOptions): GraphileConfig.Plugin;
|
package/esm/plugin.js
CHANGED
|
@@ -7,21 +7,7 @@
|
|
|
7
7
|
* logical bucket row in the database. Reads the bucket config via RLS,
|
|
8
8
|
* then calls BucketProvisioner to create and configure the S3 bucket.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* with `@storageBuckets` to automatically provision the S3 bucket after
|
|
12
|
-
* the database row is created.
|
|
13
|
-
*
|
|
14
|
-
* 3. CORS update hook — wraps `update*` mutations on `@storageBuckets` tables
|
|
15
|
-
* to detect changes to `allowed_origins` and re-apply CORS rules to the
|
|
16
|
-
* S3 bucket.
|
|
17
|
-
*
|
|
18
|
-
* CORS resolution hierarchy (most specific wins):
|
|
19
|
-
* 1. Bucket-level `allowed_origins` column (per-bucket override)
|
|
20
|
-
* 2. Storage-module-level `allowed_origins` column (per-database default)
|
|
21
|
-
* 3. Plugin config `allowedOrigins` (global fallback)
|
|
22
|
-
* Supports `['*']` for open/CDN mode (wildcard CORS).
|
|
23
|
-
*
|
|
24
|
-
* Both pathways use `@constructive-io/bucket-provisioner` for the actual
|
|
10
|
+
* This plugin uses `@constructive-io/bucket-provisioner` for the actual
|
|
25
11
|
* S3 operations (bucket creation, Block Public Access, CORS, policies,
|
|
26
12
|
* versioning, lifecycle rules).
|
|
27
13
|
*
|
|
@@ -34,32 +20,13 @@ import { BucketProvisioner, } from '@constructive-io/bucket-provisioner';
|
|
|
34
20
|
import { Logger } from '@pgpmjs/logger';
|
|
35
21
|
import { QuoteUtils } from '@pgsql/quotes';
|
|
36
22
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
23
|
+
import { recordPhysicalName as recordPhysicalBucketName } from 'graphile-storage-registry';
|
|
37
24
|
import { extendSchema, gql } from 'graphile-utils';
|
|
38
25
|
const log = new Logger('graphile-bucket-provisioner:plugin');
|
|
39
26
|
// --- Storage module queries ---
|
|
40
27
|
/**
|
|
41
|
-
* Resolve
|
|
42
|
-
|
|
43
|
-
const APP_STORAGE_MODULE_QUERY = `
|
|
44
|
-
SELECT
|
|
45
|
-
sm.id,
|
|
46
|
-
sm.scope,
|
|
47
|
-
sm.entity_table_id,
|
|
48
|
-
bs.schema_name AS buckets_schema,
|
|
49
|
-
bt.name AS buckets_table,
|
|
50
|
-
sm.endpoint,
|
|
51
|
-
sm.public_url_prefix,
|
|
52
|
-
sm.provider,
|
|
53
|
-
sm.allowed_origins
|
|
54
|
-
FROM metaschema_modules_public.storage_module sm
|
|
55
|
-
JOIN metaschema_public.table bt ON bt.id = sm.buckets_table_id
|
|
56
|
-
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
57
|
-
WHERE sm.database_id = $1
|
|
58
|
-
AND sm.scope = 'app'
|
|
59
|
-
LIMIT 1
|
|
60
|
-
`;
|
|
61
|
-
/**
|
|
62
|
-
* Resolve ALL storage modules for a database (for ownerId-based resolution).
|
|
28
|
+
* Resolve ALL storage modules for a database (for bucket-key and ownerId-based
|
|
29
|
+
* resolution in the explicit provisionBucket mutation).
|
|
63
30
|
*/
|
|
64
31
|
const ALL_STORAGE_MODULES_QUERY = `
|
|
65
32
|
SELECT
|
|
@@ -93,28 +60,58 @@ function runQuery(pgClient, text, values) {
|
|
|
93
60
|
return pgClient.query(values === undefined ? { text } : { text, values });
|
|
94
61
|
}
|
|
95
62
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
63
|
+
* The explicit provisionBucket mutation's resolution: find the plane that
|
|
64
|
+
* actually holds the named bucket row.
|
|
65
|
+
*
|
|
66
|
+
* With an ownerId, the owning entity row names the plane (probed through each
|
|
67
|
+
* entity-keyed module's recorded entity table). Without one, every
|
|
68
|
+
* non-entity-keyed plane's buckets table is probed for the key under RLS —
|
|
69
|
+
* exactly one plane may hold it; several is ambiguous and throws rather than
|
|
70
|
+
* silently picking.
|
|
99
71
|
*/
|
|
100
|
-
async function
|
|
101
|
-
if (!ownerId) {
|
|
102
|
-
// App-level resolution
|
|
103
|
-
const result = await runQuery(pgClient, APP_STORAGE_MODULE_QUERY, [databaseId]);
|
|
104
|
-
return result.rows[0] ?? null;
|
|
105
|
-
}
|
|
106
|
-
// Entity-scoped: load all modules and probe entity tables
|
|
72
|
+
async function resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId) {
|
|
107
73
|
const result = await runQuery(pgClient, ALL_STORAGE_MODULES_QUERY, [databaseId]);
|
|
108
74
|
const modules = result.rows;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
75
|
+
if (modules.length === 0) {
|
|
76
|
+
throw new Error(`STORAGE_MODULE_NOT_PROVISIONED: database ${databaseId} has no storage modules`);
|
|
77
|
+
}
|
|
78
|
+
if (ownerId) {
|
|
79
|
+
const entityModules = modules.filter((m) => m.entity_table_id !== null && m.entity_schema && m.entity_table);
|
|
80
|
+
for (const mod of entityModules) {
|
|
81
|
+
const entityTable = QuoteUtils.quoteQualifiedIdentifier(mod.entity_schema, mod.entity_table);
|
|
82
|
+
const probe = await runQuery(pgClient, `SELECT 1 FROM ${entityTable} WHERE id = $1 LIMIT 1`, [ownerId]);
|
|
83
|
+
if (probe.rows.length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
86
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
87
|
+
FROM ${bucketsTable}
|
|
88
|
+
WHERE key = $1 AND owner_id = $2
|
|
89
|
+
LIMIT 1`, [bucketKey, ownerId]);
|
|
90
|
+
if (bucketResult.rows.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
return { storageModule: mod, bucket: bucketResult.rows[0] };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const matches = [];
|
|
97
|
+
for (const mod of modules.filter((m) => m.entity_table_id === null)) {
|
|
98
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(mod.buckets_schema, mod.buckets_table);
|
|
99
|
+
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
100
|
+
FROM ${bucketsTable}
|
|
101
|
+
WHERE key = $1
|
|
102
|
+
LIMIT 1`, [bucketKey]);
|
|
103
|
+
if (bucketResult.rows.length > 0) {
|
|
104
|
+
matches.push({ storageModule: mod, bucket: bucketResult.rows[0] });
|
|
115
105
|
}
|
|
116
106
|
}
|
|
117
|
-
|
|
107
|
+
if (matches.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
if (matches.length > 1) {
|
|
110
|
+
const scopes = matches.map((m) => `'${m.storageModule.scope}'`).join(', ');
|
|
111
|
+
throw new Error(`BUCKET_KEY_AMBIGUOUS: bucket key "${bucketKey}" exists in ${matches.length} storage planes ` +
|
|
112
|
+
`(scopes ${scopes}); provide an ownerId or use a plane-unique key`);
|
|
113
|
+
}
|
|
114
|
+
return matches[0];
|
|
118
115
|
}
|
|
119
116
|
/**
|
|
120
117
|
* Normalize the recorded physical coordinate at the DB boundary.
|
|
@@ -127,17 +124,6 @@ async function resolveStorageModule(pgClient, databaseId, ownerId) {
|
|
|
127
124
|
function storedPhysicalName(row) {
|
|
128
125
|
return row.physical_name == null ? null : row.physical_name;
|
|
129
126
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Record the physical S3 bucket name on the source bucket row.
|
|
132
|
-
*
|
|
133
|
-
* Runs in the system lane (`withPgClient(null, ...)`) — server bookkeeping,
|
|
134
|
-
* RLS-independent. Idempotent via the `physical_name IS NULL` guard so a
|
|
135
|
-
* re-provision never clobbers an already-recorded coordinate.
|
|
136
|
-
*/
|
|
137
|
-
async function recordPhysicalName(withPgClient, bucketsTable, bucketId, physicalName) {
|
|
138
|
-
await withPgClient(null, (client) => runQuery(client, `UPDATE ${bucketsTable} SET physical_name = $1 WHERE id = $2 AND physical_name IS NULL`, [physicalName, bucketId]));
|
|
139
|
-
log.info(`Recorded physical_name="${physicalName}" on bucket ${bucketId}`);
|
|
140
|
-
}
|
|
141
127
|
// --- Helpers ---
|
|
142
128
|
/**
|
|
143
129
|
* Resolve the connection config from the options. If the option is a lazy
|
|
@@ -155,14 +141,14 @@ function resolveConnection(options) {
|
|
|
155
141
|
/**
|
|
156
142
|
* Resolve the S3 bucket name from a logical bucket key.
|
|
157
143
|
*/
|
|
158
|
-
function resolveBucketName(
|
|
159
|
-
if (options.resolveBucketName) {
|
|
160
|
-
|
|
144
|
+
function resolveBucketName(databaseId, bucketKey, options) {
|
|
145
|
+
if (!options.resolveBucketName) {
|
|
146
|
+
throw new Error('STORAGE_BUCKET_NAME_POLICY_MISSING: no resolveBucketName was configured, so there is ' +
|
|
147
|
+
`no name to provision for bucket "${bucketKey}" of database ${databaseId}. ` +
|
|
148
|
+
'Physical bucket naming is a deployment policy; the configured s3.bucket is a ' +
|
|
149
|
+
'connection default and is never a tenant bucket.');
|
|
161
150
|
}
|
|
162
|
-
|
|
163
|
-
return `${options.bucketNamePrefix}-${bucketKey}`;
|
|
164
|
-
}
|
|
165
|
-
return bucketKey;
|
|
151
|
+
return options.resolveBucketName(databaseId, bucketKey);
|
|
166
152
|
}
|
|
167
153
|
/**
|
|
168
154
|
* Resolve the database_id from the JWT context.
|
|
@@ -204,13 +190,10 @@ function buildProvisioner(options, storageModule, effectiveOrigins) {
|
|
|
204
190
|
});
|
|
205
191
|
}
|
|
206
192
|
/**
|
|
207
|
-
* Core provisioning logic
|
|
208
|
-
* auto-provisioning hook.
|
|
193
|
+
* Core provisioning logic for the explicit mutation.
|
|
209
194
|
*/
|
|
210
|
-
async function provisionBucketForRow(
|
|
195
|
+
async function provisionBucketForRow(storageModule, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
211
196
|
const accessType = bucketType;
|
|
212
|
-
// Read storage module config to check for endpoint/provider/CORS overrides
|
|
213
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
214
197
|
// Resolve CORS origins using the 3-tier hierarchy
|
|
215
198
|
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
216
199
|
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
@@ -227,41 +210,19 @@ async function provisionBucketForRow(pgClient, databaseId, bucketKey, bucketType
|
|
|
227
210
|
`(provider=${result.provider}, blockPublicAccess=${result.blockPublicAccess})`);
|
|
228
211
|
return result;
|
|
229
212
|
}
|
|
230
|
-
/**
|
|
231
|
-
* Update CORS on an existing S3 bucket when allowed_origins changes.
|
|
232
|
-
*/
|
|
233
|
-
async function updateBucketCors(pgClient, databaseId, bucketKey, bucketType, bucketAllowedOrigins, options, s3BucketName) {
|
|
234
|
-
const accessType = bucketType;
|
|
235
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
236
|
-
const effectiveOrigins = resolveAllowedOrigins(bucketAllowedOrigins, storageModule?.allowed_origins, options.allowedOrigins);
|
|
237
|
-
const provisioner = buildProvisioner(options, storageModule, effectiveOrigins);
|
|
238
|
-
log.info(`Updating CORS on S3 bucket "${s3BucketName}" ` +
|
|
239
|
-
`(origins=${JSON.stringify(effectiveOrigins)}) for database ${databaseId}`);
|
|
240
|
-
await provisioner.updateCors({
|
|
241
|
-
bucketName: s3BucketName,
|
|
242
|
-
accessType,
|
|
243
|
-
allowedOrigins: effectiveOrigins,
|
|
244
|
-
});
|
|
245
|
-
log.info(`Successfully updated CORS on S3 bucket "${s3BucketName}"`);
|
|
246
|
-
}
|
|
247
213
|
// --- Plugin factory ---
|
|
248
214
|
/**
|
|
249
215
|
* Creates the bucket provisioner plugin.
|
|
250
216
|
*
|
|
251
|
-
* This plugin provides
|
|
217
|
+
* This plugin provides one provisioning pathway:
|
|
252
218
|
*
|
|
253
219
|
* 1. **Explicit `provisionBucket` mutation** — Call this mutation with a
|
|
254
220
|
* bucket key to provision (or re-provision) the S3 bucket. Protected
|
|
255
221
|
* by RLS on the buckets table.
|
|
256
222
|
*
|
|
257
|
-
* 2. **Auto-provisioning hook** — When `autoProvision` is true (default),
|
|
258
|
-
* wraps `create*` mutation resolvers on tables tagged with `@storageBuckets`
|
|
259
|
-
* to automatically provision the S3 bucket after the row is created.
|
|
260
|
-
*
|
|
261
223
|
* @param options - Plugin configuration (S3 credentials, CORS origins, naming)
|
|
262
224
|
*/
|
|
263
225
|
export function createBucketProvisionerPlugin(options) {
|
|
264
|
-
const autoProvision = options.autoProvision ?? true;
|
|
265
226
|
// The extendSchema plugin adds the explicit provisionBucket mutation
|
|
266
227
|
const mutationPlugin = extendSchema(() => ({
|
|
267
228
|
typeDefs: gql `
|
|
@@ -324,39 +285,25 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
324
285
|
if (!databaseId) {
|
|
325
286
|
throw new Error('DATABASE_NOT_FOUND');
|
|
326
287
|
}
|
|
327
|
-
// Resolve
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
|
|
332
|
-
: 'STORAGE_MODULE_NOT_PROVISIONED');
|
|
333
|
-
}
|
|
334
|
-
// Look up the bucket row (RLS enforced via pgSettings)
|
|
335
|
-
const hasOwner = ownerId && storageModule.scope !== 'app';
|
|
336
|
-
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
337
|
-
const bucketResult = await runQuery(pgClient, hasOwner
|
|
338
|
-
? `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
339
|
-
FROM ${bucketsTable}
|
|
340
|
-
WHERE key = $1 AND owner_id = $2
|
|
341
|
-
LIMIT 1`
|
|
342
|
-
: `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
343
|
-
FROM ${bucketsTable}
|
|
344
|
-
WHERE key = $1
|
|
345
|
-
LIMIT 1`, hasOwner ? [bucketKey, ownerId] : [bucketKey]);
|
|
346
|
-
if (bucketResult.rows.length === 0) {
|
|
288
|
+
// Resolve the plane that actually holds the named bucket row
|
|
289
|
+
// (RLS enforced via pgSettings) — never a guessed scope.
|
|
290
|
+
const resolution = await resolveBucketByKey(pgClient, databaseId, bucketKey, ownerId);
|
|
291
|
+
if (!resolution) {
|
|
347
292
|
throw new Error('BUCKET_NOT_FOUND');
|
|
348
293
|
}
|
|
349
|
-
const bucket =
|
|
294
|
+
const { storageModule, bucket } = resolution;
|
|
295
|
+
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
350
296
|
// First provision mints a name; afterwards the stored coordinate
|
|
351
297
|
// is authoritative and the naming hook is never consulted again.
|
|
352
298
|
const recorded = storedPhysicalName(bucket);
|
|
353
299
|
const s3BucketName = recorded === null
|
|
354
|
-
? resolveBucketName(bucket.key,
|
|
300
|
+
? resolveBucketName(databaseId, bucket.key, options)
|
|
355
301
|
: recorded;
|
|
356
302
|
try {
|
|
357
|
-
const result = await provisionBucketForRow(
|
|
303
|
+
const result = await provisionBucketForRow(storageModule, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, s3BucketName);
|
|
358
304
|
// Record the exact provisioned name on the source row.
|
|
359
|
-
await
|
|
305
|
+
await withPgClient(null, (client) => recordPhysicalBucketName((query) => runQuery(client, query.text, query.values), bucketsTable, bucket.id, result.bucketName));
|
|
306
|
+
log.info(`Recorded physical_name="${result.bucketName}" on bucket ${bucket.id}`);
|
|
360
307
|
return {
|
|
361
308
|
success: true,
|
|
362
309
|
bucketName: result.bucketName,
|
|
@@ -383,155 +330,7 @@ export function createBucketProvisionerPlugin(options) {
|
|
|
383
330
|
},
|
|
384
331
|
},
|
|
385
332
|
}));
|
|
386
|
-
|
|
387
|
-
if (!autoProvision) {
|
|
388
|
-
return mutationPlugin;
|
|
389
|
-
}
|
|
390
|
-
// Build a composite plugin that includes both the mutation and the hook
|
|
391
|
-
return {
|
|
392
|
-
...mutationPlugin,
|
|
393
|
-
name: 'BucketProvisionerPlugin',
|
|
394
|
-
version: '0.1.0',
|
|
395
|
-
description: 'Auto-provisions S3 buckets when bucket rows are created, ' +
|
|
396
|
-
'updates CORS when allowed_origins changes on update, ' +
|
|
397
|
-
'and provides a provisionBucket mutation for explicit provisioning',
|
|
398
|
-
after: ['PgAttributesPlugin', 'PgMutationCreatePlugin', 'PgMutationUpdateDeletePlugin'],
|
|
399
|
-
schema: {
|
|
400
|
-
...mutationPlugin.schema,
|
|
401
|
-
hooks: {
|
|
402
|
-
...(mutationPlugin.schema?.hooks ?? {}),
|
|
403
|
-
/**
|
|
404
|
-
* Wrap create and update mutation resolvers on tables tagged with @storageBuckets.
|
|
405
|
-
*
|
|
406
|
-
* - create*: After the row is created, provision the S3 bucket.
|
|
407
|
-
* - update*: After the row is updated, re-apply CORS if allowed_origins changed.
|
|
408
|
-
*
|
|
409
|
-
* If provisioning/CORS update fails, the DB row still exists (the mutation
|
|
410
|
-
* already committed), and the error is logged. Admin can retry via provisionBucket.
|
|
411
|
-
*/
|
|
412
|
-
GraphQLObjectType_fields_field(field, build, context) {
|
|
413
|
-
const { scope: { isRootMutation, fieldName, pgCodec }, } = context;
|
|
414
|
-
// Only wrap root mutation fields
|
|
415
|
-
if (!isRootMutation || !pgCodec || !pgCodec.attributes) {
|
|
416
|
-
return field;
|
|
417
|
-
}
|
|
418
|
-
// Check for @storageBuckets smart tag
|
|
419
|
-
const tags = pgCodec.extensions?.tags;
|
|
420
|
-
if (!tags?.storageBuckets) {
|
|
421
|
-
return field;
|
|
422
|
-
}
|
|
423
|
-
const isCreate = fieldName.startsWith('create');
|
|
424
|
-
const isUpdate = fieldName.startsWith('update');
|
|
425
|
-
// Only wrap create and update mutations (not delete)
|
|
426
|
-
if (!isCreate && !isUpdate) {
|
|
427
|
-
return field;
|
|
428
|
-
}
|
|
429
|
-
log.debug(`Wrapping mutation "${fieldName}" for ${isCreate ? 'auto-provisioning' : 'CORS update'} (codec: ${pgCodec.name})`);
|
|
430
|
-
const defaultResolver = (obj) => obj[fieldName];
|
|
431
|
-
const { resolve: oldResolve = defaultResolver, ...rest } = field;
|
|
432
|
-
return {
|
|
433
|
-
...rest,
|
|
434
|
-
async resolve(source, args, graphqlContext, info) {
|
|
435
|
-
// Call the original resolver first (creates/updates the DB row)
|
|
436
|
-
const result = await oldResolve(source, args, graphqlContext, info);
|
|
437
|
-
try {
|
|
438
|
-
const inputKey = Object.keys(args.input || {}).find((k) => k !== 'clientMutationId');
|
|
439
|
-
const bucketInput = inputKey ? args.input[inputKey] : null;
|
|
440
|
-
const withPgClient = graphqlContext.withPgClient;
|
|
441
|
-
const pgSettings = graphqlContext.pgSettings;
|
|
442
|
-
if (!withPgClient) {
|
|
443
|
-
log.warn(`${isCreate ? 'Auto-provision' : 'CORS update'} skipped: withPgClient not available in context`);
|
|
444
|
-
return result;
|
|
445
|
-
}
|
|
446
|
-
if (isCreate) {
|
|
447
|
-
// --- CREATE: full provisioning ---
|
|
448
|
-
if (!bucketInput?.key || !bucketInput?.type) {
|
|
449
|
-
log.warn(`Auto-provision skipped for "${fieldName}": ` +
|
|
450
|
-
`could not extract key/type from mutation input`);
|
|
451
|
-
return result;
|
|
452
|
-
}
|
|
453
|
-
await withPgClient(pgSettings, async (pgClient) => {
|
|
454
|
-
const databaseId = await resolveDatabaseId(pgClient);
|
|
455
|
-
if (!databaseId) {
|
|
456
|
-
log.warn('Auto-provision skipped: could not resolve database_id');
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
// Newly-created row has no stored coordinate yet — mint on first provision.
|
|
460
|
-
const result = await provisionBucketForRow(pgClient, databaseId, bucketInput.key, bucketInput.type, bucketInput.allowedOrigins ?? bucketInput.allowed_origins ?? null, options, resolveBucketName(bucketInput.key, databaseId, options));
|
|
461
|
-
// Record the provisioned name on the just-created row.
|
|
462
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
463
|
-
if (storageModule) {
|
|
464
|
-
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
465
|
-
const idResult = await runQuery(pgClient, `SELECT id FROM ${bucketsTable} WHERE key = $1 LIMIT 1`, [bucketInput.key]);
|
|
466
|
-
const bucketId = idResult.rows[0]?.id;
|
|
467
|
-
if (bucketId)
|
|
468
|
-
await recordPhysicalName(withPgClient, bucketsTable, bucketId, result.bucketName);
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
// --- UPDATE: re-apply CORS if allowed_origins is in the patch ---
|
|
474
|
-
const hasOriginsUpdate = bucketInput &&
|
|
475
|
-
('allowedOrigins' in bucketInput || 'allowed_origins' in bucketInput);
|
|
476
|
-
if (!hasOriginsUpdate) {
|
|
477
|
-
// allowed_origins not being changed, nothing to do
|
|
478
|
-
return result;
|
|
479
|
-
}
|
|
480
|
-
await withPgClient(pgSettings, async (pgClient) => {
|
|
481
|
-
const databaseId = await resolveDatabaseId(pgClient);
|
|
482
|
-
if (!databaseId) {
|
|
483
|
-
log.warn('CORS update skipped: could not resolve database_id');
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
// Read the storage module config (app-level; auto-hook doesn't have ownerId context)
|
|
487
|
-
const storageModule = await resolveStorageModule(pgClient, databaseId);
|
|
488
|
-
if (!storageModule) {
|
|
489
|
-
log.warn('CORS update skipped: storage module not provisioned');
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
// We need the bucket key — it may come from input or patch
|
|
493
|
-
// For updates, PostGraphile uses nodeId or the row's PK, so
|
|
494
|
-
// we read the bucket from the patch's key or from the nodeId
|
|
495
|
-
const patchKey = bucketInput?.key;
|
|
496
|
-
if (!patchKey) {
|
|
497
|
-
log.warn(`CORS update skipped for "${fieldName}": ` +
|
|
498
|
-
`could not determine bucket key from mutation input`);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
// Read the full bucket row (post-update) to get type + origins
|
|
502
|
-
const bucketsTable = QuoteUtils.quoteQualifiedIdentifier(storageModule.buckets_schema, storageModule.buckets_table);
|
|
503
|
-
const bucketResult = await runQuery(pgClient, `SELECT id, key, type, is_public, allowed_origins, physical_name
|
|
504
|
-
FROM ${bucketsTable}
|
|
505
|
-
WHERE key = $1
|
|
506
|
-
LIMIT 1`, [patchKey]);
|
|
507
|
-
if (bucketResult.rows.length === 0) {
|
|
508
|
-
log.warn(`CORS update skipped: bucket "${patchKey}" not found`);
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
const bucket = bucketResult.rows[0];
|
|
512
|
-
// CORS applies to the recorded physical bucket; if the row was
|
|
513
|
-
// never provisioned there is nothing to update yet, so mint the
|
|
514
|
-
// conventional name the first provision would use.
|
|
515
|
-
const recorded = storedPhysicalName(bucket);
|
|
516
|
-
await updateBucketCors(pgClient, databaseId, bucket.key, bucket.type, bucket.allowed_origins, options, recorded === null
|
|
517
|
-
? resolveBucketName(bucket.key, databaseId, options)
|
|
518
|
-
: recorded);
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
log.error(`${isCreate ? 'Auto-provision' : 'CORS update'} failed for "${fieldName}": ${err.message}. ` +
|
|
524
|
-
(isCreate
|
|
525
|
-
? `The bucket row was created but the S3 bucket was not provisioned. Use the provisionBucket mutation to retry.`
|
|
526
|
-
: `The bucket row was updated but CORS was not applied to the S3 bucket. Use the provisionBucket mutation to retry.`));
|
|
527
|
-
}
|
|
528
|
-
return result;
|
|
529
|
-
},
|
|
530
|
-
};
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
};
|
|
333
|
+
return mutationPlugin;
|
|
535
334
|
}
|
|
536
335
|
export const BucketProvisionerPlugin = createBucketProvisionerPlugin;
|
|
537
336
|
export default BucketProvisionerPlugin;
|
package/esm/preset.d.ts
CHANGED
|
@@ -31,7 +31,6 @@ import type { BucketProvisionerPluginOptions } from './types';
|
|
|
31
31
|
* BucketProvisionerPreset({
|
|
32
32
|
* connection: getConnection, // pass function ref, NOT getConnection()
|
|
33
33
|
* allowedOrigins: ['https://app.example.com'],
|
|
34
|
-
* bucketNamePrefix: 'myapp',
|
|
35
34
|
* }),
|
|
36
35
|
* ],
|
|
37
36
|
* };
|
package/esm/preset.js
CHANGED
|
@@ -30,7 +30,6 @@ import { createBucketProvisionerPlugin } from './plugin';
|
|
|
30
30
|
* BucketProvisionerPreset({
|
|
31
31
|
* connection: getConnection, // pass function ref, NOT getConnection()
|
|
32
32
|
* allowedOrigins: ['https://app.example.com'],
|
|
33
|
-
* bucketNamePrefix: 'myapp',
|
|
34
33
|
* }),
|
|
35
34
|
* ],
|
|
36
35
|
* };
|
package/esm/types.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the bucket provisioner plugin.
|
|
3
|
-
*
|
|
4
|
-
* Defines plugin options, connection configuration, and provisioning result
|
|
5
|
-
* types used by the Graphile plugin to auto-provision S3 buckets when
|
|
6
|
-
* bucket rows are created via GraphQL mutations.
|
|
7
3
|
*/
|
|
8
4
|
import type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider } from '@constructive-io/bucket-provisioner';
|
|
9
5
|
export type { BucketAccessType, ProvisionResult, StorageConnectionConfig, StorageProvider };
|
|
@@ -18,11 +14,11 @@ export type ConnectionConfigOrGetter = StorageConnectionConfig | (() => StorageC
|
|
|
18
14
|
/**
|
|
19
15
|
* Function to derive the actual S3 bucket name from a logical bucket key.
|
|
20
16
|
*
|
|
21
|
-
* @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
|
|
22
17
|
* @param databaseId - The metaschema database UUID
|
|
18
|
+
* @param bucketKey - The logical bucket key from the database (e.g., "public", "private")
|
|
23
19
|
* @returns The S3 bucket name to create/configure
|
|
24
20
|
*/
|
|
25
|
-
export type BucketNameResolver = (
|
|
21
|
+
export type BucketNameResolver = (databaseId: string, bucketKey: string) => string;
|
|
26
22
|
/**
|
|
27
23
|
* Plugin options for the bucket provisioner plugin.
|
|
28
24
|
*/
|
|
@@ -38,15 +34,9 @@ export interface BucketProvisionerPluginOptions {
|
|
|
38
34
|
* Required for browser-based presigned URL uploads.
|
|
39
35
|
*/
|
|
40
36
|
allowedOrigins: string[];
|
|
41
|
-
/**
|
|
42
|
-
* Optional prefix for S3 bucket names.
|
|
43
|
-
* When set, the S3 bucket name becomes `{prefix}-{bucketKey}`.
|
|
44
|
-
* Example: prefix "myapp" + key "public" → S3 bucket "myapp-public"
|
|
45
|
-
*/
|
|
46
|
-
bucketNamePrefix?: string;
|
|
47
37
|
/**
|
|
48
38
|
* Optional custom function to derive S3 bucket names from logical bucket keys.
|
|
49
|
-
*
|
|
39
|
+
* Naming is a deployment policy and must be supplied by the caller.
|
|
50
40
|
*/
|
|
51
41
|
resolveBucketName?: BucketNameResolver;
|
|
52
42
|
/**
|
|
@@ -54,15 +44,6 @@ export interface BucketProvisionerPluginOptions {
|
|
|
54
44
|
* Default: false
|
|
55
45
|
*/
|
|
56
46
|
versioning?: boolean;
|
|
57
|
-
/**
|
|
58
|
-
* Whether to auto-provision S3 buckets when bucket rows are created
|
|
59
|
-
* via GraphQL mutations. When true, the plugin wraps create mutations
|
|
60
|
-
* on tables tagged with `@storageBuckets` to trigger provisioning
|
|
61
|
-
* after the mutation succeeds.
|
|
62
|
-
*
|
|
63
|
-
* Default: true
|
|
64
|
-
*/
|
|
65
|
-
autoProvision?: boolean;
|
|
66
47
|
}
|
|
67
48
|
/**
|
|
68
49
|
* Input for the provisionBucket mutation.
|
package/esm/types.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the bucket provisioner plugin.
|
|
3
|
-
*
|
|
4
|
-
* Defines plugin options, connection configuration, and provisioning result
|
|
5
|
-
* types used by the Graphile plugin to auto-provision S3 buckets when
|
|
6
|
-
* bucket rows are created via GraphQL mutations.
|
|
7
3
|
*/
|
|
8
4
|
export {};
|