Open registration to Discord guild members (Step 2: multi-Reader) #19

Closed
opened 2026-08-08 06:00:41 +07:00 by sulthan · 1 comment
Owner

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.md still 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

  1. As a community member, I want to log in with Discord and have an account created for me automatically, so that there is no signup form, invite code, or waiting for approval.
  2. As a community member, I want my first login to work the same way as every subsequent login, so that there is no separate "register" path to discover.
  3. As a community member who is not in the guild, I want to be told clearly that I need to be in the server, so that I understand why I was refused rather than assuming the app is broken.
  4. As a community member, I want to see an empty library the first time I log in rather than an error or someone else's data, so that I know the app is working and waiting for me.
  5. As a new Reader, I want the empty library to tell me what to do next, so that I am not staring at a blank page wondering whether it loaded.
  6. As a new Reader, I want my userscript install links available immediately on first login, so that I can start tracking within a minute of arriving.
  7. As a new Reader, I want the userscript I install to carry my own credential, so that my reading progress is recorded against me and not against whoever set the app up.
  8. As a new Reader, I want to bookmark a series the owner already tracks and get its title, cover and Latest Chapter straight away, so that I do not wait for a Poll to see a populated row.
  9. As a new Reader, I want my reading position on a shared series to start at zero regardless of how far anyone else has read, so that another Reader's progress is not mistaken for mine.
  10. As a Reader, I want my Progress, Favourites and Lifecycle buckets to be invisible to every other Reader, so that my library is private to me.
  11. As a Reader, I want to be unable to read or modify another Reader's Bookmarks even by guessing a key, so that privacy is enforced by the server rather than by the UI hiding things.
  12. As the owner, I want a series tracked by many Readers to still be polled exactly once, so that adding Readers does not multiply outbound traffic.
  13. As the owner, I want a series' poll priority to rise as more Readers bookmark it, so that the most-read series stay the freshest.
  14. As the owner, I want the poll budget to be unaffected by the number of Readers, so that opening registration cannot get the VPS bot-scored.
  15. As a Reader, I want a series that another Reader removes to keep working for me, so that one person's cleanup does not affect anyone else's library.
  16. As the owner, I want a Series to stop being polled only when the last Reader tracking it removes it, so that shared series stay fresh while anyone still cares.
  17. As the owner, I want to require a specific Discord role rather than plain guild membership if I choose to, so that I can restrict access further without a code change.
  18. As the owner, I want role restriction to stay off by default, so that opening registration does not require configuring anything extra.
  19. As the owner, I want to revoke a specific Reader's session, so that someone who has left the community can be cut off immediately rather than waiting for expiry.
  20. As the owner, I want to see how many Readers exist, so that I know whether the poll-priority weighting is doing anything and whether growth matches expectations.
  21. As the owner, I want the expired grace path removed once its window has passed, so that no unauthenticated-by-Reader route survives into the multi-Reader era.
  22. As the owner, I want the retired global API token configuration removed rather than left inert, so that the deployment contract does not describe a secret that no longer does anything.
  23. As a maintainer, I want PRODUCT.md to describe the product that actually exists, so that the next person reading it is not misled about a fundamental constraint.
  24. As a maintainer, I want the multi-Reader isolation guarantees covered by tests, so that a future refactor cannot quietly leak one Reader's library into another's.
  25. As a maintainer, I want the shared-Series behaviour tested with more than one Reader, so that the deduplication that justified the schema split is actually proven.
  26. As a maintainer, I want registration itself tested, so that the difference between "in the guild" and "not in the guild" is enforced by code rather than by assumption.

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 --ember reserved 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:

  • A guild member with no existing Reader logs in and a Reader is created; the same person logging in again reuses it rather than creating a second.
  • A non-member is refused, and no Reader is created as a side effect of the refusal.
  • With the required-role configuration set, a member without the role is refused; with it empty, any member passes.
  • Two Readers each with Bookmarks: neither can read the other's list, and neither can modify or delete the other's Bookmark by addressing its key directly.
  • Two Readers bookmarking the same Series: both succeed, both see correct metadata, and their Progress values are independent.
  • A second Reader bookmarking a Series the first already tracks creates no second Series.
  • One Reader deleting their Bookmark leaves the other Reader's Bookmark and the shared Series intact.
  • A brand-new Reader's library renders its empty state rather than erroring.
  • A new Reader's rendered userscript carries their own token, not the owner's.
  • A revoked session is rejected on the next request.
  • The retired grace-window token is refused after removal.

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

  • Any schema change. If this step needs one, something was missed in #18 and belongs there instead.
  • Self-serve account deletion. A genuine expectation for a service holding other people's data, and the cascading foreign keys from #18 make it a small later addition — but it is not required to open registration.
  • An admin console. Session revocation is a single targeted action, not a management UI.
  • Continuous guild-membership enforcement. Membership is still checked only at login; someone who leaves keeps their session until it is revoked or expires. Re-checking on every request would put Discord in the path of every page load.
  • Rate limiting or abuse controls beyond the guild gate. Guild membership is the trust boundary; hardening past it is separate work driven by evidence.
  • Email, passwords, and password reset. Still none, per ADR-0002.
  • Raising the poller's throughput. Unchanged from #18 — prioritisation within the existing budget only.
  • Per-device userscript tokens. Still one per Reader.
  • Onboarding beyond the empty state. No tours, no tooltips, no welcome email — there is no mailer.

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.

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.md` still 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 1. As a community member, I want to log in with Discord and have an account created for me automatically, so that there is no signup form, invite code, or waiting for approval. 2. As a community member, I want my first login to work the same way as every subsequent login, so that there is no separate "register" path to discover. 3. As a community member who is not in the guild, I want to be told clearly that I need to be in the server, so that I understand why I was refused rather than assuming the app is broken. 4. As a community member, I want to see an empty library the first time I log in rather than an error or someone else's data, so that I know the app is working and waiting for me. 5. As a new Reader, I want the empty library to tell me what to do next, so that I am not staring at a blank page wondering whether it loaded. 6. As a new Reader, I want my userscript install links available immediately on first login, so that I can start tracking within a minute of arriving. 7. As a new Reader, I want the userscript I install to carry my own credential, so that my reading progress is recorded against me and not against whoever set the app up. 8. As a new Reader, I want to bookmark a series the owner already tracks and get its title, cover and Latest Chapter straight away, so that I do not wait for a Poll to see a populated row. 9. As a new Reader, I want my reading position on a shared series to start at zero regardless of how far anyone else has read, so that another Reader's progress is not mistaken for mine. 10. As a Reader, I want my Progress, Favourites and Lifecycle buckets to be invisible to every other Reader, so that my library is private to me. 11. As a Reader, I want to be unable to read or modify another Reader's Bookmarks even by guessing a key, so that privacy is enforced by the server rather than by the UI hiding things. 12. As the owner, I want a series tracked by many Readers to still be polled exactly once, so that adding Readers does not multiply outbound traffic. 13. As the owner, I want a series' poll priority to rise as more Readers bookmark it, so that the most-read series stay the freshest. 14. As the owner, I want the poll budget to be unaffected by the number of Readers, so that opening registration cannot get the VPS bot-scored. 15. As a Reader, I want a series that another Reader removes to keep working for me, so that one person's cleanup does not affect anyone else's library. 16. As the owner, I want a Series to stop being polled only when the last Reader tracking it removes it, so that shared series stay fresh while anyone still cares. 17. As the owner, I want to require a specific Discord role rather than plain guild membership if I choose to, so that I can restrict access further without a code change. 18. As the owner, I want role restriction to stay off by default, so that opening registration does not require configuring anything extra. 19. As the owner, I want to revoke a specific Reader's session, so that someone who has left the community can be cut off immediately rather than waiting for expiry. 20. As the owner, I want to see how many Readers exist, so that I know whether the poll-priority weighting is doing anything and whether growth matches expectations. 21. As the owner, I want the expired grace path removed once its window has passed, so that no unauthenticated-by-Reader route survives into the multi-Reader era. 22. As the owner, I want the retired global API token configuration removed rather than left inert, so that the deployment contract does not describe a secret that no longer does anything. 23. As a maintainer, I want `PRODUCT.md` to describe the product that actually exists, so that the next person reading it is not misled about a fundamental constraint. 24. As a maintainer, I want the multi-Reader isolation guarantees covered by tests, so that a future refactor cannot quietly leak one Reader's library into another's. 25. As a maintainer, I want the shared-Series behaviour tested with more than one Reader, so that the deduplication that justified the schema split is actually proven. 26. As a maintainer, I want registration itself tested, so that the difference between "in the guild" and "not in the guild" is enforced by code rather than by assumption. ## 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 `--ember` reserved 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: - A guild member with no existing Reader logs in and a Reader is created; the same person logging in again reuses it rather than creating a second. - A non-member is refused, and no Reader is created as a side effect of the refusal. - With the required-role configuration set, a member without the role is refused; with it empty, any member passes. - Two Readers each with Bookmarks: neither can read the other's list, and neither can modify or delete the other's Bookmark by addressing its key directly. - Two Readers bookmarking the same Series: both succeed, both see correct metadata, and their Progress values are independent. - A second Reader bookmarking a Series the first already tracks creates no second Series. - One Reader deleting their Bookmark leaves the other Reader's Bookmark and the shared Series intact. - A brand-new Reader's library renders its empty state rather than erroring. - A new Reader's rendered userscript carries their own token, not the owner's. - A revoked session is rejected on the next request. - The retired grace-window token is refused after removal. **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 - **Any schema change.** If this step needs one, something was missed in #18 and belongs there instead. - **Self-serve account deletion.** A genuine expectation for a service holding other people's data, and the cascading foreign keys from #18 make it a small later addition — but it is not required to open registration. - **An admin console.** Session revocation is a single targeted action, not a management UI. - **Continuous guild-membership enforcement.** Membership is still checked only at login; someone who leaves keeps their session until it is revoked or expires. Re-checking on every request would put Discord in the path of every page load. - **Rate limiting or abuse controls beyond the guild gate.** Guild membership is the trust boundary; hardening past it is separate work driven by evidence. - **Email, passwords, and password reset.** Still none, per ADR-0002. - **Raising the poller's throughput.** Unchanged from #18 — prioritisation within the existing budget only. - **Per-device userscript tokens.** Still one per Reader. - **Onboarding beyond the empty state.** No tours, no tooltips, no welcome email — there is no mailer. ## 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.
sulthan added the ready-for-agent label 2026-08-08 06:00:41 +07:00
Author
Owner

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.

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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#19