ask-opus widget — /api/establish-session repair sketch
Date: 2026-05-20 (end-of-day) · Status: sketched, NOT built — diagnosis pending confirmation · Build effort: ~15-20 min when triggered
⚠ Diagnosis update — 2026-05-20 18:15 ET
Dan ran Test A (top-level visit to https://ask-opus.gf.cx/api/history?context=test):
404 {"error":"invalid or expired JWT"}
This is a different failure mode than the sketch below assumes:
- CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access let the request through (no 302 to xlabs SSO) → Dan’s team-session cookie IS valid
- The Worker received the request + tried to verify the JWT it received
- The Worker’s
verifyAccessJWT()rejected it → AUD mismatch is the most likely cause (the Worker is checking against the wrong Access app’s audience tag), with “expectedCF-Access-Jwt-Assertionheader present but invalid” as the surface symptom
Implication: the establish-session bridge described below is not the right fix. The fix is in the Worker’s JWT verification config — probably an ASK_OPUS_ACCESS_AUD env var pointing at the Access app’s old AUD before the cors_headers / self_hosted_domains updates earlier today rotated the app’s identifier.
The sketch below is kept for reference as the cookie-establishment pattern (still useful for future cross-app embeds where the cookie genuinely doesn’t exist), but the immediate next step is:
- Inspect the Worker’s expected AUD:
wrangler secret list+ grepsrc/index.tsfor theAUDconstant - Pull the current Access app’s AUD via the CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF API
- If they differ, update the Worker secret + redeploy
- Re-run Test A — expected
200 {"context":"test","turns":[]}(empty history) - Hard-refresh claim.gf.cx cockpit — widget should mount green
Tracked as the revised resume condition below.
The diagnosis (the original cookie-scope hypothesis — partially superseded by Test A)
Today’s discovery — surfaced after migrating the cockpit to same-zone claim.gf.cx and STILL getting “history offline” / “Failed to fetch”:
CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access cookies are per-app, not per-zone. The team’s SSO is unified (one xlabs.cloudflareaccess.com sign-in covers all apps), but the CF_Authorization cookie that proves “this session is auth’d” is set independently for each gated app — scoped to that app’s specific hostname.
So when:
- Dan visits claim.gf.cx/<cockpit> → CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access auths, sets cookie scoped to claim.gf.cx
- Widget on that page fetches ask-opus.gf.cx/api/history with credentials: include
- Browser looks for cookies scoped to ask-opus.gf.cx → none (Dan never authed to that app’s gated paths because they’re only reachable via cross-origin fetch)
- CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access at the edge sees no cookie → 302 to xlabs login → browser CORS-fails the redirect
Confirmation test (Dan to verify before this fix builds): visit https://ask-opus.gf.cx/api/history?context=test as a top-level navigation in a new tab. CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access intercepts → SSO → cookie set on ask-opus.gf.cx → user lands on the JSON response. Back on claim.gf.cx cockpit, widget should now work.
If that test confirms → the cookie scope is the issue → this sketch is the production fix.
The fix — one-time “establish session” handshake
Add a gated endpoint that exists solely to trigger CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access auth, set the cookie, and bounce back to the calling page. Widget detects the missing-cookie state on first mount and offers a one-click “Activate Opus access” button that opens this endpoint as a top-level navigation.
Architecture
Pieces to build
1. Worker — new endpoint (~/Code/ask-opus-worker/src/index.ts)
Add to the request handler, in the gated-paths section:
if (path === "/api/establish-session" && request.method === "GET") {
// This endpoint exists ONLY to trigger CF Access auth + set the
// ask-opus.gf.cx cookie. The path is gated; reaching this handler
// means CF Access has already auth'd + set the cookie. We just
// bounce back to the caller's page.
const returnUrl = url.searchParams.get("return") || "/";
// Open-redirect protection: only allow returning to allowlisted hosts.
// Same list as ALLOWED_ORIGINS — the surfaces that embed the widget.
try {
const parsed = new URL(returnUrl);
const allowedHosts = new Set(
ALLOWED_ORIGINS.map((o) => new URL(o).host)
);
if (!allowedHosts.has(parsed.host)) {
return new Response(
`Refusing to redirect to non-allowlisted host: ${parsed.host}`,
{ status: 400, headers: { "content-type": "text/plain" } }
);
}
return new Response(null, {
status: 302,
headers: { "Location": returnUrl },
});
} catch {
return new Response("Invalid return URL", { status: 400 });
}
}
2. CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access app — update self_hosted_domains
Add ask-opus.gf.cx/api/establish-session to the existing self_hosted_domains array on the Portfolio AI connector app:
# Via API
curl -X PUT \
-d '{"self_hosted_domains": [
"ask-opus.gf.cx/api/ask",
"ask-opus.gf.cx/api/history",
"ask-opus.gf.cx/api/establish-session"
]}' \
/accounts/<id>/access/apps/<ask-opus-app-id>
The existing CORS settings + email-allowlist policy automatically apply to the new path.
3. Widget JS — failure-state UI (~/Code/ask-opus-worker/public/widget.js)
In the loadHistory() catch block, replace the current count.textContent = "history offline" with a more useful failure state:
async function loadHistory() {
try {
const resp = await fetch(`${WORKER_BASE}/api/history?context=${encodeURIComponent(contextId)}&limit=20`, {
credentials: "include",
});
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
// CF Access cookie missing for ask-opus.gf.cx — offer one-click fix
showAuthPrompt();
return;
}
if (!resp.ok) {
countEl.textContent = `unavailable (${resp.status})`;
return;
}
const data = await resp.json();
// ... rest of existing success path
} catch (e) {
// Cross-origin CORS failure on 302 redirect manifests as fetch throw
showAuthPrompt();
}
}
function showAuthPrompt() {
countEl.textContent = "not authenticated";
const promptEl = document.createElement("div");
promptEl.className = "ask-opus-auth-prompt";
promptEl.innerHTML = `
<p>One-time CF Access SSO needed to use the embedded co-pilot.
Your session lasts 24 hours.</p>
<button type="button" class="ask-opus-auth-btn">
Activate Opus access →
</button>
`;
promptEl.querySelector(".ask-opus-auth-btn").addEventListener("click", () => {
const returnUrl = window.location.href;
window.location.href = `${WORKER_BASE}/api/establish-session?return=${encodeURIComponent(returnUrl)}`;
});
historyEl.appendChild(promptEl);
}
4. Widget CSS — auth-prompt state
Add to the inline styles:
.ask-opus-auth-prompt {
padding: 1rem 1.25rem;
background: rgba(200, 54, 76, 0.05);
border: 1px dashed rgba(200, 54, 76, 0.32);
border-radius: 6px;
margin: 1rem 0;
font-size: 0.92rem;
}
.ask-opus-auth-prompt p {
margin: 0 0 0.75rem;
color: var(--ink-soft);
line-height: 1.5;
}
.ask-opus-auth-btn {
background: var(--accent); color: white; border: 0;
padding: 0.5rem 1rem; border-radius: 4px;
font: inherit; font-weight: 600; font-size: 0.9rem;
cursor: pointer; transition: background 0.12s ease;
}
.ask-opus-auth-btn:hover { background: #a8231e; }
Deploy + verify
# 1. Build Worker
cd ~/Code/ask-opus-worker
# (edits to src/index.ts + public/widget.js per above)
npx wrangler deploy
# 2. Update Access app via API (as shown in piece 2)
# 3. Hard-refresh claim.gf.cx cockpit
# 4. Expected: widget mounts, shows "not authenticated" + "Activate Opus access" button
# 5. Click button → CF Access flow → return → cookie set → widget loads history
Why this is the right pattern
- Same-zone embedding wasn’t the actual constraint — even within
.gf.cx, CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access cookies are per-app. The earlierclaim.gf.cxwork isn’t wasted (cookies DO share within an app’s matching subdomain set; future surfaces could share an Access app), but for nowclaim.gf.cx+ask-opus.gf.cxare separate apps with separate cookies. - One-time UX cost per session is acceptable. 24-hour cookie persistence means: log in once per day. Same shape as any “your session has expired, please sign in” flow.
- Open-redirect protection is non-negotiable — without it, anyone could trick a logged-in user into visiting
ask-opus.gf.cx/api/establish-session?return=evil.comand silently redirect them post-auth. The allowlist check is 5 lines. - No new credentials — uses the existing CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access app’s email allowlist + the existing Worker AUD-check logic. No service tokens, no key rotation.
Sibling memories
feedback_cf_access_cookies_dont_cross_zones.md— banked today; this fix is its implementationproject_ask_opus_portfolio_connector.md— needs an addendum about the per-app cookie reality + this establish-session stepfeedback_rocket_loader_breaks_external_widgets.md— same family of CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF-zone-gotchas surfaced today
Build trigger
Dan confirms Test A (top-level navigation to ask-opus.gf.cx/api/history?context=test triggers auth + then claim.gf.cx widget works). That’s the diagnosis confirmation. From there:
- ~15-20 min active work
- 3 deploy actions (Worker, Access app update, push to GitHub)
- 1 verification round-trip
Ready to execute.
Resume conditions
Build when ANY hold: 1. Dan confirms Test A works 2. Dan wants the cockpit’s embedded co-pilot operational 3. The cockpit is a daily-use surface (not yet — used a few times so far this week, ramps as the claim work continues)
The aphorism
CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access’s “single SSO across apps” doesn’t mean “single cookie across apps.” Same team, different cookies — and one establish-session handshake bridges them.