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" /> - - + + - + Loop Library: Repeatable AI Agent Workflows | Forward Future @@ -429,7 +429,7 @@