@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/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
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
|