From 1da85cda6a954fe28f2e2e7ae8376d63ce8af2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nejc=20Drobni=C4=8D?= Date: Mon, 18 May 2026 08:20:18 +0000 Subject: [PATCH] feat: real gateway auth, profile config discovery, and critical bug fixes Gateway: - Implement /config endpoint for mobile discovery - Implement GitHub OAuth PKCE flow (/auth/start, /auth/callback) - Implement HMAC-signed proxy to control plane for all /sessions/* endpoints - Add JWT issuance and verification for mobile sessions - Update tests for new gateway behavior - Add KV namespace binding to wrangler.jsonc Mobile: - Fix app.json iOS icon path (was pointing to a directory) - Fix Fonts.sans invalid CSS keyword on iOS (use undefined for system font) - Remove @expo/ui Metro shim that could crash Expo Go builds - Fix listSessions to parse paginated {sessions, total, hasMore} response - Fix useRef type cast in useSessionStream - Fix makeId collision risk by using expo-crypto randomUUID - Fix Composer returnKeyType with multiline (removed, doesn't work on iOS) - Fix KeyboardAvoidingView redundant ternary - Rewrite auth context with expo-secure-store persistence and JWT parsing - Rewrite sign-in screen for real OAuth via gateway + deep link callback - Update profile store to support wsUrl and githubOAuthClientId discovery - Add config fetch on profile creation - Add HttpSessionGateway for real HTTP/WS communication - Update _layout to wire ProfileStoreProvider and gateway URL --- apps/gateway/src/auth.ts | 205 ++++++++++++++++++ apps/gateway/src/config.ts | 10 + apps/gateway/src/index.ts | 65 +++++- apps/gateway/src/proxy.ts | 109 ++++++++++ apps/gateway/src/types.ts | 9 + apps/gateway/test/index.spec.ts | 22 +- apps/gateway/wrangler.jsonc | 35 +-- apps/mobile/app.json | 2 +- apps/mobile/src/app/_layout.tsx | 20 +- apps/mobile/src/constants/theme.ts | 16 +- apps/mobile/src/data/auth.tsx | 84 ++++++- apps/mobile/src/data/config.ts | 37 ++++ apps/mobile/src/data/gateway.ts | 8 +- apps/mobile/src/data/gateway/http.ts | 169 +++++++++++++++ apps/mobile/src/data/mock/mock-gateway.ts | 6 +- apps/mobile/src/data/provider.tsx | 24 +- apps/mobile/src/data/queries.ts | 12 +- apps/mobile/src/features/auth/screen.tsx | 102 ++++++--- .../src/features/profiles/profile-store.tsx | 34 ++- apps/mobile/src/features/profiles/screen.tsx | 33 ++- .../src/features/sessions/detail/Composer.tsx | 5 +- apps/mobile/src/ui/index.tsx | 14 -- 22 files changed, 870 insertions(+), 151 deletions(-) create mode 100644 apps/gateway/src/auth.ts create mode 100644 apps/gateway/src/config.ts create mode 100644 apps/gateway/src/proxy.ts create mode 100644 apps/gateway/src/types.ts create mode 100644 apps/mobile/src/data/config.ts create mode 100644 apps/mobile/src/data/gateway/http.ts diff --git a/apps/gateway/src/auth.ts b/apps/gateway/src/auth.ts new file mode 100644 index 0000000..f47e6ed --- /dev/null +++ b/apps/gateway/src/auth.ts @@ -0,0 +1,205 @@ +import { jwtVerify, SignJWT } from "jose"; +import type { GatewayEnv } from "./types"; +import { errorResponse, json } from "./index"; + +const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"; +const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const GITHUB_USER_URL = "https://api.github.com/user"; +const GITHUB_EMAILS_URL = "https://api.github.com/user/emails"; + +const PKCE_TTL_SECONDS = 600; // 10 minutes +const JWT_TTL_SECONDS = 24 * 60 * 60; // 24 hours + +interface PkceSession { + verifier: string; + appRedirectUri: string; +} + +function generateCodeVerifier(): string { + const arr = new Uint8Array(64); + crypto.getRandomValues(arr); + return Array.from(arr) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + const bytes = new Uint8Array(hash); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export async function handleAuthStart(request: Request, env: GatewayEnv): Promise { + const url = new URL(request.url); + const appRedirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state") || generateCodeVerifier().slice(0, 32); + + if (!appRedirectUri) { + return errorResponse("redirect_uri is required", 400); + } + + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + const session: PkceSession = { verifier, appRedirectUri }; + await env.GATEWAY_KV.put(`pkce:${state}`, JSON.stringify(session), { + expirationTtl: PKCE_TTL_SECONDS, + }); + + const authUrl = new URL(GITHUB_AUTH_URL); + authUrl.searchParams.set("client_id", env.GITHUB_OAUTH_CLIENT_ID); + authUrl.searchParams.set("redirect_uri", `${url.origin}/auth/callback`); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "repo user"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + + return Response.redirect(authUrl.toString(), 302); +} + +export async function handleAuthCallback(request: Request, env: GatewayEnv): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + if (!code || !state) { + return errorResponse("Missing code or state", 400); + } + + const sessionRaw = await env.GATEWAY_KV.get(`pkce:${state}`); + if (!sessionRaw) { + return errorResponse("Invalid or expired session", 400); + } + const session = JSON.parse(sessionRaw) as PkceSession; + + // Exchange code for GitHub access token + const tokenRes = await fetch(GITHUB_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: env.GITHUB_OAUTH_CLIENT_ID, + client_secret: env.GITHUB_OAUTH_CLIENT_SECRET, + code, + redirect_uri: `${url.origin}/auth/callback`, + code_verifier: session.verifier, + }), + }); + + if (!tokenRes.ok) { + return errorResponse("Failed to exchange GitHub code", 502); + } + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + error?: string; + }; + + if (tokenData.error || !tokenData.access_token) { + return errorResponse(tokenData.error || "GitHub auth failed", 502); + } + + const ghToken = tokenData.access_token; + + // Fetch GitHub user profile + const [userRes, emailsRes] = await Promise.all([ + fetch(GITHUB_USER_URL, { + headers: { Authorization: `Bearer ${ghToken}`, Accept: "application/vnd.github+json" }, + }), + fetch(GITHUB_EMAILS_URL, { + headers: { Authorization: `Bearer ${ghToken}`, Accept: "application/vnd.github+json" }, + }), + ]); + + if (!userRes.ok) { + return errorResponse("Failed to fetch GitHub user", 502); + } + + const user = (await userRes.json()) as { + id: number; + login: string; + name?: string | null; + email?: string | null; + }; + + let email = user.email; + if (!email && emailsRes.ok) { + const emails = (await emailsRes.json()) as Array<{ email: string; primary: boolean; verified: boolean }>; + const primary = emails.find((e) => e.primary && e.verified); + if (primary) email = primary.email; + } + + // Issue app JWT + const jwt = await signAppJwt(env, { + sub: String(user.id), + 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); + + return Response.redirect(redirect.toString(), 302); +} + +interface JwtPayload { + sub: string; + scmUserId: string; + scmLogin: string; + scmName: string; + scmEmail: string; + scmToken: string; +} + +async function signAppJwt(env: GatewayEnv, payload: JwtPayload): Promise { + const key = await importJwk(env.APP_JWT_SIGNING_KEY); + return new SignJWT({ + scmUserId: payload.scmUserId, + scmLogin: payload.scmLogin, + scmName: payload.scmName, + scmEmail: payload.scmEmail, + scmToken: payload.scmToken, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(payload.sub) + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(key); +} + +export async function verifyAppJwt(env: GatewayEnv, token: string): Promise { + try { + const key = await importJwk(env.APP_JWT_SIGNING_KEY); + const { payload } = await jwtVerify(token, key, { + algorithms: ["HS256"], + clockTolerance: 60, + }); + return { + sub: payload.sub as string, + scmUserId: payload.scmUserId as string, + scmLogin: payload.scmLogin as string, + scmName: payload.scmName as string, + scmEmail: payload.scmEmail as string, + scmToken: payload.scmToken as string, + }; + } catch { + return null; + } +} + +async function importJwk(secret: string): Promise { + const encoder = new TextEncoder(); + return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); +} diff --git a/apps/gateway/src/config.ts b/apps/gateway/src/config.ts new file mode 100644 index 0000000..4d66075 --- /dev/null +++ b/apps/gateway/src/config.ts @@ -0,0 +1,10 @@ +import type { GatewayEnv } from "./types"; +import { json } from "./index"; + +export function handleConfig(_request: Request, env: GatewayEnv): Response { + return json({ + controlPlaneUrl: env.CONTROL_PLANE_URL, + wsUrl: env.WS_URL, + githubOAuthClientId: env.GITHUB_OAUTH_CLIENT_ID, + }); +} diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts index c2c916a..c169800 100644 --- a/apps/gateway/src/index.ts +++ b/apps/gateway/src/index.ts @@ -1,18 +1,61 @@ /** - * Welcome to Cloudflare Workers! This is your first worker. + * Constructor Mobile Gateway * - * - Run `npm run dev` in your terminal to start a development server - * - Open a browser tab at http://localhost:8787/ to see your worker in action - * - Run `npm run deploy` to publish your worker + * Compatibility layer between the mobile app and the background-agents control + * plane. No changes are made to background-agents — all transforms happen here. * - * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the - * `Env` object can be regenerated with `npm run cf-typegen`. - * - * Learn more at https://developers.cloudflare.com/workers/ + * Endpoints: + * GET /config → public config (controlPlaneUrl, wsUrl, githubOAuthClientId) + * GET /auth/start → begins GitHub OAuth PKCE flow + * GET /auth/callback → GitHub OAuth callback, issues app JWT + * GET|POST /sessions/... → HMAC-signed proxy to control plane with user injection */ +import { jwtVerify, SignJWT } from "jose"; +import type { GatewayEnv } from "./types"; +import { handleConfig } from "./config"; +import { handleAuthStart, handleAuthCallback } from "./auth"; +import { handleProxy } from "./proxy"; + +export type { GatewayEnv } from "./types"; + export default { - async fetch(request, env, ctx): Promise { - return new Response("Hello World!"); + async fetch(request: Request, env: GatewayEnv, _ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const path = url.pathname; + + try { + if (path === "/config") { + return handleConfig(request, env); + } + + if (path === "/auth/start") { + return handleAuthStart(request, env); + } + + if (path === "/auth/callback") { + return handleAuthCallback(request, env); + } + + if (path.startsWith("/sessions")) { + return handleProxy(request, env); + } + + return json({ error: "Not found" }, 404); + } catch (e) { + console.error("Gateway error:", e); + return json({ error: "Internal server error" }, 500); + } }, -} satisfies ExportedHandler; +} satisfies ExportedHandler; + +export function json(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { "Access-Control-Allow-Origin": "*" }, + }); +} + +export function errorResponse(message: string, status = 400): Response { + return json({ error: message }, status); +} diff --git a/apps/gateway/src/proxy.ts b/apps/gateway/src/proxy.ts new file mode 100644 index 0000000..9641e89 --- /dev/null +++ b/apps/gateway/src/proxy.ts @@ -0,0 +1,109 @@ +import type { GatewayEnv } from "./types"; +import { errorResponse, json } 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}`; +} + +export async function handleProxy(request: Request, env: GatewayEnv): Promise { + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return errorResponse("Unauthorized", 401); + } + + const appToken = authHeader.slice(7); + const user = await verifyAppJwt(env, appToken); + if (!user) { + return errorResponse("Invalid or expired token", 401); + } + + // Build the upstream URL + const url = new URL(request.url); + const upstreamPath = url.pathname + url.search; + const upstreamUrl = `${env.CONTROL_PLANE_URL.replace(/\/$/, "")}${upstreamPath}`; + + // Copy headers and inject HMAC auth + const headers = new Headers(request.headers); + headers.delete("Authorization"); // remove app JWT + + const internalToken = await generateInternalToken(env.INTERNAL_CALLBACK_SECRET); + headers.set("Authorization", `Bearer ${internalToken}`); + headers.set("x-request-id", crypto.randomUUID().slice(0, 8)); + headers.set("x-trace-id", crypto.randomUUID()); + + // Build upstream request body + let body: BodyInit | null = null; + if (["POST", "PUT", "PATCH"].includes(request.method)) { + const contentType = headers.get("Content-Type") || ""; + if (contentType.includes("application/json")) { + const originalBody = await request.json(); + const enrichedBody = enrichBody(originalBody, user); + body = JSON.stringify(enrichedBody); + headers.set("Content-Type", "application/json"); + } else if (contentType.includes("multipart/form-data")) { + body = request.body; + } else { + body = await request.text(); + } + } + + const upstream = new Request(upstreamUrl, { + method: request.method, + headers, + body, + }); + + const response = await fetch(upstream); + + // Pass through CORS + const corsHeaders = new Headers(response.headers); + corsHeaders.set("Access-Control-Allow-Origin", "*"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: corsHeaders, + }); +} + +function enrichBody(body: unknown, user: { + sub: string; + scmUserId: string; + scmLogin: string; + scmName: string; + scmEmail: string; + scmToken: string; +}): unknown { + if (body && typeof body === "object") { + return { + ...body, + userId: user.sub, + scmUserId: user.scmUserId, + scmLogin: user.scmLogin, + scmName: user.scmName, + scmEmail: user.scmEmail, + scmToken: user.scmToken, + }; + } + return body; +} diff --git a/apps/gateway/src/types.ts b/apps/gateway/src/types.ts new file mode 100644 index 0000000..7aa1821 --- /dev/null +++ b/apps/gateway/src/types.ts @@ -0,0 +1,9 @@ +export interface GatewayEnv { + CONTROL_PLANE_URL: string; + WS_URL: string; + GITHUB_OAUTH_CLIENT_ID: string; + GITHUB_OAUTH_CLIENT_SECRET: string; + INTERNAL_CALLBACK_SECRET: string; + APP_JWT_SIGNING_KEY: string; + GATEWAY_KV: KVNamespace; +} diff --git a/apps/gateway/test/index.spec.ts b/apps/gateway/test/index.spec.ts index 81abe3d..06fd121 100644 --- a/apps/gateway/test/index.spec.ts +++ b/apps/gateway/test/index.spec.ts @@ -7,23 +7,23 @@ import { import { describe, it, expect } from "vitest"; import worker from "../src/index"; -// For now, you'll need to do something like this to get a correctly-typed -// `Request` to pass to `worker.fetch()`. const IncomingRequest = Request; -describe("Hello World worker", () => { - it("responds with Hello World! (unit style)", async () => { - const request = new IncomingRequest("http://example.com"); - // Create an empty context to pass to `worker.fetch()`. +describe("Gateway worker", () => { + it("GET /config returns JSON", async () => { + const request = new IncomingRequest("http://example.com/config"); const ctx = createExecutionContext(); const response = await worker.fetch(request, env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + const body = (await response.json()) as Record; + expect(typeof body).toBe("object"); }); - it("responds with Hello World! (integration style)", async () => { - const response = await SELF.fetch("https://example.com"); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + it("unknown paths return 404", async () => { + const response = await SELF.fetch("https://example.com/unknown"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Not found" }); }); }); diff --git a/apps/gateway/wrangler.jsonc b/apps/gateway/wrangler.jsonc index 1fd03c8..4546792 100644 --- a/apps/gateway/wrangler.jsonc +++ b/apps/gateway/wrangler.jsonc @@ -13,33 +13,12 @@ "upload_source_maps": true, "compatibility_flags": [ "nodejs_compat" + ], + "kv_namespaces": [ + { + "binding": "GATEWAY_KV", + "id": "local-kv-id", + "preview_id": "local-kv-preview-id" + } ] - /** - * Smart Placement - * https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement - */ - // "placement": { "mode": "smart" } - /** - * Bindings - * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including - * databases, object storage, AI inference, real-time communication and more. - * https://developers.cloudflare.com/workers/runtime-apis/bindings/ - */ - /** - * Environment Variables - * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables - * Note: Use secrets to store sensitive data. - * https://developers.cloudflare.com/workers/configuration/secrets/ - */ - // "vars": { "MY_VARIABLE": "production_value" } - /** - * Static Assets - * https://developers.cloudflare.com/workers/static-assets/binding/ - */ - // "assets": { "directory": "./public/", "binding": "ASSETS" } - /** - * Service Bindings (communicate between multiple Workers) - * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings - */ - // "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ] } \ No newline at end of file diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 051ef13..3cf080f 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -11,7 +11,7 @@ "policy": "appVersion" }, "ios": { - "icon": "./assets/expo.icon", + "icon": "./assets/images/icon.png", "bundleIdentifier": "dev.nejc.constructor", "infoPlist": { "ITSAppUsesNonExemptEncryption": false diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 8f316c7..214a649 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { Stack } from 'expo-router'; import { useAuth } from '@/data/auth'; import { AppProviders } from '@/data/provider'; +import { ProfileStoreProvider, useProfileStore } from '@/features/profiles/profile-store'; import { useThemeColors } from '@/ui'; /** Fallback route Expo Router resolves to when a guard redirects. */ @@ -22,8 +23,6 @@ function StackNav() { return ( - + + {children} ); } + +export default function RootLayout() { + return ( + + + + + + ); +} diff --git a/apps/mobile/src/constants/theme.ts b/apps/mobile/src/constants/theme.ts index c10ed27..a16779a 100644 --- a/apps/mobile/src/constants/theme.ts +++ b/apps/mobile/src/constants/theme.ts @@ -28,14 +28,14 @@ export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark; export const Fonts = Platform.select({ ios: { - /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', - /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', - /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', - /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', + /** iOS system font — omit `fontFamily` to use SF Pro */ + sans: undefined, + /** iOS Times New Roman fallback */ + serif: 'Times New Roman', + /** iOS rounded variant — no exact match, use system default */ + rounded: undefined, + /** iOS SF Mono for monospaced text */ + mono: 'Courier', }, default: { sans: 'normal', diff --git a/apps/mobile/src/data/auth.tsx b/apps/mobile/src/data/auth.tsx index 6a67c4a..71c0e90 100644 --- a/apps/mobile/src/data/auth.tsx +++ b/apps/mobile/src/data/auth.tsx @@ -1,33 +1,97 @@ /** - * Mock auth state for the navigation gate. Real GitHub OAuth (via the gateway) - * is M1 — this only flips a boolean so `Stack.Protected` in the router can gate - * the app behind sign-in. State is in-memory (resets on reload) by design: it - * is a UI gate, not a credential store. + * Auth state backed by expo-secure-store. The JWT (issued by the gateway) is + * persisted across reloads and included in every API call via the gateway. */ import React, { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from 'react'; +import * as SecureStore from 'expo-secure-store'; + +const AUTH_KEY = 'constructor.auth_token'; + +type AuthUser = { + sub: string; + scmLogin: string; + scmName: string; + scmEmail: string; +}; type Auth = { signedIn: boolean; - signIn: () => void; + token: string | null; + user: AuthUser | null; + signIn: (token: string) => void; signOut: () => void; }; const AuthContext = createContext(null); +function parseJwtPayload(token: string): AuthUser | null { + try { + const [, payload] = token.split('.'); + if (!payload) return null; + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + const data = JSON.parse(json); + return { + sub: data.sub ?? '', + scmLogin: data.scmLogin ?? '', + scmName: data.scmName ?? '', + scmEmail: data.scmEmail ?? '', + }; + } catch { + return null; + } +} + export function AuthProvider({ children }: { children: React.ReactNode }) { - const [signedIn, setSignedIn] = useState(false); - const signIn = useCallback(() => setSignedIn(true), []); - const signOut = useCallback(() => setSignedIn(false), []); + const [token, setToken] = useState(null); + const [ready, setReady] = useState(false); + + // Hydrate from secure store on mount + useEffect(() => { + SecureStore.getItemAsync(AUTH_KEY) + .then((t) => { + if (t) setToken(t); + }) + .catch(() => { + // ignore + }) + .finally(() => setReady(true)); + }, []); + + const signIn = useCallback((newToken: string) => { + setToken(newToken); + SecureStore.setItemAsync(AUTH_KEY, newToken).catch(() => { + // ignore + }); + }, []); + + const signOut = useCallback(() => { + setToken(null); + SecureStore.deleteItemAsync(AUTH_KEY).catch(() => { + // ignore + }); + }, []); + + const user = useMemo(() => (token ? parseJwtPayload(token) : null), [token]); const value = useMemo( - () => ({ signedIn, signIn, signOut }), - [signedIn, signIn, signOut], + () => ({ + signedIn: !!token, + token, + user, + signIn, + signOut, + }), + [token, user, signIn, signOut], ); + + if (!ready) return null; + return {children}; } diff --git a/apps/mobile/src/data/config.ts b/apps/mobile/src/data/config.ts new file mode 100644 index 0000000..a757099 --- /dev/null +++ b/apps/mobile/src/data/config.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; + +import { useProfileStore, type ProfileConfig } from '@/features/profiles/profile-store'; + +export interface GatewayConfig { + controlPlaneUrl: string; + wsUrl: string; + githubOAuthClientId: string; +} + +export function useGatewayConfig() { + const { activeProfile, setProfileConfig } = useProfileStore(); + + const fetchConfig = useCallback(async (): Promise => { + if (!activeProfile) return null; + const url = activeProfile.gatewayUrl.replace(/\/$/, ''); + const res = await fetch(`${url}/config`, { + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return null; + const data = (await res.json()) as Partial; + if (!data.wsUrl || !data.githubOAuthClientId) return null; + + const config: ProfileConfig = { + wsUrl: data.wsUrl, + githubOAuthClientId: data.githubOAuthClientId, + }; + setProfileConfig(activeProfile.id, config); + return { + controlPlaneUrl: data.controlPlaneUrl ?? url, + wsUrl: data.wsUrl, + githubOAuthClientId: data.githubOAuthClientId, + }; + }, [activeProfile, setProfileConfig]); + + return { fetchConfig }; +} diff --git a/apps/mobile/src/data/gateway.ts b/apps/mobile/src/data/gateway.ts index bab4df4..b40a073 100644 --- a/apps/mobile/src/data/gateway.ts +++ b/apps/mobile/src/data/gateway.ts @@ -34,8 +34,14 @@ export interface StreamHandle { unsubscribe(): void; } +export interface ListSessionsResult { + sessions: Session[]; + total: number; + hasMore: boolean; +} + export interface SessionGateway { - listSessions(): Promise; + listSessions(): Promise; getSession(id: string): Promise; createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>; /** Mirrors the real DO: a `snapshot` (state + replay) then a live event stream. */ diff --git a/apps/mobile/src/data/gateway/http.ts b/apps/mobile/src/data/gateway/http.ts new file mode 100644 index 0000000..57d7f86 --- /dev/null +++ b/apps/mobile/src/data/gateway/http.ts @@ -0,0 +1,169 @@ +/** + * Real HTTP/WS SessionGateway implementation. Talks to the gateway worker + * (not directly to the control plane). Auth token is read from secure store + * on every request so the gateway instance is stateless. + */ +import * as SecureStore from 'expo-secure-store'; + +import type { + CreateSessionRequest, + SandboxEvent, + Session, + SessionState, +} from '@constructor/protocol'; +import type { + ListSessionsResult, + SessionGateway, + StreamHandle, + StreamListeners, + SubscribeSnapshot, +} from '../gateway'; + +const AUTH_KEY = 'constructor.auth_token'; + +async function getToken(): Promise { + try { + return await SecureStore.getItemAsync(AUTH_KEY); + } catch { + return null; + } +} + +function buildHeaders(token: string): HeadersInit { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; +} + +export class HttpSessionGateway implements SessionGateway { + constructor(private baseUrl: string) {} + + async listSessions(): Promise { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const res = await fetch(`${this.baseUrl}/sessions`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await res.text()); + return (await res.json()) as ListSessionsResult; + } + + async getSession(id: string): Promise { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const res = await fetch(`${this.baseUrl}/sessions/${id}`, { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(await res.text()); + return (await res.json()) as SessionState; + } + + async createSession(req: CreateSessionRequest): Promise<{ sessionId: string }> { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const res = await fetch(`${this.baseUrl}/sessions`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(await res.text()); + return (await res.json()) as { sessionId: string }; + } + + subscribe(id: string, on: StreamListeners): StreamHandle { + // WebSocket is opened directly against the control plane's WS URL, + // but the auth token is fetched via the gateway's /sessions/:id/ws-token proxy. + // For simplicity, we fetch the ws-token via HTTP first, then connect. + let ws: WebSocket | null = null; + 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 }; + + // Connect to WS — use wss:// if gateway is https:// + const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/sessions/${id}/ws`; + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + ws?.send( + JSON.stringify({ + type: 'subscribe', + token: wsToken, + clientId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + }), + ); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'subscribed') { + const snapshot: SubscribeSnapshot = { + state: data.state, + artifacts: data.artifacts ?? [], + replay: data.replay ?? { events: [], hasMore: false, cursor: null }, + }; + on.snapshot(snapshot); + } else if (data.type === 'sandbox_event') { + on.event(data.event as SandboxEvent); + } + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (!closed) on.closed?.('WebSocket closed'); + }; + + ws.onerror = () => { + if (!closed) on.closed?.('WebSocket error'); + }; + }; + + connect(); + + return { + unsubscribe: () => { + closed = true; + ws?.close(); + }, + }; + } + + async sendFollowUp(id: string, content: string): Promise { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const res = await fetch(`${this.baseUrl}/sessions/${id}/prompt`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify({ content }), + }); + if (!res.ok) throw new Error(await res.text()); + } + + async stop(id: string): Promise { + const token = await getToken(); + if (!token) throw new Error('Not authenticated'); + const res = await fetch(`${this.baseUrl}/sessions/${id}/stop`, { + method: 'POST', + headers: buildHeaders(token), + }); + if (!res.ok) throw new Error(await res.text()); + } +} diff --git a/apps/mobile/src/data/mock/mock-gateway.ts b/apps/mobile/src/data/mock/mock-gateway.ts index 982d60a..af0e5b0 100644 --- a/apps/mobile/src/data/mock/mock-gateway.ts +++ b/apps/mobile/src/data/mock/mock-gateway.ts @@ -2,16 +2,16 @@ * implement later — screens never know which one is wired. */ import type { CreateSessionRequest, Session, SessionState } from '@constructor/protocol'; -import type { SessionGateway, StreamHandle, StreamListeners, SubscribeSnapshot } from '../gateway'; +import type { ListSessionsResult, SessionGateway, StreamHandle, StreamListeners, SubscribeSnapshot } from '../gateway'; import { startScriptedStream } from './emitter'; import { mockSessionState, mockSessions, scenarioError, scenarioHappy } from './fixtures'; export class MockSessionGateway implements SessionGateway { private sessions: Session[] = [...mockSessions]; - async listSessions(): Promise { + async listSessions(): Promise { await tick(); - return [...this.sessions]; + return { sessions: [...this.sessions], total: this.sessions.length, hasMore: false }; } async getSession(id: string): Promise { diff --git a/apps/mobile/src/data/provider.tsx b/apps/mobile/src/data/provider.tsx index fcf9060..c95ff20 100644 --- a/apps/mobile/src/data/provider.tsx +++ b/apps/mobile/src/data/provider.tsx @@ -1,9 +1,11 @@ -/** Wires the gateway seam + TanStack Query. Swap `defaultGateway` for the real - * HTTP/WS impl later — nothing else changes. */ +/** Wires the gateway seam + TanStack Query. Swaps MockSessionGateway for + * HttpSessionGateway when the active profile is real. Auth token is read from + * secure store by HttpSessionGateway on every request. */ import React, { createContext, useContext, useMemo } from 'react'; 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'; @@ -17,13 +19,23 @@ export function useGateway(): SessionGateway { export function AppProviders({ children, - gateway, + gatewayUrl, }: { children: React.ReactNode; - gateway?: SessionGateway; + gatewayUrl?: string; }) { - const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), []); - const gw = useMemo(() => gateway ?? new MockSessionGateway(), [gateway]); + const client = useMemo( + () => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), + [], + ); + + const gw = useMemo(() => { + if (gatewayUrl && !gatewayUrl.startsWith('mock://')) { + return new HttpSessionGateway(gatewayUrl.replace(/\/$/, '')); + } + return new MockSessionGateway(); + }, [gatewayUrl]); + return ( diff --git a/apps/mobile/src/data/queries.ts b/apps/mobile/src/data/queries.ts index 480ee45..ff5e309 100644 --- a/apps/mobile/src/data/queries.ts +++ b/apps/mobile/src/data/queries.ts @@ -4,7 +4,7 @@ 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 } from '@/features/sessions/stream/transforms'; +import { costDelta, foldEvent, type PendingRef, type PendingToken } from '@/features/sessions/stream/transforms'; import { useGateway } from './provider'; @@ -12,7 +12,13 @@ export { useGateway } from './provider'; export function useSessions() { const gw = useGateway(); - return useQuery({ queryKey: ['sessions'], queryFn: () => gw.listSessions() }); + return useQuery({ + queryKey: ['sessions'], + queryFn: async () => { + const result = await gw.listSessions(); + return result.sessions; + }, + }); } export function useSession(id: string) { @@ -44,7 +50,7 @@ export function useSessionStream(id: string): SessionStream { const [state, setState] = useState(null); const [events, setEvents] = useState([]); const [cost, setCost] = useState(0); - const pending = useRef(null) as PendingRef; + const pending = useRef(null); useEffect(() => { if (!id) return; diff --git a/apps/mobile/src/features/auth/screen.tsx b/apps/mobile/src/features/auth/screen.tsx index bd34af0..b29c928 100644 --- a/apps/mobile/src/features/auth/screen.tsx +++ b/apps/mobile/src/features/auth/screen.tsx @@ -1,15 +1,13 @@ -/** Phase-1 slice owner: auth. Visual shell only — real OAuth is M1 (gated on - * deployment + a mobile GitHub OAuth App). The primary action is a MOCK that - * routes to '/'; no expo-auth-session, no real GitHub flow here. The in-progress - * state below is cosmetic so the screen feels production-real on Expo Go. - * - * Visual richness is built from `@/ui` primitives + RN core only: no - * expo-linear-gradient / react-native-svg (not in the manifest, no new deps). */ +/** Real GitHub OAuth sign-in via the gateway (PKCE). Uses expo-web-browser + * to open the gateway's /auth/start endpoint, then catches the deep-link + * callback `mobile://auth/callback?token=...` via expo-linking. */ import React from 'react'; -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; -import { Stack } from 'expo-router'; +import { ActivityIndicator, Alert, Linking, StyleSheet, Text, View } from 'react-native'; +import { Stack, useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; import { useAuth } from '@/data/auth'; +import { useProfileStore } from '@/features/profiles/profile-store'; import { Button, Screen, useThemeColors } from '@/ui'; import { Fonts, Spacing } from '@/constants/theme'; @@ -20,33 +18,64 @@ const ACCENT = '#208AEF'; export function SignInScreen() { const { signIn } = useAuth(); + const router = useRouter(); const c = useThemeColors(); + const { activeProfile } = useProfileStore(); const [signingIn, setSigningIn] = React.useState(false); - const timer = React.useRef | null>(null); - React.useEffect(() => () => { - if (timer.current) clearTimeout(timer.current); - }, []); + // 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); + } + } + }); + return () => sub.remove(); + }, [signIn]); - // Mock-only: brief cosmetic "connecting" beat, then route home. The real - // GitHub OAuth handshake replaces this body wholesale in M1. - const onContinue = React.useCallback(() => { - if (signingIn) return; + // 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); + } + }); + }, [signIn]); + + const onContinue = React.useCallback(async () => { + if (!activeProfile) { + Alert.alert('No connection', 'Add a gateway connection in Settings first.'); + return; + } + if (!activeProfile.githubOAuthClientId) { + Alert.alert('No OAuth config', 'This gateway has not returned a GitHub OAuth client id. Check the connection URL.'); + return; + } setSigningIn(true); - // Flip mock auth; the router's Stack.Protected gate reveals the app and - // resolves to the `index` anchor. - timer.current = setTimeout(signIn, 550); - }, [signIn, signingIn]); + try { + const gatewayUrl = activeProfile.gatewayUrl.replace(/\/$/, ''); + const authUrl = `${gatewayUrl}/auth/start?redirect_uri=mobile://auth/callback`; + await WebBrowser.openBrowserAsync(authUrl); + } catch { + setSigningIn(false); + } + }, [activeProfile]); return ( - {/* Per-screen override only — does not touch the frozen src/app layout; - keeps the modal presentation, drops the stock "Sign in" header so the - brand lockup reads as the hero. */} - {/* --- Brand lockup -------------------------------------------------- */} C @@ -57,10 +86,8 @@ export function SignInScreen() { - {/* --- Spacer ------------------------------------------------------- */} - {/* --- Auth affordance ---------------------------------------------- */} - - Mock sign-in · real GitHub OAuth lands in M1 - + {!activeProfile ? ( + + Add a gateway connection in Settings to sign in. + + ) : !activeProfile.githubOAuthClientId ? ( + + Gateway config not discovered yet. Pull to refresh or re-add the connection. + + ) : ( + + Authenticating via {activeProfile.name} + + )} - {/* --- Legal footnote ----------------------------------------------- */} By continuing you agree to the Terms of Service and acknowledge the Privacy Policy. @@ -107,7 +143,6 @@ const styles = StyleSheet.create({ paddingTop: Spacing.six, paddingBottom: Spacing.four, }, - // Brand lockup lockup: { alignItems: 'center', gap: Spacing.three }, appMark: { width: 76, @@ -145,8 +180,6 @@ const styles = StyleSheet.create({ maxWidth: 280, }, flexGap: { flex: 1, minHeight: Spacing.five }, - // Auth block — the @/ui Button supplies its own marginTop (Spacing.four), - // so no extra gap here keeps the provider row → button rhythm tight. authBlock: {}, providerRow: { flexDirection: 'row', @@ -174,7 +207,6 @@ const styles = StyleSheet.create({ fontFamily: Fonts.sans, marginTop: Spacing.two, }, - // Legal legal: { fontSize: 11, lineHeight: 16, diff --git a/apps/mobile/src/features/profiles/profile-store.tsx b/apps/mobile/src/features/profiles/profile-store.tsx index 1a67b9b..330f9f2 100644 --- a/apps/mobile/src/features/profiles/profile-store.tsx +++ b/apps/mobile/src/features/profiles/profile-store.tsx @@ -26,13 +26,18 @@ export type Profile = { id: string; name: string; gatewayUrl: string; - /** Discovered later from the gateway (GET /config) — never user-entered. */ + /** Discovered from the gateway's GET /config — never user-entered. */ wsUrl?: string; + /** GitHub OAuth client id returned by GET /config — used for login. */ + githubOAuthClientId?: string; }; /** Fields the user actually edits in the UI. */ export type ProfileDraft = { name: string; gatewayUrl: string }; +/** Discovered config from a gateway — written back into the profile. */ +export type ProfileConfig = { wsUrl: string; githubOAuthClientId: string }; + type State = { profiles: Profile[]; activeProfileId: string | null; @@ -41,13 +46,14 @@ type State = { type Action = | { type: 'add'; profile: Profile } | { type: 'update'; id: string; draft: ProfileDraft } + | { type: 'setConfig'; id: string; config: ProfileConfig } | { type: 'remove'; id: string } | { type: 'setActive'; id: string }; -let _seq = 0; +import { randomUUID } from 'expo-crypto'; + function makeId(): string { - _seq += 1; - return `p_${Date.now().toString(36)}_${_seq.toString(36)}`; + return `p_${randomUUID()}`; } const SEED: State = (() => { @@ -103,6 +109,18 @@ function reducer(state: State, action: Action): State { ); return { ...state, profiles }; } + case 'setConfig': { + const profiles = state.profiles.map((p) => + p.id === action.id + ? { + ...p, + wsUrl: action.config.wsUrl, + githubOAuthClientId: action.config.githubOAuthClientId, + } + : p, + ); + return { ...state, profiles }; + } case 'remove': { const profiles = state.profiles.filter((p) => p.id !== action.id); let activeProfileId = state.activeProfileId; @@ -126,6 +144,7 @@ type ProfileStore = { activeProfile: Profile | null; addProfile: (draft: ProfileDraft) => Profile; updateProfile: (id: string, draft: ProfileDraft) => void; + setProfileConfig: (id: string, config: ProfileConfig) => void; removeProfile: (id: string) => void; setActiveProfile: (id: string) => void; }; @@ -163,6 +182,10 @@ export function ProfileStoreProvider({ dispatch({ type: 'update', id, draft }); }, []); + const setProfileConfig = useCallback((id: string, config: ProfileConfig) => { + dispatch({ type: 'setConfig', id, config }); + }, []); + const removeProfile = useCallback((id: string) => { dispatch({ type: 'remove', id }); }, []); @@ -180,10 +203,11 @@ export function ProfileStoreProvider({ activeProfile, addProfile, updateProfile, + setProfileConfig, removeProfile, setActiveProfile, }; - }, [state, addProfile, updateProfile, removeProfile, setActiveProfile]); + }, [state, addProfile, updateProfile, setProfileConfig, removeProfile, setActiveProfile]); return ( diff --git a/apps/mobile/src/features/profiles/screen.tsx b/apps/mobile/src/features/profiles/screen.tsx index ad57a87..ec83330 100644 --- a/apps/mobile/src/features/profiles/screen.tsx +++ b/apps/mobile/src/features/profiles/screen.tsx @@ -151,11 +151,32 @@ function ConnectionsList({ // --- add ------------------------------------------------------------------- function AddConnection({ onDone }: { onDone: () => void }) { - const { addProfile } = useProfileStore(); - - const handleSubmit = (draft: ProfileDraft) => { - addProfile(draft); - onDone(); + const { addProfile, setProfileConfig } = useProfileStore(); + const [fetching, setFetching] = React.useState(false); + + const handleSubmit = async (draft: ProfileDraft) => { + const profile = addProfile(draft); + setFetching(true); + try { + const url = draft.gatewayUrl.trim().replace(/\/$/, ''); + const res = await fetch(`${url}/config`, { + headers: { Accept: 'application/json' }, + }); + if (res.ok) { + const data = (await res.json()) as { wsUrl?: string; githubOAuthClientId?: string }; + if (data.wsUrl && data.githubOAuthClientId) { + setProfileConfig(profile.id, { + wsUrl: data.wsUrl, + githubOAuthClientId: data.githubOAuthClientId, + }); + } + } + } catch { + // ignore — non-fatal, user can retry later + } finally { + setFetching(false); + onDone(); + } }; return ( @@ -163,7 +184,7 @@ function AddConnection({ onDone }: { onDone: () => void }) { diff --git a/apps/mobile/src/features/sessions/detail/Composer.tsx b/apps/mobile/src/features/sessions/detail/Composer.tsx index f03960a..f3befda 100644 --- a/apps/mobile/src/features/sessions/detail/Composer.tsx +++ b/apps/mobile/src/features/sessions/detail/Composer.tsx @@ -57,7 +57,7 @@ export function Composer({ return ( diff --git a/apps/mobile/src/ui/index.tsx b/apps/mobile/src/ui/index.tsx index a37cc74..bc6cbe7 100644 --- a/apps/mobile/src/ui/index.tsx +++ b/apps/mobile/src/ui/index.tsx @@ -20,7 +20,6 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Stack } from 'expo-router'; -import Constants from 'expo-constants'; import { Colors, Fonts, Spacing } from '@/constants/theme'; @@ -30,19 +29,6 @@ export function useThemeColors(): Palette { return (useColorScheme() === 'dark' ? Colors.dark : Colors.light) as Palette; } -// --- @expo/ui capability shim --------------------------------------------- -export const isExpoGo = Constants.appOwnership === 'expo'; -let _swiftUI: typeof import('@expo/ui/swift-ui') | null = null; -if (!isExpoGo) { - try { - _swiftUI = require('@expo/ui/swift-ui'); - } catch { - _swiftUI = null; - } -} -/** Opt-in SwiftUI surface. `available` is false in Expo Go — always branch on it. */ -export const nativeUI = { available: !!_swiftUI, swiftUI: _swiftUI } as const; - // --- primitives ------------------------------------------------------------ export function Screen({ children,