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:
217
PROPOSAL-invite-links.md
Normal file
217
PROPOSAL-invite-links.md
Normal 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.
|
||||||
Reference in New Issue
Block a user