From 39c1663334141b3923febf04bbbe419a5c8d3d2c Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 23:12:07 +0200 Subject: [PATCH] feat: invite-only registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registration_enabled was a single switch: open to anyone, or closed to everyone. This adds the setting an operator actually wants in between — open to people you invited. A code is single-use, expires (7 days by default, 90 maximum), and can be revoked or deleted. It is stored hashed with only its last four characters kept, because an invite grants account creation and a database dump should not hand someone a working one. The code is readable exactly once, in the response that creates it. The claim is a single conditional UPDATE carrying every condition, so two registrations racing the same code cannot both succeed. It happens after the account exists, so a code is never spent on a failed registration — and if the race is lost, the just-created account is removed rather than left behind as a free registration. The rejection never says which of the four reasons applied; distinguishing them would tell someone probing codes which guesses were closer. Codes avoid I, L, O and U so they survive being read aloud or copied off a screen, and matching ignores case and separators. The sign-up field appears only when the server says a code is required. The admin card creates, lists, revokes and deletes, and carries the toggle. Verified against the live database: create, claim, second claim refused, unknown code refused, revoking a used code refused, delete. 684 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- .../1780100000000_registration-invites.js | 38 +++++ public/components/admin.html | 34 +++++ public/index.html | 5 + public/js/admin.js | 116 +++++++++++++++ public/js/auth.js | 13 +- public/js/clinicalAssistant.js | 12 +- src/routes/adminConfig.js | 43 +++++- src/routes/auth.js | 28 +++- src/utils/registrationInvites.js | 138 ++++++++++++++++++ test/backend-hardening.test.js | 42 ++++++ 10 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 migrations/1780100000000_registration-invites.js create mode 100644 src/utils/registrationInvites.js diff --git a/migrations/1780100000000_registration-invites.js b/migrations/1780100000000_registration-invites.js new file mode 100644 index 00000000..f29f5ab6 --- /dev/null +++ b/migrations/1780100000000_registration-invites.js @@ -0,0 +1,38 @@ +// Invite-only registration. +// +// registration_enabled is a single on/off switch: open to anyone, or closed to +// everyone. This adds the middle setting an operator actually wants — open to +// people you invited. A code is single-use, expires, and can be revoked or +// deleted without touching the account it created. +// +// The code is stored hashed. An invite grants account creation, so a leaked +// settings dump or database backup should not hand someone a working code, the +// same reason password reset tokens are not stored in the clear. + +exports.up = pgm => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS registration_invites ( + id SERIAL PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + -- The last few characters, so the list can show which code a row is + -- without being able to reconstruct it. + code_hint TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + -- Set when used. The row is kept so an admin can see who used which code. + used_at TIMESTAMPTZ, + used_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + -- Set when revoked. Separate from deletion: a revoked code stays visible. + revoked_at TIMESTAMPTZ, + revoked_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_registration_invites_hash ON registration_invites(code_hash); + CREATE INDEX IF NOT EXISTS idx_registration_invites_expires ON registration_invites(expires_at); + `); +}; + +exports.down = pgm => { + pgm.sql('DROP TABLE IF EXISTS registration_invites;'); +}; diff --git a/public/components/admin.html b/public/components/admin.html index 15e526fe..3d0f3ccb 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -373,6 +373,40 @@ + +
+
+

Registration Invitations

+
+
+
+ Invite only +
+ +

Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.

+
+
+ +
+ +
+ + + +
+
+

The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards.

+
+ +
+
+
+
diff --git a/public/index.html b/public/index.html index bc3c0613..f7a3bfa5 100644 --- a/public/index.html +++ b/public/index.html @@ -111,6 +111,11 @@
+ +