Skip to content

Let a server admit people by request (GRYT-193) - #46

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-193-approval-queue
Aug 12, 2026
Merged

Let a server admit people by request (GRYT-193)#46
sivert-io merged 1 commit into
mainfrom
claude/GRYT-193-approval-queue

Conversation

@sivert-io

Copy link
Copy Markdown
Member

Part of GRYT-193. Server half — the client half follows immediately and is the thing that
makes this reachable.
Nobody can approve anyone through a UI until it lands, so please read
them together rather than merging this alone.

A public server had two answers and neither fits. open lets anyone walk in; invite makes a
shareable link the entire security model, and links get forwarded. join_policy gains a third
value, request: anybody may ask, nobody gets in until an admin says so.

Verified against a real server

Ran the worktree server on a spare port and drove the socket flow with local identities — the
same harness shape as the GRYT-170 link test:

step result
owner joins first JOINED, isOwner: true
policy set to request request
stranger asks, with a note refused, approval_pending
stranger asks again refused, and the queue still has one entry
queue as an admin sees it Stranger, note trimmed to hello, I am a friend of Sivert
approve, stranger returns JOINED
queue afterwards empty — the row is deleted on use
a different person, denied, returns refused with the same words as pending

The denied row survives in the table with decided_at set, so a refusal cannot be re-asked
into a fresh pending request.

Choices worth arguing with

  • They come back rather than being held open. The alternative keeps a half-joined socket
    in a pending state and pushes them in the moment an admin clicks. Nicer to be on the end of,
    but it needs a registry of half-joined sockets and a second path through the handler that
    holds ban checks, invite consumption and owner claiming. This adds one branch to that
    handler instead. Sivert picked this shape.
  • A denial says exactly what a pending request says. Telling somebody they were turned
    down confirms a moderator looked and decided — the same leak the ban refusal a few lines
    above deliberately avoids, and it invites arguing with the message instead of with a person.
    The decision is in the audit log.
  • Unknown status reads as pending, never approved. Same fail-shut property
    normalizeJoinPolicy has, and there is a test that enumerates near-misses (approve,
    accepted, "approved ") for it.
  • Asking is rate limited per address, at 10/hour. The row is keyed on identity, so nobody
    builds a backlog alone — but a local identity costs nothing to mint, and each new one is a
    new line in somebody's moderation queue.
  • The approved row is deleted once used, so leaving the server later puts somebody back at
    the door instead of an old approval readmitting them forever.
  • The note rides on server:verify, not the challenge. The challenge binds what the client
    must not change between the two steps — the nickname it will be admitted under, the invite it
    claimed. A note is a message to a human that nothing downstream trusts, so binding it would
    widen createChallenge in src/auth/ for no property gained. Say if you would rather it were
    bound anyway.

Review-required surface

src/db/** — the new join_requests table, joinRequests.ts, and JoinPolicy gaining a
value. src/auth/** is untouched. src/socket/handlers/join.ts gets one branch in the
existing "no invite, not LAN" path, after the owner claim, which has to stay first so the
first person through the door still becomes the owner on a request server.

🤖 Generated with Claude Code

A public server had two answers and neither fits: open lets anyone walk in,
invite makes a shareable link the whole security model. join_policy gains a
third value, request — anybody may ask, nobody gets in until an admin says so.

Somebody who asks is told to come back rather than held open. The alternative
was keeping a half-joined socket in a pending state and pushing them in on
approval, which is nicer to be on the end of and needs a new path through the
handler that holds ban checks, invite consumption and owner claiming. This adds
one branch to it instead.

Notable choices:

- Unknown status reads as pending, never approved, matching normalizeJoinPolicy.
  A row written by a newer server or edited by hand must leave somebody outside
  the door.
- One row per identity, so asking twice does not build a queue, and re-asking
  keeps the original created_at rather than jumping the order.
- Asking is rate limited per address. The row is keyed on identity, which bounds
  one person, not a script minting a fresh local identity per attempt.
- A denial is told exactly what a pending request is told. Saying "you were
  turned down" confirms a moderator looked and decided, which is the leak the
  ban refusal above it already avoids.
- The approved row is deleted once used, so leaving the server later puts
  somebody back at the door rather than letting an old approval readmit them.
- The note rides on server:verify rather than the challenge. The challenge binds
  what must not change between the two steps; a note is a message to a human and
  nothing downstream trusts it.

Verified against a real server on a spare port, driving the socket flow with
local identities: a stranger is held with the request in the queue, asking twice
leaves one entry, approving lets them in on the next attempt and empties the
queue, and a denied person gets the same words as a pending one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit f8b6835 into main Aug 12, 2026
1 check passed
@sivert-io
sivert-io deleted the claude/GRYT-193-approval-queue branch August 12, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant