@everystack/storage 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +5 -1
- package/package.json +23 -6
- package/src/handler/index.ts +37 -17
- package/src/models/blob.ts +55 -0
- package/src/models/index.ts +16 -0
- package/src/plugin.ts +7 -0
- package/src/schema.ts +9 -81
- package/src/types.ts +9 -0
package/LICENSE
CHANGED
|
@@ -16,7 +16,7 @@ A commercial license is available for organizations that cannot or do not
|
|
|
16
16
|
wish to comply with the AGPL-3.0 terms. Commercial licenses are issued
|
|
17
17
|
exclusively by Scalable Technology, Inc.
|
|
18
18
|
|
|
19
|
-
For commercial licensing inquiries, contact: licensing@
|
|
19
|
+
For commercial licensing inquiries, contact: licensing@scalable.technology
|
|
20
20
|
|
|
21
21
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
22
22
|
Version 3, 19 November 2007
|
package/README.md
CHANGED
|
@@ -267,4 +267,8 @@ Part of [everystack](https://github.com/scalable-technology/everystack) — a se
|
|
|
267
267
|
|
|
268
268
|
## License
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
|
|
271
|
+
|
|
272
|
+
A commercial license is available for organizations that cannot or do not wish to
|
|
273
|
+
comply with the AGPL-3.0 terms. For commercial licensing, contact
|
|
274
|
+
licensing@scalable.technology.
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "File uploads via S3 + CloudFront CDN with presigned URLs",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scalable-technology/everystack.git",
|
|
10
|
+
"directory": "packages/storage"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/scalable-technology/everystack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/scalable-technology/everystack/issues"
|
|
15
|
+
},
|
|
6
16
|
"publishConfig": {
|
|
7
17
|
"access": "public"
|
|
8
18
|
},
|
|
@@ -19,6 +29,10 @@
|
|
|
19
29
|
"types": "./src/schema.ts",
|
|
20
30
|
"default": "./src/schema.ts"
|
|
21
31
|
},
|
|
32
|
+
"./models": {
|
|
33
|
+
"types": "./src/models/index.ts",
|
|
34
|
+
"default": "./src/models/index.ts"
|
|
35
|
+
},
|
|
22
36
|
"./client": {
|
|
23
37
|
"types": "./src/client/index.ts",
|
|
24
38
|
"default": "./src/client/index.ts"
|
|
@@ -28,9 +42,12 @@
|
|
|
28
42
|
"default": "./src/plugin.ts"
|
|
29
43
|
}
|
|
30
44
|
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@everystack/model": "0.3.3"
|
|
47
|
+
},
|
|
31
48
|
"peerDependencies": {
|
|
32
|
-
"@aws-sdk/client-s3": "3.
|
|
33
|
-
"@aws-sdk/s3-request-presigner": "3.
|
|
49
|
+
"@aws-sdk/client-s3": "3.1053.0",
|
|
50
|
+
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
34
51
|
"@everystack/server": ">=0.1.0",
|
|
35
52
|
"drizzle-orm": "0.41.0",
|
|
36
53
|
"react": "19.2.6",
|
|
@@ -54,8 +71,8 @@
|
|
|
54
71
|
}
|
|
55
72
|
},
|
|
56
73
|
"devDependencies": {
|
|
57
|
-
"@aws-sdk/client-s3": "3.
|
|
58
|
-
"@aws-sdk/s3-request-presigner": "3.
|
|
74
|
+
"@aws-sdk/client-s3": "3.1053.0",
|
|
75
|
+
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
59
76
|
"@testing-library/react-native": "13.3.3",
|
|
60
77
|
"@types/jest": "29.5.14",
|
|
61
78
|
"@types/node": "22.19.18",
|
|
@@ -66,7 +83,7 @@
|
|
|
66
83
|
"react-test-renderer": "19.2.6",
|
|
67
84
|
"ts-jest": "29.4.9",
|
|
68
85
|
"typescript": "5.9.3",
|
|
69
|
-
"@everystack/server": "0.
|
|
86
|
+
"@everystack/server": "0.3.1"
|
|
70
87
|
},
|
|
71
88
|
"scripts": {
|
|
72
89
|
"test": "jest",
|
package/src/handler/index.ts
CHANGED
|
@@ -14,12 +14,34 @@ async function computeSha256(data: ArrayBuffer | Buffer | Uint8Array): Promise<s
|
|
|
14
14
|
.join('');
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Postgres identifier rule — the only value interpolated into SET ROLE. The role is set by
|
|
18
|
+
// server config, never user input, but validate defensively against injection.
|
|
19
|
+
const ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
|
|
20
|
+
|
|
17
21
|
export function createStorageHandler(options: StorageHandlerOptions): (request: Request) => Promise<Response> {
|
|
18
22
|
const basePath = options.basePath || '';
|
|
19
23
|
const { db, storage, auth, validation, publishJob } = options;
|
|
20
24
|
const allowUnauthenticated = options.allowUnauthenticated === true;
|
|
21
25
|
const keyGenerator = options.keyGenerator || defaultKeyGenerator;
|
|
22
26
|
const bucket = options.bucket || 'default';
|
|
27
|
+
const role = options.role;
|
|
28
|
+
if (role && !ROLE_NAME.test(role)) {
|
|
29
|
+
throw new Error(`Invalid role name: ${role}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run `fn` against the connection — directly when no role is configured, or inside a
|
|
34
|
+
* SET LOCAL ROLE transaction when one is (the least-privilege API path: dist.blobs is
|
|
35
|
+
* reachable only via the admin policy). Per-user ownership is still enforced in the handler.
|
|
36
|
+
*/
|
|
37
|
+
async function scope(fn: (db: any) => any): Promise<any> {
|
|
38
|
+
if (!role) return fn(db);
|
|
39
|
+
return db.transaction(async (tx: any) => {
|
|
40
|
+
await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
|
|
41
|
+
await tx.execute(sql`SELECT set_config('request.jwt.claims', ${JSON.stringify({ role })}, true)`);
|
|
42
|
+
return fn(tx);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
23
45
|
|
|
24
46
|
function json(data: unknown, status: number = 200): Response {
|
|
25
47
|
return new Response(JSON.stringify(data), {
|
|
@@ -121,8 +143,8 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
121
143
|
// Compute content hash for dedup
|
|
122
144
|
const sha256 = await computeSha256(buffer);
|
|
123
145
|
|
|
124
|
-
// Write metadata to DB
|
|
125
|
-
const result = await
|
|
146
|
+
// Write metadata to DB (as the configured role; dist.blobs is admin-policy gated).
|
|
147
|
+
const result = await scope((d) => d
|
|
126
148
|
.insert(blobs)
|
|
127
149
|
.values({
|
|
128
150
|
type: 'original',
|
|
@@ -136,7 +158,7 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
136
158
|
userId: (user.sub as string) || null,
|
|
137
159
|
},
|
|
138
160
|
})
|
|
139
|
-
.returning({ id: blobs.id });
|
|
161
|
+
.returning({ id: blobs.id }));
|
|
140
162
|
|
|
141
163
|
const uploadId = result[0].id;
|
|
142
164
|
|
|
@@ -180,15 +202,13 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
180
202
|
const limit = Math.min(Math.max(1, parsedLimit || 50), maxLimit);
|
|
181
203
|
const offset = url.searchParams.has('offset') ? Math.max(0, parseInt(url.searchParams.get('offset')!, 10) || 0) : 0;
|
|
182
204
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const result = await query;
|
|
205
|
+
const result = await scope((d) => {
|
|
206
|
+
let query = d.select().from(blobs);
|
|
207
|
+
if (user.sub) {
|
|
208
|
+
query = query.where(sql`${blobs.metadata}->>'userId' = ${user.sub}`);
|
|
209
|
+
}
|
|
210
|
+
return query.orderBy(sql`${blobs.createdAt} DESC`).limit(limit).offset(offset);
|
|
211
|
+
});
|
|
192
212
|
return json(result);
|
|
193
213
|
}
|
|
194
214
|
|
|
@@ -198,10 +218,10 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
198
218
|
const user = await verifyAuth(request);
|
|
199
219
|
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
200
220
|
|
|
201
|
-
const result = await
|
|
221
|
+
const result = await scope((d) => d
|
|
202
222
|
.select()
|
|
203
223
|
.from(blobs)
|
|
204
|
-
.where(sql`${blobs.id} = ${uploadMatch[1]}`);
|
|
224
|
+
.where(sql`${blobs.id} = ${uploadMatch[1]}`));
|
|
205
225
|
|
|
206
226
|
if (!result[0]) return json({ error: 'Upload not found' }, 404);
|
|
207
227
|
|
|
@@ -220,10 +240,10 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
220
240
|
const user = await verifyAuth(request);
|
|
221
241
|
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
222
242
|
|
|
223
|
-
const result = await
|
|
243
|
+
const result = await scope((d) => d
|
|
224
244
|
.select()
|
|
225
245
|
.from(blobs)
|
|
226
|
-
.where(sql`${blobs.id} = ${deleteMatch[1]}`);
|
|
246
|
+
.where(sql`${blobs.id} = ${deleteMatch[1]}`));
|
|
227
247
|
|
|
228
248
|
if (!result[0]) return json({ error: 'Upload not found' }, 404);
|
|
229
249
|
|
|
@@ -234,7 +254,7 @@ export function createStorageHandler(options: StorageHandlerOptions): (request:
|
|
|
234
254
|
}
|
|
235
255
|
|
|
236
256
|
await storage.delete(result[0].s3Key!);
|
|
237
|
-
await
|
|
257
|
+
await scope((d) => d.delete(blobs).where(sql`${blobs.id} = ${deleteMatch[1]}`));
|
|
238
258
|
|
|
239
259
|
return json({ ok: true });
|
|
240
260
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dist.blobs — the content-addressed S3 object registry (deduped by sha256).
|
|
3
|
+
*
|
|
4
|
+
* The v3 Model that owns this table's schema AND its authorization. The drizzle
|
|
5
|
+
* runtime table (`../schema`) derives from it, and `db:generate` reads it to emit
|
|
6
|
+
* the table's DDL + RLS + grants — so the package, not the consuming app, owns
|
|
7
|
+
* dist.blobs end to end.
|
|
8
|
+
*
|
|
9
|
+
* Authz shape (matches the live 0020 migration):
|
|
10
|
+
* - `writtenBy: 'worker'` — the image worker writes derived blobs (thumbnail/webp)
|
|
11
|
+
* as the table owner and must keep its owner bypass, so RLS is ENABLE-not-FORCE.
|
|
12
|
+
* - `can('manage', { role: 'admin' })` — the storage handler runs as `admin`
|
|
13
|
+
* (SET ROLE) and enforces per-user ownership in code; admin gets CRUD + the
|
|
14
|
+
* `blobs_admin` policy. No anon/authenticated grant exists.
|
|
15
|
+
* - `private: true` — reached through the storage handler (/api/storage), never the
|
|
16
|
+
* generic data API, so it never enters `exposedTables`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
20
|
+
|
|
21
|
+
export const Blob = defineModel('blobs', {
|
|
22
|
+
schema: 'dist',
|
|
23
|
+
writtenBy: 'worker',
|
|
24
|
+
private: true,
|
|
25
|
+
fields: {
|
|
26
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
27
|
+
repositoryId: field.uuid().nullable(),
|
|
28
|
+
type: field.text().notNull(), // 'original' | 'thumbnail' | 'preview' | 'webp' | …
|
|
29
|
+
sha256: field.text().notNull().unique(),
|
|
30
|
+
|
|
31
|
+
// S3 object location (bytes live in S3, not the database)
|
|
32
|
+
s3Key: field.text().nullable(),
|
|
33
|
+
bucket: field.text().nullable(),
|
|
34
|
+
|
|
35
|
+
sizeBytes: field.bigint().notNull(),
|
|
36
|
+
contentType: field.text().nullable(),
|
|
37
|
+
|
|
38
|
+
// Derived content (thumbnail → original, webp → original, …)
|
|
39
|
+
parentId: field.uuid().nullable(),
|
|
40
|
+
|
|
41
|
+
// Intrinsic content metadata ({ width, height, format, originalName, userId })
|
|
42
|
+
metadata: field.jsonb().nullable(),
|
|
43
|
+
|
|
44
|
+
createdAt: field.timestamptz().defaultNow().notNull(),
|
|
45
|
+
},
|
|
46
|
+
constraints: [
|
|
47
|
+
index('type'),
|
|
48
|
+
index('contentType'),
|
|
49
|
+
index('createdAt'),
|
|
50
|
+
index('repositoryId'),
|
|
51
|
+
index('parentId'),
|
|
52
|
+
index('s3Key'),
|
|
53
|
+
],
|
|
54
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
55
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage's package-owned models. A consuming app composes these into its own
|
|
3
|
+
* model list so `db:generate` emits dist.blobs' schema + authz alongside the app's
|
|
4
|
+
* tables: `models = [...appModels, ...storageModels]`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { Blob } from './blob';
|
|
8
|
+
|
|
9
|
+
import { defineModule } from '@everystack/model';
|
|
10
|
+
import { Blob } from './blob';
|
|
11
|
+
|
|
12
|
+
/** All storage-owned models, for the app to spread into its db:generate model list. */
|
|
13
|
+
export const storageModels = [Blob];
|
|
14
|
+
|
|
15
|
+
/** The storage Module — dist.blobs (schema + authz). No functions/extensions of its own. */
|
|
16
|
+
export const storageModule = defineModule({ models: storageModels });
|
package/src/plugin.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface StoragePluginOptions {
|
|
|
20
20
|
basePath?: string;
|
|
21
21
|
/** Presigned URL expiry in seconds (default: 3600) */
|
|
22
22
|
presignedUrlExpiry?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Postgres role for the dist.blobs access (e.g. 'admin' on the least-privilege API
|
|
25
|
+
* connection, so the handler reaches dist.blobs under the admin policy). Unset = run on the
|
|
26
|
+
* bound connection (single-credential / owner apps).
|
|
27
|
+
*/
|
|
28
|
+
role?: string;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export function storagePlugin(options: StoragePluginOptions): Plugin {
|
|
@@ -44,6 +50,7 @@ export function storagePlugin(options: StoragePluginOptions): Plugin {
|
|
|
44
50
|
validation: options.validation,
|
|
45
51
|
publishJob: ctx.publishJob,
|
|
46
52
|
presignedUrlExpiry: options.presignedUrlExpiry,
|
|
53
|
+
role: options.role,
|
|
47
54
|
});
|
|
48
55
|
|
|
49
56
|
return {
|
package/src/schema.ts
CHANGED
|
@@ -1,87 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storage schema —
|
|
2
|
+
* Storage schema — dist.blobs, the runtime drizzle table.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Type values for everystack:
|
|
8
|
-
* 'original' — user-uploaded content (S3-backed)
|
|
9
|
-
* 'thumbnail' — derived image variant (parentId → original)
|
|
10
|
-
* 'preview' — derived preview image
|
|
11
|
-
* 'webp' — derived WebP variant
|
|
12
|
-
*
|
|
13
|
-
* @see everyschema — canonical schema definitions
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
pgSchema,
|
|
18
|
-
text,
|
|
19
|
-
timestamp,
|
|
20
|
-
uuid,
|
|
21
|
-
bigint,
|
|
22
|
-
index,
|
|
23
|
-
uniqueIndex,
|
|
24
|
-
jsonb,
|
|
25
|
-
customType,
|
|
26
|
-
} from 'drizzle-orm/pg-core';
|
|
27
|
-
|
|
28
|
-
const distSchema = pgSchema('dist');
|
|
29
|
-
|
|
30
|
-
const bytea = customType<{ data: Buffer; driverValue: Buffer }>({
|
|
31
|
-
dataType() {
|
|
32
|
-
return 'bytea';
|
|
33
|
-
},
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// =============================================================================
|
|
37
|
-
// BLOBS - Raw binary storage, deduped by sha256
|
|
38
|
-
// =============================================================================
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Content-addressed storage — in-DB or S3-backed.
|
|
42
|
-
*
|
|
43
|
-
* Storage modes:
|
|
44
|
-
* In-DB: data (bytea) populated, s3Key/bucket NULL
|
|
45
|
-
* S3: s3Key + bucket populated, data NULL
|
|
46
|
-
*
|
|
47
|
-
* Derived content uses parentId:
|
|
48
|
-
* original → thumbnail, preview, webp, etc.
|
|
4
|
+
* DERIVED from the package-owned Model (`./models/blob`) — the Model is the single
|
|
5
|
+
* source of the table's columns, schema, indexes, and authz; this just materializes
|
|
6
|
+
* the drizzle-orm runtime object the handler queries. Edit the Model, not this file.
|
|
49
7
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* Video/audio: { codec, duration, bitrate }
|
|
8
|
+
* Content-addressed storage — S3-backed (the object registry; bytes live in S3).
|
|
9
|
+
* Replaces the old `uploads` table.
|
|
53
10
|
*/
|
|
54
|
-
export const blobs = distSchema.table(
|
|
55
|
-
'blobs',
|
|
56
|
-
{
|
|
57
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
58
|
-
repositoryId: uuid('repository_id'),
|
|
59
|
-
type: text('type').notNull(), // 'original' | 'thumbnail' | 'preview' | 'webp' | etc.
|
|
60
|
-
sha256: text('sha256').notNull().unique(),
|
|
61
|
-
|
|
62
|
-
// Storage: in-DB (bytea) or S3-backed (s3Key + bucket)
|
|
63
|
-
data: bytea('data'),
|
|
64
|
-
s3Key: text('s3_key'),
|
|
65
|
-
bucket: text('bucket'),
|
|
66
|
-
|
|
67
|
-
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
|
68
|
-
contentType: text('content_type'),
|
|
69
|
-
|
|
70
|
-
// Derived content (thumbnail → original, webp → original, etc.)
|
|
71
|
-
parentId: uuid('parent_id'),
|
|
72
11
|
|
|
73
|
-
|
|
74
|
-
|
|
12
|
+
import { toDrizzleTable } from '@everystack/model/drizzle';
|
|
13
|
+
import { Blob } from './models/blob';
|
|
75
14
|
|
|
76
|
-
|
|
77
|
-
},
|
|
78
|
-
(table) => ({
|
|
79
|
-
typeIdx: index('blobs_type_idx').on(table.type),
|
|
80
|
-
sha256Idx: uniqueIndex('blobs_sha256_idx').on(table.sha256),
|
|
81
|
-
contentTypeIdx: index('blobs_content_type_idx').on(table.contentType),
|
|
82
|
-
createdAtIdx: index('blobs_created_at_idx').on(table.createdAt),
|
|
83
|
-
repoIdx: index('blobs_repo_idx').on(table.repositoryId),
|
|
84
|
-
parentIdx: index('blobs_parent_idx').on(table.parentId),
|
|
85
|
-
s3KeyIdx: index('blobs_s3_key_idx').on(table.s3Key),
|
|
86
|
-
})
|
|
87
|
-
);
|
|
15
|
+
export const blobs = toDrizzleTable(Blob);
|
package/src/types.ts
CHANGED
|
@@ -51,4 +51,13 @@ export interface StorageHandlerOptions {
|
|
|
51
51
|
presignedUrlExpiry?: number;
|
|
52
52
|
/** Maximum value for ?limit= on listing endpoints. Default: 1000. */
|
|
53
53
|
maxLimit?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Postgres role to run the dist.blobs access as. On the least-privilege API connection
|
|
56
|
+
* (`authenticator`) set this to `admin` so the handler reaches dist.blobs under the admin
|
|
57
|
+
* policy; the per-user ownership check stays in the handler (metadata.userId === sub). Unset
|
|
58
|
+
* = run directly on the bound connection (single-credential / owner apps). When set, each DB
|
|
59
|
+
* operation runs inside a transaction with SET LOCAL ROLE (transaction-scoped, resets on
|
|
60
|
+
* return).
|
|
61
|
+
*/
|
|
62
|
+
role?: string;
|
|
54
63
|
}
|