# Proposal: scoped upload invites **Status:** proposed, not built. This document is self-contained — written so a fresh agent with no prior conversation context can pick it up and implement it without needing anything explained first. ## Context (read this first) wisp is a short-lived, end-to-end encrypted file drop. Full background is in [README.md](./README.md) and [IMPLEMENTATION.md](./IMPLEMENTATION.md) — read both before touching code, especially IMPLEMENTATION.md's "deletion race" section, since this feature must not regress that design. The relevant existing pieces, for orientation: - `server/src/config.ts` — env-driven config, including `UPLOAD_PASSWORD` (required at startup; server refuses to boot without it). - `server/src/routes.ts` — `checkUploadPassword()` compares `X-Upload-Password` header against `config.uploadPassword`. Used by `POST /auth/check` and `POST /drops`. - `server/src/db.ts` — SQLite (better-sqlite3, WAL mode) with a `drops` table and a `confirm_tokens` table (single-use, expiring tokens tied to a specific drop — the pattern this proposal reuses). - `server/src/ids.ts` — `newBlobId()` / `newConfirmToken()`, both 128-bit random values rendered as base62. Reuse this for invite tokens. - `client/src/upload.ts` — `renderUploadPage()` shows the password gate; `renderUploadForm()` shows the dropzone after a successful `checkPassword()` call. The unlock state is in-memory only, not persisted (no cookie, no localStorage) — confirmed in prior testing that a fresh browser session always re-hits the lock screen. - `client/src/api.ts` — `checkPassword()` and `uploadDrop()`, both send `X-Upload-Password`. ## Motivation Today there is exactly one credential: `UPLOAD_PASSWORD`, shared by whoever needs upload access. That has one failure mode worth fixing: **granting access to one specific person for one specific reason means either giving them permanent access to the master password, or rotating the password after they're done (which breaks it for everyone else too).** There is no way today to say "Alice can upload one file in the next 24 hours" without handing her the same credential that controls the whole upload gate indefinitely. This proposal adds **scoped, revocable, time-boxed upload invites** that bypass the password screen entirely — a second, narrower credential type alongside the master password, not a replacement for it. ## Design ### Data model New table in `server/src/db.ts`, same file/pattern as the existing `drops` and `confirm_tokens` tables: ```sql CREATE TABLE IF NOT EXISTS upload_invites ( token TEXT PRIMARY KEY, -- 128-bit base62, via newInviteToken() (mirrors newBlobId()) label TEXT, -- optional, e.g. "for Alice" — admin-facing only, never shown to the invite holder max_uses INTEGER NOT NULL, -- 1 for single-use; higher for "this group can each drop one file" used_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); ``` An invite is valid iff `used_count < max_uses AND expires_at > now()`. Consuming one increments `used_count` — this must be an atomic check-and-increment (a single `UPDATE ... WHERE token = ? AND used_count < max_uses AND expires_at > ?` followed by checking `changes > 0`, not a separate SELECT-then-UPDATE, to avoid a race between two near-simultaneous uploads both passing a stale check against the same single-use invite). ### Server-side: `POST /drops` accepts either credential In `routes.ts`, generalize `checkUploadPassword()` into something like: ```ts function checkUploadPassword(req): boolean { return req.get('X-Upload-Password') === config.uploadPassword; } function tryConsumeInvite(req): boolean { const token = req.get('X-Upload-Invite'); if (!token) return false; const result = db.prepare( `UPDATE upload_invites SET used_count = used_count + 1 WHERE token = ? AND used_count < max_uses AND expires_at > ?` ).run(token, Math.floor(Date.now() / 1000)); return result.changes > 0; } ``` `POST /drops` (and `POST /auth/check`, so the client can pre-flight without burning a use — see below) accepts the request if *either* `checkUploadPassword(req)` *or* an invite token would validate. The two checks are independent; an invite never reveals or depends on the master password. **Pre-flight without consuming a use**: `checkPassword()`-equivalent for invites needs a read-only validity check (does this token exist, is it unexpired, does it have uses left) that does *not* increment `used_count` — otherwise just loading the upload page with an invite link would burn a use before the user has chosen a file. Add a separate `GET /invites/:token/valid` (or fold into `/auth/check` with the token as a query param) that does the read-only check; only the actual `POST /drops` call consumes a use, at upload time. ### Client-side: skip the lock screen when a valid invite is present `client/src/main.ts` currently routes purely on pathname (`/d/:id` vs. everything else). Add: on the upload path, check `new URLSearchParams(location.search).get('invite')`. If present, call the read-only validity endpoint; if valid, call `renderUploadForm(root, undefined, inviteToken)` directly, skipping `renderUploadPage()`'s password prompt. `renderUploadForm` needs a second optional credential parameter (invite token) alongside the existing `password` parameter, and `uploadDrop()` in `api.ts` needs to send `X-Upload-Invite` instead of (or alongside — doesn't matter, server checks both) `X-Upload-Password` when called with an invite. **Invite tokens go in a query parameter (`?invite=...`), not the URL fragment.** This is a deliberate, important difference from the `#k=...` decryption key on download links. The download key must *never* reach the server — that's the entire point of putting it after `#`. An invite token is the opposite: the server *must* see it to validate it. Putting it in the fragment would make it unusable — don't copy the download-link pattern here by reflex. ### Admin: a separate gate, a separate password Invites need to be created and revoked somewhere. This should **not** share `UPLOAD_PASSWORD` — an admin capable of minting invites is a different trust level than someone who can merely upload. Add `ADMIN_PASSWORD` (new env var, same "unset = feature disabled entirely" posture as `UPLOAD_PASSWORD`'s "refuse to start" — except here, unset should mean "admin endpoints and page return 404/503, everything else works exactly as it does today," not a hard boot failure, since not every deployment needs invite management). This exact pattern — a second password-gated admin surface, disabled outright if its env var is unset, `X-Admin-Password` header, stateless — was just built for `npm-statuspage` (sibling project, same author). Look at that implementation before writing this one: - `npm-statuspage/src/admin.ts` — router structure, the `router.use(...)` gate that 401s/503s before every route - `npm-statuspage/src/adminPage.ts` — inline HTML+JS admin page, no build step, matches wisp's own lack of... actually wisp *does* have a Vite client build, unlike npm-statuspage's inline-template-string pages. Decide whether the invite-admin page lives as a new route in the existing Vite client (`client/src/admin.ts`, new `renderAdminPage()`, routed via a new `/admin` path check in `main.ts`) or as a server-rendered page like npm-statuspage's, served directly by Express without going through the Vite build. Given wisp already has a client build pipeline, the Vite-client route is more consistent with wisp's own conventions than importing npm-statuspage's inline-HTML pattern wholesale — lean toward extending the existing client, not copying the other project's page-rendering style. Admin API surface: ``` POST /api/admin/auth/check GET /api/admin/invites -- list, with used_count/max_uses/expires_at/label POST /api/admin/invites -- { label?, maxUses?, ttlSeconds? } -> { token, url } DELETE /api/admin/invites/:token -- revoke immediately (delete row, or set max_uses = used_count) ``` The create response's `url` should be the fully-formed link (`https:///#/?invite=` or whatever wisp's actual routing produces — check `main.ts`'s current path scheme) ready to copy-paste and send to whoever needs it. ## Security considerations - An invite token is a bearer credential during its validity window, exactly like the master password is today — anyone holding a valid, unexpired, not-yet-exhausted invite link can upload. This is not a new category of risk; it's a strictly *narrower* one than the master password, since it's scoped, time-boxed, and independently revocable. - Revocation is immediate and doesn't affect the master password or other invites — deleting one row. - The server can log invite usage (label + timestamp) for basic accountability. This is a genuine improvement over the shared-password status quo, where there is no way to tell *which* upload came from which person — every drop looks identical regardless of which credential authorized it. Consider recording `used_by_invite: token | null` on the `drops` row itself (nullable, no new join needed) so a future admin view could show "this drop came from the invite labeled 'for Alice'." - Existing upload guardrails (`maxUploadBytes`, whatever rate limiting exists or gets added) apply identically regardless of which credential authorized the request — an invite does not bypass size/rate limits, only the password check. - Does not touch the deletion-race design in IMPLEMENTATION.md at all — invites only gate *upload*, not download/confirm/burn, which are unauthenticated by design (the link itself, including `#k=...`, is already the credential for retrieval). ## Open questions - Default `max_uses` when unspecified — 1 (single-use) is the safer default; requiring an explicit larger number for multi-use invites makes accidental over-sharing less likely. - Should invite creation require the admin to already know `UPLOAD_PASSWORD`, or is `ADMIN_PASSWORD` alone sufficient authority? Leaning toward: `ADMIN_PASSWORD` alone is sufficient — that's the entire point of having a separate, narrower-purpose admin credential. - Whether to expose invite management only via the admin page, or also as a small CLI/curl-friendly flow for scripting (e.g. minting an invite as part of some other automation). Not needed for v1; the admin page covers the actual motivating use case (one-off manual sharing). - Per-invite size/TTL overrides beyond the global config (e.g. "Alice can upload up to 500MB, everyone else gets the default 200MB") — likely out of scope v1, flag as a possible v2 if it comes up.