All docs

Hosting: on your machine or a server

Lampo is one app. On your own machine it signs you in by itself and can link renders where they are on your disk. On a server (LAMPO_MODE=server) everyone signs in, renders arrive as uploads, and they can be kept in Bunny Storage or an S3 bucket. Accounts, invites, API tokens, uploads, review links, sign-in for MCP clients and the settings work the same in both places. A server can also hold several teams, each in a workspace of its own.

This page is about running a server. With Docker, start with docker.md; the first production deploy, step by step, is go-live.md; every setting is in configuration.md.

Quick start

  1. Start the server with the address people will open:

    LAMPO_MODE=server \
    LAMPO_PUBLIC_URL=https://review.example.com \
    LAMPO_HOST=127.0.0.1 \
    LAMPO_TRUST_PROXY=loopback \
    npm start

    The last two lines are for a reverse proxy with HTTPS on the same machine, such as Caddy (below).

  2. On its first start the log prints a one-time setup token. Open https://review.example.com/?setup and enter it with your email, name and password: that creates the owner account. On the server itself, lampo admin create-user --email you@example.com --name "You" --role owner does the same.

    A hosted server’s first start: the setup screen with the one-time setup token, name, email and passwordA hosted server’s first start: the setup screen with the one-time setup token, name, email and password
  3. Invite the others from Settings → Users, or with lampo admin invite.

  4. On each machine where an agent works (the sign-in screen has the command under Sign in an agent):

    lampo login https://review.example.com        # opens your browser; keeps an API token here
    lampo push render.mp4 --folder "Acme/Reels"   # upload; the same name again becomes v2, v3, …
    lampo watch                                   # live feedback

    Inside a Claude Code session, lampo watch also makes that session one you can pick in Assign agent… while it watches.

lampo login opens your browser at the server: sign in there if you aren't, and press Allow on the consent screen, which names the machine (lampo on <machine> is the API token it gets, in the workspace you work in there) and what the token may do. The terminal says what is happening and who you are signed in as; Ctrl-C cancels, and it gives up after 5 minutes. Over SSH it prints the address instead: open it in a browser on any device, and after Allow paste the address that browser ends on (a page that can't load) into the terminal. Where no browser can reach (CI, scripts), lampo login https://review.example.com --email you@example.com asks for your password, and with an API token from Settings, --token - asks for it or reads it from a pipe. Don't type the token itself on the command line: the process list and your shell's history would keep it. lampo login refuses a plain http:// address on another machine (the password, the token and every note would cross the network unencrypted) unless you add --insecure, for a network that is yours alone; http://localhost needs nothing. lampo logout goes back to the local store: a token lampo login made is revoked, one you pasted is only forgotten (revoke it in Settings → API tokens), and so is a login vr login saved before the rename, on its own server. It forgets the logins first and waits 5 s for each server: one that doesn't confirm is named, and its token stays valid until you revoke it there. In CI or a container, LAMPO_SERVER and LAMPO_TOKEN do the same as lampo login without a file; LAMPO_REMOTE=0 keeps lampo on the local store.

Your machine or a server

On your machine (the default)Hosted (LAMPO_MODE=server)
Signing inYou are signed in automatically at the machine; your owner account is made on the first start. Teammates sign in with their accounts; a phone can also use the link npm run lan prints.Everyone signs in. The first account is made with the one-time setup token; others come through invites or, when LAMPO_SIGNUP allows it, sign up.
Videos come fromuploads, and renders linked where they are on this disk (a re-render to the same path becomes the next version)uploads: the browser, lampo push, MCP
AgentsClaude Code sessions on this machine show up by themselves; any MCP client or lampo connects tooany MCP client (by signing in, or with an API token) and lampo login
Renders are kept inversions/ on this disklocal disk, Bunny Storage, or an S3-compatible bucket
Extraslinking files and browsing folders, Claude Code sessions (and starting one for a request), Finder and macOS text recognition on a Mac, project timelines, INBOX.md, the tunnel for review links, the phone linknone: a hosted server never touches anyone's disk or machine

Only the machine itself may name paths on it: a teammate or a phone uploads, it never links a file or browses your folders. Everything else is the same in both places: frame-exact notes, screenshots, versions and diffs, Auto-check, review links, and the files agents read.

On your machine, npm start opens the app signed in as you. To use it from your phone or another computer on your network, start it with npm run lan so it listens there. Then open the link it prints on that device (it is you from then on), or sign in with an email and a password you set in Settings → Profile. Invite teammates from Settings → Users, as on a server; they sign in with their own accounts. The tunnel serves review links only.

A store that ran on a machine works on a server for its uploads only: linked renders point at a disk the server can't see. To move one, linked renders and all, use lampo export there and lampo admin import here: moving.md.

Configuration

The settings that matter most on a server. All the others, and where config.json is found: configuration.md.

