# Webcloster Storage Wrapper > A multi-tenant file-storage API. It wraps Cloudflare R2 (S3-compatible object > storage) behind a per-tenant HTTP API with authentication, per-user and > per-space quotas, plan-based limits, delegated end-user tokens, async image > compression, usage stats, and an immutable audit log. Built with Hono + Bun + > Drizzle ORM + PostgreSQL + Redis. This document (served at `/llms.txt`) is a deep, self-contained guide to the API for LLMs and integrators. Every path below is under the version prefix `/api/v1`. The interactive OpenAPI reference is linked at the end. --- ## Platform The product is a Bun monorepo of several apps around one API: - **api** (`apps/api`) — the HTTP API described here (Hono + `@hono/zod-openapi`). Runs a paired **worker** process (`apps/api/src/workers`) for CPU-bound image compression and the orphan-object reaper. - **portal** (`apps/portal`) — Vue 3 admin/owner web app. Two audiences: the super-admin (manage projects, global stats, audit, settings) and the project owner ("Sensei": integrations, API keys, plans, users, spaces, stats). - **demo** + **demo-api** (`apps/demo`, `apps/demo-api`) — a reference integration ("Liquida") showing how a third-party app consumes the API with an integration key and space ("Ruta") logins. - **docs** (`apps/docs`) — Astro Starlight documentation site. Infrastructure: PostgreSQL (system of record), Redis (rate limiting + token blocklist + live stat counters), Cloudflare R2 (object storage). All state that matters is in Postgres; Redis is an accelerator with in-process fallbacks. --- ## Conventions - **Base URL / versioning.** All endpoints are mounted under `/api/v1`. The server exposes `servers: [{ url: '/api/v1' }]` in its OpenAPI document. - **Auth transport.** Tokens are read **cookie first** (HttpOnly `token`), then `Authorization: Bearer `. API keys are always `Authorization: Bearer pak_<64-hex>`. - **Content types.** JSON everywhere except file/image uploads, which are `multipart/form-data`. - **Error envelope.** Every error is `{ "error": { "code": "", "message": "...", "details": { ... } } }`. - **Timestamps.** ISO-8601 UTC. **IDs.** UUID v4 unless noted. - **Pagination.** Cursor-based where lists can grow (files, folders, audit): an opaque base64url cursor, order `created_at DESC, id DESC`, `next_cursor` is `null` when the page is the last one. - **Secrets shown once.** Project keys, integration keys, and 2FA recovery codes are returned exactly once at creation; only hashes are stored. --- ## Authentication axes (actor types) The API resolves one unified **principal** per request. There are five caller identities plus a short-lived 2FA challenge: | Actor | Credential | Authority | |---|---|---| | **admin** (super-admin) | JWT `role:admin` (cookie or Bearer) | Everything under `/admin/*`. No project scope. | | **project owner** ("Sensei") | JWT `role:project`, **or** a project API key `pak_…` | Full authority over its own project — scopes `['*']`. | | **integration** | an integration API key `pak_…` | Its stored `scopes` (may be empty), plus its CORS/rate-limit policy. | | **user token** | JWT `role:user`, minted for one end-user | Delegated subset of scopes, **pinned to that user** (`subject`). | | **space token** ("Ruta") | JWT `role:space`, minted by a space login | The space's scopes, **pinned to that space + its owning user**. | | **2fa_challenge** | JWT `role:2fa_challenge`, 5-minute TTL | Not a session. Accepted only by `POST /auth/2fa/login`; every other guard rejects it. | Notes: - **API-key format.** `pak_` + 64 lowercase hex (32 random bytes). Stored as a SHA-256 hash. A project key and an integration key share this format; the server disambiguates by lookup. - **JWT.** HS256, signed with `JWT_SECRET`, always carries a `jti`. Session TTL is `JWT_EXPIRES_IN` (default `7d`). - **Revocation.** A `jti` blocklist (Redis-backed when `REDIS_URL` is set, else in-process) is checked on every request. Logout, space password rotation / deactivation / deletion, and the token-revoke endpoints add to it. - **Subject pinning.** User tokens carry `subjectUserId = sub`; space tokens carry `subjectUserId = owning user` and `spaceId = sub`. The `subjectGuard` forces the request's `:userId` / `:externalId` to equal the pinned subject and denies collection-level endpoints for these tokens. --- ## Scopes and enforcement Scope vocabulary (`lib/scopes.ts`): ``` users:read users:write files:read files:write files:delete plans:read plans:assign stats:read spaces:login ``` `*` is the wildcard (full authority) carried by project keys and project/admin sessions. **Enforcement mode** — env `SCOPE_ENFORCEMENT`, **default `enforce`**: - `off` — never checked. - `warn` — logs the missing scope, then continues. - `enforce` — returns `403 FORBIDDEN` `Missing required scope: .` Because several routers share a URL prefix (`/project/users`, `/project`), scope guards are **confined per segment** (`onSegment('files', …)`, `onSegment('jobs', …)`, etc.) so a guard never bleeds onto a sibling router and wrongly 403s it. Beyond staged scopes, some **hard actor boundaries** are always enforced: - **owner-only** — API keys, integrations, plan-definition mutations, and the project audit read require `actorType === 'project'`. - **provisioner-only** — user create/update/delete/plan-assign require a `project` or `integration` actor (never a user/space token). - **space-manager-only** — space management requires a `project` or `user` actor (never an integration or space token). --- ## Rate limiting Two independent limiters: 1. **Login limiter** (per-IP, fixed window): **10 attempts / 15 min** on `POST /auth/login`, `POST /auth/forgot-password`, `POST /auth/2fa/login`. IP from `X-Forwarded-For` (first) then `X-Real-IP`. On breach: `429` + `Retry-After` (body code `RATE_LIMIT_EXCEEDED`). 2. **Integration limiter** — applies only to `integration` actors that carry a policy. Hierarchy (most specific wins): **integration → project → global**. The integration's own `rateLimitMax`/`rateLimitWindow`; else the project tier (`projects.rate_limit_*`); else the global fallback (admin-configurable in `system_settings`, else env `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS`, defaults `1000` / `60000` ms). `dev_mode` integrations are **not** rate limited and get open CORS. Every limited response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds). Integration breach: `429` + `Retry-After`, body `{ error: { code: "RATE_LIMIT_EXCEEDED", … } }`. --- ## Error codes `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `STORAGE_LIMIT_EXCEEDED`, `FILE_TOO_LARGE`, `MIME_NOT_ALLOWED`, `OVER_QUOTA`, `RATE_LIMIT_EXCEEDED`, `INTERNAL_ERROR`, `STORAGE_UNAVAILABLE`. Validation failures return **422** for JSON/form bodies and **400** for path/query/header params, with `details.issues` holding the Zod issues. --- ## Endpoints Grouped by area. "Auth" lists accepted actors; "Scope" is the required scope (hard boundaries noted inline). All paths are under `/api/v1`. ### Health & docs - `GET /health` — public readiness probe. `200` when the DB check passes, `503` (same body) when degraded, so orchestrators can gate on it. - `GET /openapi.json` — OpenAPI 3.1 document. - `GET /reference` and `GET /docs` — interactive Scalar API reference UI. ### Auth — `/auth` (CSRF-protected) - `POST /auth/login` — public. Body `{ email, password }`. Returns `200 { token }` + HttpOnly cookie. If 2FA is enabled, returns `200 { two_factor_required: true, challenge_token }` and issues **no** session. - `POST /auth/logout` — clears cookie, blocklists the token. `204`. - `GET /auth/me` — any session. `200 { session }`. - `PUT /auth/change-password` — admin/project session. Body `{ currentPassword, newPassword }`. `200 { ok: true }`. - `POST /auth/forgot-password` — public, rate-limited. Body `{ email }`. Always `200 { ok: true }` (anti-enumeration); emails a 15-min single-use reset link. - `POST /auth/reset-password` — public. Body `{ token, newPassword }`. ### Two-factor (TOTP) — `/auth/2fa` Managed by the account itself (admin or project session only). Secrets are AES-256-GCM encrypted at rest. - `POST /auth/2fa/enroll` — create a pending secret. `200 { otpauth_url, secret }` (once). `409` if already enabled. - `POST /auth/2fa/activate` — body `{ code }` → enable, returns `200 { recovery_codes: [...] }` (once). - `POST /auth/2fa/disable` — body `{ code }` (TOTP or recovery). Deletes the row. - `GET /auth/2fa/status` — `200 { enabled }`. - `POST /auth/2fa/login` — **step 2**, public but gated by the challenge token. Body `{ challenge_token, code }` (TOTP or single-use recovery code). Returns a full session `200 { token }`. Rate-limited. **2FA login flow:** password at `/auth/login` → if enabled, receive a `challenge_token` (5-min TTL) → exchange it plus the code at `/auth/2fa/login` for a real session. Recovery codes are single-use and hashed. ### Admin / projects — `/admin/*` (admin session; 401 no session, 403 non-admin) - `GET /admin/projects` — list projects. - `POST /admin/projects` — create. Body `{ name, email, password }`. `201` returns the project **and its `pak_` key once**. `409` duplicate email. - `GET /admin/projects/{projectId}` — detail. - `PATCH /admin/projects/{projectId}` — body `{ name?, email? }`. - `PATCH /admin/projects/{projectId}/status` — body `{ active }`. - `POST /admin/projects/{projectId}/rotate-key` — replaces all keys with one new key (returned once). - `DELETE /admin/projects/{projectId}` — hard delete + FK cascade (integrations, keys, plans, users, files, spaces, stats). R2 objects are **queued for the async orphan reaper**; audit rows are retained with a null `project_id`. `204`. ### Admin / stats & settings — `/admin/*` - `GET /admin/stats/global` — system-wide 30-day counters + storage totals. - `GET /admin/stats/projects` — per-project breakdown. - `GET /admin/audit` — all audit logs, filterable by `project_id, action, user_id, actor_type, from, to, limit (1–100), cursor`. Cursor-paginated. The read is itself audited. - `GET|PUT /admin/settings/audit-retention` — retention window in days (`1..3650`, default 365). - `GET|PUT /admin/settings/rate-limit` — global rate-limit fallback `{ max (≥1), window_ms (≥1000) }`. ### Project / plans — `/project/plans` Plan definitions cap what a user may store. `plans:read` to read; **owner-only** to mutate. - `GET /project/plans` — list. Scope `plans:read`. - `POST /project/plans` — owner-only. Body `{ name, description?, storageLimit?, maxFileSize, allowedMimes? }` (the first plan becomes the default). `201`. - `GET /project/plans/{planId}` — detail. - `PUT /project/plans/{planId}` — owner-only, partial update. - `DELETE /project/plans/{planId}` — owner-only. `409` if users are assigned. - `PUT /project/plans/{planId}/default` — owner-only atomic default swap. ### Project / users — `/project/users` Provisioning end-users. `users:read`/`users:write`; provisioner-only mutations; subject-pinned tokens are confined to their own user. - `GET /project/users` — list with plan + quota. Scope `users:read`. - `POST /project/users` — provisioner + `users:write`. Body `{ externalId, planId? }` (falls back to the default plan). `409` duplicate externalId. - `GET /project/users/{userId}` and `…/ext/{externalId}` — detail. - `PUT /project/users/{userId}` — provisioner + `users:write`. Body `{ active? }` (externalId is immutable). - `DELETE /project/users/{userId}` — provisioner + `users:write`. - `PUT /project/users/{userId}/plan` (+ `/ext/…`) — provisioner + `plans:assign`. Body `{ planId }`. - `GET /project/users/{userId}/storage` (+ `/ext/…`) — quota snapshot (`storageAvailable`, `usagePercent`, `overQuota`). Every `{userId}` route has an `/ext/{externalId}` twin that resolves the user by your external id instead of the internal UUID. ### Project / files — `/project/users/{userId}/files` Scopes: `files:read` (GET), `files:write` (POST/PUT), `files:delete` (DELETE). Space-bound tokens see only their space; a project/user may filter by `space_id=` or `space_id=none`. - `POST …/files` (+ `/ext/…`) — upload. `multipart/form-data`, field `file`; optional `meta` JSON `{ folder?, tags?, classifier?, metadata?, visibility? }`. Enforces the plan's `maxFileSize` + `allowedMimes` (supports `type/*`), the user's `storageLimit`, and the space's `limitBytes`. Charges storage **atomically** (concurrent overshoot → `422 STORAGE_LIMIT_EXCEEDED`). `201`. Other errors: `422 FILE_TOO_LARGE` / `MIME_NOT_ALLOWED`, `403` space unavailable, `404` user. - `GET …/files` (+ `/ext/…`) — list. Query `folder?, tags? (all must match), classifier?, mime_type?, visibility?, space_id?, limit (1–100, default 20), cursor?`. Cursor-paginated. - `GET …/files/folders` (+ `/ext/…`) — directory-style browse over the semantic `folder` field. Query `prefix?, limit, cursor`. Returns `{ prefix, folders: [...], files: [...], next_cursor }`. - `GET …/files/{fileId}` (+ `/ext/…`) — detail + a download URL. Public files with a configured public bucket get a permanent CDN URL (`url_expires_at: null`); otherwise a presigned URL with `url_expires_at` (TTL `SIGNED_URL_EXPIRY_SECONDS`, default 7 days). - `PUT …/files/{fileId}` — update mutable fields `{ folder?, tags?, classifier?, metadata?, visibility? }` (name, size, mimeType, storageKey are immutable). - `DELETE …/files/{fileId}` — hard delete, decrements user + space usage, best-effort R2 delete. `204`. ### Project / images (async compression) — `/project/users/{userId}/images` - `POST …/images` (+ `/ext/…`) — upload an image (`multipart/form-data`, field `image`, must be `image/*`) and enqueue compression. Optional `preset (thumbnail|medium|large|original)`, `format (webp|jpeg|png|avif)`, `quality (1–100)`, `width`, `height`, plus the same metadata fields as files. Quota is charged against the **original** bytes. The original is stored and immediately usable. Returns **`202 { job_id, file_id, status: 'pending' }`**. ### Project / jobs — `/project/jobs/{jobId}` - `GET /project/jobs/{jobId}` — poll a compression job. Scope `files:read`. Statuses `pending → processing → done | failed`. `done` adds `{ original_size, compressed_size, compression_ratio, format, width, height }`; `failed` adds `{ error }`. Ownership mismatch returns **404** (never leaks existence across tenants/subjects). ### Project / stats — `/project/stats/*` Hybrid of Postgres history (`daily_stats`) + today's live Redis counters, 30-day window. Scope `stats:read`. Every read is audited. - `GET /project/stats/overview` — today + last 30 days + storage totals. - `GET /project/stats/files` — distribution by mime type and classifier. - `GET /project/stats/storage` — users, files, usage, over-quota count, split by visibility. - `GET /project/stats/requests` — daily traffic series. - `GET /project/users/{userId}/stats` (+ `/ext/…`) — per-user (subject-pinned). ### Project / audit — `/project/audit` - `GET /project/audit` — this project's own audit logs, **owner-only**. Same filters as the admin audit minus `project_id`. Cursor-paginated. ### Project / keys & integrations — `/project/*` (owner-only) - `GET|POST /project/keys`, `DELETE /project/keys/{keyId}` — manage project API keys (max 10 active; create returns the key once; delete soft-deactivates). - `GET|POST /project/integrations`, `GET|PUT|DELETE /project/integrations/{id}`, `POST /project/integrations/{id}/rotate-key` — manage integrations. An integration carries `scopes`, `corsOrigins`, `devMode`, and `rateLimit*` — **all enforced at request time**. Create/rotate return the key once. ### Project / user tokens — `/project/users/{userId}/tokens` - `POST …/tokens` (+ `/ext/…`) — mint a delegated end-user JWT. Scope `users:read`; project/integration only. Body `{ scopes? }` must be a subset of the caller's. `201 { token, jti, user_id, scopes, expires_at }`. TTL `USER_TOKEN_TTL_SECONDS` (default 900 s). - `POST …/tokens/revoke` (+ `/ext/…`) — body `{ jti }`, blocklists it. `204`. ### Project / spaces ("Rutas") — `/project/users/{userId}/spaces` + `/project/spaces` A space is an isolated storage partition owned by one user, with its own scopes, byte limit, password, and single active session. Management is space-manager-only (project or the owning user). - `GET|POST /project/users/{userId}/spaces` (+ `/ext/…`) — list / create. Create body `{ external_id, name, scopes?, limit_bytes?, password? }`. `409` duplicate external_id. - `GET|PUT|DELETE /project/users/{userId}/spaces/{spaceId}` (+ `/ext/…`) — detail / update `{ name?, scopes?, limit_bytes?, active? }` / delete. Setting `active:false` or deleting **immediately revokes the live space token**. - `PUT …/spaces/{spaceId}/credentials` — set/rotate the space password (revokes the live session). `204`. Space session lifecycle lives at `/project/spaces`: - `POST /project/spaces/login` — sign in as a space and mint a space token. Scope `spaces:login` (carried by the consumer integration key). Body `{ external_id, password }`. Constant-time (dummy verify on miss). **Single-device, last-wins**: the previous token is blocklisted. `201 { token, jti, space_id, user_id, scopes, expires_at }` (TTL `SPACE_TOKEN_TTL_SECONDS`, default 86400 s). `401` on bad credentials. - `POST /project/spaces/token/revoke` — body `{ jti }`. `204`. --- ## Image compression pipeline The API never runs the image codec on its event loop. `POST …/images` writes the original to R2, inserts a `pending` job, and wakes a separate worker process. The worker (Sharp) compresses and replaces the object in place. Clients poll `GET /project/jobs/{jobId}`. - Formats: `webp | jpeg | png | avif`. - Presets: `thumbnail` (webp q75, 150×150), `medium` (webp q80, 800w), `large` (webp q85, 1920w), `original` (webp q90). No preset = webp q80, no resize. Explicit `format`/`quality`/`width`/`height` override the preset. - Resize is `fit: inside` + no enlargement (never distorts or upscales). - Safety: input pixels are capped (default 50 MP) and processing is time-bounded (default 30 s); a hostile/oversized image lands the job in `failed`, not a hung worker. Failed jobs retry up to a cap, then dead-letter. --- ## Audit log Every sensitive operation appends an immutable `audit_logs` row. Recording is fire-and-forget and never breaks the request. A row is `{ id, project_id, actor_type, actor_id, actor_key_id, user_id, action, resource_type, resource_id, ip_address, user_agent, metadata, created_at }`. `actor_type` is one of `admin | project | integration` (user and space tokens fold into `integration`, keeping their token id in `actor_key_id`). Actions cover auth (incl. 2FA), projects, integrations, API keys, plans, users, files, stats reads, and audit reads. Retention defaults to 365 days and is purged daily; both the retention window and the global rate limit are admin-configurable (`/admin/settings/*`). --- ## Multi-tenancy & storage layout Hierarchy: **project → users → spaces → files**. - The **project** is the tenant boundary; every query is scoped by the principal's `project_id`, so tenants can never see each other's data. - **Users** are the project's end-users (unique `external_id` per project), each on a **plan** (storage limit, max file size, allowed mime types). Exactly one default plan per project. - **Spaces** ("Rutas") are per-user isolated partitions with their own scopes, byte limit, usage counter, password, and single active device. - **Files** belong to a user and optionally a space (`space_id: null` = a Master-level file). Space tokens are confined to their space; the owning user/project sees the user's own files and all their spaces. **Storage keys** (R2 object keys) are namespaced `{projectId}/{userId}/[{safeFolder}/]{uuid}/{safeName}`. The folder and name are sanitized and a random UUID guarantees uniqueness; the bucket layout mirrors the tenancy tree. Objects whose DB row never committed (or whose project was deleted) are cleaned up asynchronously by the orphan-object reaper. --- ## Environment (consumer-relevant) - `SCOPE_ENFORCEMENT` — `off | warn | enforce` (**default enforce**). - `JWT_EXPIRES_IN` — session TTL (default `7d`). - `USER_TOKEN_TTL_SECONDS` — delegated user token TTL (default 900). - `SPACE_TOKEN_TTL_SECONDS` — space token TTL (default 86400). - `SIGNED_URL_EXPIRY_SECONDS` — presigned file URL TTL (default 604800 = 7 days). - `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MS` — global rate-limit fallback (defaults 1000 / 60000; overridable via `/admin/settings/rate-limit`). - `API_CORS_ALLOW_ORIGINS` — global CORS allowlist (`*` disables credentials). - `R2_PUBLIC_URL` — when set, public files get a permanent CDN URL instead of a presigned one. - `REDIS_URL` — when set, token revocation + rate limits + stat counters are shared across instances; otherwise they are per-process. --- ## Further resources - OpenAPI 3.1 document: `/api/v1/openapi.json` - Interactive API reference (Scalar): `/api/v1/reference` (also `/api/v1/docs`) - Health probe: `/api/v1/health`