From 0e066175b7143f548670b0519a4be9bed4c7acf3 Mon Sep 17 00:00:00 2001
From: Matthew Berman <748450+mberman84@users.noreply.github.com>
Date: Tue, 23 Jun 2026 10:16:37 -0700
Subject: [PATCH 1/2] Add GitHub-authenticated loop voting
---
AGENTS.md | 24 ++
README.md | 9 +
scripts/check.mjs | 38 ++-
site/.herenow/proxy.json | 8 +
site/agents/index.html | 4 +-
site/index.html | 6 +-
site/learn/index.html | 4 +-
site/script.js | 240 ++++++++++++++-
site/styles.css | 262 +++++++++++++++--
worker/.dev.vars.example | 4 +
worker/package.json | 2 +-
worker/src/auth-votes.js | 516 +++++++++++++++++++++++++++++++++
worker/src/index.js | 12 +
worker/src/render-loops.js | 14 +-
worker/src/vote-store.js | 146 ++++++++++
worker/test/auth-votes.test.js | 286 ++++++++++++++++++
worker/wrangler.jsonc | 8 +
17 files changed, 1531 insertions(+), 52 deletions(-)
create mode 100644 worker/src/auth-votes.js
create mode 100644 worker/src/vote-store.js
create mode 100644 worker/test/auth-votes.test.js
diff --git a/AGENTS.md b/AGENTS.md
index 22c4154..5b7d911 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -91,6 +91,30 @@ 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`.
+- 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..1c27bf7 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,15 @@ 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.
+
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..e3c4794 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-popular-ranking"));
+assert(html.includes("script.js?v=20260623-popular-ranking"));
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-popular-ranking"));
+ assert(page.includes("script.js?v=20260623-popular-ranking"));
}
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,20 @@ 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(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,10 +219,16 @@ 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/");
@@ -211,6 +239,8 @@ assert.deepEqual(Object.keys(proxyManifest.proxies).sort(), [
"/",
"/api/loops",
"/api/loops/*",
+ "/api/votes",
+ "/auth/*",
"/catalog.json",
"/catalog.md",
"/catalog.txt",
@@ -221,7 +251,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..266ea21 100644
--- a/site/agents/index.html
+++ b/site/agents/index.html
@@ -76,8 +76,8 @@
href="https://signals.forwardfuture.ai/loop-library/catalog.txt"
/>
-
-
+
+
-
+
Loop Library: Repeatable AI Agent Workflows | Forward Future
@@ -429,7 +429,7 @@