Let a server admit people by request (GRYT-193) - #46
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
openlets anyone walk in;invitemakes ashareable link the entire security model, and links get forwarded.
join_policygains a thirdvalue,
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:
JOINED,isOwner: truerequestrequestapproval_pendingStranger, note trimmed tohello, I am a friend of SivertJOINEDThe denied row survives in the table with
decided_atset, so a refusal cannot be re-askedinto a fresh pending request.
Choices worth arguing with
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.
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.
pending, neverapproved. Same fail-shut propertynormalizeJoinPolicyhas, and there is a test that enumerates near-misses (approve,accepted,"approved ") for it.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 door instead of an old approval readmitting them forever.
server:verify, not the challenge. The challenge binds what the clientmust 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
createChallengeinsrc/auth/for no property gained. Say if you would rather it werebound anyway.
Review-required surface
src/db/**— the newjoin_requeststable,joinRequests.ts, andJoinPolicygaining avalue.
src/auth/**is untouched.src/socket/handlers/join.tsgets one branch in theexisting "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
requestserver.🤖 Generated with Claude Code