Files
wisp/PROPOSAL-invite-links.md
explewd 0b62776282 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>
2026-07-12 19:00:16 +02:00

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, including UPLOAD_PASSWORD (required at startup; server refuses to boot without it).
  • server/src/routes.tscheckUploadPassword() 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.tsnewBlobId() / newConfirmToken(), both 128-bit random values rendered as base62. Reuse this for invite tokens.
  • client/src/upload.tsrenderUploadPage() 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.tscheckPassword() 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:

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, 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.