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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
38 lines
1.7 KiB
JavaScript
38 lines
1.7 KiB
JavaScript
// 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;');
|
|
};
|