invites: an admin lets one person register while registration stays closed
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.

POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.

Refs #5172

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:13:03 -04:00
co-authored by Claude Opus 5.5
parent df85535ea2
commit 28fa8badcb
18 changed files with 715 additions and 28 deletions
+42
View File
@@ -0,0 +1,42 @@
"""invites: single-use, expiring registration links an admin issues
Revision ID: 0032
Revises: 0031
Create Date: 2026-10-07
Until now the only way to add a second person was to re-open registration to the
whole internet while they signed up (#2939 §1). An invite lets one person register
while registration stays closed. Only the token's SHA-256 hash is stored.
## Downgrade
Drops the table. Accounts created through invites are untouched; the record of who
invited them goes with it.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import CITEXT, UUID
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"invites",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("email", CITEXT(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("invites")
+3 -5
View File
@@ -20,8 +20,9 @@ first account is created, so a server whose admin already existed keeps whatever
Access → Allow new registrations** before exposing an instance you have been running
on a LAN.
To let someone else in, turn it back on, have them register, turn it off. There is no
invite system yet, so that is the mechanism.
To let someone else in, send them an invite: **Settings → Invites** makes a link that
works once, expires (a week by default), and can be pinned to one email address. It
lets that one person register while registration stays closed.
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
@@ -121,9 +122,6 @@ Know these before you decide who gets an account.
with `docker compose logs app`, which is enough to see whether anyone is knocking.
They are not queryable, not retained beyond the container's log rotation, and not
attributable after the fact.
- **No invites.** Adding a second person means re-opening registration while they
sign up, then closing it again. There is no per-person token, no expiry, and no
record of who invited whom.
None of these are hard blockers for an instance whose accounts are you and people you
know. They are the reason not to hand out open registration to strangers.
+3 -1
View File
@@ -66,7 +66,9 @@ export interface ConfigRepo {
export interface AuthRepo {
me(): Promise<User>;
login(email: string, password: string): Promise<User>;
register(email: string, password: string, displayName: string): Promise<User>;
/** `invite` is the token from an invite link, which lets this one account in
* while registration is closed. */
register(email: string, password: string, displayName: string, invite?: string): Promise<User>;
logout(): Promise<void>;
}
+2 -2
View File
@@ -53,8 +53,8 @@ export const rest: Repo = {
auth: {
me: () => api.get<User>("/api/auth/me"),
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
register: (email, password, displayName) =>
api.post<User>("/api/auth/register", { email, password, display_name: displayName }),
register: (email, password, displayName, invite) =>
api.post<User>("/api/auth/register", { email, password, display_name: displayName, invite }),
logout: () => api.post<void>("/api/auth/logout"),
},
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { useUiStore } from "../stores/ui";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
// Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once,
// right after it is made — the same contract as a device token on Linked devices.
interface Invite {
id: string;
email: string | null;
created_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_by_email: string | null;
revoked_at: string | null;
status: "pending" | "redeemed" | "expired" | "revoked";
}
const LIFETIMES = [
{ days: 1, label: "1 day" },
{ days: 7, label: "7 days" },
{ days: 30, label: "30 days" },
];
const router = useRouter();
const ui = useUiStore();
const invites = ref<Invite[]>([]);
const loading = ref(true);
const error = ref("");
const email = ref("");
const days = ref(7);
const creating = ref(false);
// The link for the invite just made. Never retrievable again once dismissed.
const freshLink = ref("");
async function load() {
error.value = "";
try {
invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites;
} catch (e) {
error.value = errorMessage(e, "Couldn't load invites.");
} finally {
loading.value = false;
}
}
/** The register page's address with the token on it, as people reach this server. */
function linkFor(token: string): string {
const path = router.resolve({ name: "register", query: { invite: token } }).href;
return new URL(path, window.location.origin).href;
}
async function create() {
creating.value = true;
error.value = "";
freshLink.value = "";
try {
const res = await api.post<{ invite: Invite; token: string }>("/api/invites", {
email: email.value.trim() || null,
days: days.value,
});
freshLink.value = linkFor(res.token);
invites.value = [res.invite, ...invites.value];
email.value = "";
} catch (e) {
error.value = errorMessage(e, "Couldn't create an invite.");
} finally {
creating.value = false;
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(freshLink.value);
ui.showToast("Invite link copied.");
} catch {
ui.showToast("Couldn't copy — select and copy it manually.");
}
}
async function revoke(invite: Invite) {
if (!window.confirm(`Revoke the invite for ${who(invite)}? Its link will stop working.`)) return;
try {
const updated = await api.del<Invite>(`/api/invites/${invite.id}`);
invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i));
} catch (e) {
ui.showToast(errorMessage(e, "Couldn't revoke that invite."));
}
}
function who(invite: Invite): string {
return invite.email ?? "anyone with the link";
}
function day(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString() : "";
}
/** One short line saying where the invite stands. */
function state(invite: Invite): string {
switch (invite.status) {
case "redeemed":
return `Used by ${invite.redeemed_by_email ?? "a removed account"} · ${day(invite.redeemed_at)}`;
case "revoked":
return `Revoked · ${day(invite.revoked_at)}`;
case "expired":
return `Expired · ${day(invite.expires_at)}`;
default:
return `Waiting · expires ${day(invite.expires_at)}`;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Invites</h2>
<!-- One-time link reveal -->
<div
v-if="freshLink"
class="rounded-xl border border-brand/40 bg-brand/5 p-4 dark:border-brand/30 dark:bg-brand/10"
>
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
Copy this link now — it won't be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-xs text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>{{ freshLink }}</code
>
<button type="button" class="icon-btn shrink-0" title="Copy link" aria-label="Copy link" @click="copyLink">
<Icon name="copy" />
</button>
</div>
<button
type="button"
class="mt-3 text-xs text-neutral-500 underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="freshLink = ''"
>
Done
</button>
</div>
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
<BaseInput
id="invite-email"
v-model="email"
label="Email (optional)"
type="email"
placeholder="Only this address may use it"
class="min-w-48 flex-1"
/>
<div class="flex flex-col gap-1">
<label for="invite-days" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">Expires in</label>
<select
id="invite-days"
v-model.number="days"
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option v-for="l in LIFETIMES" :key="l.days" :value="l.days">{{ l.label }}</option>
</select>
</div>
<BaseButton type="submit" :loading="creating">Create invite</BaseButton>
</form>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-if="loading" class="py-6 text-center text-sm text-neutral-400">Loading…</div>
<p v-else-if="!invites.length" class="text-sm text-neutral-400">No invites yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="i in invites"
:key="i.id"
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">
{{ i.email ?? "Anyone with the link" }}
</p>
<p class="text-xs text-neutral-400">{{ state(i) }}</p>
</div>
<button
v-if="i.status === 'pending'"
type="button"
class="shrink-0 rounded-md border border-neutral-300 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-red-400 dark:hover:bg-red-950/40"
@click="revoke(i)"
>
Revoke
</button>
</li>
</ul>
</section>
</template>
+3 -1
View File
@@ -112,7 +112,9 @@ router.beforeEach(async (to) => {
if (to.meta.requiresServer && isDesktop()) {
return { name: "board" };
}
if (to.name === "register" && !config.allowRegistration) {
// An invite link opens the form while registration is closed; the server decides
// whether the invite holds.
if (to.name === "register" && !config.allowRegistration && !to.query.invite) {
return { name: "login" };
}
if (to.meta.guestOnly && session.user) {
+2 -2
View File
@@ -29,8 +29,8 @@ export const useSessionStore = defineStore("session", () => {
user.value = await repo.auth.login(email, password);
}
async function register(email: string, password: string, displayName: string): Promise<void> {
user.value = await repo.auth.register(email, password, displayName);
async function register(email: string, password: string, displayName: string, invite?: string): Promise<void> {
user.value = await repo.auth.register(email, password, displayName, invite);
}
async function logout(): Promise<void> {
+12 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useSessionStore } from "../stores/session";
import { useConfigStore } from "../stores/config";
import BaseInput from "../components/BaseInput.vue";
@@ -10,6 +10,11 @@ import { errorMessage } from "../api/errors";
const session = useSessionStore();
const config = useConfigStore();
const router = useRouter();
const route = useRoute();
// The token from an invite link (/register?invite=…). It lets this one account in
// while registration is closed.
const invite = computed(() => (typeof route.query.invite === "string" ? route.query.invite : ""));
const displayName = ref("");
const email = ref("");
@@ -25,7 +30,7 @@ async function submit() {
}
loading.value = true;
try {
await session.register(email.value, password.value, displayName.value);
await session.register(email.value, password.value, displayName.value, invite.value || undefined);
await router.replace("/");
} catch (e) {
error.value = errorMessage(e, "Could not create your account.");
@@ -46,8 +51,10 @@ async function submit() {
width="48"
height="48"
/>
<h1 class="text-2xl font-bold tracking-tight">Create your space</h1>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Start capturing in seconds.</p>
<h1 class="text-2xl font-bold tracking-tight">{{ invite ? "You're invited" : "Create your space" }}</h1>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{{ invite ? `Create your account on ${config.siteName}.` : "Start capturing in seconds." }}
</p>
</div>
<form class="flex flex-col gap-4" novalidate @submit.prevent="submit">
+5
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { api } from "../api/client";
import { useConfigStore } from "../stores/config";
import BaseButton from "../components/BaseButton.vue";
import InviteList from "../components/InviteList.vue";
import { errorMessage } from "../api/errors";
interface SettingItem {
@@ -170,5 +171,9 @@ onMounted(load);
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
</div>
</form>
<!-- Outside the settings form: each invite action saves on its own, and the
form's Save button has nothing to do with them. -->
<InviteList v-if="items.length" class="mt-10" />
</div>
</template>
+2
View File
@@ -15,6 +15,7 @@ from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
from .db import session_scope
from .invites_api import bp as invites_bp
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .proxy import is_https
@@ -91,6 +92,7 @@ def create_app() -> Quart:
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(invites_bp)
app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp)
+22 -6
View File
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
from .common import iso
from .db import session_scope
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
from .models.device_token import DeviceToken
from .models.user import User
from .proxy import client_address
@@ -176,6 +177,7 @@ async def register():
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
invite = (data.get("invite") or "").strip()
if not email or "@" not in email:
return jsonify({"error": "a valid email is required"}), 400
@@ -196,10 +198,21 @@ async def register():
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when
# registration is otherwise closed.
if not is_first and not await get_setting(db, "allow_registration"):
# registration is otherwise closed. After that, an invite lets one person in
# while it is closed (#5172). One that was offered is redeemed even while
# registration is open, so the list still says who used it, and one that
# doesn't hold is refused rather than ignored.
invite_id = None
if not is_first and invite:
invite_id = await redeem(db, invite, email)
if invite_id is None:
logger.warning("registration refused (bad invite) email=%s from=%s", email, client_address())
return jsonify({"error": INVALID_INVITE}), 403
elif not is_first and not await get_setting(db, "allow_registration"):
logger.warning("registration refused (closed) email=%s from=%s", email, client_address())
return jsonify({"error": "registration is closed"}), 403
# Returning without a commit rolls back the redemption above with it, so a
# taken email leaves the invite usable.
existing = await db.scalar(select(User).where(User.email == email))
if existing is not None:
return jsonify({"error": "an account with that email already exists"}), 409
@@ -220,16 +233,19 @@ async def register():
# off in Settings" was wide open, and on a public host that gap is the
# entire exposure — it starts the moment DNS resolves.
#
# An admin who wants a second person turns it back on in Settings → Access,
# adds them, and turns it off. Crude until invites exist, but it is a
# deliberate act rather than a default nobody chose.
# An admin who wants a second person sends them an invite (Settings →
# Invites), which works with registration closed.
await set_settings(db, {"allow_registration": False})
if invite_id is not None:
await db.flush()
await record_redeemer(db, invite_id, user.id)
await db.commit()
await db.refresh(user)
session[SESSION_KEY] = str(user.id)
session.permanent = True
logger.info(
"account created email=%s admin=%s from=%s", email, is_first, client_address()
"account created email=%s admin=%s invite=%s from=%s",
email, is_first, invite_id, client_address(),
)
return jsonify(_serialize_user(user)), 201
+103
View File
@@ -0,0 +1,103 @@
"""Invites: an admin lets one person register while registration is closed (#5172).
Before these, adding a second person meant turning registration back on for the
whole internet while they signed up, with no record of who was let in (#2939 §1).
An invite is a link carrying a random token. It works once, until it expires, and
optionally only for one email address. Only the token's hash is kept, so the link is
shown once, when it is made; an admin who loses it revokes it and makes another.
This module is the invite itself: its status, its wire shape, and redemption, which
`auth.register` calls inside the transaction that creates the account. The admin
routes are in `invites_api.py`, the same split as `settings` and `settings_api`, and
for the same reason: the routes need `auth`, and `auth` needs this.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from .common import iso
from .models.invite import Invite
from .security import hash_token
# A week covers "I'll send it tonight and they'll get to it at the weekend"; a month
# is the longest a live credential to an instance should sit in someone's inbox.
DEFAULT_DAYS = 7
MAX_DAYS = 30
# The one answer to every failed redemption. Which of unknown, used, expired, revoked
# or "not for this email" it was is the admin's business (the list says), not the
# business of whoever is holding the link.
INVALID = "invalid or expired invite"
def status(invite: Invite, now: datetime) -> str:
"""pending, redeemed, revoked or expired. Redeemed wins over revoked: once an
account exists, revoking the link that made it changes nothing."""
if invite.redeemed_at is not None:
return "redeemed"
if invite.revoked_at is not None:
return "revoked"
if invite.expires_at <= now:
return "expired"
return "pending"
def serialize(invite: Invite, now: datetime, redeemed_email: str | None = None) -> dict:
return {
"id": str(invite.id),
"email": invite.email,
"created_at": iso(invite.created_at),
"expires_at": iso(invite.expires_at),
"redeemed_at": iso(invite.redeemed_at),
"redeemed_by_email": redeemed_email,
"revoked_at": iso(invite.revoked_at),
"status": status(invite, now),
}
def lifetime_days(raw: object) -> int | None:
"""The requested lifetime in whole days, or None when it isn't one we allow."""
if raw is None:
return DEFAULT_DAYS
# Whole numbers only, refused rather than rounded: an invite that quietly lasts a
# different time from the one asked for is a surprise in the wrong direction.
if isinstance(raw, int) and not isinstance(raw, bool):
days = raw
elif isinstance(raw, str) and raw.strip().isdigit():
days = int(raw)
else:
return None
return days if 1 <= days <= MAX_DAYS else None
async def redeem(db: AsyncSession, token: str, email: str) -> uuid.UUID | None:
"""Claim the invite for `email`, returning its id, or None if it can't be used.
One conditional UPDATE, so two people racing the same link can't both win: the
second waits on the first's row lock and then finds `redeemed_at` set. Not
committed here. The caller commits together with the new account, so an account
that fails to be created (the email is taken) leaves the invite unused.
"""
now = datetime.now(timezone.utc)
return await db.scalar(
update(Invite)
.where(
Invite.token_hash == hash_token(token),
Invite.redeemed_at.is_(None),
Invite.revoked_at.is_(None),
Invite.expires_at > now,
or_(Invite.email.is_(None), Invite.email == email),
)
.values(redeemed_at=now)
.returning(Invite.id)
)
async def record_redeemer(db: AsyncSession, invite_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""Name the account an invite made, once that account has an id."""
await db.execute(update(Invite).where(Invite.id == invite_id).values(redeemed_by=user_id))
+95
View File
@@ -0,0 +1,95 @@
"""Admin routes for invites: make one, list them, revoke one (#5172).
What an invite is, and how it is redeemed, is in `invites.py`.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from sqlalchemy.orm import aliased
from .auth import require_admin
from .db import session_scope
from .invites import MAX_DAYS, lifetime_days, serialize
from .models.invite import Invite
from .models.user import User
from .proxy import client_address
from .security import generate_token, hash_token
bp = Blueprint("invites", __name__, url_prefix="/api/invites")
# Credential events go to the app log, as in `auth` (there is no audit table yet).
logger = logging.getLogger(__name__)
@bp.post("")
@require_admin
async def create_invite():
data = await request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower() or None
if email is not None and "@" not in email:
return jsonify({"error": "that doesn't look like an email address"}), 400
days = lifetime_days(data.get("days"))
if days is None:
return jsonify({"error": f"an invite lasts between 1 and {MAX_DAYS} days"}), 400
token = generate_token()
now = datetime.now(timezone.utc)
async with session_scope() as db:
invite = Invite(
token_hash=hash_token(token),
created_by=g.user_id,
email=email,
expires_at=now + timedelta(days=days),
)
db.add(invite)
await db.commit()
await db.refresh(invite)
logger.info(
"invite created id=%s for=%s days=%s by=%s from=%s",
invite.id, email or "anyone", days, g.user_id, client_address(),
)
# The token goes back exactly once. The client builds the link from it, since only
# the browser knows the address people actually reach this server at.
return jsonify({"invite": serialize(invite, now), "token": token}), 201
@bp.get("")
@require_admin
async def list_invites():
redeemer = aliased(User)
now = datetime.now(timezone.utc)
async with session_scope() as db:
rows = (
await db.execute(
select(Invite, redeemer.email)
.outerjoin(redeemer, redeemer.id == Invite.redeemed_by)
.order_by(Invite.created_at.desc())
)
).all()
return jsonify({"invites": [serialize(invite, now, email) for invite, email in rows]})
@bp.delete("/<invite_id>")
@require_admin
async def revoke_invite(invite_id: str):
try:
iid = uuid.UUID(invite_id)
except ValueError:
return jsonify({"error": "not found"}), 404
now = datetime.now(timezone.utc)
async with session_scope() as db:
invite = await db.get(Invite, iid)
if invite is None:
return jsonify({"error": "not found"}), 404
# A used invite has done its work and its record stays as it is; an already
# revoked one keeps its first revocation time.
if invite.redeemed_at is None and invite.revoked_at is None:
invite.revoked_at = now
await db.commit()
logger.info("invite revoked id=%s by=%s from=%s", iid, g.user_id, client_address())
return jsonify(serialize(invite, now))
+1
View File
@@ -6,6 +6,7 @@ Imported for side effects only (model registration on Base.metadata).
from . import ( # noqa: F401
device_token,
group,
invite,
label,
note,
note_attachment,
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import CITEXT, UUID
from sqlalchemy.orm import Mapped, mapped_column
from . import Base
class Invite(Base):
"""A single-use, expiring link an admin hands to one person so they can register
while registration is closed (#5172).
Only the token's SHA-256 hash is stored, as for device tokens; the link is shown
once, when the invite is made. Rows are never deleted by the app: a used, expired
or revoked invite stays as the record of who let whom in.
"""
__tablename__ = "invites"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
token_hash: Mapped[str] = mapped_column(Text(), nullable=False, unique=True)
# SET NULL rather than CASCADE: the account that redeemed an invite outlives the
# admin who issued it, and so should the record of how it got here.
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# When set, only this address may register with the invite. CITEXT, like
# users.email, so the pin can't be dodged or tripped by capitalisation.
email: Mapped[str | None] = mapped_column(CITEXT(), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
redeemed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
redeemed_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+2 -2
View File
@@ -39,8 +39,8 @@ REGISTRY: list[SettingDef] = [
"bool",
True,
"Allow new registrations",
"When off, only existing users can sign in. Closes itself once the first "
"account exists — turn it back on only while you're adding someone.",
"When off, new people can join only with an invite. Closes itself once the "
"first account exists.",
"Access",
),
SettingDef(
+136 -4
View File
@@ -15,18 +15,20 @@ deployment has ever seen.
"""
from __future__ import annotations
import asyncio
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
from sqlalchemy import func, select, text
from sqlalchemy import func, select, text, update
from inkwell import ratelimit
from inkwell.app import create_app
from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope
from inkwell.models.invite import Invite
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment
@@ -44,7 +46,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, users"
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, invites, users"
@pytest_asyncio.fixture
@@ -390,8 +392,8 @@ async def test_registration_closes_itself_once_an_admin_exists(app_client, db):
)
assert second.status_code == 403
# Re-opening it deliberately still works — that is how a second person gets in
# until invites exist.
# Re-opening it deliberately still works, for an admin who would rather open the
# door than send an invite.
async with session_scope() as fresh:
await set_settings(fresh, {"allow_registration": True})
await fresh.commit()
@@ -932,3 +934,133 @@ async def test_a_keep_note_that_is_only_a_photo_imports(app_client, db):
[note] = (await db.scalars(select(Note))).all()
[att] = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
assert att.mime == "image/png"
# ---- invites (#5172) ---------------------------------------------------------------
_PASSWORD = "a-long-enough-password"
async def _admin_with_invite(app_client, **body) -> str:
"""Register the instance's first account (the admin, signed in on `app_client`)
and have it issue an invite. Returns the token."""
created = await app_client.post("/api/auth/register", json={"email": "owner@example.test", "password": _PASSWORD})
assert created.status_code == 201
resp = await app_client.post("/api/invites", json=body)
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["token"]
async def _register(email: str, invite: str | None = None):
"""Register from a separate client, so the admin's session stays where it is."""
body = {"email": email, "password": _PASSWORD}
if invite is not None:
body["invite"] = invite
return await create_app().test_client().post("/api/auth/register", json=body)
async def test_an_invite_lets_one_person_in_while_registration_stays_closed(app_client, db):
token = await _admin_with_invite(app_client)
# Registration closed itself behind the admin; a stranger with no invite is out.
stranger = await _register("stranger@example.test")
assert stranger.status_code == 403
invited = await _register("guest@example.test", token)
assert invited.status_code == 201, await invited.get_data(as_text=True)
assert (await invited.get_json())["is_admin"] is False
# Single use: the same link again, for anyone, is refused with the generic answer.
again = await _register("someone-else@example.test", token)
assert again.status_code == 403
assert (await again.get_json())["error"] == "invalid or expired invite"
# The admin's list says who used it.
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
assert [(i["status"], i["redeemed_by_email"]) for i in listed] == [("redeemed", "guest@example.test")]
async with session_scope() as fresh:
assert await get_setting(fresh, "allow_registration") is False
async def test_only_an_admin_handles_invites(app_client, db):
token = await _admin_with_invite(app_client)
guest = create_app().test_client()
joined = await guest.post(
"/api/auth/register", json={"email": "guest@example.test", "password": _PASSWORD, "invite": token}
)
assert joined.status_code == 201
# Signed in as the guest now, who is not an admin.
assert (await guest.post("/api/invites", json={})).status_code == 403
assert (await guest.get("/api/invites")).status_code == 403
async def test_an_invite_pinned_to_an_email_admits_only_that_email(app_client, db):
token = await _admin_with_invite(app_client, email="Pinned@Example.test")
wrong = await _register("other@example.test", token)
assert wrong.status_code == 403
assert (await wrong.get_json())["error"] == "invalid or expired invite"
# Any capitalisation of the pinned address, since emails compare case-insensitively.
right = await _register("PINNED@example.test", token)
assert right.status_code == 201
async def test_revoked_and_expired_invites_are_refused(app_client, db):
revoked = await _admin_with_invite(app_client)
expiring = (await (await app_client.post("/api/invites", json={"days": 1})).get_json())["token"]
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
by_status = {i["status"] for i in listed}
assert by_status == {"pending"}
# The invite made first is the last in the list (newest first).
revoked_id = listed[-1]["id"]
resp = await app_client.delete(f"/api/invites/{revoked_id}")
assert resp.status_code == 200
assert (await resp.get_json())["status"] == "revoked"
async with session_scope() as fresh:
await fresh.execute(
update(Invite)
.where(Invite.id != uuid.UUID(revoked_id))
.values(expires_at=datetime.now(timezone.utc) - timedelta(minutes=1))
)
await fresh.commit()
assert (await _register("a@example.test", revoked)).status_code == 403
assert (await _register("b@example.test", expiring)).status_code == 403
statuses = sorted(i["status"] for i in (await (await app_client.get("/api/invites")).get_json())["invites"])
assert statuses == ["expired", "revoked"]
async def test_an_invite_lifetime_outside_the_bounds_is_refused(app_client, db):
await _admin_with_invite(app_client)
for days in (0, 31, "a week", True):
resp = await app_client.post("/api/invites", json={"days": days})
assert resp.status_code == 400, days
resp = await app_client.post("/api/invites", json={"email": "not-an-address"})
assert resp.status_code == 400
async def test_a_taken_email_leaves_the_invite_unused(app_client, db):
"""The redemption and the new account are one transaction: an account that can't
be created must not use up the link."""
token = await _admin_with_invite(app_client)
taken = await _register("owner@example.test", token)
assert taken.status_code == 409
listed = (await (await app_client.get("/api/invites")).get_json())["invites"]
assert listed[0]["status"] == "pending"
assert (await _register("guest@example.test", token)).status_code == 201
async def test_two_people_racing_one_invite_cannot_both_get_in(app_client, db):
token = await _admin_with_invite(app_client)
results = await asyncio.gather(
_register("first@example.test", token),
_register("second@example.test", token),
)
assert sorted(r.status_code for r in results) == [201, 403]
async with session_scope() as fresh:
count = await fresh.scalar(select(func.count()).select_from(User))
assert count == 2, "the admin and exactly one of the two"
+37
View File
@@ -0,0 +1,37 @@
"""Invite status and lifetime rules, DB-free. Redemption itself is a conditional
UPDATE and is tested against Postgres in test_integration.py."""
from datetime import datetime, timedelta, timezone
import pytest
from inkwell.invites import DEFAULT_DAYS, MAX_DAYS, lifetime_days, status
from inkwell.models.invite import Invite
NOW = datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
def _invite(**fields) -> Invite:
fields.setdefault("expires_at", NOW + timedelta(days=1))
return Invite(token_hash="x", **fields)
def test_an_untouched_invite_in_date_is_pending():
assert status(_invite(), NOW) == "pending"
def test_an_invite_is_expired_from_its_expiry_instant():
assert status(_invite(expires_at=NOW), NOW) == "expired"
def test_revoked_beats_expired_and_redeemed_beats_both():
assert status(_invite(expires_at=NOW, revoked_at=NOW), NOW) == "revoked"
assert status(_invite(expires_at=NOW, revoked_at=NOW, redeemed_at=NOW), NOW) == "redeemed"
@pytest.mark.parametrize(
"raw, days",
[(None, DEFAULT_DAYS), (1, 1), ("14", 14), (MAX_DAYS, MAX_DAYS), (0, None), (MAX_DAYS + 1, None),
("soon", None), (True, None), (2.5, None)],
)
def test_lifetimes(raw, days):
assert lifetime_days(raw) == days