diff --git a/AGENTS.md b/AGENTS.md
index 22c4154..505736a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,6 +91,39 @@ npm run deploy
`TURNSTILE_HOSTNAMES` is a comma-separated exact allowlist containing
`signals.forwardfuture.ai` and the current backing `*.here.now` hostname.
+## Authenticated voting
+
+- Public vote totals live in the `VOTE_STORE` SQLite Durable Object. A GitHub
+ account may hold at most one vote per loop and can switch or remove it.
+- Vote writes require a valid HMAC-signed, HTTP-only session cookie plus an
+ exact trusted `Origin`. Never accept provider IDs, usernames, or voter keys
+ from browser JSON.
+- Keep OAuth state in short-lived, signed, HTTP-only cookies. Keep post-login
+ redirects on the canonical Loop Library path.
+- Do not expose OAuth client secrets or `SESSION_SECRET` in Worker variables,
+ browser code, logs, or committed development files. Configure them with:
+
+ ```bash
+ cd worker
+ npm exec -- wrangler secret put SESSION_SECRET
+ npm exec -- wrangler secret put GITHUB_OAUTH_CLIENT_ID
+ npm exec -- wrangler secret put GITHUB_OAUTH_CLIENT_SECRET
+ ```
+
+- Register this exact provider callback:
+ `https://signals.forwardfuture.ai/loop-library/auth/callback/github`.
+- Keep `VOTING_UI_ENABLED` set to the exact string `false` for the first
+ production release. Vote controls render hidden and disabled, then appear
+ only when `/api/votes` returns `uiEnabled: true`; missing or malformed values
+ must remain fail-closed.
+- With the launch flag off, verify the canonical GitHub start, callback,
+ session, vote persistence, reload, and logout flow. Change the flag to the
+ exact string `true` and redeploy the Worker from newest integrated `main`
+ only after that smoke test passes. No site republish is required to reveal
+ the controls.
+- Deploy and verify the Worker before publishing a shell or proxy manifest that
+ exposes voting or auth routes.
+
For local development, copy `worker/.dev.vars.example` to `worker/.dev.vars`,
replace the here.now development credentials, then run:
diff --git a/README.md b/README.md
index e4405fc..43e21cb 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,21 @@ python3 -m json.tool scripts/seo-geo-query-benchmark.json >/dev/null
git diff --check
```
+### Configure voting
+
+Voting is stored in a dedicated SQLite Durable Object. Reading totals is
+public, but casting, changing, or removing a vote requires a GitHub login.
+Set `SESSION_SECRET` and the GitHub OAuth client credentials as Worker
+secrets; use `worker/.dev.vars.example` for local variable names only. Register
+the canonical callbacks shown in `AGENTS.md`, then deploy the Worker before the
+site shell because the shell calls the new auth and vote routes.
+
+The production launch is fail-closed. Keep `VOTING_UI_ENABLED=false` while the
+Worker and proxy are deployed, then complete a GitHub login, session, vote,
+reload, and logout smoke test on the canonical domain. Set the value to the
+exact string `true` and redeploy only the Worker after the smoke test passes;
+the already-published site will reveal voting without another site publish.
+
Read [AGENTS.md](AGENTS.md) before editing loops or publishing the site. It
contains the source-of-truth rules for database publishing, generated
responses, form security, and clean-main deployments.
diff --git a/scripts/check.mjs b/scripts/check.mjs
index 2b39d7f..d9ad62f 100644
--- a/scripts/check.mjs
+++ b/scripts/check.mjs
@@ -19,6 +19,8 @@ const [
workerSource,
loopRoutesSource,
catalogStoreSource,
+ authVotesSource,
+ voteStoreSource,
rendererSource,
workerPackageSource,
workerLockSource,
@@ -39,6 +41,8 @@ const [
readFile(path.join(workerRoot, "src", "index.js"), "utf8"),
readFile(path.join(workerRoot, "src", "loop-routes.js"), "utf8"),
readFile(path.join(workerRoot, "src", "catalog-store.js"), "utf8"),
+ readFile(path.join(workerRoot, "src", "auth-votes.js"), "utf8"),
+ readFile(path.join(workerRoot, "src", "vote-store.js"), "utf8"),
readFile(path.join(workerRoot, "src", "render-loops.js"), "utf8"),
readFile(path.join(workerRoot, "package.json"), "utf8"),
readFile(path.join(workerRoot, "package-lock.json"), "utf8"),
@@ -121,14 +125,15 @@ for (const value of [
assert(html.includes("Search the library"));
assert(html.includes("Search by title, task, or contributor"));
assert(html.includes('class="search-field"'));
-assert(html.includes("styles.css?v=20260622-sort-focus"));
-assert(html.includes("script.js?v=20260622-sort-focus"));
+assert(html.includes("styles.css?v=20260623-auth-gate"));
+assert(html.includes("script.js?v=20260623-auth-gate"));
assert(css.includes(".search-control-label"));
assert(css.includes(".search-control:hover .search-field"));
assert(css.includes(".search-control:focus-within .search-field"));
assert.equal((html.match(/data-here-now-credit/g) || []).length, 2);
for (const page of [learnHtml, agentHtml]) {
- assert(page.includes("styles.css?v=20260622-centered-host-credit"));
+ assert(page.includes("styles.css?v=20260623-auth-gate"));
+ assert(page.includes("script.js?v=20260623-auth-gate"));
}
for (const page of [html, learnHtml, agentHtml]) {
const brandPosition = page.indexOf('class="brand-lockup"');
@@ -155,6 +160,9 @@ assert(html.includes(''));
assert(browserScript.includes('"oldest"'));
assert(browserScript.includes('sortSelect.addEventListener("change"'));
assert(browserScript.includes('params.set("sort", activeSort)'));
+assert(browserScript.includes("function comparePopular"));
+assert(browserScript.includes("Number(b.dataset.upvotes || 0)"));
+assert(html.includes(''));
assert(browserScript.includes("library-pagination"));
assert(!browserScript.includes("innerHTML"));
@@ -172,6 +180,23 @@ for (const collectionConfig of [suggestions, weeklySignups]) {
assert(workerSource.includes("TURNSTILE_RATE_LIMITER.limit"));
assert(workerSource.includes("https://challenges.cloudflare.com/turnstile/v0/siteverify"));
assert(workerSource.includes("handleLoopRoute"));
+assert(workerSource.includes("handleAuthVoteRoute"));
+assert(browserScript.includes('document.querySelectorAll("[data-vote-controls]")'));
+assert(browserScript.includes('credentials: "same-origin"'));
+assert(css.includes(".vote-controls"));
+assert(css.includes(".login-dialog"));
+assert(rendererSource.includes("renderVoteControls(loop.slug)"));
+assert(rendererSource.includes('class="vote-label"'));
+assert(rendererSource.includes('aria-label="Vote on this loop" hidden'));
+assert(browserScript.includes("setVotingUiVisible(body.uiEnabled === true)"));
+assert(css.includes(".vote-controls[hidden]"));
+assert(authVotesSource.includes('scope: "read:user"'));
+assert(!authVotesSource.includes("X_OAUTH"));
+assert(!authVotesSource.includes('"/auth/x"'));
+assert(!browserScript.includes("Continue with X"));
+assert(authVotesSource.includes("isTrustedMutationOrigin"));
+assert(voteStoreSource.includes("PRIMARY KEY (loop_slug, voter_key)"));
+assert(voteStoreSource.includes("CHECK (value IN (-1, 1))"));
// Publishing, backup, rendering, and activation all terminate at the database.
assert(loopRoutesSource.includes('"/admin/loops/export"'));
@@ -197,20 +222,29 @@ assert.equal(wrangler.workers_dev, true);
assert.equal(wrangler.routes, undefined);
assert.equal(wrangler.durable_objects.bindings[1].name, "LOOP_CATALOG");
assert.equal(wrangler.durable_objects.bindings[1].class_name, "LoopCatalog");
+assert.equal(wrangler.durable_objects.bindings[2].name, "VOTE_STORE");
+assert.equal(wrangler.durable_objects.bindings[2].class_name, "VoteStore");
assert.deepEqual(wrangler.migrations[1], {
tag: "v2",
new_sqlite_classes: ["LoopCatalog"],
});
+assert.deepEqual(wrangler.migrations[2], {
+ tag: "v3",
+ new_sqlite_classes: ["VoteStore"],
+});
assert.match(wrangler.vars.BOOTSTRAP_CATALOG_DIGEST, /^[a-f0-9]{64}$/);
assert.equal(wrangler.vars.BOOTSTRAP_LOOP_COUNT, "50");
assert.equal(wrangler.vars.PUBLIC_ORIGIN_URL, "https://calm-mortar-jtek.here.now/");
assert.equal(wrangler.vars.PUBLIC_SHELL_URL, "https://calm-mortar-jtek.here.now/index.html");
assert.equal(wrangler.vars.PUBLIC_SITE_HOSTNAME, "signals.forwardfuture.ai");
assert.equal(wrangler.vars.PUBLIC_SITE_PATH, "/loop-library");
+assert.equal(wrangler.vars.VOTING_UI_ENABLED, "false");
assert.deepEqual(Object.keys(proxyManifest.proxies).sort(), [
"/",
"/api/loops",
"/api/loops/*",
+ "/api/votes",
+ "/auth/*",
"/catalog.json",
"/catalog.md",
"/catalog.txt",
@@ -221,7 +255,7 @@ assert.deepEqual(Object.keys(proxyManifest.proxies).sort(), [
]);
for (const proxy of Object.values(proxyManifest.proxies)) {
assert.match(proxy.upstream, /^https:\/\/loop-library-forms\.mberman84\.workers\.dev\/loop-library(?:\/|$)/);
- assert.equal(proxy.rateLimit, "600/hour/ip");
+ assert(["120/hour/ip", "600/hour/ip"].includes(proxy.rateLimit));
}
assert.match(skillSource, /The live catalog is the\s+source of truth/);
diff --git a/site/.herenow/proxy.json b/site/.herenow/proxy.json
index 3deba99..0b3c978 100644
--- a/site/.herenow/proxy.json
+++ b/site/.herenow/proxy.json
@@ -39,6 +39,14 @@
"/api/loops/*": {
"upstream": "https://loop-library-forms.mberman84.workers.dev/loop-library/api/loops",
"rateLimit": "600/hour/ip"
+ },
+ "/api/votes": {
+ "upstream": "https://loop-library-forms.mberman84.workers.dev/loop-library/api/votes",
+ "rateLimit": "600/hour/ip"
+ },
+ "/auth/*": {
+ "upstream": "https://loop-library-forms.mberman84.workers.dev/loop-library/auth",
+ "rateLimit": "120/hour/ip"
}
}
}
diff --git a/site/agents/index.html b/site/agents/index.html
index 63c30aa..5decd87 100644
--- a/site/agents/index.html
+++ b/site/agents/index.html
@@ -76,8 +76,8 @@
href="https://signals.forwardfuture.ai/loop-library/catalog.txt"
/>
-
-
+
+
-
+