drobek / docs

drobek — architecture

docs/ARCHITECTURE.md at v0.5.2 · View on GitHub · Markdown

On this page
  1. 1. One process, one image
  2. 2. Workspaces, apps and versions
  3. 3. The compile step
  4. 4. Origins and hosts
  5. 5. Serving
  6. 6. Platform modules
  7. 7. TLS
  8. 8. Background jobs
  9. 9. Agents, the dashboard and abuse

drobek is a cloud workspace for web apps that people build with their own AI agent. The agent connects over MCP and works directly in drobek: it writes files, drobek compiles them in-process with esbuild and returns the compile result in the same response, every write becomes an immutable version with an instant preview host, and a version goes live when the user asks for it. An app's backend is never code the agent writes: it is a set of platform modules (TypeScript, installed by the operator) that the app calls through a small browser SDK. The dashboard is for what does not belong in a chat with an LLM: secrets, confirmations, domains, data, users, logs.

This document is the map of how that works. The neighbours: SELF-HOSTING.md (running it), MODULES.md (the module contract and the built-in modules), AGENT.md (the agent-facing contract), SECURITY.md (the threat model), LICENSING.md (AGPL and the SaaS boundary).

1. One process, one image

                     dashboard host (PUBLIC_APP_URL)                  apps origin (*.APPS_DOMAIN + custom domains)
                     ────────────────────────────────                 ─────────────────────────────────────────────
  MCP client  ──►  /mcp  (Streamable HTTP, Bearer)                    <slug>.<APPS_DOMAIN>            published version
  browser     ──►  /     (React Router 7 SSR dashboard)               <slug>--preview.<APPS_DOMAIN>   newest version that compiled
  MCP client  ──►  /oauth/*, /.well-known/*  (OAuth 2.1 AS)           <slug>--v<N>.<APPS_DOMAIN>      exactly version N
  browser     ──►  /api/*    (dashboard JSON API)                     shop.example.org (verified)     published version
                                                                      /__drobek/sdk.js, /__drobek/v1/<module>/…
 ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
 │ caddy: TLS for the dashboard host, *.APPS_DOMAIN and verified custom domains  ──►  drobek:3000                │
 ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
 │ drobek — one Node 22 process (apps/server, Express)                                                          │
 │   1. apps-host middleware (@drobek/serving): an app host is answered here and never reaches the dashboard    │
 │   2. origin check (@drobek/auth): mutating dashboard requests from app / null / foreign origins → 403        │
 │   3. /health, /version, /api/internal/tls/ask (internal address only)                                        │
 │   4. /mcp: @drobek/oauth resource server (Bearer → user, scopes, audience) + @drobek/mcp tool bodies          │
 │   5. everything else: React Router (@drobek/dashboard routes, OAuth AS routes, /llms.txt, /report …)         │
 │   in-process jobs (apps/server/server/jobs.ts): blob GC, slug release, domain re-check, audit, files sweep   │
 └──────────────┬───────────────────────────────────────────┬────────────────────────────────┬──────────────────┘
                │ postgres-js + drizzle                     │ ioredis                        │ SMTP (nodemailer) or Resend
          Postgres 17: users, workspaces, apps,       Redis 7: sessions, rate limits,     any SMTP server
          versions + blobs, module data, OAuth,       OTP counters, leases, caches,       (EMAIL_TRANSPORT; Mailpit in dev)
          API keys, domains, abuse, audit             serve-cache bust pub/sub

2. Workspaces, apps and versions

3. The compile step

write_files (1–20 changes) → validate the paths and limits → scan for secrets (a hit refuses the write and stores nothing) → compile → store the new version (also when the compile failed, so no work is lost) → notify the serve cache. The compile result is part of the tool response.

4. Origins and hosts

Origin Serves Never
dashboard host (PUBLIC_APP_URL) dashboard, dashboard API, OAuth AS, /mcp, /llms.txt, /report any app file or app JavaScript
<slug>.<APPS_DOMAIN> the published version (indexable) the dashboard session cookie is never read here
<slug>--preview.<APPS_DOMAIN> the newest version that compiled (noindex)
<slug>--v<N>.<APPS_DOMAIN> exactly version N (noindex)
a verified custom domain the published version (indexable)

5. Serving

@drobek/serving answers every app-host request in a fixed order, each step before any byte of the app is touched:

  1. method: GET/HEAD (plus the password-unlock POST); else 405;
  2. /.well-known/drobek-report → the report pointer (works for any host);
  3. the unknown-host limiter: a client IP past APPS_UNKNOWN_HOST_LIMIT "no app here" answers per window gets 429 (without a lookup for hosts the cache does not know as live apps);
  4. the app lookup — a miss is a counted 404 page; misses are kept in a separate negative cache for 30 s, hits in the positive cache for 60 s;
  5. X-Drobek-App: <slug> on every response from here on;
  6. a taken-down app: 451 on every host and path (JSON 451 on platform paths), before the redirect, the password gate and the modules;
  7. the primary-domain 302 (production host, GET/HEAD page requests);
  8. visibility: a password app shows the password page (401) until the host-only __Host-drobek_app_access cookie (HMAC, key derived from DROBEK_MASTER_KEY) is set;
  9. /__drobek/*: the SDK, the module routes and the beacon — handed to the module runtime, never to the app's files;
  10. the version the host serves (404 "not published" / "nothing compiled"), then the file: built output wins over sources, .ts/.tsx/.jsx sources and drobek.json are never served, extension-less paths fall back to index.html, ETag = sha256 → 304;
  11. no such file: the app's asset at that path, if any (see below).

Assets (video, audio, images, fonts) share the app's URL space: the asset img/s1.jpg answers /img/s1.jpg, so a page keeps its own relative paths (<video src="film.mp4" poster="poster.jpg">). The app's own file at the same path wins; an asset path always has a media extension, so the SPA fallback never swallows one. Assets honour publish: app_assets is the draft — uploads, replacements and deletes change only it, and the preview host serves it; publish freezes a set for the version it puts live (app_version_assets, app_versions.assets_frozen_at) and the production host and custom domains serve only the live version's set; a version host serves its version's set, or the draft when it was never published. Publishing the newest version that compiled freezes the draft; publishing an older one (the rollback) keeps the set it had when it was last live; restore_version of a published version resets the draft to its set. The bytes live on disk under ASSETS_DIR (/data/assets/<app_id>/<sha256> — content-addressed and never rewritten, so the draft and any number of sets share a file; files from before this keep a random key; the assets_data volume). APP_ASSETS_QUOTA counts unique files: what the draft and the live set need must fit; the sets of up to ten earlier publishes are kept for a rollback while they fit besides, the oldest dropped first. Serving: the sniffed Content-Type, Accept-Ranges: bytes, one byte range → 206 (Content-Range) or 416, ETag (sha256) / Last-Modified → 304, If-Range, HEAD; public, max-age=300, must-revalidate on the published and custom hosts, revalidate-always on preview and version hosts, private for a password app; SVG as an attachment with a second CSP sandbox. Takedown, the password gate and "not published" answer first, like for any file.

Uploading never goes through MCP or the model: create_asset_upload (or the dashboard's Assets tab) checks the path, the declared size and type, the quota and the hourly budget, then mints a single-use upload URL on the dashboard host — PUT /api/assets/upload/<token>, 32 random bytes, only its sha256 stored in Redis for 30 minutes, bound to the app, the path, the size, the type family and the user who asked (the upload is audited as theirs). The PUT takes the token before reading a byte, streams the body to a temp file while it counts (over APP_ASSET_MAX_BYTES or past the declared size → stop), hashes and sniffs it (png, jpeg, gif, webp, avif, ico, svg, mp4, webm, m4a, mp3, ogg, wav, woff, woff2 — the bytes decide, never the name), renames it into place under its sha256, and writes the draft row under a per-app advisory lock that re-checks APP_ASSETS_QUOTA — and that the user the URL was issued for is still an editor of the app. A browser GET on the URL shows a small upload page (strict CSP, the token never in the page). A delete or replace removes a file nothing references any more; an hourly sweep removes the assets of apps deleted 24 h ago, stale temp files and files neither the draft nor a kept set references.

Every response carries the app CSP (default-src 'self', scripts from the app and https://esm.sh, connect-src 'self' https://esm.sh, images, fonts, styles and media-src (<video>, <audio>) from the app, blob: or any https URL, frame-src only the curated embeds — YouTube (www.youtube-nocookie.com, www.youtube.com), player.vimeo.com, drive.google.com — plus the operator's APP_FRAME_SRC_EXTRA, frame-ancestors = the dashboard origin only, plus the origins the owner set in apps.frame_ancestors, plus — on the production host and custom domains of an app the public gallery shows, never on preview or version hosts — the operator's GALLERY_FRAME_ANCESTORS; the dashboard frames an app solely for the app-list thumbnail, the gallery for its live preview, see SECURITY.md), nosniff and Referrer-Policy: no-referrer; preview and version hosts add X-Robots-Tag: noindex. Bytes come from a 256 MiB in-memory LRU; the host and manifest caches are busted through a local event emitter first and Redis pub/sub (drobek:app-changed) for other replicas, so a write is visible on its preview host at once.

6. Platform modules

A module is an npm package whose default export comes from defineModule() (@drobek/modules, contract 1.1.0; a module states the versions it works with in contract, e.g. '^1.1', and one this server does not satisfy refuses the start). The operator enables modules with DROBEK_MODULES; a short name x loads drobek-module-x (which must export the module x), a full package name may replace a built-in. Each entry is looked up first in DROBEK_MODULES_DIR (the modules_data volume: modules installed without a new image, each listed with its integrity in modules.lock.json, given the server's own @drobek/* / zod / drizzle-orm through a node:module resolve hook, migrations linted to mod_<name>[_*]), then among the server's dependencies; /healthz and /api/version list the active modules with their source (dir | builtin). A module contributes routes under /__drobek/v1/<name>/… on every app host, a slice of the browser SDK (drobek.<name>), a zod per-app config schema (its defaults overridable per server with DROBEK_MODULE_<NAME>_DEFAULTS), access rules, secrets (names only), env-named limits, its own error codes, its own tables and migrations, and a skill the agent reads with skill_info. Modules extend each other through typed slots: a host module declares one with a zod schema, other modules contribute values, checked at start and read with contributions(slot) (a host may compose its config schema, confirm rules and secrets from the contributions at start). Built in: auth (end-user sign-in by e-mailed code, plus the sign-in providers other modules contribute to its auth.provider slot), email (notifications to the app's owners), forms, data (collections with per-operation rules), proxy (external APIs with the secret injected server-side) and files (end-user uploads). The contract is MODULES.md.

7. TLS

Caddy runs next to drobek (docker-compose.production.yaml) and terminates TLS; drobek speaks plain HTTP on the internal network and trusts only Caddy's X-Real-IP (TRUST_PROXY=x-real-ip). The Caddyfile is generated from the environment (@drobek/core caddy.ts, task selfhost:init / task caddy:config) and contains no secrets. The dashboard host gets a normal ACME certificate; the app hosts use exactly one of three paths:

Verified custom domains get their certificates from an on-demand catch-all behind the same ask (on by default in mode (c), TLS_CUSTOM_DOMAINS). tls internal (Caddy's local CA) serves a test box and task dev:tls. Details: SELF-HOSTING.md → TLS.

8. Background jobs

All in-process (apps/server/server/jobs.ts), started with the server:

Job Interval What
blob GC hourly, Redis lease deletes blobs no version references, after 7 days
slug release hourly, Redis lease a soft-deleted app's slug is free again after 30 days
domain re-check DOMAINS_RECHECK_INTERVAL_MS (1 h), Redis lease re-verifies domains checked more than 24 h ago; unverifies + mails on a definitive failure
files sweep (only with the files module) FILES_SWEEP_INTERVAL_MS (1 h), Redis lease removes the uploads of apps deleted FILES_SWEEP_RETENTION_MS (24 h) ago, stale temp uploads and blobs no mod_files row references (drobek-module-files)
assets sweep hourly, Redis lease removes the asset files and rows of apps deleted 24 h ago, stale temp uploads and files neither the draft (app_assets) nor a kept published set (app_version_assets) references (@drobek/apps)
logs prune LOGS_PRUNE_INTERVAL_MS (1 h), Redis lease removes get_logs rows past their retention for every app: browser errors older than 30 days or past the newest 500 per app, compiles and daily request stats older than 30 days (@drobek/insights)
audit retention at start, then daily deletes audit rows older than AUDIT_RETENTION_DAYS (365) — the only deletion of audit rows anywhere

Request counters for get_logs('requests') accumulate in Redis and are flushed into Postgres on read (the whole window in one pipelined round trip) and at most once a minute per app and day; reads never delete.

9. Agents, the dashboard and abuse