From c1db55959ece875b7e35e9eda38dc13adbc2b34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nejc=20Drobni=C4=8D?= Date: Mon, 18 May 2026 10:58:07 +0000 Subject: [PATCH] feat: harden mobile gateway auth and push --- apps/gateway/package.json | 3 +- apps/gateway/src/auth.ts | 46 +- apps/gateway/src/index.ts | 30 +- apps/gateway/src/internal-auth.ts | 27 + apps/gateway/src/proxy.ts | 39 +- apps/gateway/src/push.ts | 157 ++ apps/gateway/src/types.ts | 1 + apps/gateway/test/index.spec.ts | 30 + apps/mobile/app.json | 8 +- apps/mobile/eslint.config.js | 10 + apps/mobile/package.json | 2 + apps/mobile/src/app/_layout.tsx | 8 +- apps/mobile/src/data/auth.tsx | 21 +- apps/mobile/src/data/gateway/http.ts | 68 +- apps/mobile/src/data/provider.tsx | 29 +- apps/mobile/src/data/push.tsx | 77 + apps/mobile/src/data/queries.ts | 17 +- apps/mobile/src/features/auth/screen.tsx | 52 +- .../src/features/profiles/ProfileForm.tsx | 3 +- .../src/features/profiles/profile-store.tsx | 7 +- apps/mobile/src/features/profiles/screen.tsx | 7 +- apps/mobile/src/ui/index.tsx | 1 - packages/protocol/src/git.ts | 6 +- packages/protocol/src/index.ts | 2 + packages/protocol/src/triggers-conditions.ts | 9 +- pnpm-lock.yaml | 2280 ++++++++++++++++- terraform/environments/production/checks.tf | 8 +- .../production/terraform.tfvars.example | 2 +- .../environments/production/variables.tf | 14 +- .../environments/production/worker-gateway.tf | 12 +- terraform/modules/cloudflare-worker/main.tf | 14 + .../modules/cloudflare-worker/outputs.tf | 8 +- .../modules/cloudflare-worker/variables.tf | 8 +- 33 files changed, 2844 insertions(+), 162 deletions(-) create mode 100644 apps/gateway/src/internal-auth.ts create mode 100644 apps/gateway/src/push.ts create mode 100644 apps/mobile/eslint.config.js create mode 100644 apps/mobile/src/data/push.tsx diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 16af9a1..4c84007 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -18,6 +18,7 @@ "wrangler": "^4.92.0" }, "dependencies": { + "@constructor/protocol": "workspace:*", "jose": "^6.2.3" } -} \ No newline at end of file +} diff --git a/apps/gateway/src/auth.ts b/apps/gateway/src/auth.ts index f47e6ed..9673b74 100644 --- a/apps/gateway/src/auth.ts +++ b/apps/gateway/src/auth.ts @@ -1,6 +1,6 @@ import { jwtVerify, SignJWT } from "jose"; import type { GatewayEnv } from "./types"; -import { errorResponse, json } from "./index"; +import { errorResponse } from "./index"; const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"; const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; @@ -15,6 +15,8 @@ interface PkceSession { appRedirectUri: string; } +const ALLOWED_APP_REDIRECT = "mobile://auth/callback"; + function generateCodeVerifier(): string { const arr = new Uint8Array(64); crypto.getRandomValues(arr); @@ -42,6 +44,9 @@ export async function handleAuthStart(request: Request, env: GatewayEnv): Promis if (!appRedirectUri) { return errorResponse("redirect_uri is required", 400); } + if (appRedirectUri !== ALLOWED_APP_REDIRECT) { + return errorResponse("redirect_uri is not allowed", 400); + } const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); @@ -77,6 +82,7 @@ export async function handleAuthCallback(request: Request, env: GatewayEnv): Pro return errorResponse("Invalid or expired session", 400); } const session = JSON.parse(sessionRaw) as PkceSession; + await env.GATEWAY_KV.delete(`pkce:${state}`); // Exchange code for GitHub access token const tokenRes = await fetch(GITHUB_TOKEN_URL, { @@ -138,24 +144,36 @@ export async function handleAuthCallback(request: Request, env: GatewayEnv): Pro } // Issue app JWT + const sessionId = crypto.randomUUID(); + await env.GATEWAY_KV.put(`app-session:${sessionId}`, JSON.stringify({ + scmUserId: String(user.id), + scmLogin: user.login, + scmName: user.name || user.login, + scmEmail: email || `${user.id}+${user.login}@users.noreply.github.com`, + scmToken: ghToken, + } satisfies StoredAppSession), { + expirationTtl: JWT_TTL_SECONDS, + }); const jwt = await signAppJwt(env, { sub: String(user.id), + sessionId, scmUserId: String(user.id), scmLogin: user.login, scmName: user.name || user.login, scmEmail: email || `${user.id}+${user.login}@users.noreply.github.com`, - scmToken: ghToken, }); // Redirect back to app const redirect = new URL(session.appRedirectUri); redirect.searchParams.set("token", jwt); + redirect.searchParams.set("state", state); return Response.redirect(redirect.toString(), 302); } -interface JwtPayload { +export interface JwtPayload { sub: string; + sessionId: string; scmUserId: string; scmLogin: string; scmName: string; @@ -163,14 +181,16 @@ interface JwtPayload { scmToken: string; } -async function signAppJwt(env: GatewayEnv, payload: JwtPayload): Promise { +type StoredAppSession = Omit; + +async function signAppJwt(env: GatewayEnv, payload: Omit): Promise { const key = await importJwk(env.APP_JWT_SIGNING_KEY); return new SignJWT({ + sessionId: payload.sessionId, scmUserId: payload.scmUserId, scmLogin: payload.scmLogin, scmName: payload.scmName, scmEmail: payload.scmEmail, - scmToken: payload.scmToken, }) .setProtectedHeader({ alg: "HS256" }) .setSubject(payload.sub) @@ -186,13 +206,19 @@ export async function verifyAppJwt(env: GatewayEnv, token: string): Promise { + ctx.waitUntil(pollAndSendPushNotifications(env)); + }, } satisfies ExportedHandler; +export const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,HEAD,POST,PUT,PATCH,OPTIONS", + "Access-Control-Allow-Headers": "Authorization,Content-Type,Accept", + "Access-Control-Max-Age": "86400", +}; + +export function handleOptions(request: Request): Response { + if (request.headers.get("Origin") && request.headers.get("Access-Control-Request-Method")) { + return new Response(null, { headers: corsHeaders }); + } + return new Response(null, { headers: { Allow: "GET, HEAD, POST, PUT, PATCH, OPTIONS" } }); +} + export function json(body: unknown, status = 200): Response { return Response.json(body, { status, - headers: { "Access-Control-Allow-Origin": "*" }, + headers: corsHeaders, }); } diff --git a/apps/gateway/src/internal-auth.ts b/apps/gateway/src/internal-auth.ts new file mode 100644 index 0000000..a1567c0 --- /dev/null +++ b/apps/gateway/src/internal-auth.ts @@ -0,0 +1,27 @@ +export async function computeHmacHex(data: string, secret: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export async function generateInternalToken(secret: string): Promise { + const timestamp = Date.now().toString(); + const signatureHex = await computeHmacHex(timestamp, secret); + return `${timestamp}.${signatureHex}`; +} + +export async function internalAuthHeaders(secret: string): Promise { + return { + Accept: "application/json", + Authorization: `Bearer ${await generateInternalToken(secret)}`, + }; +} diff --git a/apps/gateway/src/proxy.ts b/apps/gateway/src/proxy.ts index 9641e89..d07b568 100644 --- a/apps/gateway/src/proxy.ts +++ b/apps/gateway/src/proxy.ts @@ -1,29 +1,8 @@ import type { GatewayEnv } from "./types"; -import { errorResponse, json } from "./index"; +import { corsHeaders as defaultCorsHeaders, errorResponse } from "./index"; import { verifyAppJwt } from "./auth"; - -const TOKEN_VALIDITY_MS = 5 * 60 * 1000; - -async function computeHmacHex(data: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); - return Array.from(new Uint8Array(sig)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -async function generateInternalToken(secret: string): Promise { - const timestamp = Date.now().toString(); - const signatureHex = await computeHmacHex(timestamp, secret); - return `${timestamp}.${signatureHex}`; -} +import { generateInternalToken } from "./internal-auth"; +import { recordUserSessions } from "./push"; export async function handleProxy(request: Request, env: GatewayEnv): Promise { const authHeader = request.headers.get("Authorization"); @@ -74,15 +53,21 @@ export async function handleProxy(request: Request, env: GatewayEnv): Promise recordUserSessions(env, user, body)).catch(() => undefined); + } // Pass through CORS - const corsHeaders = new Headers(response.headers); - corsHeaders.set("Access-Control-Allow-Origin", "*"); + const responseHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(defaultCorsHeaders)) { + responseHeaders.set(key, value); + } return new Response(response.body, { status: response.status, statusText: response.statusText, - headers: corsHeaders, + headers: responseHeaders, }); } diff --git a/apps/gateway/src/push.ts b/apps/gateway/src/push.ts new file mode 100644 index 0000000..7121d2c --- /dev/null +++ b/apps/gateway/src/push.ts @@ -0,0 +1,157 @@ +import type { SandboxEvent, Session } from "@constructor/protocol"; +import type { JwtPayload } from "./auth"; +import { verifyAppJwt } from "./auth"; +import { errorResponse, json } from "./index"; +import { internalAuthHeaders } from "./internal-auth"; +import type { GatewayEnv } from "./types"; + +const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; +const MAX_DEVICES_PER_USER = 5; +const MAX_TRACKED_SESSIONS_PER_USER = 25; +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "archived"]); +const NOTIFY_EVENT_TYPES = new Set(["execution_complete", "error", "artifact", "push_complete", "push_error"]); + +type Device = { expoToken: string; addedAt: number }; +type TrackedSession = { + id: string; + title: string | null; + repoOwner: string; + repoName: string; + cursor: { timestamp: number; id: string } | null; + notified: Record; +}; +type UserRegistry = { user: Pick; devices: Device[]; sessions: Record }; + +function userKey(userId: string): string { + return `user:${userId}`; +} + +async function loadRegistry(env: GatewayEnv, user: JwtPayload): Promise { + const raw = await env.GATEWAY_KV.get(userKey(user.sub)); + if (raw) return JSON.parse(raw) as UserRegistry; + return { + user: { sub: user.sub, scmLogin: user.scmLogin, scmName: user.scmName, scmEmail: user.scmEmail }, + devices: [], + sessions: {}, + }; +} + +async function saveRegistry(env: GatewayEnv, registry: UserRegistry): Promise { + await env.GATEWAY_KV.put(userKey(registry.user.sub), JSON.stringify(registry)); +} + +export async function handlePushRegister(request: Request, env: GatewayEnv): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) return errorResponse("Unauthorized", 401); + const user = await verifyAppJwt(env, authHeader.slice(7)); + if (!user) return errorResponse("Invalid or expired token", 401); + + const body = (await request.json().catch(() => null)) as { expoToken?: string } | null; + if (!body?.expoToken?.startsWith("ExponentPushToken[") && !body?.expoToken?.startsWith("ExpoPushToken[")) { + return errorResponse("expoToken is required", 400); + } + + const registry = await loadRegistry(env, user); + registry.user = { sub: user.sub, scmLogin: user.scmLogin, scmName: user.scmName, scmEmail: user.scmEmail }; + registry.devices = [ + { expoToken: body.expoToken, addedAt: Date.now() }, + ...registry.devices.filter((d) => d.expoToken !== body.expoToken), + ].slice(0, MAX_DEVICES_PER_USER); + await saveRegistry(env, registry); + return json({ ok: true }); +} + +export async function recordUserSessions(env: GatewayEnv, user: JwtPayload, body: unknown): Promise { + const sessions = Array.isArray((body as { sessions?: unknown }).sessions) ? (body as { sessions: Session[] }).sessions : []; + if (sessions.length === 0) return; + const registry = await loadRegistry(env, user); + for (const session of sessions.slice(0, MAX_TRACKED_SESSIONS_PER_USER)) { + if (TERMINAL_STATUSES.has(session.status)) continue; + const existing = registry.sessions[session.id]; + registry.sessions[session.id] = { + id: session.id, + title: session.title, + repoOwner: session.repoOwner, + repoName: session.repoName, + cursor: existing?.cursor ?? null, + notified: existing?.notified ?? {}, + }; + } + await saveRegistry(env, registry); +} + +export async function pollAndSendPushNotifications(env: GatewayEnv): Promise { + let cursor: string | undefined; + do { + const page = await env.GATEWAY_KV.list({ prefix: "user:", cursor, limit: 100 }); + await Promise.all(page.keys.map(async (key) => { + const raw = await env.GATEWAY_KV.get(key.name); + if (!raw) return; + await pollUser(env, JSON.parse(raw) as UserRegistry); + })); + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); +} + +async function pollUser(env: GatewayEnv, registry: UserRegistry): Promise { + if (registry.devices.length === 0) return; + let changed = false; + for (const session of Object.values(registry.sessions)) { + const result = await fetchSessionEvents(env, session); + if (!result) continue; + for (const event of result.events) { + if (!NOTIFY_EVENT_TYPES.has(event.type)) continue; + const key = notificationKey(session.id, event); + if (session.notified[key]) continue; + const sent = await sendExpoPush(env, registry.devices, summarizeEvent(session, event)); + if (!sent) return; + session.notified[key] = true; + changed = true; + } + if (result.cursor) { + session.cursor = result.cursor; + changed = true; + } + } + if (changed) await env.GATEWAY_KV.put(userKey(registry.user.sub), JSON.stringify(registry)); +} + +async function fetchSessionEvents(env: GatewayEnv, session: TrackedSession): Promise<{ events: SandboxEvent[]; cursor: { timestamp: number; id: string } | null } | null> { + const url = new URL(`${env.CONTROL_PLANE_URL.replace(/\/$/, "")}/sessions/${session.id}/events`); + url.searchParams.set("limit", "50"); + if (session.cursor) url.searchParams.set("cursor", JSON.stringify(session.cursor)); + const response = await fetch(url, { headers: await internalAuthHeaders(env.INTERNAL_CALLBACK_SECRET) }); + if (!response.ok) return null; + const body = (await response.json()) as { events?: SandboxEvent[]; items?: SandboxEvent[]; cursor?: { timestamp: number; id: string } | null }; + return { events: body.events ?? body.items ?? [], cursor: body.cursor ?? null }; +} + +function notificationKey(sessionId: string, event: SandboxEvent): string { + const id = "messageId" in event ? event.messageId : "artifactId" in event ? event.artifactId : "branchName" in event ? event.branchName : event.timestamp; + return `${sessionId}:${event.type}:${id}:${event.timestamp}`; +} + +function summarizeEvent(session: TrackedSession, event: SandboxEvent): { title: string; body: string; data: Record } { + const name = session.title || `${session.repoOwner}/${session.repoName}`; + if (event.type === "execution_complete") { + return { title: event.success ? "Session completed" : "Session failed", body: name, data: { sessionId: session.id, url: `/s/${session.id}` } }; + } + if (event.type === "artifact") { + return { title: "New session artifact", body: `${event.artifactType} created for ${name}`, data: { sessionId: session.id, url: `/s/${session.id}` } }; + } + if (event.type === "push_complete") { + return { title: "Branch pushed", body: event.branchName, data: { sessionId: session.id, url: `/s/${session.id}` } }; + } + return { title: "Session needs attention", body: name, data: { sessionId: session.id, url: `/s/${session.id}` } }; +} + +async function sendExpoPush(env: GatewayEnv, devices: Device[], message: { title: string; body: string; data: Record }): Promise { + const headers: Record = { Accept: "application/json", "Content-Type": "application/json" }; + if (env.EXPO_ACCESS_TOKEN) headers.Authorization = `Bearer ${env.EXPO_ACCESS_TOKEN}`; + const response = await fetch(EXPO_PUSH_URL, { + method: "POST", + headers, + body: JSON.stringify(devices.map((device) => ({ to: device.expoToken, ...message, sound: "default", channelId: "session-updates" }))), + }); + return response.ok; +} diff --git a/apps/gateway/src/types.ts b/apps/gateway/src/types.ts index 7aa1821..12a1864 100644 --- a/apps/gateway/src/types.ts +++ b/apps/gateway/src/types.ts @@ -5,5 +5,6 @@ export interface GatewayEnv { GITHUB_OAUTH_CLIENT_SECRET: string; INTERNAL_CALLBACK_SECRET: string; APP_JWT_SIGNING_KEY: string; + EXPO_ACCESS_TOKEN?: string; GATEWAY_KV: KVNamespace; } diff --git a/apps/gateway/test/index.spec.ts b/apps/gateway/test/index.spec.ts index 06fd121..23e810d 100644 --- a/apps/gateway/test/index.spec.ts +++ b/apps/gateway/test/index.spec.ts @@ -26,4 +26,34 @@ describe("Gateway worker", () => { expect(response.status).toBe(404); expect(await response.json()).toEqual({ error: "Not found" }); }); + + it("rejects untrusted OAuth redirect URIs", async () => { + const response = await SELF.fetch("https://example.com/auth/start?redirect_uri=https%3A%2F%2Fevil.example%2Fcallback"); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "redirect_uri is not allowed" }); + }); + + it("handles CORS preflight requests", async () => { + const response = await SELF.fetch("https://example.com/sessions", { + method: "OPTIONS", + headers: { + Origin: "https://app.example", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "Authorization,Content-Type", + }, + }); + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + }); + + it("requires auth for push registration", async () => { + const response = await SELF.fetch("https://example.com/push/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expoToken: "ExpoPushToken[test]" }), + }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + }); }); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3cf080f..782375e 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -44,7 +44,13 @@ ], "expo-secure-store", "expo-web-browser", - "expo-sqlite" + "expo-sqlite", + [ + "expo-notifications", + { + "defaultChannel": "session-updates" + } + ] ], "experiments": { "typedRoutes": true, diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js new file mode 100644 index 0000000..ba708ed --- /dev/null +++ b/apps/mobile/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require("eslint-config-expo/flat"); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ["dist/*"], + } +]); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7e6f93f..a338e90 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -52,6 +52,8 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "eslint": "^9.39.4", + "eslint-config-expo": "~55.0.1", "typescript": "~5.9.2" }, "private": true diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 214a649..1c07a60 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -30,11 +30,11 @@ function StackNav() { contentStyle: { backgroundColor: c.background }, }} > + - + {children} ); diff --git a/apps/mobile/src/data/auth.tsx b/apps/mobile/src/data/auth.tsx index 71c0e90..6dfef5c 100644 --- a/apps/mobile/src/data/auth.tsx +++ b/apps/mobile/src/data/auth.tsx @@ -14,6 +14,10 @@ import * as SecureStore from 'expo-secure-store'; const AUTH_KEY = 'constructor.auth_token'; +export function authTokenKey(profileId?: string | null): string { + return profileId ? `${AUTH_KEY}.${profileId}` : AUTH_KEY; +} + type AuthUser = { sub: string; scmLogin: string; @@ -48,13 +52,16 @@ function parseJwtPayload(token: string): AuthUser | null { } } -export function AuthProvider({ children }: { children: React.ReactNode }) { +export function AuthProvider({ children, profileId }: { children: React.ReactNode; profileId?: string }) { const [token, setToken] = useState(null); const [ready, setReady] = useState(false); + const key = authTokenKey(profileId); // Hydrate from secure store on mount useEffect(() => { - SecureStore.getItemAsync(AUTH_KEY) + setReady(false); + setToken(null); + SecureStore.getItemAsync(key) .then((t) => { if (t) setToken(t); }) @@ -62,21 +69,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // ignore }) .finally(() => setReady(true)); - }, []); + }, [key]); const signIn = useCallback((newToken: string) => { setToken(newToken); - SecureStore.setItemAsync(AUTH_KEY, newToken).catch(() => { + SecureStore.setItemAsync(key, newToken).catch(() => { // ignore }); - }, []); + }, [key]); const signOut = useCallback(() => { setToken(null); - SecureStore.deleteItemAsync(AUTH_KEY).catch(() => { + SecureStore.deleteItemAsync(key).catch(() => { // ignore }); - }, []); + }, [key]); const user = useMemo(() => (token ? parseJwtPayload(token) : null), [token]); const value = useMemo( diff --git a/apps/mobile/src/data/gateway/http.ts b/apps/mobile/src/data/gateway/http.ts index 57d7f86..ca410f6 100644 --- a/apps/mobile/src/data/gateway/http.ts +++ b/apps/mobile/src/data/gateway/http.ts @@ -4,11 +4,11 @@ * on every request so the gateway instance is stateless. */ import * as SecureStore from 'expo-secure-store'; +import { authTokenKey } from '../auth'; import type { CreateSessionRequest, SandboxEvent, - Session, SessionState, } from '@constructor/protocol'; import type { @@ -19,11 +19,9 @@ import type { SubscribeSnapshot, } from '../gateway'; -const AUTH_KEY = 'constructor.auth_token'; - -async function getToken(): Promise { +async function getToken(key: string): Promise { try { - return await SecureStore.getItemAsync(AUTH_KEY); + return await SecureStore.getItemAsync(key); } catch { return null; } @@ -38,10 +36,14 @@ function buildHeaders(token: string): HeadersInit { } export class HttpSessionGateway implements SessionGateway { - constructor(private baseUrl: string) {} + constructor( + private baseUrl: string, + private tokenKey = authTokenKey(), + private wsBaseUrl?: string, + ) {} async listSessions(): Promise { - const token = await getToken(); + const token = await getToken(this.tokenKey); if (!token) throw new Error('Not authenticated'); const res = await fetch(`${this.baseUrl}/sessions`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, @@ -51,7 +53,7 @@ export class HttpSessionGateway implements SessionGateway { } async getSession(id: string): Promise { - const token = await getToken(); + const token = await getToken(this.tokenKey); if (!token) throw new Error('Not authenticated'); const res = await fetch(`${this.baseUrl}/sessions/${id}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, @@ -61,7 +63,7 @@ export class HttpSessionGateway implements SessionGateway { } async createSession(req: CreateSessionRequest): Promise<{ sessionId: string }> { - const token = await getToken(); + const token = await getToken(this.tokenKey); if (!token) throw new Error('Not authenticated'); const res = await fetch(`${this.baseUrl}/sessions`, { method: 'POST', @@ -80,24 +82,25 @@ export class HttpSessionGateway implements SessionGateway { let closed = false; const connect = async () => { - const token = await getToken(); - if (!token || closed) return; - - // Fetch WS auth token from gateway - const tokenRes = await fetch(`${this.baseUrl}/sessions/${id}/ws-token`, { - method: 'POST', - headers: buildHeaders(token), - body: JSON.stringify({}), // user identity injected by gateway proxy - }); - if (!tokenRes.ok) { - on.closed?.('Failed to get WebSocket token'); - return; - } - const { token: wsToken } = (await tokenRes.json()) as { token: string }; + try { + const token = await getToken(this.tokenKey); + if (!token || closed) return; + + // Fetch WS auth token from gateway + const tokenRes = await fetch(`${this.baseUrl}/sessions/${id}/ws-token`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify({}), // user identity injected by gateway proxy + }); + if (!tokenRes.ok) { + on.closed?.('Failed to get WebSocket token'); + return; + } + const { token: wsToken } = (await tokenRes.json()) as { token: string }; - // Connect to WS — use wss:// if gateway is https:// - const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/sessions/${id}/ws`; - ws = new WebSocket(wsUrl); + // Connect to the discovered WS endpoint when available. + const wsUrl = (this.wsBaseUrl ?? this.baseUrl.replace(/^http/, 'ws')) + `/sessions/${id}/ws`; + ws = new WebSocket(wsUrl); ws.onopen = () => { ws?.send( @@ -131,9 +134,12 @@ export class HttpSessionGateway implements SessionGateway { if (!closed) on.closed?.('WebSocket closed'); }; - ws.onerror = () => { - if (!closed) on.closed?.('WebSocket error'); - }; + ws.onerror = () => { + if (!closed) on.closed?.('WebSocket error'); + }; + } catch { + if (!closed) on.closed?.('WebSocket connection failed'); + } }; connect(); @@ -147,7 +153,7 @@ export class HttpSessionGateway implements SessionGateway { } async sendFollowUp(id: string, content: string): Promise { - const token = await getToken(); + const token = await getToken(this.tokenKey); if (!token) throw new Error('Not authenticated'); const res = await fetch(`${this.baseUrl}/sessions/${id}/prompt`, { method: 'POST', @@ -158,7 +164,7 @@ export class HttpSessionGateway implements SessionGateway { } async stop(id: string): Promise { - const token = await getToken(); + const token = await getToken(this.tokenKey); if (!token) throw new Error('Not authenticated'); const res = await fetch(`${this.baseUrl}/sessions/${id}/stop`, { method: 'POST', diff --git a/apps/mobile/src/data/provider.tsx b/apps/mobile/src/data/provider.tsx index c95ff20..387faba 100644 --- a/apps/mobile/src/data/provider.tsx +++ b/apps/mobile/src/data/provider.tsx @@ -7,9 +7,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { SessionGateway } from './gateway'; import { HttpSessionGateway } from './gateway/http'; import { MockSessionGateway } from './mock/mock-gateway'; -import { AuthProvider } from './auth'; +import { AuthProvider, authTokenKey } from './auth'; +import { PushRegistration } from './push'; const GatewayContext = createContext(null); +const GatewayScopeContext = createContext('mock'); export function useGateway(): SessionGateway { const g = useContext(GatewayContext); @@ -17,12 +19,20 @@ export function useGateway(): SessionGateway { return g; } +export function useGatewayScope(): string { + return useContext(GatewayScopeContext); +} + export function AppProviders({ children, gatewayUrl, + profileId, + wsUrl, }: { children: React.ReactNode; gatewayUrl?: string; + profileId?: string; + wsUrl?: string; }) { const client = useMemo( () => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), @@ -31,15 +41,24 @@ export function AppProviders({ const gw = useMemo(() => { if (gatewayUrl && !gatewayUrl.startsWith('mock://')) { - return new HttpSessionGateway(gatewayUrl.replace(/\/$/, '')); + return new HttpSessionGateway( + gatewayUrl.replace(/\/$/, ''), + authTokenKey(profileId), + wsUrl?.replace(/\/$/, ''), + ); } return new MockSessionGateway(); - }, [gatewayUrl]); + }, [gatewayUrl, profileId, wsUrl]); + + const scope = profileId ?? gatewayUrl ?? 'mock'; return ( - - {children} + + + {children} + + ); diff --git a/apps/mobile/src/data/push.tsx b/apps/mobile/src/data/push.tsx new file mode 100644 index 0000000..c76734b --- /dev/null +++ b/apps/mobile/src/data/push.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Platform } from 'react-native'; +import Constants from 'expo-constants'; +import * as Notifications from 'expo-notifications'; +import { router } from 'expo-router'; + +import { useAuth } from './auth'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export function PushRegistration({ gatewayUrl }: { gatewayUrl?: string }) { + const { signedIn, token } = useAuth(); + + React.useEffect(() => { + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + const url = response.notification.request.content.data?.url; + const sessionId = response.notification.request.content.data?.sessionId; + if (typeof url === 'string') router.push(url); + else if (typeof sessionId === 'string') router.push(`/s/${sessionId}`); + }); + return () => sub.remove(); + }, []); + + React.useEffect(() => { + if (!signedIn || !token || !gatewayUrl || gatewayUrl.startsWith('mock://')) return; + let cancelled = false; + registerForPushNotificationsAsync() + .then(async (expoToken) => { + if (!expoToken || cancelled) return; + await fetch(`${gatewayUrl.replace(/\/$/, '')}/push/register`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ expoToken }), + }); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [gatewayUrl, signedIn, token]); + + return null; +} + +async function registerForPushNotificationsAsync(): Promise { + if (Platform.OS === 'web') return null; + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('session-updates', { + name: 'Session updates', + importance: Notifications.AndroidImportance.HIGH, + }); + } + + const existing = await Notifications.getPermissionsAsync(); + let granted = existing.granted; + if (!granted) { + const requested = await Notifications.requestPermissionsAsync(); + granted = requested.granted; + } + if (!granted) return null; + + const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId; + if (!projectId) return null; + return (await Notifications.getExpoPushTokenAsync({ projectId })).data; +} diff --git a/apps/mobile/src/data/queries.ts b/apps/mobile/src/data/queries.ts index ff5e309..12d1f22 100644 --- a/apps/mobile/src/data/queries.ts +++ b/apps/mobile/src/data/queries.ts @@ -4,16 +4,17 @@ import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { CreateSessionRequest, SandboxEvent, SessionState } from '@constructor/protocol'; -import { costDelta, foldEvent, type PendingRef, type PendingToken } from '@/features/sessions/stream/transforms'; +import { collapseTokenEvents, costDelta, foldEvent, type PendingToken } from '@/features/sessions/stream/transforms'; -import { useGateway } from './provider'; +import { useGateway, useGatewayScope } from './provider'; export { useGateway } from './provider'; export function useSessions() { const gw = useGateway(); + const scope = useGatewayScope(); return useQuery({ - queryKey: ['sessions'], + queryKey: ['sessions', scope], queryFn: async () => { const result = await gw.listSessions(); return result.sessions; @@ -23,15 +24,17 @@ export function useSessions() { export function useSession(id: string) { const gw = useGateway(); - return useQuery({ queryKey: ['session', id], queryFn: () => gw.getSession(id), enabled: !!id }); + const scope = useGatewayScope(); + return useQuery({ queryKey: ['session', scope, id], queryFn: () => gw.getSession(id), enabled: !!id }); } export function useCreateSession() { const gw = useGateway(); + const scope = useGatewayScope(); const qc = useQueryClient(); return useMutation({ mutationFn: (req: CreateSessionRequest) => gw.createSession(req), - onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions', scope] }), }); } @@ -56,11 +59,13 @@ export function useSessionStream(id: string): SessionStream { if (!id) return; pending.current = null; setStatus('connecting'); + setState(null); setEvents([]); + setCost(0); const handle = gw.subscribe(id, { snapshot: (snap) => { setState(snap.state); - setEvents(snap.replay.events); + setEvents(collapseTokenEvents(snap.replay.events, pending)); setStatus('live'); }, event: (e) => { diff --git a/apps/mobile/src/features/auth/screen.tsx b/apps/mobile/src/features/auth/screen.tsx index b29c928..8441bcf 100644 --- a/apps/mobile/src/features/auth/screen.tsx +++ b/apps/mobile/src/features/auth/screen.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { ActivityIndicator, Alert, Linking, StyleSheet, Text, View } from 'react-native'; import { Stack, useRouter } from 'expo-router'; +import { randomUUID } from 'expo-crypto'; +import * as SecureStore from 'expo-secure-store'; import * as WebBrowser from 'expo-web-browser'; import { useAuth } from '@/data/auth'; @@ -15,6 +17,7 @@ import { BrandBackdrop } from './brand-backdrop'; import { GitHubMark } from './github-mark'; const ACCENT = '#208AEF'; +const PENDING_STATE_KEY = 'constructor.auth.pending_state'; export function SignInScreen() { const { signIn } = useAuth(); @@ -23,34 +26,37 @@ export function SignInScreen() { const { activeProfile } = useProfileStore(); const [signingIn, setSigningIn] = React.useState(false); + const finishCallback = React.useCallback(async (url: string) => { + if (!url.startsWith('mobile://auth/callback')) return; + WebBrowser.dismissBrowser(); + const parsed = new URL(url); + const token = parsed.searchParams.get('token'); + const state = parsed.searchParams.get('state'); + const pendingState = await SecureStore.getItemAsync(PENDING_STATE_KEY).catch(() => null); + if (!token || !state || !pendingState || state !== pendingState) { + Alert.alert('Sign-in failed', 'Could not complete authentication.'); + setSigningIn(false); + return; + } + await SecureStore.deleteItemAsync(PENDING_STATE_KEY).catch(() => undefined); + signIn(token); + setSigningIn(false); + }, [signIn]); + // Listen for deep-link callback React.useEffect(() => { const sub = Linking.addEventListener('url', ({ url }) => { - if (url.startsWith('mobile://auth/callback')) { - WebBrowser.dismissBrowser(); - const parsed = new URL(url); - const token = parsed.searchParams.get('token'); - if (token) { - signIn(token); - } else { - Alert.alert('Sign-in failed', 'Could not complete authentication.'); - setSigningIn(false); - } - } + finishCallback(url); }); return () => sub.remove(); - }, [signIn]); + }, [finishCallback]); // Also check for initial URL (cold-start deep link) React.useEffect(() => { Linking.getInitialURL().then((url) => { - if (url && url.startsWith('mobile://auth/callback')) { - const parsed = new URL(url); - const token = parsed.searchParams.get('token'); - if (token) signIn(token); - } + if (url) finishCallback(url); }); - }, [signIn]); + }, [finishCallback]); const onContinue = React.useCallback(async () => { if (!activeProfile) { @@ -64,8 +70,11 @@ export function SignInScreen() { setSigningIn(true); try { const gatewayUrl = activeProfile.gatewayUrl.replace(/\/$/, ''); - const authUrl = `${gatewayUrl}/auth/start?redirect_uri=mobile://auth/callback`; - await WebBrowser.openBrowserAsync(authUrl); + const state = randomUUID(); + await SecureStore.setItemAsync(PENDING_STATE_KEY, state); + const authUrl = `${gatewayUrl}/auth/start?redirect_uri=${encodeURIComponent('mobile://auth/callback')}&state=${encodeURIComponent(state)}`; + const result = await WebBrowser.openBrowserAsync(authUrl); + if (result.type !== 'opened') setSigningIn(false); } catch { setSigningIn(false); } @@ -112,6 +121,9 @@ export function SignInScreen() { onPress={onContinue} disabled={signingIn} /> + {!activeProfile?.githubOAuthClientId ? ( +