Add proposal: scoped upload invites (bypass the shared password)

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 <noreply@anthropic.com>
This commit is contained in:
explewd
2026-07-12 19:00:16 +02:00
parent 121dbe81f1
commit 0b62776282

217
PROPOSAL-invite-links.md Normal file
View File

@@ -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://<host>/#/?invite=<token>` 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.