Closes #24. Child of #18; based on current main (includes Postgres, Reader table, Discord OAuth).
## What
Each Reader's userscript credential is derived from `TOKEN_KEY`, their Discord id and a token epoch (HMAC-SHA256, hex); only its SHA-256 sits in `readers.token_sha256` (new `token_epoch` column, migration 0006). One credential authenticates the script download path and the API bearer header.
- `internal/token`: derivation + hashing; the seed refreshes the owner's epoch-0 hash only before first rotation, so a restart can never resurrect a rotated-away credential
- `httpmw.Auth`/`ResolveReader`: acting Reader resolved from the credential hash, stashed in request context; the retired global `API_TOKEN` resolves to the owner until `API_TOKEN_GRACE_UNTIL` (enforced in code, logged per use) on both the bearer and script-download paths
- Userscript handler renders the bindmounted file with the resolved Reader's credential substituted for `__API_TOKEN__`; a legacy-path request during grace serves the derived credential, so installed devices self-migrate on their next update poll
- Web UI: "Userscripts" panel — session-gated install endpoints render the script directly (credential never in markup, address bar, or a redirect), confirm-gated rotation with an atomic epoch bump + hash rewrite and a reinstall warning
- Both userscripts carry `__API_TOKEN__` placeholders; the committed global-token literal is removed
## Design note
Credentials are derived rather than stored-random because the server must rebuild install URLs after restarts while the DB holds only hashes. HMAC output is high-entropy and unbrute-forceable; the AC's intent (unguessable, DB-leak-proof) is met.
## Deploy (also in DEPLOY.md)
1. Add `TOKEN_KEY` (`openssl rand -hex 32`) — required; changing it later invalidates every credential.
2. Keep `API_TOKEN` + set `API_TOKEN_GRACE_UNTIL` for the 14-day window.
3. After deploy, sign in → Userscripts → reinstall both scripts on every device. This also retires the old global credential for real — its literal survives in git history (present since 0ef5286), so rotation is what kills it.
## Verification
- Full Go suite green against real Postgres per test; userscript JS suite 45/45
- New router-level tests: per-Reader isolation (read/write/delete), grace expiry on bearer + script path, self-migrating legacy path, install serving, rotation (old cred 401/404, new cred works, install renders new credential), app page leaks no credential
- Store tests: hash lookup, token info, atomic rotation with stale-epoch rejection, rotation survives restart
- Live smoke of the built binary: grace acceptance logged, derived auth, substitution, restart resilience, stored hash = SHA-256 of derived credential
Reviewed-on: #32
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #32.
This commit is contained in:
@@ -243,6 +243,53 @@ button { cursor: pointer; }
|
||||
/* The label is 15px tall by design; the thumb gets 44 without moving it. */
|
||||
.ghost::after { content: ""; position: absolute; inset: -15px -12px; }
|
||||
|
||||
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
|
||||
.setup {
|
||||
margin: 0 20px;
|
||||
padding: 12px 0 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
color: var(--mute);
|
||||
}
|
||||
.setup summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
font: 500 10px/1 var(--font-mono);
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.setup summary::-webkit-details-marker { display: none; }
|
||||
.setup summary:hover { color: var(--paper); }
|
||||
.setup[open] { padding-bottom: 16px; }
|
||||
.setup-copy {
|
||||
margin: 0;
|
||||
padding: 4px 0 12px;
|
||||
font: 14px/1.55 var(--font-body);
|
||||
color: var(--mute);
|
||||
}
|
||||
.setup-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.setup-links .ghost { font-size: 11px; }
|
||||
.setup-rotate { margin: 0; }
|
||||
/* Rotation confirmation: the one hot state the panel wears, and it is
|
||||
destruction, not new-chapter signal — danger, never ember. */
|
||||
.setup-warn {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
font: 500 12px/1.5 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.chrome { display: flex; flex-direction: column; }
|
||||
|
||||
.searchbar {
|
||||
|
||||
Reference in New Issue
Block a user