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>
11 KiB
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 and 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, includingUPLOAD_PASSWORD(required at startup; server refuses to boot without it).server/src/routes.ts—checkUploadPassword()comparesX-Upload-Passwordheader againstconfig.uploadPassword. Used byPOST /auth/checkandPOST /drops.server/src/db.ts— SQLite (better-sqlite3, WAL mode) with adropstable and aconfirm_tokenstable (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 successfulcheckPassword()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()anduploadDrop(), both sendX-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:
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:
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, therouter.use(...)gate that 401s/503s before every routenpm-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, newrenderAdminPage(), routed via a new/adminpath check inmain.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 | nullon thedropsrow 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_useswhen 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 isADMIN_PASSWORDalone sufficient authority? Leaning toward:ADMIN_PASSWORDalone 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.