Variableconfig.jsonDefaultWhat it does
LAMPO_MODEmodelocalserver turns on sign-in for everyone and the restrictions in the security model.
LAMPO_PUBLIC_URLpublic_urlnone (required)The address people open: scheme and host only, such as https://review.example.com (a path is refused). Only this host name (and localhost) is served, writes must come from it, emailed links are built from it, and on https cookies are marked Secure. Plain http:// is refused unless the host is this machine or LAMPO_ALLOW_HTTP=1.
LAMPO_ALLOW_HTTPoff1 lets a hosted server start with a plain-http LAMPO_PUBLIC_URL on another host (a closed test network); passwords and cookies then cross the network unencrypted.
LAMPO_TRUST_PROXYtrust_proxynoneWhich proxies may tell the app the visitor's address and that the connection is https: their addresses or subnets, loopback (a proxy on this machine), uniquelocal (a private network, such as the compose network), linklocal; separated by commas. Required when LAMPO_PUBLIC_URL is https: the server refuses to start without it (false says on purpose that no proxy forwards addresses).
LAMPO_MEDIA_ORIGINmedia_originnoneA second host name of this server for video, such as https://media.example.com: the player, review links, downloads and folder zips are sent there with signed URLs (below). For an app host behind a CDN proxy that must not carry video.
LAMPO_HOSThost0.0.0.0The address to listen on. Behind a proxy on the same machine: 127.0.0.1.
LAMPO_PORTport4747
LAMPO_UPLOAD_MAXupload_max_bytes20 GBThe largest upload accepted.
LAMPO_MIN_FREEmin_free_bytes2 GBFree disk to keep: below it the server reports not ready, and uploads that don't fit are refused.
LAMPO_STORAGEstoragelocalWhere renders are kept (Storage).
LAMPO_SOURCE_URLsource_urlthe project's repositoryWhere people get this instance's source code (AGPL-3.0). The default is right for an unmodified copy; a changed one points it at its own source. A server with none at all warns at start.
LAMPO_SMTP_URL, LAMPO_MAIL_FROMmail.smtp_url, mail.fromnone: the outboxThe mail relay invites, password resets and account notices go out through, and the sender people see. Without a relay, every email is written to the outbox in the cache instead (email.md).
LAMPO_SIGNUPsignupoffWho may sign up on their own: off, invite or open (below).
LAMPO_IMPRINT_URL, LAMPO_PRIVACY_URL, LAMPO_TERMS_URLimprint_url, privacy_url, terms_urlnoneYour imprint, privacy policy and terms, linked at the foot of the sign-in screens and of review links, and in Settings → About (configuration.md → Legal pages). LAMPO_SIGNUP=open needs the terms and the privacy policy.
LAMPO_OPERATORthe first workspace's ownersWho runs this server, by email address or account id (below).
LAMPO_OPENAI_APPS_CHALLENGEnoneThe token OpenAI asks you to host when you list this server's MCP connector in ChatGPT's app directory: the server answers it as plain text at /.well-known/openai-apps-challenge, to anyone; unset, that path is unknown (mcp.md → In the chat apps' directories).

Where the store is on disk: configuration.md → Where data lives.

A server refuses to start without LAMPO_PUBLIC_URL: it would otherwise answer to any host name, take its sign-in address for MCP clients from the request, and might not mark cookies Secure. For a quick local test, LAMPO_ALLOW_NO_PUBLIC_URL=1 starts it anyway; it then reports itself not ready.

It also refuses to start on settings that would be unsafe or can't work, with one line in the log for each that says what to change:

  • a LAMPO_PUBLIC_URL with a path, or without its scheme;
  • plain http:// on a host other than this machine, without LAMPO_ALLOW_HTTP=1;
  • an https LAMPO_PUBLIC_URL without LAMPO_TRUST_PROXY;
  • a LAMPO_MEDIA_ORIGIN that isn't a host of its own: with a path, plain http off this machine, or the public URL's host;
  • LAMPO_BUNNY_CDN_URL without LAMPO_BUNNY_TOKEN_KEY;
  • an unknown LAMPO_STORAGE, LAMPO_STT or LAMPO_SIGNUP, incomplete storage settings, or a port, size or LAMPO_TRUST_PROXY entry it can't read;
  • a LAMPO_SMTP_URL that isn't one, or one without LAMPO_MAIL_FROM; a sender or reply-to address that isn't one; a legal page (LAMPO_IMPRINT_URL, LAMPO_TERMS_URL, LAMPO_PRIVACY_URL, LAMPO_WITHDRAWAL_URL, LAMPO_CANCEL_URL) that isn't http(s);
  • sign-up without a public URL, LAMPO_SIGNUP=open anywhere but a hosted server, or LAMPO_SIGNUP=open without LAMPO_TERMS_URL and LAMPO_PRIVACY_URL (strangers who sign up accept your terms and read your privacy policy first);
  • a store folder it can't write;
  • a damaged key file (secret.key, share-secret.key);
  • a LAMPO_CLOUD_MODULE that can't be loaded, or one that names a route the server answers itself (below).

Forwarding headers from a peer that LAMPO_TRUST_PROXY doesn't name are reported once in the log: the proxy isn't named, or the app's port can be reached without it.

Behind a reverse proxy

TLS belongs in the proxy. With Caddy, three lines do:

review.example.com {
    reverse_proxy 127.0.0.1:4747
}

Caddy's defaults already pass the live event stream through as it comes, accept uploads of any size and keep range requests (frame-exact seeking) intact. Run the app as in the quick start: listening on 127.0.0.1 only, and trusting the proxy's forwarding headers with LAMPO_TRUST_PROXY=loopback. With the proxy on another machine, listen on an address it can reach and name its address or subnet in LAMPO_TRUST_PROXY instead.

With nginx:

  • turn proxy_buffering off for the live streams, /api/events and /mcp;
  • set client_max_body_size to your upload limit (or 0) and turn proxy_request_buffering off. Browsers and lampo push send uploads in pieces of up to 64 MB, but a one-time upload address (request_upload) takes the whole file in one request.

Speed over a real network

What the app already does, so the proxy doesn't have to:

  • Compression. JSON answers of 1 KB or more go out compressed (brotli, or gzip for clients without it), and the web app is compressed once when it is built. The library of a 1,000-video test store is 1.4 MB of JSON and travels as 14 KB. Answers that can carry secrets (tokens, invites, sign-in, review links, OAuth) are never compressed. Leave Caddy's encode out: it would hold back the event stream, and it skips compressed answers anyway.
  • Asking again instead of downloading again. Every JSON answer carries an ETag, and the browser asks each time: when nothing changed, it gets an empty "not modified" answer. No proxy or CDN stores API answers. Built files with a hash in their name are cached for a year; the page, its service worker and the manifest are checked every time, so a new version is picked up at once. Posters, sprites and waveforms are addressed per render and cached for good.
  • Timings. Every JSON answer has a Server-Timing header (the route, turning it into JSON, compressing it, in milliseconds). In the browser's developer tools, Network → a request → Timing shows where a slow answer spent its time.
  • Keep-alive. The app keeps idle connections open for 65 seconds, longer than Caddy and nginx keep theirs, so a proxy never sends a request into a connection the app is closing (a stray 502).
  • HTTP/2 and HTTP/3 come from the proxy: Caddy turns both on with its certificate (nginx: listen 443 ssl; http2 on;), and talks HTTP/1.1 with keep-alive to the app.
  • Video from the CDN. With Bunny (or S3), the player fetches video straight from the CDN or the bucket, with signed links that expire (Storage); with a media host of its own, from that host (below). Posters, sprites and waveforms still come from the app.

In the browser, the app keeps what it showed last (the library, recent reviews, the inbox) for the signed-in account, and shows it at once on the next visit while it asks what changed. Signing out deletes it (architecture.md → Speed).

A host of its own for video

A CDN proxy in front of the app host (Cloudflare's, for example) hides the server's address, filters attacks and serves the built files from its edge. It is also no place for video: a CDN's terms may forbid serving video through its proxy, and its limits (a request size, a timeout) don't fit renders of several gigabytes. Give video a second host name that points straight at the server, without the proxy, and tell the app:

LAMPO_PUBLIC_URL=https://review.example.com    # behind the CDN proxy
LAMPO_MEDIA_ORIGIN=https://media.example.com   # DNS only, straight to this server

The same TLS proxy on the server serves both names to the same app:

review.example.com {
    reverse_proxy 127.0.0.1:4747
}
media.example.com {
    reverse_proxy 127.0.0.1:4747
}

What changes:

  • Video goes to the media host. The player's /media/…, a review link's renders and previews, fix-preview and reference clips, a question's clips and sounds, downloads and Download all zips answer with a redirect to a signed URL there, exactly as they do with Bunny or S3: the app host checks who asks, the bytes go straight from the server to the browser. The URL is the only credential (no cookie reaches the media host); it names no video, folder or workspace, and it lives 6 hours for the team and 5 minutes on a review link (how long a signed URL lives). The player asks for a fresh one when one runs out. A Download all zip is put together when it is fetched, so its URL asks again there: a review link must still be valid, and the team member who asked must still be one (the same API token or browser session, still in the workspace, still allowed to download); the folder must also hold what it held when the URL was made. Otherwise the zip doesn't start, and asking the app host again gives a new URL to whoever may still have it.
  • One-time upload URLs (request_upload, lampo for big references and previews) point at the media host too, so a whole render in one request never meets the proxy's request limit. Such a request may take up to 6 hours to send (Node's own limit of 5 minutes is raised); a TLS proxy in front keeps its own timeouts, so give it as long. Browsers and lampo push upload in pieces through the app host (48 MiB from a browser, 64 MiB from lampo push: under the 100 MB a CDN usually takes per request). A chat app's sandbox must be allowed to reach the media host to upload there (uploads from a chat app's sandbox).
  • The media host answers nothing else: no page, no API, no sign-in; every other path is a 404 there, whoever asks. /healthz answers, for a monitor.
  • The page's Content-Security-Policy names the media host in media-src and connect-src.

