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
23 changes: 16 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,19 @@ npm run deploy

- 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.
- The here.now proxy strips browser cookies and mutation `Origin` headers, and
it follows upstream redirects. Do not build voting auth around proxied
cookies, HTTP redirect responses, or forwarded authorization headers.
- Start OAuth with a browser-generated nonce held in `sessionStorage`. Bind the
nonce and safe return path into a short-lived HMAC-signed OAuth state value.
The callback must return a no-store HTML bridge that verifies the stored
nonce before saving the signed session token and returning to the canonical
Loop Library path.
- Keep the signed session token in tab-scoped `sessionStorage` and send it only
in JSON bodies to the session and vote endpoints. Vote writes must derive the
provider ID, username, and voter key exclusively from that verified token.
Reject explicit untrusted Origins; missing Origins are expected through the
here.now proxy and remain protected by the required bearer token.
- Do not expose OAuth client secrets or `SESSION_SECRET` in Worker variables,
browser code, logs, or committed development files. Configure them with:

Expand All @@ -116,8 +124,9 @@ npm run deploy
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
- With the launch flag off, verify the canonical GitHub start, nonce-bound
callback bridge, session, vote persistence, reload, and local 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.
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,16 @@ 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 here.now proxy does not forward browser cookies or mutation Origin headers
and follows upstream redirects. The OAuth flow therefore uses an HMAC-signed,
browser-nonce-bound state value and a no-store callback bridge. The bridge saves
the signed session token in tab-scoped `sessionStorage`; session lookup and vote
writes send it only inside same-origin JSON request bodies.

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
Worker and proxy are deployed, then complete a GitHub login, nonce-bound
callback, 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.

