27cf0955de
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>
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package userscript
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
|
// @version line, the credential placeholder in its metadata and body, plus
|
|
// content that must survive the rewrites untouched.
|
|
const sampleScript = `// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @version 1.5.0
|
|
// @downloadURL https://api.example/u/__API_TOKEN__/manga-bookmark.user.js
|
|
// @match https://asurascans.com/*
|
|
// ==/UserScript==
|
|
(function () { "use strict";
|
|
const API_TOKEN = "__API_TOKEN__";
|
|
})();
|
|
`
|
|
|
|
func TestStampVersionReplacesVersionLineOnly(t *testing.T) {
|
|
mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC)
|
|
got := string(stampVersion([]byte(sampleScript), mod))
|
|
|
|
if !strings.Contains(got, "// @version "+mod.UTC().Format("2006.01.02.1504")) {
|
|
t.Errorf("body has no stamped version:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "1.5.0") {
|
|
t.Errorf("body still carries the file's own version:\n%s", got)
|
|
}
|
|
// Everything outside the @version line is served verbatim, including the
|
|
// placeholder — stamping must not do the substitution's job.
|
|
if !strings.Contains(got, `const API_TOKEN = "__API_TOKEN__";`) {
|
|
t.Errorf("body was altered beyond the version line:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestStampVersionWithoutVersionLineServedUnmodified(t *testing.T) {
|
|
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
|
if got := string(stampVersion([]byte(noVersion), time.Now())); got != noVersion {
|
|
t.Errorf("stampVersion altered a file with no @version line:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestSubstituteTokenReplacesEveryPlaceholder(t *testing.T) {
|
|
got := string(substituteToken([]byte(sampleScript), "abc123"))
|
|
|
|
if strings.Contains(got, "__API_TOKEN__") {
|
|
t.Errorf("placeholder survived substitution:\n%s", got)
|
|
}
|
|
// The credential lands in the constant and in both metadata lines.
|
|
if want := `const API_TOKEN = "abc123";`; !strings.Contains(got, want) {
|
|
t.Errorf("no substituted constant %q:\n%s", want, got)
|
|
}
|
|
if want := "https://api.example/u/abc123/manga-bookmark.user.js"; !strings.Contains(got, want) {
|
|
t.Errorf("no substituted download URL %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
func TestSubstituteTokenWithoutPlaceholderServedUnmodified(t *testing.T) {
|
|
const noPlaceholder = "// ==UserScript==\n// @name x\n// ==/UserScript==\n"
|
|
if got := string(substituteToken([]byte(noPlaceholder), "abc123")); got != noPlaceholder {
|
|
t.Errorf("substituteToken altered a file without the placeholder:\n%s", got)
|
|
}
|
|
}
|