Posters, sprites, waveforms, frames, screenshots and voice notes stay on the app host (pictures and small files), and so does a publish kit's download. The setting is refused at start when it isn't an origin of its own (a path, plain http off this machine, or the public URL's own host). node scripts/smoke.ts https://review.example.com --media https://media.example.com checks the media host too.

Behind a CDN, also: send the client's address from the CDN's own header to the app only for requests that come from the CDN's addresses, bypass the CDN's cache for everything but /assets/ (the app marks what may be kept), and leave its HTML rewriting off (the page's one inline script is pinned by the CSP's hash; the page, the live stream and every answer that carries a secret say Cache-Control: no-transform, which Cloudflare respects). The live stream sends a ping every 25 seconds, well inside a proxy's idle timeout.

Workspaces

A hosted server can hold many teams, each in its own workspace: its own videos, notes, review links, playbooks, events, inbox, Insights and people. Nobody sees another workspace's work, its names, or that it exists. An account can belong to several workspaces, with a role in each. While a server has one workspace none of this shows, and the app on your own machine always has exactly one.

  1. Make one. In the app: Settings → Workspace → New workspace… (the account menu has it too once you belong to two or more); whoever makes it becomes its owner. By default only whoever runs the server may (the operator): whoever runs a workspace invites and emails people and takes turns in the server's job queue, so handing that out is the operator's choice. LAMPO_WORKSPACE_CREATE=anyone lets everyone signed in make some: each account at most LAMPO_WORKSPACE_CREATE_LIMIT (3; the workspace its sign-up gave it counts, the operator has no limit) and ten a day, counted on the server. On the server, lampo admin workspaces create --name "Acme" --owner you@example.com makes one owned by an existing account.
  2. Bring people in. Settings → Users shows the current workspace's members and invites; invites work as below. Someone who already has an account on the server joins with it: signed in, the invite screen asks only for their password, checked and limited like a sign-in. On the server: lampo admin invite --workspace <id> or lampo admin create-user --workspace <id>.
  3. Switch. People switch between their workspaces in the account menu or in Settings → Workspace, where owners and admins also rename it. The switch belongs to the browser's sign-in, so every tab follows.

Roles are per workspace: someone can own one and review in another. An API token acts in the workspace it was made in (lampo login <server> --email <address> --workspace <id> picks it; signed in through the browser, it is the one you work in there), and an app connected through sign-in in the one its consent screen named, each with the person's role there. Admins manage their workspace's members and roles, and disable them there: a disabled member has no role in that workspace (their sessions there, tokens and apps stop) until let in again, while the account goes on everywhere else; its person still signs in, resets their password and joins other workspaces. Someone who also works in another workspace changes their own name, email and password themselves. Another person's address is never an admin's to change: its person changes it, confirmed from the new inbox.

No accounts for other people's addresses. On a hosted server whoever runs a workspace may be anyone (a sign-up's own, or anyone's with LAMPO_WORKSPACE_CREATE=anyone), so Add a user with a temporary password makes no account there: it sends the address an invite into the workspace, and answers the same whatever the address, so a workspace's admins learn nothing about who has an account on the server. Taking an invite proves no inbox either, since its maker holds the link too: an account that gives its own password joins at once; anyone else is held until the confirm link mailed to their address is opened, and only then joins with the invite's role. The first address confirmed takes the invite, and the answer to taking one is the same whatever the address (email.md). Once a person has proved their address from their inbox (a confirm or reset link), no workspace admin sets their password. Invite emails are in the server's own words; the inviter's and the workspace's names are only quoted, in a line of their own: the workspace's once someone named it (a sign-up's workspace starts out called after its owner, a person's name, so the invite email and page then name only the inviter), and the subject carries LAMPO_ORG_NAME for the first workspace's invites only. On your own machine, which has no workspaces, admins still add people with a temporary password.

Invites are bounded. The invites of every workspace live in one file (data/invites.json), so one account makes at most 60 invites an hour and revokes at most 60, and a workspace has at most 200 invites waiting or ended unused in the last 30 days (429 past any of them). A revoked or expired invite leaves the file 30 days after it ended (Settings lists that long) and counts toward the 200 until then, so revoking one makes no room; accepted ones stay, since they say who invited whom, and don't count.

Sign-up. With LAMPO_SIGNUP=open (email.md), everyone who signs up gets a workspace of their own once their address is confirmed: empty, with them as its owner, named after them until they rename it. They never see anyone else's. With LAMPO_SIGNUP=invite an invited address gets its invite again, and the invite's link puts them in its workspace.

Links name their workspace. A notification, a chat webhook's link and an agent's "open in the player" link carry w=<id> once a server has more than one workspace: two teams can have a video of the same name. Opened while the session works elsewhere, the app moves the session to that workspace first, then opens the link; someone who isn't in it is told so and lands in their library.

Details

  • Workspace #1 is the store you have. Nothing moves: data/, versions/, cache/ and the Bunny or S3 keys stay where they are. Every other workspace lives in data/w/<id>/, versions/w/<id>/ and cache/w/<id>/, and under w/<id>/ in the bucket (data-format.md). Accounts, keys and the list of workspaces with their members (data/workspaces.json) belong to no workspace.
  • The move. A hosted server moves its store to workspaces once, the first time it starts with them. It first copies users.json, invites.json, oauth/grants.json and shares.json to data/backups/workspaces-<time>/, and the log names that folder. lampo admin workspaces migrate does the same by hand; run again, it does nothing. It never runs while data/w/ holds a workspace folder, empty or not: it would write w1 alone over the others. Afterwards a version of Lampo from before workspaces can't open the store without losing them.
  • A lost workspaces.json. A store that moved and then lost data/workspaces.json, or can't read it, doesn't start: the log says to restore it (lampo: … restore it from your backup). Without the file every account would read as a member of the first workspace, so the memberships are never rebuilt from the accounts. A server that loses the file while it runs, or can't read it for a moment, stays up: it answers signed-in requests with 503, and /readyz is red until the file can be read again; then it serves as before.
  • On the server's own store, lampo admin workspaces lists the workspaces and their members. lampo admin list-users shows every account with its role in one workspace (--workspace <id>) and, on a store with several, its role in the others. lampo admin invite says which workspace its link is for, and lampo admin invites and revoke-invite keep to one (invites --all lists every workspace's, each named). LAMPO_WORKSPACE=<id> points lampo and the stdio MCP server at one (default: the first, w1): everything they read, write and follow (lampo watch, the MCP change feed, lampo admin without --workspace) is that workspace's. An id the store has no workspace for is refused at the start, in one sentence.
  • Isolation is enforced where the data lives, not only in the routes. Each workspace's files are a tree of their own; every request, job, timer and event runs in its workspace, and work that loses it is refused once a server has a second workspace (never handed the first one's store). In-memory caches are kept per workspace: two teams may upload the same file under the same name, and nothing is shared or deduplicated between them. Live updates reach the workspace's own streams only, and an upload, a one-time upload address or a connected app works only in its workspace. Another workspace's things answer 404, never 403, and each workspace has its own request budget (Security model).
  • Background work (scrub copies, posters, Auto-check …) runs one job at a time for the whole server. The people who run workspaces take turns, then their workspaces, and each turn runs that workspace's most urgent job: neither a backlog nor one account's many workspaces holds another team's next job back by more than one job per owner. A workspace may have 200 jobs waiting; past that, a request that needs one gets a 503 that says to try again in a few minutes. The last 20 places are kept for the scrub copies a player waits on; past those the player says the server is busy, and the copy is made once there is room. A job that brought the server down twice (out of memory, a crash) is not started again on the next starts: no crash loop from one render (architecture).
  • Webhooks from config.json or the environment belong to the first workspace; other workspaces add theirs in Settings → Notifications.
  • One way in. Every workspace is made by createWorkspace({ name, ownerId }) in lib/workspaces.ts, whether through lampo admin, the app or a sign-up: the place to start for a sign-up flow of your own.

