Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 38 additions & 4 deletions scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const [
workerSource,
loopRoutesSource,
catalogStoreSource,
authVotesSource,
voteStoreSource,
rendererSource,
workerPackageSource,
workerLockSource,
Expand All @@ -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"),
Expand Down Expand Up @@ -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"');
Expand All @@ -155,6 +160,9 @@ assert(html.includes('<option value="oldest">Oldest → newest</option>'));
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('<option value="featured">Featured, then popular</option>'));
assert(browserScript.includes("library-pagination"));
assert(!browserScript.includes("innerHTML"));

Expand All @@ -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"'));
Expand All @@ -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",
Expand All @@ -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/);
Expand Down
8 changes: 8 additions & 0 deletions site/.herenow/proxy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
4 changes: 2 additions & 2 deletions site/agents/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@
href="https://signals.forwardfuture.ai/loop-library/catalog.txt"
/>
<link rel="icon" type="image/png" href="../assets/favicon.png" />
<link rel="stylesheet" href="../styles.css?v=20260622-centered-host-credit" />
<script src="../script.js?v=20260622-url-state" defer></script>
<link rel="stylesheet" href="../styles.css?v=20260623-auth-gate" />
<script src="../script.js?v=20260623-auth-gate" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
6 changes: 3 additions & 3 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
href="https://signals.forwardfuture.ai/loop-library/agents/"
/>
<link rel="icon" type="image/png" href="./assets/favicon.png" />
<link rel="stylesheet" href="./styles.css?v=20260622-sort-focus" />
<link rel="stylesheet" href="./styles.css?v=20260623-auth-gate" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down Expand Up @@ -198,7 +198,7 @@
]
}
</script>
<script src="./script.js?v=20260622-sort-focus" defer></script>
<script src="./script.js?v=20260623-auth-gate" defer></script>
<title>Loop Library: Repeatable AI Agent Workflows | Forward Future</title>
</head>
<body>
Expand Down Expand Up @@ -429,7 +429,7 @@ <h2 id="agent-skill-title">
<label class="sort-control" for="loop-sort">
<span>Sort</span>
<select id="loop-sort">
<option value="featured">Featured first</option>
<option value="featured">Featured, then popular</option>
<option value="newest">Newest → oldest</option>
<option value="oldest">Oldest → newest</option>
<option value="alphabetical">A–Z</option>
Expand Down
4 changes: 2 additions & 2 deletions site/learn/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@
href="https://signals.forwardfuture.ai/loop-library/agents/"
/>
<link rel="icon" type="image/png" href="../assets/favicon.png" />
<link rel="stylesheet" href="../styles.css?v=20260622-centered-host-credit" />
<script src="../script.js?v=20260622-url-state" defer></script>
<link rel="stylesheet" href="../styles.css?v=20260623-auth-gate" />
<script src="../script.js?v=20260623-auth-gate" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
Loading
Loading