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>/...) so list(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).