The first account can only be made in a 30-minute setup window
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s

Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.

Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.

Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:08:29 -04:00
co-authored by Claude Opus 5.5
parent 4c350838b1
commit 5d08d8a7a6
4 changed files with 52 additions and 3 deletions
+6
View File
@@ -14,6 +14,12 @@ itself: the first account created becomes the admin *and* closes registration be
it, so there is no window between "my account exists" and "I remembered to turn it
off". A brand-new instance is never locked out of itself, and never left open either.
**The first account has to be made within 30 minutes of the server starting.** After
that, `/register` refuses the first account. Otherwise a fresh server on a public
address would belong to whoever found it first. If you miss the window, restart the
container and register straight away. Once an account exists, the window no longer
matters.
**Instances that predate this still need one manual flip.** The close fires when the
first account is created, so a server whose admin already existed keeps whatever
`allow_registration` was set to — which was **on** by default. Check **Settings →
+3
View File
@@ -5,6 +5,7 @@ import logging
import mimetypes
import os
import secrets
import time
from contextlib import suppress
from datetime import timedelta
@@ -64,6 +65,8 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
def create_app() -> Quart:
# static_folder=None: the SPA catch-all below owns static serving.
app = Quart(__name__, static_folder=None)
# When this server started, for the first-account setup window (auth.py).
app.config["STARTED_AT"] = time.monotonic()
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
# database. before_serving swaps in the real, DB-persisted key before serving.
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
+21 -3
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import functools
import logging
import time
import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request, session
from quart import Blueprint, current_app, g, jsonify, request, session
from sqlalchemy import delete, func, select
from .common import iso
@@ -45,6 +46,20 @@ SESSION_KEY = "user_id"
# no key and reads as 0, the epoch every account started at.
EPOCH_KEY = "epoch"
MIN_PASSWORD_LEN = 8
# The first account can only be created this long after the server starts (family
# idea #5105, practice 8). Without it, a fresh server on a public address belongs to
# whoever reaches /register first, and a new DNS name is found within minutes.
#
# A constant, not a Setting: there is no admin yet to change one. Anyone who misses
# the window restarts the server, which opens it again; once an account exists the
# window no longer matters. Measured from `create_app` (STARTED_AT), so each start
# counts afresh.
SETUP_WINDOW_S = 30 * 60
SETUP_CLOSED = (
"this server's setup window has closed. Restart the server, then create the "
"first account within 30 minutes"
)
DEVICE_NAME_CAP = 100
@@ -228,8 +243,11 @@ async def register():
async with session_scope() as db:
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. After that, an invite lets one person in
if is_first and time.monotonic() - current_app.config["STARTED_AT"] > SETUP_WINDOW_S:
logger.warning("registration refused (setup window closed) email=%s from=%s", email, client_address())
return json_error(SETUP_CLOSED, 403)
# The first account bootstraps the admin and is allowed, inside the setup
# window above, even when 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.
+22
View File
@@ -958,6 +958,28 @@ async def _admin_with_invite(app_client, **body) -> str:
return (await resp.get_json())["token"]
async def test_the_first_account_is_refused_once_the_setup_window_has_closed(db):
"""A fresh server on a public address must not belong to whoever finds it first
(family idea #5105, practice 8). Past the window, nobody becomes admin."""
from inkwell.auth import SETUP_WINDOW_S
ratelimit.reset_all()
app = create_app()
app.config["STARTED_AT"] -= SETUP_WINDOW_S + 1
resp = await app.test_client().post(
"/api/auth/register", json={"email": "late@example.test", "password": _PASSWORD}
)
assert resp.status_code == 403
assert "setup window" in (await resp.get_json())["error"]
assert await db.scalar(select(func.count()).select_from(User)) == 0
async def test_the_first_account_is_allowed_inside_the_setup_window(app_client, db):
resp = await app_client.post("/api/auth/register", json={"email": "prompt@example.test", "password": _PASSWORD})
assert resp.status_code == 201
assert (await resp.get_json())["is_admin"] is True
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}