Open registration to Discord guild members (Step 2: multi-Reader) #19
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Blocked by: #18
Problem Statement
After Step 1 (#18) the system is structurally multi-Reader — rows are owned, identity comes from Discord, Series are shared and polled once — but exactly one person can actually get in. Registration is locked to the owner's Discord ID, so a community member who clicks "Login with Discord" is turned away even when they are in the guild.
The owner wants to publish the app to their Discord community. Right now they cannot, and the only thing standing in the way is a deliberate lock left in place so the data migration could be proven safely first.
Two smaller things are also left over from Step 1 and are honest debt rather than features: the 14-day compatibility path that let already-installed userscripts keep working across the cutover is still in the code after its window has expired, and
PRODUCT.mdstill describes the app as "Single user (self-hosted, no accounts, no multi-user planned)" — a sentence that is false in all three of its clauses.Solution
Remove the owner-only restriction so that any member of the configured Discord guild becomes a Reader on their first successful login, with an empty library and their own userscript install links waiting for them. Then remove the expired grace path and correct the product documentation.
The Reader who arrives second is the first real test of everything Step 1 built: their Bookmarks must be invisible to the owner and vice versa, and when they bookmark a Series the owner already tracks, they must attach to the existing shared Series rather than creating a second one — which also means that Series is still polled exactly once.
User Stories
PRODUCT.mdto describe the product that actually exists, so that the next person reading it is not misled about a fundamental constraint.Implementation Decisions
Depends on everything landed in #18. The ADRs governing this work are ADR-0002 (Discord OAuth, guild membership as the gate) and ADR-0003 (shared, Poll-owned Series). Domain vocabulary comes from
CONTEXT.md.Registration becomes implicit. There is no registration endpoint and no signup form. A successful Discord login for a Discord user ID that has no Reader creates one, with a freshly generated userscript token, and issues a session exactly as a returning Reader's login does. First login and every later login are the same code path with one branch.
The owner-only restriction is removed. The configuration value naming the owner's Discord ID remains meaningful only during the Step 1 data migration; after this change it no longer gates login. Whether it is retired entirely or kept purely as migration provenance is an implementation judgement — it must not remain as an authorization check.
Guild membership remains the only gate, checked at login, against the single configured guild, using the narrow endpoint that answers membership in that one guild. The optional required-role configuration added in Step 1 stays empty by default; when set, a member lacking the role is refused exactly as a non-member is.
Rejection is a clear refusal, not an error. A non-member reaching the callback gets an explanatory page telling them they must be in the community server. It must not leak whether the guild exists, expose the guild ID, or render any Discord-supplied string as markup.
New Readers land in an empty state, not a broken one. A Reader with no Bookmarks sees a deliberate empty library that explains the next step and offers both userscript install links. This is the first screen every new community member sees. It follows the Cinder design system: no cards, no corners, no shadows, the single measure column, tokens only, both colour branches touched together, and
--emberreserved strictly for the New Chapter signal — an empty state is not a New Chapter and must not borrow the accent.Shared Series attachment is already implemented by the Step 1 schema and needs no new code — a second Reader bookmarking an existing Series inserts only a Bookmark row. What this step adds is the proof: it is the first time the path runs with more than one Reader, and it is where the schema's guarantees stop being theoretical.
Session revocation is a delete against the sessions table. A minimal owner-facing action is in scope; a general admin console is not.
Grace path removal. The compatibility route that resolved the retired global API token to the owner's Reader is deleted along with its configuration and tests. This is a clean cutover, consistent with the project's no-shims default — the 14-day window was time-boxed precisely so it would not become permanent.
Documentation.
PRODUCT.md's single-user constraint is rewritten to describe a Discord-gated, multi-Reader, self-hosted service. The deployment runbook and environment contract are updated for any variable retired here.Testing Decisions
What makes a good test here. The same standard as #18: assert externally observable behaviour — status codes, response bodies, and what a subsequent request sees — never internal calls or private structure. Every test must be able to fail on a plausible bug. No test touches the real network.
This step's tests are unusual in that most of them are two-Reader tests. A single-Reader test cannot fail on the bugs this change risks; isolation and sharing are only observable when a second Reader exists. Any test here that uses one Reader is probably testing the wrong thing.
Primary seam — the HTTP router, the same root-package seam used throughout #18, driving the full router over an in-process HTTP server against the stubbed Discord. It should carry:
Secondary seam — the store's public API, only for what the router cannot observe: that poll-queue ordering actually shifts when a Series gains Readers, and that a Series is dropped from the queue only when its last Bookmark is deleted rather than its first.
Third seam — the poller's fetcher interface, unchanged and reused: a Series held by several Readers is fetched exactly once per due cycle.
Prior art. Everything established in #18: the root-package integration harness and its server construction, the stubbed Discord reached through the configured base URL, the containerised Postgres with a per-test database, and the poller's fake fetcher with a frozen clock. This step should add no new seams at all — if one seems necessary, that is a signal the work has drifted beyond its scope.
Out of Scope
Further Notes
This step is deliberately small, and that is the payoff from splitting the work rather than a sign it was under-specified. Almost everything lands in #18 while the owner is the only person who can be broken by it; what remains here is opening a gate and cleaning up two pieces of scaffolding. If this spec starts growing, the likely cause is that something belonging to #18 was deferred into it.
The single riskiest area is Reader isolation, because its failure mode is silent. A bug that leaks one Reader's library into another's does not throw, does not log, and looks like working software until somebody notices a series they never bookmarked. That is why the isolation tests are two-Reader and assert from both directions rather than checking one Reader sees "the right count".
Worth watching after launch rather than designing for now: the poll-priority weighting only starts doing visible work once Readers overlap on popular Series, so the long tail may look neglected early on with few Readers. That is expected and self-correcting as the community grows — it is not evidence the budget needs raising.
Every child is closed: #27 (open registration, grace-path removal, PRODUCT.md) merged as PR #36. Step 2 is complete in the repo; what remains is redeploying main, which is operations rather than spec. Closing.