A billing provider

A self-hosted server is complete and unlimited, except that its review links always show the Powered by Lampo badge: hiding it is a feature of Lampo Cloud's paid plans. A hosted service that sells plans adds a module of its own, which the server loads when LAMPO_CLOUD_MODULE names its file (the one extension point, server/extension.ts; without it nothing below exists). Lampo Cloud (lampo.video) runs its plans and billing this way, in a module kept outside this repository. What such a module may do:

  • Limit what costs storage or a seat. A new upload, video, member, review link or publishing connection is asked for first; a workspace whose plan has no room for it, or that is read-only, is refused with a 402 and the module's sentence (api.md). Reviewing, notes, answers, approvals, downloads and the review links already sent keep working, and nothing is ever deleted for a plan.
  • Hear a workspace made or deleted and a member count changed (after the fact, never in the way).
  • Let a workspace hide the badge. Review links and embeds show a small Powered by Lampo; a module may let a workspace's owners and admins hide it (Settings → Review links, on a paid plan). Without a module it always shows.
  • Answer a sign-up in place of the server's own: it places the person exactly as the server would (Sign-up above: their own workspace on a server open to sign-ups, an invite's workspace otherwise) and then does what its plans give a newcomer, such as a trial. If it fails, the confirm link stays unused.
  • Email a workspace's owners and admins (or other roles) through the server's mailer: its own words per language, the server's layout and footer, a button that opens a screen of this app (email.md).
  • Provide billing: /api/info says so, Settings → Billing shows the workspace's plan, what it uses and, for owners and admins, the plans to choose from, paid for right on the page (the provider's payment form loads only when it opens), the payment methods, the details on the invoices and the invoices. Nobody is sent to the provider's own pages. While a trial runs, the library's sidebar ends with its line (the plan, the days left, a ruler of the trial's days) and a card with what the plan includes for the workspace; the account menu carries the same line. A banner above the library, one line until it is opened, says when a trial is in its last three days or ends today, what Free would mean for the workspace, a grace period and its three ways on, or that the workspace is read-only (then Add video is a locked button that explains why instead of an upload that fails). The first fix checked on a video of the workspace's own and its first review link opened each show a short, dismissible note about the plan, once per workspace. Nothing of it counts down in hours or blocks the work. The checkout tells a consumer from a business (a consumer reads the withdrawal information, a business gives its name and VAT ID) and links your legal pages; it speaks of reverse charge only where the module says the seller offers it (reverseCharge, api.md).
  • Name the origins its payment form loads from (contentSecurity: https origins only, or the server doesn't start). The hosted app's own pages allow them in their Content-Security-Policy; review links and a person's own machine never do.

Its routes are mounted behind the server's guard (one may be public and get the raw body, as a payment provider's webhook needs). Every other route declares the lowest workspace role that may call it and whether only a person signed in may (role, person in server/extension.ts); the server's role table holds every caller to that, and a route that declares nothing stops the server at start. An API token never changes what a workspace pays. A module's own 5xx answers like one of the server's: a sentence and a ref for the caller, the module's text in the log under that ref. They are routes of its own (such as /api/billing/…): a module that names one the server answers itself is refused at start, in one line.

What it counts, and what it never does

Where a billing module runs, the server counts how sign-ups become paying workspaces, for its operator alone (below), at #/operator/funnel (GET /api/operator/funnel?weeks=4|8|12; anyone else gets a 404, as if there were no such page). It is first-party and small:

  • Eight steps, once per workspace, the first time each happens: signed up, setup done (finished or skipped), the first video of its own (not the sample), the first review link and a link's first opening by a visitor (never the team's own preview, never a link on the sample), the first fix checked, active at the trial's end and the first payment. The last two are the module's to tell (host.funnel(workspace, 'trial_end' | 'plan_paid', { plan, active })); a trial's end counts when anything happened in the workspace in its last three days (the module's active, or when it leaves that out, the workspace's own log). The server records the rest where they happen. Each is a workspace id, the day (UTC) and the plan, in data/funnel.json (0600, next to workspaces.json).
  • The conversion moments (a trial's popover, the first loop, the first link opened, an invite beyond the plan, the trial's banner, a limit's sheet): how often each was shown, used, put away or made room for, per week and place — never who (POST /api/moments/event).
  • Never: names, email addresses, IP addresses, devices, videos, titles, notes, frames or anything a visitor types; no third-party trackers, no cookies for it, no fingerprinting. Nothing leaves the server.
  • Step records are kept 13 months from the sign-up, then only their week's counts. A self-hosted server (no module) counts nothing, and a counts file that can't be read is never written over.

"Not now" on a moment is kept with the person's account for that workspace (PUT /api/moments/:id, up to 90 days; null brings it back), so it holds on every device. The first loop and the first link opened wait a day for the one person they are for (GET /api/moments), told live to their own pages only.

The operator's pages

Whoever runs a hosted server has three pages nobody else sees, behind Operator in their account menu: the funnel (above), Workspaces and Accounts.

  • Who the operator is: the accounts LAMPO_OPERATOR names, by address or account id (comma-separated, an address only once it is confirmed). Without it, the owners of the server's first workspace (the one its setup page made), so a self-hosted server is never locked out of its own pages. This one rule decides everything that is the server's rather than a workspace's: these pages, the server's setup and its health check and test mail, the speech engine's internals in /api/info, and making workspaces without a limit. Anyone invited into the first workspace, as an owner or admin too, works there and runs nothing. Only in the browser, signed in as themselves: an API token, anyone else and the app on a person's own machine get a 404, the same for ids that exist and ids that don't. A listed address with no account yet is said once in the log at start.
  • Workspaces (#/operator/workspaces): every workspace with its owner, members, videos, storage (of the plan, with a billing module), when it was created and when anything last happened in it; searched by name or owner, filtered by where the plan stands, ordered by last activity or by when it was made. One opened shows its facts and its members.
  • Its plan, set by hand (with a billing module that offers it): complimentary on a plan (no limits, never billed), the trial run to a day, or back to normal billing, each with a reason. The module keeps the plan and a log of every change — who, when, what, why — and the page shows it. A workspace that pays keeps its subscription (end it first), and the server's own workspace, or one the module's own settings make complimentary (Lampo Cloud's: LAMPO_COMPLIMENTARY), is changed in those settings. Without a module the pages list the workspaces without plans.
  • Accounts (#/operator/accounts): every account with its workspaces and roles, when it was made, when it was last active and whether it is disabled; searched by name or email. Last active is its last sign-in or its last use of the app in a browser, whichever came later (kept at most once an hour; never by a review link or an API token): "now" for your own, "never" for an account that never signed in, "not recorded" for one from before the server kept it. Disable signs the account out everywhere and stops its API tokens and connected apps at once, in every workspace; its notes and memberships stay. Enable lets it sign in again. Never one's own.
  • Suspend or delete (a workspace's page, never the server's own): Suspend… asks for a reason (kept on the page, never shown to anyone else) and makes the workspace read-only for its people: they sign in, read, watch and download, but every change is refused (423), its agents write nothing over MCP, its scheduled posts wait, upload links made before stop, and its review links answer like an ended link (410). Everyone in it is emailed, and the library says so. Lift the suspension gives all of it back, and tells them again. Delete… counts what goes (videos and their bytes, review links, members and how many of them work nowhere else, invites, tokens, apps), and needs a reason and the workspace's name typed as it is. Then everything it holds goes: see Deleting.
  • Never: reading anyone's notes or videos, signing in as someone, a password hash or a token.
LAMPO_OPERATOR=you@example.com,u_0a1b2c3d4e5f   # who runs this server; unset: #1's owners

Deleting and exporting

People's own data is theirs to take home and to end (GDPR Art. 15, 17, 20); the operator can do both for them.

  • Export my data (Settings → Profile, on your own machine too): one zip, lampo-data-<day>.zip, of plain JSON files and a README — the account (name, address, settings, picture), its workspaces and roles, its API tokens (names and dates, never the tokens), connected apps and devices, and per workspace the notes it wrote with its own replies, its replies on other people's notes (with that note's id, never its words), its drafts, unsent recordings with their audio, its approvals and requests for changes, the review links it made (never their addresses), what it watched, what it asked of agents (as their work on a video keeps it) and what it uploaded (file metadata: the videos stay the workspace's). Notes from before accounts were recorded with them aren't in it. A few an hour.
  • Delete my account (Settings → Profile, a hosted server): its password confirms it (or a sign-in in the last ten minutes). Refused while it is the last owner of a workspace others work in: make someone else an owner there, or delete that workspace, first. Refused too for an account that works alone in the server's own workspace, or alone in a suspended one. It leaves the workspaces others go on with; the workspaces only it works in go with it. Its address gets one last email.
  • Delete workspace (Settings → Workspace, its owners): its name typed. Its people are emailed; whoever worked nowhere else loses their account with it (the owner too).
  • What goes with an account, however it goes (deleted, removed from its last workspace, a sign-up nobody confirmed): its record, sessions, API tokens and app connections, its picture, push devices, account links, drafts and unsent recordings in every workspace, what it put away in For you, and its copies in the backups the move to workspaces made (data/backups/workspaces-*). What it watched stays in the team's numbers, under no name. Notes and replies it wrote stay in their workspaces, signed with its name: they are the team's record of a review. Someone removed from one workspace while the account goes on loses their drafts and recordings there.
  • What goes with a workspace: its folders (data/w/<id>, versions/w/<id>, cache/w/<id>) and every object under its storage prefix (w/<id>/ in Bunny or S3), its review links (they name nothing any more), invites, API tokens and app connections, its waiting jobs, the accounts it leaves in no workspace, and its billing (a billing module cancels the subscription at once and forgets its state). Never the server's own workspace.
  • The erasure log (data/erasures.jsonl, ids only): every deleted account and workspace. Your nightly backups keep what was deleted until their retention drops it (with restic forget --keep-monthly 12, up to a year). After you restore one, run lampo admin erasures (what is back that was deleted) and lampo admin erasures --apply (delete it again) — keep the newest erasures.jsonl aside before the restore and put it back first.
  • On the server: lampo admin delete-account <email|id> and lampo admin delete-workspace <id> say what would go and delete nothing; --yes deletes it (and emails the people, through the server's mail settings). lampo admin export-account <email|id> --out data.zip writes an account's export.
lampo admin delete-workspace w_0a1b2c3d4e5f        # what goes: videos, links, members, accounts
lampo admin delete-workspace w_0a1b2c3d4e5f --yes  # and gone
lampo admin export-account mia@example.com --out mia.zip

Accounts and tokens

One workspace, one team. Every account sees every video, note, insight and event in its workspace; roles decide what someone may do, not what they may see. Clients therefore get review links (one video or a whole folder, no account; see sharing.md), not accounts. An agency working for several clients gives each client their own folder link. Limiting accounts to folders is on the roadmap.

Roles. One table decides what each role may do (undefined). The server checks it on every request, and the app hides what a role can't do.

reviewermemberadminowner
watch, read notes, the inbox, Insights, taste✓✓✓✓
add notes, replies and voice notes; edit or delete their own notes✓✓✓✓
check fixes (Looks right, Still wrong), reopen notes✓✓✓✓
approve a version or request changes✓✓✓✓
mark a video final, or reopen it✓✓✓
edit or delete anyone's notes; mark notes fixed or won't fix✓✓✓
upload videos and new versions; remove videos they uploaded✓✓✓
projects and folders, moving videos, assigning agents✓✓✓
make review links✓✓✓
download a video (any version, as rendered) or whole folders from the library✓✓✓
see, list and download project files (files.md)✓✓✓
add, replace, rename, move and restore project files; trash the ones they added✓✓✓
requests to agents, agent status, connecting agents, writing as agent:…✓✓✓
rerun Auto-check, dismiss its findings✓✓✓
edit playbooks; accept or reject what agents suggest✓✓✓
draft posts of a final video, download the publish kit (publishing.md)✓✓✓
remove any video; trash anyone's project files✓✓
archive a project and restore it; move a video out of an archived project✓✓
connect publishing accounts; publish, schedule, cancel or retry posts✓✓
turn footage search on or off for the workspace (footage.md)✓✓
accounts, invites, everyone's API tokens✓ (not owners')✓
hide the Lampo badge on review links (where a billing provider allows it)✓✓
delete the workspace (Deleting)✓

Reviewers are people on your side who give feedback: producers, colleagues, freelancers. They see the videos, notes and folders like everyone else, but they don't hand work to agents (it costs time and money) or open the project to outsiders, and they aren't shown where connected agents run (their folder and machine). The one thing they don't see at all is the project files: the material is the team's, often confidential, and its routes answer them 404. Everyone manages their own profile, password and API tokens; a reviewer's token carries a reviewer's rights. Only owners manage owners, and the last owner can't be removed, demoted or disabled.

Invites. Owners and admins create a one-time link in Settings → Users, or with lampo admin invite --role reviewer --email mia@example.com. An invite has a role, optionally a name and email to fill in, and lasts 7 days unless you choose otherwise (1 to 90; --days). Whoever opens it chooses a name, email and password; on a hosted server they are in once that address is confirmed (above), on your own machine at once. With an email address the app can email the invite (Email the invite, and Send again in the list); otherwise it gives you a message to send. Without a mail relay an emailed invite only reaches the outbox, so copy the link instead. lampo admin invite only prints the link.

  • The link is <public URL>/#/invite/inv_…. The token sits after the #, so it never reaches a server's or a proxy's logs.
  • Pending invites can be copied again, sent again or revoked (lampo admin invites, lampo admin revoke-invite <id>). A used, revoked or expired link says so.
  • On your own machine an invite made out to an address confirms that address, and someone who joins through an invite that named none is in at once and gets a link to confirm the address they typed. On a hosted server an invite confirms nothing: the address is confirmed from its inbox.

Email (email.md). With LAMPO_SMTP_URL and LAMPO_MAIL_FROM set (Brevo or any SMTP relay), the server sends invites, password resets (Forgot password? on the sign-in screen: a link that works for 60 minutes; the new password ends every other session of the account, and its API tokens and connected apps), confirmations of a new address (the old one gets a notice) and account notices. Without them every message is written to the outbox in the cache instead, and the server says so at start. lampo admin mail-test <to> sends one message now and prints the relay's answer.

Sign-up (LAMPO_SIGNUP, email.md): off (the default), invite (an address a pending invite names gets the invite again; its link makes the account) or open (anyone, each into a workspace of their own; a hosted server only). An open sign-up can do nothing until its address is confirmed, and can't change that address meanwhile.

Browser sign-ins are signed HttpOnly; SameSite=Lax cookies, over https named __Host-vr_session (this host only, Secure, the whole site: a sibling subdomain can't set or overwrite it; a vr_session is never read over https: a browser that still holds one signs in once more, and the old cookie is expired then, or with any answer to a request signed in with the new one). They last at most 30 days and end after 14 days without use (LAMPO_SESSION_DAYS, LAMPO_SESSION_IDLE_DAYS). Signing out ends that session on the server too, so a copy of its cookie stops working, and tells the browser to drop its cached renders and pictures; changing the password, disabling the account or sign out everywhere ends all of them. Sign out everywhere and any new password (in Profile, set by an admin, or from a reset link) also end the account's notifications on every device (its push subscriptions); a new password also ends its apps connected through sign-in, and a reset its API tokens. A browser told its session is gone (a 401, or a status that names nobody) deletes the data it kept for the next visit. Any sign-out also clears what the screens remembered in the browser's storage (open folders, the last video, zoom per video); the theme and language chosen on the device stay.

API tokens (vr_…) are for lampo, MCP clients and scripts. They are created in Settings → API tokens or by lampo login, shown once, listed with their last use, and can be revoked or made to expire (lampo login --expires 90d). Send them as Authorization: Bearer vr_…. Whatever the role, a token can't make or change credentials, roles, members, invites, tokens, connected apps, webhooks, workspaces or review links, subscribe a device to notifications, connect publishing accounts or publish, schedule, cancel or retry a post, end a person's first run (PUT /api/onboarding), read the server's health check or send its test mail, put away a person's conversion moments, archive or restore a project, export a person's data, delete an account or a workspace, or open the operator's pages (api.md): that takes a person signed in in the app. Sign-off is a person's too: a token never approves, requests changes, carries an approval over, marks a video final or reopens it.

Agents may sign their writes agent:<name> (with --by, or automatically from the Claude Code session), so their notes and replies read as an agent's; everything else is written as the token's user. Reviewers can't: their notes never pass for an agent's.

Review links (/g/<token>) work as on your machine: one video or a folder, no account, only what the link covers. Visitors see videos under ids the link gives them, never under their internal names.

Accounts and API tokens live in data/users.json, invites in data/invites.json, the key that signs cookies in data/secret.key, emailed one-time links (hashed) in data/account-links.json and emails waiting to go out (sealed) in data/mail/, all readable only by the app's user. Back them up with the rest of data/.

Uploads

Uploads use the tus protocol at /api/uploads, so a dropped connection resumes instead of starting over; lampo push also resumes an interrupted upload when run again. Each upload names a file and either a folder ("Acme/Reels") or the video it is a new version of.

When the last byte arrives, the server checks the file and registers it:

  • It must be a QuickTime/MP4 or Matroska/WebM file with frames (.mp4, .mov, .m4v, .webm, .mkv), within the limits.
  • The first upload of a name in a folder creates the video.
  • The next upload of the same name (or to the same video) becomes V2, V3, …, and open notes are carried forward to be checked again, exactly like a re-render on disk.
  • The same bytes again change nothing.

The finishing request answers with {slug, v, created, duplicate, video}. A render that takes longer than 45 seconds to register (a big file going into remote storage) answers {pending: true, id} instead, and the result appears at GET /api/upload-results/<id> for an hour. Unfinished uploads are deleted after a day.

An upload under way holds its room from its start: the disk's reserve (LAMPO_MIN_FREE) counts the bytes every unfinished upload still has to send, and a workspace's plan counts their whole sizes as stored already, so two uploads can't each take the room that was left for one. The rest of an upload must still fit on the disk each time more of it arrives (else 507), and one account may have 50 uploads under way at once (429 past that: let some finish, or cancel them).

Only an upload that moves holds room on the disk: one that no byte reached for ten minutes holds none, and when it goes on, its rest must fit beside the uploads that moved meanwhile. What one account's or one workspace's uploads hold counts against anyone else's upload up to half the room, so nobody keeps the others out by starting uploads and sending little or nothing; whoever holds more than that gives way when the disk gets short (507 on its next PATCH).

Agents that reach the server only over MCP can ask for a one-time upload address instead (request_upload) and send the file with one curl -T.

Storage

Renders, and their playback copies, go through one storage layer. Posters, waveforms, analysis and diffs are always kept in the local cache, by render: a cached one never fetches the render again. When one is missing, the render is downloaded once into the working copies. After a restart, missing posters are made again in the background, one render at a time. Bunny and S3 are tested against mock servers so far: check a first real setup as go-live.md → Known gaps describes.

Local disk (the default)

versions/ and cache/ next to data/, as on your own machine. Put them on a disk you back up: versions/ can't be rebuilt (docker.md → Backups).

Bunny Storage + CDN

Renders are uploaded to a Bunny storage zone and played through its pull zone with signed links that expire after 6 hours (Bunny's token authentication). Video bytes never pass through your server, and the range requests of frame-exact seeking go straight to the CDN. The server keeps a size-capped working copy for ffmpeg (exact frames, waveforms, Auto-check, diffs) and fetches renders back when it needs them. Download all reads the zone piece by piece instead, without filling the working copies.

  1. Storage zone. Bunny dashboard → Storage → Add storage zone. Pick the region closest to your server (the working copies come from there). Under FTP & API Access, copy the zone's password (not your account's API key).

  2. Pull zone. Add a pull zone with the origin type Storage Zone and your zone. Note its host name (acme-review.b-cdn.net, or add your own host name with SSL).

  3. Token authentication. Pull zone → Security → Token Authentication: turn it on and copy the token authentication key. Leave Token IP Validation off: viewers switch networks while they review.

  4. Configure the server:

    LAMPO_STORAGE=bunny
    LAMPO_BUNNY_ZONE=acme-review   # storage zone name
    LAMPO_BUNNY_ACCESS_KEY=…       # storage zone password
    LAMPO_BUNNY_REGION=de          # de (Frankfurt), uk, ny, la, sg, se, br, jh, syd
    LAMPO_BUNNY_CDN_URL=https://acme-review.b-cdn.net
    LAMPO_BUNNY_TOKEN_KEY=…        # pull zone token authentication key
    LAMPO_BUNNY_PREFIX=review      # optional: a folder inside the zone
    LAMPO_WORK_CACHE=20GB          # optional: local working copies (default 20 GB)

Without LAMPO_BUNNY_CDN_URL the server streams video from its working copies instead. LAMPO_BUNNY_CDN_URL needs LAMPO_BUNNY_TOKEN_KEY: a server with a CDN address and no token key refuses to start, because unsigned CDN links would let anyone who has one watch the render. Don't use Bunny Stream (Bunny's video product): it re-encodes renders into streaming copies with keyframes far apart, which loses quality and makes frame-exact scrubbing slow.

S3-compatible storage

Cloudflare R2, Hetzner Object Storage, MinIO, Backblaze B2 and Bunny S3 speak the same API. Lampo is tested against a mock S3, not against each of these.

LAMPO_STORAGE=s3
LAMPO_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
LAMPO_S3_REGION=auto     # the provider's region name
LAMPO_S3_BUCKET=review
LAMPO_S3_ACCESS_KEY_ID=…
LAMPO_S3_SECRET_ACCESS_KEY=…
LAMPO_S3_PREFIX=review   # optional: a folder inside the bucket
LAMPO_S3_PRESIGN=true    # the default: browsers get signed links; false: through the server

Signed links expire after 6 hours. Files above 64 MB are uploaded in parts, and path-style addresses are used, which every S3-compatible store accepts.

How long a signed URL lives. The team's player gets URLs that live 6 hours. A review link's visitor gets URLs that live 5 minutes (renders, preview copies, reference clips, downloads): such a URL carries no check of the link, so it is what still plays after a link is revoked, expires or gets a password, and what a visitor could pass on. The bytes still come straight from the CDN, the bucket or the media host; the server checks the link again on every redirect, and the player asks for a fresh URL when an old one stops working, picking up on the frame it showed. A download keeps running past its 5 minutes (the store checks a URL when a download starts); resuming a broken one later starts again through the link.

Apps that sign in (OAuth)

MCP clients that sign in instead of taking an API token (ChatGPT and Claude connectors, or Cursor and Codex when you choose sign-in) connect with one click. The server is its own OAuth 2.1 authorization server for its MCP endpoint, as the MCP authorization specification (2026-07-28) describes. There is nothing to configure beyond a public URL on https: clients refuse sign-in over plain http, and ChatGPT and Claude.ai must reach the server from the internet.

What happens when you add https://review.example.com/mcp to such a client:

  1. The client calls /mcp and gets 401, with a pointer to /.well-known/oauth-protected-resource/mcp, which tells it where to sign in.
  2. It identifies itself with a Client ID Metadata Document (its client id is an https address of a JSON document it hosts; the server fetches and checks it), or registers itself (/oauth/register; deprecated in MCP but still used by some clients). The server takes public clients with PKCE: a document must allow none among its token methods (Claude's says none; ChatGPT's lists none beside private_key_jwt and signs in the first way here).
  3. Your browser opens the server's consent screen, after you sign in if needed. It shows who asks (a name verified by its metadata's host, or marked as self-named), where the answer goes (with a warning when that is a program on your own computer) and what the app may do. You allow or deny.
  4. The app exchanges a one-time code (with PKCE) for a short-lived access token for /mcp and a refresh token.
The consent screen an app’s sign-in opens: Claude Code asks, the answer goes to a program on this computer, what it may do, Deny and AllowThe consent screen an app’s sign-in opens: Claude Code asks, the answer goes to a program on this computer, what it may do, Deny and Allow

Scopes cap what a connected app may do, and the account's role still applies on top: an app never gets more than the person who allowed it, so a reviewer's app can't mark notes fixed, whatever it asked for.

ScopeThe app may
review:readlist videos, read notes with their marked frames, wait for new feedback, show the review card
review:commentthe above, plus ask questions, leave notes on frames, and reply
review:actthe above, plus mark notes fixed or won't fix, add and file renders, report what the agent is doing
post:draftlist videos and read notes, plus draft posts of final videos for YouTube, Instagram and Facebook and download the publish kit (a person publishes them)
files:readlist and download the project files (files.md)
files:writethe above, plus add, replace, rename, move and trash project files (every change a version anyone can bring back)

The project files have scopes of their own, so an app connected with review:act (which uploads renders) never gains a project's footage without being asked for it; an app connected before they existed has neither. What /mcp asks a connecting app for (the scope of its 401 challenge, and what an authorization request without a scope gets) is what its tools need — review:read review:comment review:act post:draft — so nobody is asked to allow what no tool uses; an app may still name more.

Apps never check fixes, approve, mark final, edit other people's notes, remove videos, make review links, download folders, edit playbooks, publish or administer the server. A call outside the granted scopes gets 403 with an insufficient_scope challenge, so the client can ask for more.

Connected apps are listed under Settings → API tokens (admins also see everyone's) and can be disconnected there; their tokens stop working at once. Removing an account disconnects its apps.

Connecting, per client (the address is always https://<your server>/mcp; details in mcp.md):

  • ChatGPT (developer mode) and Claude (web and desktop): add a custom connector with that address; they find the sign-in themselves.
  • Cursor: an entry with only "url" (no headers) makes Cursor offer to connect and sign in.
  • Codex: url = "https://<your server>/mcp" in config.toml without bearer_token_env_var, then codex mcp login lampo (the name of its [mcp_servers.lampo] entry).
  • API tokens keep working for every client that sends a header.

Registered clients are kept in data/oauth/clients.json and connections with their hashed tokens in data/oauth/grants.json (both readable only by the app's user). Pending consent requests (10 minutes) and one-time codes (60 seconds; lampo login's 2 minutes) are kept in memory only.

Agents against a hosted server

Agents can use MCP straight against the server: https://<server>/mcp with Authorization: Bearer <token>, nothing to install on their machine (mcp.md; Settings → Connect an agent shows ready configs). After lampo login, every lampo command and the stdio MCP server (bin/lampo-mcp) talk to the server too:

  • Reading (ls, open, show, prompt, inbox, qa, diff, taste) downloads screenshots into ~/.cache/lampo/<host>/ and prints those paths, so an agent opens them like local files.
  • Writing (add, fix, reply, wontfix, move, assign, status) goes through the API.
  • lampo push and MCP track_video upload renders; agents on /mcp without lampo use request_upload and one curl -T. lampo sync only means something for files on a local store.
  • lampo watch follows the server's event stream (GET /api/events, whose event messages carry the whole event). Run inside a Claude Code session, it also says it's there every 30 seconds, so that session shows in Assign agent… while it watches.

Security model

  • Deny by default. Everything needs a signed-in user (a cookie or a token) except a short list: the app's pages and static files, /healthz, /readyz, /robots.txt, /api/info, signing in, setup and invites, what an emailed link or a signed-out person asks (signing up, confirming an address, sending the confirmation again, Forgot password?, a reset: each answers alike for every address), one-time upload addresses, review links (each limited to its video or folder; an Embed link's player /e/<token> and /oembed too), and /mcp, /oauth/* and /.well-known/*, which check credentials themselves (isPublicPath in server/guard.ts). A new route is never public by accident.
  • Workspaces. Each team's files are a tree of their own, every request, job and event runs in its workspace, and another workspace's things answer 404 (Workspaces).
  • Paths are taken as written. Routes match exactly, capital letters included. A doubled slash, a dot segment or a trailing slash is a 404 before anything else is checked, and the API's paths stay private however they are capitalised. The role table runs for every signed-in request, and a write route missing from it is refused to everyone. test/unit/route-walk.test.ts tries every route in every spelling.
  • Signing in. Failed attempts are limited per address (20 in 15 minutes), per account and address (8), and per account from anywhere (30). The last limit never applies to a browser that signed in to that account before (a signed vr_device cookie, one year), so guessing wrong on purpose can't lock a person out; lampo login from a new machine waits it out. Such a browser has a budget of its own instead (10 failures in 15 minutes for its cookie, whatever address a copy of it comes from, and 60 for all the account's known browsers together), and its failures count against the account too. Successful sign-ins are limited too, to 30 an hour per account and per address. Wrong invite tokens are limited per address (30 in 15 minutes). Every limit kept per address counts an IPv6 address as its /64 (one connection holds that many). All limits share one limiter (lib/rateLimit.ts), whose memory stays bounded.
  • Request budgets. Each workspace's signed-in requests share 20,000 a minute. /mcp takes requests of up to 1 MiB, 600 a minute per account and 6,000 per workspace, and caps open waits and listens per connection and per person (mcp.md).
  • Sessions and tokens. Signing out ends that session on the server. A session ends after LAMPO_SESSION_IDLE_DAYS (14) without use and after LAMPO_SESSION_DAYS (30) in any case. API tokens can be made to expire (1 day to 10 years). Forwarding headers count only from the proxies named in LAMPO_TRUST_PROXY. Account names are normalised and can't imitate an agent (agent: in any script) or another account (look-alike letters).
  • Outgoing requests to addresses someone chose (webhooks, OAuth client metadata, a device's push service) reach public addresses only, pinned to the address that was checked, without following redirects (lib/netguard.ts). A push goes only to the four browser makers' push services, and only while their name resolves to public addresses. LAMPO_WEBHOOK_ALLOW_PRIVATE=1 lets the first workspace's webhooks (the operator's own team) reach private addresses; every other workspace's stay on public ones.
  • CSRF. Writes signed in with a cookie must come from the app's own address (the Origin header, or Sec-Fetch-Site: same-origin); signing in and setup from other pages are refused. Browsers never send tokens by themselves.
  • DNS rebinding. With a public URL set, other host names get 421; only /healthz, /readyz and /robots.txt answer on any.
  • The server never shows its disk. No file browser, no tracking by path, no reading project files next to renders. /data/<video>/<file> serves only screenshots and voice notes of existing reviews, and video ids that look like paths are refused before anything touches the disk. /api/info shows no paths (where the speech model is and what the speech engine said when it failed only to the server's operator, signed in in the browser, or the machine's owner at the machine), and events (the live stream, lampo watch, MCP, webhooks, the inbox) carry screenshot addresses, never server paths.
  • Hostile media. ffmpeg and ffprobe only read local files and pipes (-protocol_whitelist file,pipe on every call). On a server they only read the formats uploads may use (MP4/QuickTime, Matroska/WebM, Ogg, WAV, and PNG, JPEG, WebP and GIF pictures), so a playlist dressed up as a video is refused before it is read as one. Files that always come from outside (references on notes, voice notes, profile pictures, fix-preview clips) are held to that list in every mode, on your machine too. Every run has a time limit (LAMPO_MEDIA_TIMEOUT), and uploads whose headers claim more than LAMPO_MAX_SIDE pixels, LAMPO_MAX_DURATION seconds or 240 frames per second, less than 32 pixels on a side, or a shape thinner than LAMPO_MAX_ASPECT (8:1 either way) are refused before any work starts. Analyses (the diff, Auto-check, shots, footage search) look at small pictures of a fixed width and at most four times as tall, so no shape of render makes them big. What someone waits for outside the job queue (a frame for an agent or a finding, a note's screenshots, a contact sheet, a reference, a voice note, a fix preview's clip) runs at most four ffmpeg at once across the server, two threads each; the rest wait their turn, and past 100 waiting (or 30 seconds) the answer is 503 with Retry-After. With several workspaces each takes at most two of those places and ten of the waiting ones, and a free place goes to the workspace with the fewest running, so one team's flood of requests never keeps another team waiting. A note whose screenshots find no place is saved without them, and they follow from the job queue: a note is never refused for its pictures. Frames nobody asked for before count per account (200 in ten minutes; 429 with Retry-After past that), over the API and MCP's get_frame alike; a frame made once is served to everyone from then on. Uploads must be a real video container, voice recordings a real audio container, fix previews a single picture or a clip of at most 10 seconds.
  • Downloads. File names inside Download all zips are plain relative names that work on every system: no separators, dot segments, device names, control or direction characters, and at most 200 characters.
  • Headers. A strict Content-Security-Policy (video also from the configured CDN, bucket or media host; no inline script but the theme's, by hash), X-Content-Type-Options: nosniff, X-Frame-Options: DENY and frame-ancestors 'none' (only an Embed link's player, /e/<token>, may be framed, by any site; sharing.md), Referrer-Policy, Cross-Origin-Opener-Policy (same-origin; unsafe-none only on the way to an app's consent screen, so an app signing in in a popup keeps its opener) and Permissions-Policy (the microphone for the app itself only), HSTS when the public URL is https, and X-Robots-Tag: noindex, nofollow: nothing an instance serves is for search engines (/robots.txt lets crawlers fetch pages, so they see it). Your machine sends the same set, and so do the health checks, /robots.txt and the 404 of a path spelled another way. The pages load nothing from anywhere else (but a billing module's payment form, above): fonts are part of the build, and there is no analytics or telemetry.
  • Errors. A 5xx answer says only "something went wrong on the server (ref …)"; the details go to the log under that reference. A 4xx or an MCP tool error that comes from ffmpeg's output or names a path is answered the same way ("that file could not be read or converted (ref …)"), and so is whatever the object store (its keys, the bucket's name, its error) or the speech engine (a traceback, a host, a model's path) said when it failed; an object store's 403 answers 500. Review-link visitors of the app on your own machine get the same answers: only you, at the machine itself, read errors as they are.
  • Secrets at rest. Passwords as scrypt hashes; API, invite and review-link tokens as SHA-256 (invite and review links also sealed with a key derived from the store's secret, so the owner can copy them again); users.json and the keys readable only by the app's user. A leaked shares.json on its own opens no link, but a whole copy of data/ (keys included) does: encrypt backups.
  • Input. Every request body and query string is read through a schema that keeps only the keys it declares, without deep merges, so __proto__ or constructor keys never reach an object's prototype (test/unit/prototype-pollution.test.ts).
  • OAuth for MCP clients. PKCE with S256 only. Redirect addresses must match exactly (a native app's loopback port may vary, RFC 8252), and nothing goes to an app's redirect address before the person has decided on the consent screen: anyone may register a client, so every problem before that (an unknown client, an unregistered address, PKCE, the response type, the resource, the scope) shows on Lampo's own page, which is told a fixed code and no text from the request. One-time codes live 60 seconds; a failed or repeated exchange uses them up, and a repeat revokes what the code produced. Access tokens last one hour and are accepted only at <public URL>/mcp. Refresh tokens last 60 days and change on every use; a replayed one revokes the whole connection. Every authorization answer names its issuer (RFC 9207). Consent is given only by a person in a browser, signed in (or, on your own machine, at the machine itself), never with an API token. Client ID Metadata Documents are fetched over https only, from public addresses only (pinned to the checked address, so DNS rebinding can't swap in an internal one), without redirects, at most 16 KB and 5 seconds, and cached. Registration is rate-limited. The OAuth endpoints answer browsers from any origin (CORS without credentials) because they never use cookies.
  • lampo login in the browser is a client of its own (client_id=vr, never a registration). Its answer goes only to a loopback port of the computer the browser runs on (http://127.0.0.1:<port>/ or http://[::1]:<port>/, nothing else), with PKCE S256 and a state lampo checks. The consent screen names the machine (as it calls itself, one line) and the token's name. Its one-time code lives 2 minutes and is redeemed only at POST /api/auth/token, with its verifier and the same redirect address, for the same API token lampo login --email makes (never at /oauth/token, never an app's connection); a repeat revokes that token, and 30 attempts per 15 minutes from one address are allowed. The token travels only in that answer to lampo, never in an address.

Found a problem? See SECURITY.md.