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 @@