From 0b627762827340f37c5e169636bfaa6ae315b6e9 Mon Sep 17 00:00:00 2001 From: explewd Date: Sun, 12 Jul 2026 19:00:16 +0200 Subject: [PATCH] Add proposal: scoped upload invites (bypass the shared password) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design doc for a second, narrower credential type alongside UPLOAD_PASSWORD — time-boxed, use-limited, revocable invite links that skip the password gate entirely. Self-contained for a fresh agent to implement: covers the data model, the atomic single-use consumption race, why invite tokens belong in a query param (not the URL fragment, unlike the download decryption key), and points at npm-statuspage's just-built admin-panel pattern as the template for the admin gate. Not implemented — design stage. Co-Authored-By: Claude Sonnet 5 --- PROPOSAL-invite-links.md | 217 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 PROPOSAL-invite-links.md diff --git a/PROPOSAL-invite-links.md b/PROPOSAL-invite-links.md new file mode 100644 index 0000000..be90eec --- /dev/null +++ b/PROPOSAL-invite-links.md @@ -0,0 +1,217 @@ +# 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.