Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0a6576c
feat(desktop): show running version in header + auto-update progress UI
robdmac Jul 12, 2026
9bee3be
fix(sandbox): force Claude Code classic renderer (no full-screen alt-…
robdmac Jul 12, 2026
d56d63e
fix(frontend): stop the browser block's black margin growing when zoo…
robdmac Jul 12, 2026
cbf67a6
feat(desktop): first-run account screen — "Free Desktop Use" or sign …
robdmac Jul 12, 2026
981e969
Merge remote-tracking branch 'origin/main' into feat/desktop-account-…
robdmac Jul 13, 2026
a44cfb8
fix(desktop): logout returns to the first-run welcome screen (Free vs…
robdmac Jul 13, 2026
c0d2264
fix(desktop): clean frontend build so stale cache/chunks can't ship
robdmac Jul 13, 2026
e9f915f
fix(desktop): sandbox port conflict slips past dynamic allocation (wi…
robdmac Jul 13, 2026
7763b7d
fix(desktop): crash on load — TDZ ReferenceError in desktop-account-s…
robdmac Jul 13, 2026
f6687c8
feat(desktop): Sign in with Google on the first-run screen (local, no…
robdmac Jul 13, 2026
6235098
fix(desktop): address review — event listeners, centralized logout, a…
robdmac Jul 13, 2026
802510f
fix(desktop): point cloud-account calls at api.orcabot.com, not the d…
robdmac Jul 13, 2026
6549061
fix(desktop): identity switch stuck — /auth/dev/session minted from s…
robdmac Jul 13, 2026
d23b0aa
feat(desktop): cloud sync foundation — store credential + list cloud …
robdmac Jul 13, 2026
38c8933
feat(desktop): picker shows cloud dashboards with download; download …
robdmac Jul 13, 2026
a3cf67b
feat(desktop): Google sign-in authenticates against the cloud and ret…
robdmac Jul 13, 2026
55d8853
fix(controlplane): set dashboard cloud_id via UPDATE, not in the crea…
robdmac Jul 13, 2026
c67998b
fix(desktop): setCloudCredential fails loud instead of silently skipp…
robdmac Jul 13, 2026
0e3f4fd
fix(desktop): logout also clears the stored cloud credential
robdmac Jul 13, 2026
13be2a5
fix(desktop): cloud dashboards list crash — unwrap { dashboards: [...] }
robdmac Jul 13, 2026
a38824e
feat(desktop): machine-wide local dashboards — stable local identity
robdmac Jul 13, 2026
92a125f
feat(desktop): Online / Local Storage tabs under "Your Dashboards"
robdmac Jul 13, 2026
c0628c9
feat(desktop): keep Local Storage tab on Free + "Sign in" (not Log ou…
robdmac Jul 13, 2026
4eda80a
copy(desktop): "Local Desktop Use" — clearer than "Free Desktop Use"
robdmac Jul 13, 2026
f3bda3f
chore(desktop): align DesktopWelcome revision header comment
robdmac Jul 13, 2026
95f7a87
feat(desktop): "Open online" cloud link on Online-tab dashboard cards
robdmac Jul 13, 2026
4e85e9b
style(desktop): move Download/Local Storage to card bottom, Open onli…
robdmac Jul 13, 2026
f74f967
feat(desktop): copy workspace files on cloud dashboard download
robdmac Jul 13, 2026
d6fb50e
fix(desktop): walk cloud workspace dir-by-dir (recursive list timed out)
robdmac Jul 13, 2026
beee8c7
fix(desktop): 180s timeout for cloud session-start (Fly cold boot)
robdmac Jul 13, 2026
9047b41
fix(desktop): retry cloud file API on 503 (sandbox warming up)
robdmac Jul 13, 2026
94798ef
feat(desktop): live progress for cloud workspace download
robdmac Jul 13, 2026
f84ed44
chore(desktop): diagnostic logging for cloud workspace download
robdmac Jul 13, 2026
48eb5d2
fix(desktop): always ensure cloud sandbox (stale "active" session hun…
robdmac Jul 13, 2026
decbb16
security: address code-review findings on desktop cloud download
robdmac Jul 14, 2026
71ffd5c
security[P1]: loopback (RFC 8252) desktop Google sign-in — closes phi…
robdmac Jul 14, 2026
6cef1e6
security: harden loopback sign-in + workspace download (review round 2)
robdmac Jul 14, 2026
fcbd61b
docs(desktop): drop orphaned pinTerminalToSubdir comment
robdmac Jul 14, 2026
3912d9c
security: harden loopback + logout + download (review round 3)
robdmac Jul 14, 2026
e46437b
security: serialize credential mutations + cleanup (review round 4)
robdmac Jul 14, 2026
1e67f76
security: cancellation state machine + logout UX + PII bound (review …
robdmac Jul 14, 2026
ae7a3f6
security: per-attempt sign-in IDs with conditional rollback (review r…
robdmac Jul 14, 2026
08786d1
security: rollback delete errors + surface logout failure (review rou…
robdmac Jul 14, 2026
d3ef571
security: handle rollback failure in the UI (review round 8)
robdmac Jul 14, 2026
4414cd9
final fix
robdmac Jul 14, 2026
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
28 changes: 28 additions & 0 deletions controlplane/src/auth/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,34 @@ export async function revokeApiToken(env: Env, userId: string, id: string): Prom
return (res.meta?.changes ?? 0) > 0;
}

/**
* Revoke the PAT presented as `Authorization: Bearer` — self-revoke, so the desktop
* app can invalidate its own cloud credential on logout (the normal token-management
* routes are method-gated against PATs). Presenting the token IS the authorization;
* it can only ever revoke itself. No-op (still 200) if unknown/already revoked.
*/
export async function revokeSelfApiToken(request: Request, env: Env): Promise<Response> {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
const auth = request.headers.get('Authorization') || '';
const m = auth.match(/^Bearer\s+(.+)$/i);
const token = m?.[1]?.trim();
if (!token || !token.startsWith(PAT_PREFIX)) {
return json({ error: 'not_a_pat' }, 400);
}
const tokenHash = await hashToken(token);
await env.DB.prepare(
`UPDATE api_tokens SET revoked_at = datetime('now')
WHERE token_hash = ? AND revoked_at IS NULL`
)
.bind(tokenHash)
.run();
return json({ ok: true });
}

/**
* Resolve a bearer PAT to a user. Returns null if the token is malformed,
* unknown, revoked, or expired. Fail-closed. Updates last_used_at on success.
Expand Down
212 changes: 204 additions & 8 deletions controlplane/src/auth/google.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

// REVISION: popup-login-v1-oauth-popup
const moduleRevision = "popup-login-v1-oauth-popup";
// REVISION: desktop-login-v1-google-nonce-poll
const moduleRevision = "desktop-login-v1-google-nonce-poll";
console.log(`[auth/google] REVISION: ${moduleRevision} loaded at ${new Date().toISOString()}`);

import type { Env } from '../types';
import { buildSessionCookie, createUserSession } from './sessions';
import { createApiToken } from './api-token';
import { processPendingInvitations } from '../members/handler';

const GOOGLE_LOGIN_SCOPE = [
Expand Down Expand Up @@ -201,11 +202,30 @@ export async function loginWithGoogle(
}

const requestUrl = new URL(request.url);
const isPopupMode = requestUrl.searchParams.get('mode') === 'popup';
const mode = requestUrl.searchParams.get('mode');
const isPopupMode = mode === 'popup';
// Desktop login: the app opens this in the OS browser and polls for the result
// by a nonce (the OS browser can't hand a session back to the Tauri webview).
// The result is a PAT (full credential), so it's protected with PKCE: the app
// sends the code_challenge here and the matching verifier when it polls. Works on
// any deployment; web popup/redirect login is untouched.
// Desktop login uses a LOOPBACK redirect (RFC 8252): the app runs a temporary
// 127.0.0.1 listener and passes it as redirect_uri. After sign-in we redirect the
// browser to that loopback with a one-time code — so the credential is delivered
// only to a listener on the machine that started the flow. A phished victim's
// browser hits THEIR own loopback, not the attacker's, which closes the earlier
// pollable-rendezvous hole. `state` is the app's CSRF token for its listener.
const isDesktopMode = mode === 'desktop';
const desktopLoopback = isDesktopMode ? requestUrl.searchParams.get('redirect_uri') : null;
const desktopState = isDesktopMode ? requestUrl.searchParams.get('state') : null;
// PKCE (S256): binds the one-time code to a verifier the native app never puts on
// the wire. Even a browser extension / local process that observes the loopback
// callback URL (which carries the code) can't exchange it without the verifier.
const desktopChallenge = isDesktopMode ? requestUrl.searchParams.get('challenge') : null;

// Validate Turnstile bot verification token (skip if not configured, e.g. dev)
// Skip Turnstile in popup mode — Google OAuth consent screen provides bot protection
if (env.TURNSTILE_SECRET_KEY && !isPopupMode) {
// Skip Turnstile in popup/desktop mode — Google's consent screen provides bot protection
if (env.TURNSTILE_SECRET_KEY && !isPopupMode && !isDesktopMode) {
const turnstileToken = requestUrl.searchParams.get('turnstile_token');
if (!turnstileToken) {
return renderErrorPage('Bot verification required. Please try again.');
Expand All @@ -220,11 +240,27 @@ export async function loginWithGoogle(
}
}

if (isDesktopMode && (!desktopLoopback || !desktopState || !desktopChallenge || !isLoopbackRedirect(desktopLoopback))) {
return renderErrorPage('Desktop sign-in requires a loopback redirect and PKCE challenge.');
}

const state = crypto.randomUUID();
const redirectUri = `${getRedirectBase(request, env)}/auth/google/callback`;
// In popup mode, store sentinel "popup" so callback knows to render completion page
const postLoginRedirect = isPopupMode ? 'popup' : resolvePostLoginRedirect(request, env);

// Sentinel in the state's redirect slot tells the callback how to finish:
// - "popup" → postMessage completion page (web popup login)
// - "desktoplb:<base64 json>" → mint a one-time code, redirect to the app's
// loopback so it can exchange the code for a PAT
// - a URL → normal 302 redirect + session cookie
const postLoginRedirect = isPopupMode
? 'popup'
: isDesktopMode
? `desktoplb:${btoa(JSON.stringify({ uri: desktopLoopback, st: desktopState, ch: desktopChallenge }))}`
: resolvePostLoginRedirect(request, env);

// Sweep abandoned desktop codes on every sign-in start (no scheduler needed).
if (isDesktopMode) {
await cleanupStaleDesktopCodes(env);
}
await createAuthState(env, state, postLoginRedirect);

const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
Expand Down Expand Up @@ -320,6 +356,50 @@ export async function callbackGoogle(
// Process any pending dashboard invitations for this email
await processPendingInvitations(env, userId, userInfo.email);

// Desktop login (loopback): mint a one-time code, stash the identity (short
// expiry), and redirect the browser to the app's 127.0.0.1 listener with the
// code. The PAT is minted only when the app exchanges the code (exchangeDesktopCode)
// — so an abandoned flow never creates a token, and the code reaches only a
// listener on the machine that started the flow (not a global poll an attacker
// could hit). Re-validate the loopback target before redirecting.
if (postLoginRedirect.startsWith('desktoplb:')) {
let uri: string;
let st: string;
let ch: string;
try {
const dec = JSON.parse(atob(postLoginRedirect.slice('desktoplb:'.length))) as {
uri: string;
st: string;
ch: string;
};
uri = dec.uri;
st = dec.st;
ch = dec.ch;
} catch {
return renderErrorPage('Desktop sign-in state was corrupt.');
}
if (!isLoopbackRedirect(uri)) {
return renderErrorPage('Invalid desktop redirect target.');
}
const code = `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, '');
// Store ONLY the opaque userId (+ PKCE challenge + expiry) — no email/name — so
// an abandoned/never-swept row on an inactive install holds no PII. The identity
// is fetched from the users table at exchange time.
await createAuthState(
env,
`desktopcode:${code}`,
JSON.stringify({
userId,
challenge: ch, // PKCE S256 — exchanged only with the matching verifier
expiresAt: Date.now() + 5 * 60 * 1000, // 5 min to exchange
})
);
const dest = new URL(uri);
dest.searchParams.set('code', code);
dest.searchParams.set('state', st);
return Response.redirect(dest.toString(), 302);
}

const session = await createUserSession(env, userId);
const cookie = buildSessionCookie(request, session.id, session.expiresAt);

Expand All @@ -344,6 +424,122 @@ export async function callbackGoogle(
});
}


/** A redirect target usable by the desktop loopback flow: http on 127.0.0.1/::1/
* localhost only. Enforced at login AND before the callback redirect so a crafted
* redirect_uri can't send a signed-in victim's code anywhere but their own machine. */
function isLoopbackRedirect(uri: string): boolean {
try {
const u = new URL(uri);
if (u.protocol !== 'http:') return false;
return u.hostname === '127.0.0.1' || u.hostname === '::1' || u.hostname === 'localhost';
} catch {
return false;
}
}

/** Desktop-minted cloud PATs get a bounded lifetime so a leaked/orphaned one dies
* on its own even if logout's server-side revoke never runs (offline logout). */
const DESKTOP_PAT_TTL_DAYS = 90;

/** Best-effort sweep of abandoned desktop auth codes (cancelled sign-in / loopback
* timeout), which hold userId/email/name. The code TTL is 5 min; delete rows older
* than 15 min by created_at so PII doesn't linger indefinitely. Called on the hot
* paths (login start, exchange) so no scheduler is needed. */
async function cleanupStaleDesktopCodes(env: Env): Promise<void> {
try {
await env.DB.prepare(
"DELETE FROM auth_states WHERE state LIKE 'desktopcode:%' AND created_at < datetime('now','-15 minutes')"
).run();
} catch {
/* best-effort */
}
}

/** base64url(SHA-256(input)) — PKCE S256 challenge computation. */
async function sha256Base64Url(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
let bin = '';
for (const b of new Uint8Array(digest)) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

/**
* Desktop app exchanges the one-time `code` it received on its 127.0.0.1 listener
* (from the OAuth callback redirect) for its cloud PAT + identity. The code alone is
* NOT sufficient — the app must also present the PKCE `verifier` whose S256 hash
* matches the challenge sent at login, so a local observer of the loopback callback
* URL can't exchange the code it saw. Atomically single-use (DELETE … RETURNING) and
* short-lived; the PAT is bounded by a TTL.
*/
export async function exchangeDesktopCode(request: Request, env: Env): Promise<Response> {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});

let body: { code?: string; verifier?: string };
try {
body = (await request.json()) as { code?: string; verifier?: string };
} catch {
return json({ error: 'bad_request' }, 400);
}
const { code, verifier } = body;
if (!code || !verifier) {
return json({ error: 'missing_code_or_verifier' }, 400);
}
await cleanupStaleDesktopCodes(env);

const key = `desktopcode:${code}`;
// Peek first — do NOT consume on a bad verifier, or a local observer who saw the
// code could invalidate every legit sign-in by exchanging first with a bogus
// verifier. Only after PKCE passes do we atomically consume (compare-and-delete on
// the exact stored value, so concurrent success requests still can't double-mint).
const rec = await env.DB
.prepare('SELECT redirect_url as v FROM auth_states WHERE state = ?')
.bind(key)
.first<{ v: string }>();
if (!rec) {
return json({ error: 'not_found' }, 404);
}
let parsed: { userId: string; challenge?: string; expiresAt?: number };
try {
parsed = JSON.parse(rec.v);
} catch {
// Corrupt row — consume it so it can't wedge, then report.
await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run();
return json({ error: 'corrupt' }, 500);
}
if (parsed.expiresAt && Date.now() > parsed.expiresAt) {
await env.DB.prepare('DELETE FROM auth_states WHERE state = ?').bind(key).run();
return json({ error: 'expired' }, 410);
}
if (!parsed.challenge || (await sha256Base64Url(verifier)) !== parsed.challenge) {
return json({ error: 'forbidden' }, 403); // wrong verifier — leave the code intact
}
// PKCE verified — atomically consume this exact record. A concurrent request that
// already deleted it makes this affect 0 rows → treat as already used.
const consumed = await env.DB
.prepare('DELETE FROM auth_states WHERE state = ? AND redirect_url = ? RETURNING redirect_url as v')
.bind(key, rec.v)
.first<{ v: string }>();
if (!consumed) {
return json({ error: 'not_found' }, 404);
}
const { token } = await createApiToken(env, parsed.userId, 'Orcabot Desktop', DESKTOP_PAT_TTL_DAYS);
// Fetch the identity now (it wasn't stored in the temporary record).
const user = await env.DB
.prepare('SELECT email, name FROM users WHERE id = ?')
.bind(parsed.userId)
.first<{ email: string; name: string }>();
return json({
token,
email: user?.email ?? '',
name: user?.name || user?.email?.split('@')[0] || '',
});
}

function renderLoginCompletePage(
frontendOrigin: string,
cookie: string,
Expand Down
2 changes: 1 addition & 1 deletion controlplane/src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function authenticate(
* the host frontend (which sends the matching X-Orcabot-Surface header) may use
* dev-auth. When unset (cloud / local dev / older builds), enforcement is off.
*/
function devAuthSurfaceTrusted(request: Request, env: Env): boolean {
export function devAuthSurfaceTrusted(request: Request, env: Env): boolean {
const expected = env.SURFACE_TOKEN;
if (!expected) return true;
// Header for fetch/XHR; query param for top-level browser navigations (OAuth
Expand Down
13 changes: 11 additions & 2 deletions controlplane/src/dashboards/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function fоrmatDashbоard(row: Record<string, unknown>): Dashboard & { secretsC
updatedAt: row.updated_at as string,
secretsCount: row.secrets_count !== undefined ? Number(row.secrets_count) : undefined,
linkedCount: row.linked_count !== undefined ? Number(row.linked_count) : undefined,
cloudId: (row.cloud_id as string | null) ?? null,
};
}

Expand Down Expand Up @@ -177,18 +178,26 @@ export async function getDashbоard(
export async function createDashbоard(
env: Env,
userId: string,
data: { name: string; templateId?: string },
data: { name: string; templateId?: string; cloudId?: string },
ctx?: Pick<ExecutionContext, 'waitUntil'>,
preferredRegion?: string,
): Promise<Response> {
const id = generateId();
const now = new Date().toISOString();

// Create dashboard
// Create dashboard. cloud_id (desktop downloads only) is set via a follow-up
// UPDATE rather than in this INSERT, so the INSERT never references the column —
// keeps working on a deployment whose schema migration hasn't added it yet, and
// the UPDATE only runs on desktop where the column exists.
await env.DB.prepare(`
INSERT INTO dashboards (id, name, owner_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).bind(id, data.name, userId, now, now).run();
if (data.cloudId) {
await env.DB.prepare(`UPDATE dashboards SET cloud_id = ? WHERE id = ?`)
.bind(data.cloudId, id)
.run();
}

// Add owner as member
await env.DB.prepare(`
Expand Down
14 changes: 13 additions & 1 deletion controlplane/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ CREATE TABLE IF NOT EXISTS dashboards (
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id),
setup_guide TEXT,
-- Desktop: when this dashboard was downloaded from a cloud account, the id of
-- the source cloud dashboard (for the downloaded ✓ state + two-way sync). NULL
-- for purely-local dashboards.
cloud_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Expand Down Expand Up @@ -1145,7 +1149,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_link_edge_b ON link_edge_map(link_id, edge
`;

// Initialize the database
const SCHEMA_REVISION = "schema-v15-api-tokens";
const SCHEMA_REVISION = "schema-v16-dashboard-cloud-id";

export async function initializeDatabase(db: D1Database): Promise<void> {
console.log(`[schema] REVISION: ${SCHEMA_REVISION} loaded at ${new Date().toISOString()}`);
Expand Down Expand Up @@ -1189,6 +1193,14 @@ export async function initializeDatabase(db: D1Database): Promise<void> {
// Column already exists.
}

try {
await db.prepare(`
ALTER TABLE dashboards ADD COLUMN cloud_id TEXT
`).run();
} catch {
// Column already exists.
}

try {
await db.prepare(`
ALTER TABLE egress_blocked_defaults ADD COLUMN revoked_at TEXT
Expand Down
Loading
Loading