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:

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:

  1. Inspect the Worker’s expected AUD: wrangler secret list + grep src/index.ts for the AUD constant
  2. Pull the current Access app’s AUD via the CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF API
  3. If they differ, update the Worker secret + redeploy
  4. Re-run Test A — expected 200 {"context":"test","turns":[]} (empty history)
  5. Hard-refresh claim.gf.cx cockpit — widget should mount green

Tracked as the revised resume condition below.

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

First mount on claim.gf.cx · no ask-opus.gf.cx cookie1 · widget.js — claim.gf.cxcalls /api/history → CORS-fails (no cookie scoped to ask-opus.gf.cx)renders “Activate Opus access →” pill · user clickswindow.location = ask-opus.gf.cx/api/establish-session?return=<page>2 · CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF Access — edge interceptionpath is gated → checks for valid team session→ no session: redirect to xlabs SSO, user signs in→ valid session: pass throughCookie set: CF_Authorization · Domain=ask-opus.gf.cx3 · Worker handler — /api/establish-sessionvalidate ?return= host against ALLOWED_ORIGINS allowlistreturn 302 → ?return URLprevents open-redirect attack via off-portfolio return URLs4 · back on claim.gf.cx — cookie now sentbrowser navigates to ?return URL · widget remounts/api/history fetch succeeds · status pill flips to “N turns · synced”24h cookie persistence — subsequent visits mount seamlesslyone-time handshake per 24h session window

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

Sibling memories

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:

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.

Source: parked_sketch_ask_opus_establish_session_2026-05-20.md · Rendered 2026-05-20 18:23