@everystack/storage 0.2.0 → 0.2.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/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@scalabletechnology.com
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
- MIT
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.2.0",
3
+ "version": "0.2.1",
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
  },
@@ -29,8 +39,8 @@
29
39
  }
30
40
  },
31
41
  "peerDependencies": {
32
- "@aws-sdk/client-s3": "3.1045.0",
33
- "@aws-sdk/s3-request-presigner": "3.1045.0",
42
+ "@aws-sdk/client-s3": "3.1053.0",
43
+ "@aws-sdk/s3-request-presigner": "3.1053.0",
34
44
  "@everystack/server": ">=0.1.0",
35
45
  "drizzle-orm": "0.41.0",
36
46
  "react": "19.2.6",
@@ -54,8 +64,8 @@
54
64
  }
55
65
  },
56
66
  "devDependencies": {
57
- "@aws-sdk/client-s3": "3.1045.0",
58
- "@aws-sdk/s3-request-presigner": "3.1045.0",
67
+ "@aws-sdk/client-s3": "3.1053.0",
68
+ "@aws-sdk/s3-request-presigner": "3.1053.0",
59
69
  "@testing-library/react-native": "13.3.3",
60
70
  "@types/jest": "29.5.14",
61
71
  "@types/node": "22.19.18",
@@ -66,7 +76,7 @@
66
76
  "react-test-renderer": "19.2.6",
67
77
  "ts-jest": "29.4.9",
68
78
  "typescript": "5.9.3",
69
- "@everystack/server": "0.2.0"
79
+ "@everystack/server": "0.2.23"
70
80
  },
71
81
  "scripts": {
72
82
  "test": "jest",
@@ -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 db
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
- let query = db.select().from(blobs);
184
-
185
- if (user.sub) {
186
- query = query.where(sql`${blobs.metadata}->>'userId' = ${user.sub}`);
187
- }
188
-
189
- query = query.orderBy(sql`${blobs.createdAt} DESC`).limit(limit).offset(offset);
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 db
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 db
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 db.delete(blobs).where(sql`${blobs.id} = ${deleteMatch[1]}`);
257
+ await scope((d) => d.delete(blobs).where(sql`${blobs.id} = ${deleteMatch[1]}`));
238
258
 
239
259
  return json({ ok: true });
240
260
  }
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/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
  }