Open registration to guild members (#27) (#36)

Closes #27.

Guild membership is now the whole gate. `discordCallback` checks membership
(and `DISCORD_REQUIRED_ROLE` when set), then `Store.EnsureReader` creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before `EnsureReader`, so a turned-away sign-in leaves no row
behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the
administrator — it no longer gates login.

The cutover grace path goes with it: `API_TOKEN`, `API_TOKEN_GRACE_UNTIL` and
the legacy branch in `httpmw.ResolveReader` are deleted, so a credential
authenticates exactly one Reader or nothing. `userscript.Handler` drops its
re-derivation too — the resolved path segment is already the credential.

New surfaces: an empty library offers both install links (behind the
tab-specific empty states, so "No favourites yet" still wins), and the owner
alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own
row is not revocable — 404, not a self-logout.

Isolation is asserted from both directions for read, modify and delete, and
the shared-series invariant is pinned: two Readers on one series produce one
series row, two independent progresses, one poll per due cycle, and one
Reader's delete leaves the other's bookmark and the poll intact.

Verified: `go test ./...` green; live smoke against a throwaway Postgres —
empty-library state in both colour branches, roster rendering, a real revoke
through the panel (target 401s next request, owner untouched), owner
self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with
404 for an unknown credential.

Reviewed-on: #36
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-08-08 20:23:17 +07:00
committed by sulthan
parent c2b47eb05b
commit 2ef769d421
25 changed files with 850 additions and 367 deletions
+6 -18
View File
@@ -10,7 +10,6 @@ import (
"bookmarkmanager/backend/internal/httpmw"
"bookmarkmanager/backend/internal/store"
"bookmarkmanager/backend/internal/token"
)
// tokenPlaceholder is what the bindmounted userscript carries where the
@@ -87,26 +86,15 @@ func Render(w http.ResponseWriter, r *http.Request, path, credential string) {
// the route exists. The same credential authenticates the API bearer header,
// so the two are one secret with one blast radius.
//
// The credential substituted is the resolved Reader's derived one, not the
// raw path segment: while the retired global token is still accepted during
// the grace window (httpmw.ResolveReader), an already-installed script
// polling its legacy URL is served a copy carrying the Reader's own
// credential, so the next update poll migrates the device onto its per-Reader
// path — the window empties itself instead of ending in a silent 401 for
// every device that never visited the web UI.
func Handler(s *store.Store, tokenKey []byte, legacy string, graceUntil time.Time, path string) http.HandlerFunc {
// The path segment is the credential itself, so once it resolves it is also
// exactly what the served copy must carry — no re-derivation needed.
func Handler(s *store.Store, path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
readerID, ok := httpmw.ResolveReader(s, legacy, graceUntil, r.PathValue("token"))
if !ok {
cred := r.PathValue("token")
if _, ok := httpmw.ResolveReader(s, cred); !ok {
http.NotFound(w, r)
return
}
discordID, epoch, err := s.ReaderTokenInfo(readerID)
if err != nil {
log.Printf("userscript: reader %d token info: %v", readerID, err)
http.NotFound(w, r)
return
}
Render(w, r, path, token.Token(tokenKey, discordID, epoch))
Render(w, r, path, cred)
}
}