Sub-skills
Storage in depth
For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill storage`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/storage.md verbatim with curl; don't summarize them). Run `cubek check` after every edit until it is clean.
Files: uploads through a form, direct browser uploads with signed URLs,
downloads, listing and deleting. storage is per project; keep the file's
metadata in a table and the bytes in storage.
// schema.ts
import { table, t } from "cubek"
export const files = table("files", {
id: t.id(),
ownerId: t.string(),
key: t.string().unique(),
name: t.string(),
type: t.string(),
size: t.number(),
status: t.enum(["pending", "ready"]).default("ready"),
createdAt: t.timestamp().default("now"),
}).index("ownerId", "createdAt")Form upload (small files, through the app)
<form method="post" action="/files" enctype="multipart/form-data"><input type="file" name="file" /></form>; the request body is at most 10 MB.
// src/app.tsx
import { app, crypto, db, storage, type Bytes } from "cubek"
import "./routes/uploads"
const ALLOWED = ["image/png", "image/jpeg", "image/webp", "application/pdf"]
app.post("/files", async (c) => {
const form = await c.body<{ file?: { name: string; type: string; size: number; bytes: Bytes } }>()
const f = form.file
if (f === undefined || f.size === 0) return c.text("choose a file", 400)
if (!ALLOWED.includes(f.type)) return c.text("unsupported type", 415)
if (f.size > 5 * 1024 * 1024) return c.text("too large (5 MB max)", 413)
const key = `u/demo/${crypto.randomUUID()}`
await storage.put(key, f.bytes, { contentType: f.type })
const row = await db.files.insert({ ownerId: "demo", key, name: f.name, type: f.type, size: f.size })
return c.redirect(`/files/${row.id}`, 303)
})
// Download: check access, then redirect to a short-lived signed URL.
app.get("/files/:id", async (c) => {
const row = await db.files.get(c.param("id"))
if (row === null || row.status !== "ready") return c.notFound()
return c.redirect(storage.url(row.key, { expires: "10m" }), 302)
})
// Or stream the bytes through the app (small files, custom headers).
app.get("/files/:id/raw", async (c) => {
const row = await db.files.get(c.param("id"))
const bytes = row === null ? null : await storage.get(row.key)
if (row === null || bytes === null) return c.notFound()
return c.body(bytes, 200, { "content-type": row.type, "content-disposition": `attachment; filename="${row.name.replaceAll("\"", "")}"` })
})
app.post("/files/:id/delete", async (c) => {
const row = await db.files.get(c.param("id"))
if (row === null) return c.notFound()
await storage.delete(row.key)
await db.files.delete(row.id)
return c.redirect("/", 303)
})Direct upload (large files, browser → storage)
The app signs a URL; the browser PUTs the file to it; the app confirms.
// src/routes/uploads.ts
import { app, crypto, db, storage, validate } from "cubek"
const req = validate.object({ name: validate.string({ min: 1, max: 200 }), type: validate.string(), size: validate.number({ min: 1, max: 100 * 1024 * 1024 }) })
app.post("/api/uploads", async (c) => {
const p = req.safeParse(await c.body())
if (!p.success) return c.json({ error: p.error }, 400)
const key = `big/${crypto.randomUUID()}`
const row = await db.files.insert({ ownerId: "demo", key, name: p.data.name, type: p.data.type, size: p.data.size, status: "pending" })
const url = storage.uploadUrl(key, { maxSize: p.data.size, contentType: p.data.type, expires: "15m" })
return c.json({ id: row.id, url })
})
app.post("/api/uploads/:id/done", async (c) => {
const row = await db.files.get(c.param("id"))
if (row === null) return c.json({ error: "not found" }, 404)
const found = (await storage.list(row.key)).find((o) => o.key === row.key)
if (found === undefined) return c.json({ error: "not uploaded" }, 409)
await db.files.update(row.id, { status: "ready", size: found.size })
return c.json({ ok: true })
})Browser side (an island handler or client/):
const { id, url } = await (await fetch("/api/uploads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: file.name, type: file.type, size: file.size }) })).json()
await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file })
await fetch(`/api/uploads/${id}/done`, { method: "POST" })Rules
- Keys are yours: prefix by owner (
u/<userId>/...) solist(prefix)(≤ 1,000 objects) and cleanup stay cheap. Never use the client's file name as the key. - Signed URLs: 1 h by default, 7 days at most, on the instance's own host
(
cubek dev: relative). They are bearer links: sign them only after the access check, keep them short. - Public files that never change belong in
public/(served as/<path>,asset(path)for a cached URL), not in storage. - No image processing primitive: resize in the browser before uploading (a canvas), or store the original.
- Text files:
encoding.utf8.decode(f.bytes); CSV:csv.parse(text, { header: true }). - Storage counts against the plan (1 GB free).