@omg-dev/server 0.4.24

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/src/storage.ts ADDED
@@ -0,0 +1,384 @@
1
+ // Object storage for vibes apps — mode-aware just like triggers.
2
+ //
3
+ // User-facing API:
4
+ // import { storage } from "@omg-dev/server"
5
+ //
6
+ // // Server route handlers:
7
+ // const { url, key } = await storage.uploadUrl({ key: "avatar.png", contentType: "image/png" })
8
+ // const url2 = await storage.downloadUrl("avatar.png")
9
+ // const items = await storage.list({ prefix: "" })
10
+ // await storage.delete("avatar.png")
11
+ //
12
+ // Runtime modes:
13
+ // - VIBES_MODE=prod (default in deployed bundles): every call POSTs to the
14
+ // in-VM agent's /_storage/* endpoints, which forward to the orchestrator.
15
+ // The orchestrator mints presigned PUT/GET URLs against Tigris and the
16
+ // browser uploads/downloads direct.
17
+ // - VIBES_MODE=dev (set by the vite-plugin during `bun dev`): files are
18
+ // stored on the local filesystem under .vibes/storage/<scope>/<...>/<key>.
19
+ // `uploadUrl()` returns a local Vite-server URL that handles the PUT;
20
+ // `downloadUrl()` returns the same path for GET. No Tigris, no orchestrator.
21
+ //
22
+ // Auth scoping:
23
+ // - scope: "user" (default) — the file lives under the authed user's
24
+ // prefix. `ctx.userId` (from @omg-dev/auth middleware) is used as the
25
+ // userId. If the request has no authed user, throws.
26
+ // - scope: "app" — shared across all users of the app. Useful for things
27
+ // like a logo or static assets the app developer wants to manage at
28
+ // runtime. (App-level write authorization is the caller's responsibility.)
29
+
30
+ import path from "node:path"
31
+ import fs from "node:fs"
32
+ import crypto from "node:crypto"
33
+ import { ctxStore, ctx } from "./ctx.ts"
34
+
35
+ // ── Types ────────────────────────────────────────────────────────────────────
36
+
37
+ export type StorageScope = "user" | "app"
38
+
39
+ export interface UploadUrlOptions {
40
+ /** Object key. Must match /^[a-zA-Z0-9._\-\/]{1,256}$/. Forward slashes are OK as folder separators. */
41
+ key: string
42
+ /** Content-Type the browser will PUT. Baked into the presigned signature. Defaults to "application/octet-stream". */
43
+ contentType?: string
44
+ /** "user" (default) or "app". */
45
+ scope?: StorageScope
46
+ /** Explicit userId. Defaults to ctx.userId for scope="user". */
47
+ userId?: string
48
+ }
49
+
50
+ export interface DownloadUrlOptions {
51
+ scope?: StorageScope
52
+ userId?: string
53
+ }
54
+
55
+ export interface ListOptions {
56
+ /** Optional sub-prefix within the (scope, userId) namespace. */
57
+ prefix?: string
58
+ scope?: StorageScope
59
+ userId?: string
60
+ /** Max items to return; default 200, cap 1000. */
61
+ limit?: number
62
+ }
63
+
64
+ export interface PresignedUploadResult {
65
+ url: string
66
+ method: "PUT"
67
+ key: string
68
+ expiresAt: string
69
+ /** Max bytes the orchestrator signed for; the browser must not exceed it. */
70
+ maxBytes: number
71
+ }
72
+
73
+ export interface PresignedDownloadResult {
74
+ url: string
75
+ method: "GET"
76
+ key: string
77
+ expiresAt: string
78
+ }
79
+
80
+ export interface StorageItem {
81
+ /** Full Tigris key (or local FS path in dev). Treat as opaque. */
82
+ key: string
83
+ /** User-facing relative path within the (scope, user) bucket. */
84
+ relPath: string
85
+ size: number
86
+ lastModified: string
87
+ contentType?: string
88
+ }
89
+
90
+ // Handler decorators (build-time markers). The vite-plugin scanner detects
91
+ // `storage.onUpload(...)` / `storage.onDelete(...)` and emits entries into
92
+ // `.vibes/triggers.json` with kind "storage:upload" / "storage:delete". The
93
+ // runtime function just returns the handler unchanged so unit tests can
94
+ // import + invoke directly.
95
+ export type StorageEvent =
96
+ | { kind: "upload"; key: string; size: number; contentType: string; userId: string | null }
97
+ | { kind: "delete"; key: string; userId: string | null }
98
+ export type StorageEventHandler = (ctx: import("./ctx.ts").VibesCtx, evt: StorageEvent) => unknown | Promise<unknown>
99
+
100
+ function onUpload(handler: StorageEventHandler): StorageEventHandler {
101
+ return handler
102
+ }
103
+ function onDelete(handler: StorageEventHandler): StorageEventHandler {
104
+ return handler
105
+ }
106
+
107
+ // ── Mode detection (mirrors triggers.ts) ─────────────────────────────────────
108
+
109
+ let _vibesMode: "dev" | "prod" | null = null
110
+ function vibesMode(): "dev" | "prod" {
111
+ if (_vibesMode) return _vibesMode
112
+ const env = (typeof process !== "undefined" ? process.env?.VIBES_MODE : undefined) ?? ""
113
+ _vibesMode = env === "dev" ? "dev" : "prod"
114
+ return _vibesMode
115
+ }
116
+
117
+ // ── Common validators ───────────────────────────────────────────────────────
118
+
119
+ const KEY_RE = /^[a-zA-Z0-9._\-/]{1,256}$/
120
+ function validateKey(key: string): void {
121
+ if (!KEY_RE.test(key)) {
122
+ throw new Error(`storage: invalid key ${JSON.stringify(key)}`)
123
+ }
124
+ if (key.includes("..") || key.startsWith("/") || key.includes("//")) {
125
+ throw new Error(`storage: key cannot contain traversal segments or empty parts`)
126
+ }
127
+ }
128
+
129
+ function resolveScope(scope: StorageScope | undefined, userId: string | undefined): { scope: StorageScope; userId: string } {
130
+ const s: StorageScope = scope ?? "user"
131
+ if (s === "user") {
132
+ const resolved = userId ?? ctx.userId ?? ""
133
+ if (!resolved) {
134
+ throw new Error(
135
+ `storage: scope="user" requires an authed user — pass storage.upload(...) from a route that's behind VibesAuthGuard or set scope:"app" explicitly.`,
136
+ )
137
+ }
138
+ return { scope: "user", userId: resolved }
139
+ }
140
+ return { scope: "app", userId: "" }
141
+ }
142
+
143
+ // ── Public storage API ───────────────────────────────────────────────────────
144
+
145
+ async function uploadUrl(opts: UploadUrlOptions): Promise<PresignedUploadResult> {
146
+ validateKey(opts.key)
147
+ const { scope, userId } = resolveScope(opts.scope, opts.userId)
148
+ if (vibesMode() === "dev") {
149
+ return devUploadUrl(opts.key, scope, userId, opts.contentType)
150
+ }
151
+ return prodPresign("put", opts.key, scope, userId, opts.contentType)
152
+ }
153
+
154
+ async function downloadUrl(key: string, opts: DownloadUrlOptions = {}): Promise<PresignedDownloadResult> {
155
+ validateKey(key)
156
+ const { scope, userId } = resolveScope(opts.scope, opts.userId)
157
+ if (vibesMode() === "dev") {
158
+ return devDownloadUrl(key, scope, userId)
159
+ }
160
+ const res = await prodPresign("get", key, scope, userId)
161
+ return { url: res.url, method: "GET", key: res.key, expiresAt: res.expiresAt }
162
+ }
163
+
164
+ async function list(opts: ListOptions = {}): Promise<StorageItem[]> {
165
+ const { scope, userId } = resolveScope(opts.scope, opts.userId)
166
+ if (vibesMode() === "dev") {
167
+ return devList(scope, userId, opts.prefix ?? "")
168
+ }
169
+ return prodList(scope, userId, opts.prefix ?? "", opts.limit ?? 200)
170
+ }
171
+
172
+ async function del(
173
+ key: string,
174
+ opts: { scope?: StorageScope; userId?: string } = {},
175
+ ): Promise<void> {
176
+ validateKey(key)
177
+ const { scope, userId } = resolveScope(opts.scope, opts.userId)
178
+ if (vibesMode() === "dev") {
179
+ return devDelete(key, scope, userId)
180
+ }
181
+ return prodDelete(key, scope, userId)
182
+ }
183
+
184
+ export const storage = {
185
+ uploadUrl,
186
+ downloadUrl,
187
+ list,
188
+ delete: del,
189
+ onUpload,
190
+ onDelete,
191
+ }
192
+
193
+ // ── prod-mode: HTTP to the agent ─────────────────────────────────────────────
194
+
195
+ const AGENT_BASE = "http://localhost:8080"
196
+
197
+ async function prodPresign(
198
+ action: "put" | "get",
199
+ key: string,
200
+ scope: StorageScope,
201
+ userId: string,
202
+ contentType?: string,
203
+ ): Promise<PresignedUploadResult> {
204
+ const res = await fetch(`${AGENT_BASE}/_storage/presign`, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ action, key, scope, userId, contentType }),
208
+ })
209
+ if (!res.ok) {
210
+ const text = await res.text().catch(() => "")
211
+ throw new Error(`storage.${action === "put" ? "uploadUrl" : "downloadUrl"}(${JSON.stringify(key)}) agent ${res.status}: ${text.slice(0, 200)}`)
212
+ }
213
+ const body = (await res.json()) as { url: string; expiresAt: string; key: string; method: string; maxBytes?: number }
214
+ return {
215
+ url: body.url,
216
+ method: action === "put" ? "PUT" : ("GET" as unknown as "PUT"),
217
+ key: body.key,
218
+ expiresAt: body.expiresAt,
219
+ maxBytes: body.maxBytes ?? 25 * 1024 * 1024,
220
+ }
221
+ }
222
+
223
+ async function prodList(scope: StorageScope, userId: string, prefix: string, limit: number): Promise<StorageItem[]> {
224
+ const res = await fetch(`${AGENT_BASE}/_storage/list`, {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify({ scope, userId, prefix, limit }),
228
+ })
229
+ if (!res.ok) {
230
+ const text = await res.text().catch(() => "")
231
+ throw new Error(`storage.list agent ${res.status}: ${text.slice(0, 200)}`)
232
+ }
233
+ const body = (await res.json()) as Array<{
234
+ key: string
235
+ relPath: string
236
+ size: number
237
+ lastModified: string
238
+ contentType?: string
239
+ }>
240
+ return body.map(o => ({
241
+ key: o.key,
242
+ relPath: o.relPath,
243
+ size: o.size,
244
+ lastModified: o.lastModified,
245
+ contentType: o.contentType,
246
+ }))
247
+ }
248
+
249
+ async function prodDelete(key: string, scope: StorageScope, userId: string): Promise<void> {
250
+ const res = await fetch(`${AGENT_BASE}/_storage/delete`, {
251
+ method: "POST",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: JSON.stringify({ key, scope, userId }),
254
+ })
255
+ if (!res.ok && res.status !== 204) {
256
+ const text = await res.text().catch(() => "")
257
+ throw new Error(`storage.delete agent ${res.status}: ${text.slice(0, 200)}`)
258
+ }
259
+ }
260
+
261
+ // ── dev-mode: local FS shim ──────────────────────────────────────────────────
262
+ //
263
+ // Dev storage lives under .vibes/storage/ inside the project root. The
264
+ // vite-plugin registers a middleware at /_vibes_storage that serves GETs
265
+ // and accepts PUTs against this directory — so uploadUrl/downloadUrl just
266
+ // return that same URL with a signed token in the query string.
267
+
268
+ function devRoot(): string {
269
+ // Resolve relative to the cwd of the dev server (always the project root
270
+ // for `bun dev` in a vibes app). Cached lazily.
271
+ return path.resolve(process.cwd(), ".vibes", "storage")
272
+ }
273
+
274
+ function devPath(scope: StorageScope, userId: string, key: string): string {
275
+ const base = scope === "user" ? path.join("users", userId) : "app"
276
+ return path.join(devRoot(), base, key)
277
+ }
278
+
279
+ function devRelative(scope: StorageScope, userId: string, key: string): string {
280
+ return scope === "user" ? `users/${userId}/${key}` : `app/${key}`
281
+ }
282
+
283
+ const DEV_TOKEN_SECRET = process.env.VIBES_DEV_STORAGE_SECRET ?? "vibes-dev-storage"
284
+ function devSignToken(rel: string, action: "put" | "get", expiresAt: number): string {
285
+ const h = crypto.createHmac("sha256", DEV_TOKEN_SECRET)
286
+ h.update(`${rel}|${action}|${expiresAt}`)
287
+ return `${expiresAt}.${h.digest("hex").slice(0, 16)}`
288
+ }
289
+
290
+ /**
291
+ * Verify a dev-storage signed token. Exported so the vite-plugin middleware
292
+ * can call into it without duplicating crypto. (Internal use; not part of the
293
+ * @omg-dev/server public API.)
294
+ */
295
+ export function _verifyDevStorageToken(rel: string, action: "put" | "get", token: string): boolean {
296
+ const [expStr, sig] = token.split(".")
297
+ if (!expStr || !sig) return false
298
+ const exp = Number(expStr)
299
+ if (!Number.isFinite(exp) || exp < Date.now()) return false
300
+ return devSignToken(rel, action, exp).split(".")[1] === sig
301
+ }
302
+
303
+ /**
304
+ * Write a file into the dev storage tree under the given namespace. Exported
305
+ * so the vite-plugin middleware can perform the PUT after verifying the
306
+ * token. Creates parent directories as needed.
307
+ */
308
+ export function _devStorageWrite(scope: StorageScope, userId: string, key: string, data: Buffer | Uint8Array): void {
309
+ const dst = devPath(scope, userId, key)
310
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
311
+ fs.writeFileSync(dst, data)
312
+ }
313
+
314
+ /**
315
+ * Resolve a dev storage file to its absolute filesystem path so the plugin
316
+ * middleware can stream the GET. Returns null if the file does not exist.
317
+ */
318
+ export function _devStorageRead(scope: StorageScope, userId: string, key: string): string | null {
319
+ const src = devPath(scope, userId, key)
320
+ if (!fs.existsSync(src)) return null
321
+ return src
322
+ }
323
+
324
+ function devUploadUrl(key: string, scope: StorageScope, userId: string, contentType?: string): PresignedUploadResult {
325
+ const rel = devRelative(scope, userId, key)
326
+ const expiresAt = Date.now() + 5 * 60 * 1000
327
+ const token = devSignToken(rel, "put", expiresAt)
328
+ return {
329
+ url: `/_vibes_storage/${rel}?t=${token}${contentType ? `&ct=${encodeURIComponent(contentType)}` : ""}`,
330
+ method: "PUT",
331
+ key: rel,
332
+ expiresAt: new Date(expiresAt).toISOString(),
333
+ maxBytes: 25 * 1024 * 1024,
334
+ }
335
+ }
336
+
337
+ function devDownloadUrl(key: string, scope: StorageScope, userId: string): PresignedDownloadResult {
338
+ const rel = devRelative(scope, userId, key)
339
+ const expiresAt = Date.now() + 60 * 60 * 1000
340
+ const token = devSignToken(rel, "get", expiresAt)
341
+ return {
342
+ url: `/_vibes_storage/${rel}?t=${token}`,
343
+ method: "GET",
344
+ key: rel,
345
+ expiresAt: new Date(expiresAt).toISOString(),
346
+ }
347
+ }
348
+
349
+ function devList(scope: StorageScope, userId: string, prefix: string): StorageItem[] {
350
+ const root = devPath(scope, userId, prefix || ".")
351
+ if (!fs.existsSync(root)) return []
352
+ const out: StorageItem[] = []
353
+ function walk(dir: string): void {
354
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
355
+ const full = path.join(dir, ent.name)
356
+ if (ent.isDirectory()) {
357
+ walk(full)
358
+ continue
359
+ }
360
+ if (!ent.isFile()) continue
361
+ const st = fs.statSync(full)
362
+ const base = devPath(scope, userId, "")
363
+ const rel = path.relative(base, full).replaceAll(path.sep, "/")
364
+ out.push({
365
+ key: rel,
366
+ relPath: rel,
367
+ size: st.size,
368
+ lastModified: st.mtime.toISOString(),
369
+ })
370
+ }
371
+ }
372
+ walk(root)
373
+ return out
374
+ }
375
+
376
+ function devDelete(key: string, scope: StorageScope, userId: string): void {
377
+ const src = devPath(scope, userId, key)
378
+ if (fs.existsSync(src)) {
379
+ fs.rmSync(src)
380
+ }
381
+ }
382
+
383
+ // Silence unused-import warning from `ctxStore` when only `ctx` is referenced.
384
+ void ctxStore