Expand Down
15 changes: 11 additions & 4 deletions scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +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=20260623-auth-gate"));
assert(html.includes("script.js?v=20260623-auth-gate"));
assert(html.includes("styles.css?v=20260623-proxy-auth"));
assert(html.includes("script.js?v=20260623-proxy-auth"));
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=20260623-auth-gate"));
assert(page.includes("script.js?v=20260623-auth-gate"));
assert(page.includes("styles.css?v=20260623-proxy-auth"));
assert(page.includes("script.js?v=20260623-proxy-auth"));
}
for (const page of [html, learnHtml, agentHtml]) {
const brandPosition = page.indexOf('class="brand-lockup"');
Expand Down Expand Up @@ -191,6 +191,13 @@ 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("function authBridge"));
assert(authVotesSource.includes("readSignedValue(state"));
assert(browserScript.includes('window.sessionStorage.setItem(OAUTH_NONCE_KEY'));
assert(browserScript.includes('window.sessionStorage.getItem(VOTE_SESSION_KEY)'));
assert(browserScript.includes('url.searchParams.set("client_nonce", nonce)'));
assert(browserScript.includes("sessionToken: readVoteSessionToken()"));
assert(!authVotesSource.includes("Set-Cookie"));
assert(!authVotesSource.includes("X_OAUTH"));
assert(!authVotesSource.includes('"/auth/x"'));
assert(!browserScript.includes("Continue with X"));
Expand Down
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=20260623-auth-gate" />
<script src="../script.js?v=20260623-auth-gate" defer></script>
<link rel="stylesheet" href="../styles.css?v=20260623-proxy-auth" />
<script src="../script.js?v=20260623-proxy-auth" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
4 changes: 2 additions & 2 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=20260623-auth-gate" />
<link rel="stylesheet" href="./styles.css?v=20260623-proxy-auth" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down Expand Up @@ -198,7 +198,7 @@
]
}
</script>
<script src="./script.js?v=20260623-auth-gate" defer></script>
<script src="./script.js?v=20260623-proxy-auth" defer></script>
<title>Loop Library: Repeatable AI Agent Workflows | Forward Future</title>
</head>
<body>
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=20260623-auth-gate" />
<script src="../script.js?v=20260623-auth-gate" defer></script>
<link rel="stylesheet" href="../styles.css?v=20260623-proxy-auth" />
<script src="../script.js?v=20260623-proxy-auth" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
115 changes: 103 additions & 12 deletions site/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,8 @@ const LOOP_LIBRARY_PATH = window.location.pathname === "/loop-library" ||
? "/loop-library"
: "";
const VOTE_API_URL = `${LOOP_LIBRARY_PATH}/api/votes`;
const VOTE_SESSION_KEY = "ll_session";
const OAUTH_NONCE_KEY = "ll_oauth_nonce";
let voteViewer = null;
let viewerVotes = {};
let loginDialog;
Expand All @@ -617,6 +619,59 @@ function voteLoginUrl(provider) {
})}`;
}

function readVoteSessionToken() {
try {
return window.sessionStorage.getItem(VOTE_SESSION_KEY) || "";
} catch {
return "";
}
}

function clearVoteSessionToken() {
try {
window.sessionStorage.removeItem(VOTE_SESSION_KEY);
} catch {
// Storage may be unavailable in hardened browser modes.
}
}

function oauthNonce() {
const bytes = new Uint8Array(32);
window.crypto.getRandomValues(bytes);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return window.btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

async function beginGithubLogin(link) {
const nonce = oauthNonce();
try {
window.sessionStorage.setItem(OAUTH_NONCE_KEY, nonce);
const url = new URL(link.href, window.location.href);
url.searchParams.set("client_nonce", nonce);
const response = await fetch(url, {
credentials: "same-origin",
headers: { Accept: "application/json" },
});
const body = await response.json();
if (!response.ok || !body.authorizationUrl) {
throw new Error(body.error || "GitHub sign-in is unavailable.");
}
window.location.assign(body.authorizationUrl);
} catch (error) {
try {
window.sessionStorage.removeItem(OAUTH_NONCE_KEY);
} catch {
// Storage may be unavailable in hardened browser modes.
}
link.removeAttribute("aria-disabled");
showToast(error.message || "GitHub sign-in is unavailable.");
}
}

function createLoginDialog() {
if (loginDialog) {
return loginDialog;
Expand Down Expand Up @@ -644,6 +699,12 @@ function createLoginDialog() {
githubLink.className = "login-provider login-provider-github";
githubLink.href = voteLoginUrl("github");
githubLink.textContent = "Continue with GitHub";
githubLink.addEventListener("click", (event) => {
event.preventDefault();
if (githubLink.getAttribute("aria-disabled") === "true") return;
githubLink.setAttribute("aria-disabled", "true");
beginGithubLogin(githubLink);
});
providers.append(githubLink);

const cancel = document.createElement("button");
Expand Down Expand Up @@ -671,6 +732,11 @@ function showLoginDialog() {
}
}

window.addEventListener("pageshow", () => {
loginDialog?.querySelector(".login-provider-github")
?.removeAttribute("aria-disabled");
});

function updateVoteControls(control, counts = {}, viewerVote = 0) {
const upvote = control.querySelector('[data-vote-value="1"]');
const downvote = control.querySelector('[data-vote-value="-1"]');
Expand Down Expand Up @@ -721,15 +787,8 @@ function renderVoteAccount() {
const logout = document.createElement("button");
logout.type = "button";
logout.textContent = "Sign out";
logout.addEventListener("click", async () => {
const response = await fetch(`${LOOP_LIBRARY_PATH}/auth/logout`, {
method: "POST",
credentials: "same-origin",
});
if (!response.ok) {
showToast("Sign out failed. Try again.");
return;
}
logout.addEventListener("click", () => {
clearVoteSessionToken();
voteViewer = null;
viewerVotes = {};
renderVoteAccount();
Expand All @@ -753,8 +812,36 @@ async function loadVotes() {
});
if (!response.ok) throw new Error("Voting unavailable");
const body = await response.json();
voteViewer = body.viewer || null;
viewerVotes = body.viewerVotes || {};
voteViewer = null;
viewerVotes = {};
const sessionToken = readVoteSessionToken();
if (sessionToken) {
try {
const sessionResponse = await fetch(`${LOOP_LIBRARY_PATH}/auth/session`, {
method: "POST",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ sessionToken }),
});
if (!sessionResponse.ok) {
if (sessionResponse.status < 500) clearVoteSessionToken();
} else {
const sessionBody = await sessionResponse.json();
if (sessionBody.viewer) {
voteViewer = sessionBody.viewer;
viewerVotes = sessionBody.viewerVotes || {};
} else {
clearVoteSessionToken();
}
}
} catch {
// Public totals and the launch gate remain usable when restoring an
// existing session is temporarily unavailable.
}
}
voteControls.forEach((control) => {
const slug = control.dataset.loopSlug;
updateVoteControls(
Expand Down Expand Up @@ -797,11 +884,15 @@ voteControls.forEach((control) => {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ value: nextValue }),
body: JSON.stringify({
value: nextValue,
sessionToken: readVoteSessionToken(),
}),
},
);
const body = await response.json();
if (response.status === 401) {
clearVoteSessionToken();
voteViewer = null;
viewerVotes = {};
renderVoteAccount();
Expand Down
Loading
Loading