From a5052e68de7fbef1f76893d323848b3174e81d9f Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Thu, 25 Jun 2026 15:11:02 +0530 Subject: [PATCH 01/20] feat: login --- .env.example | 27 +- docs/oauth-flow.md | 126 +++++++++ package.json | 2 + pnpm-lock.yaml | 52 ++++ readme.md | 4 + src/auth/crypto.ts | 9 + src/auth/providers.ts | 278 ++++++++++++++++++++ src/auth/store.ts | 569 +++++++++++++++++++++++++++++++++++++++++ src/db/index.ts | 92 +++++++ src/db/init.sql | 97 ++++++- src/env.ts | 10 + src/main.ts | 2 + src/middleware/cors.ts | 2 +- src/routes/auth.ts | 217 ++++++++++++++++ src/websocket/index.ts | 16 ++ 15 files changed, 1499 insertions(+), 4 deletions(-) create mode 100644 docs/oauth-flow.md create mode 100644 src/auth/crypto.ts create mode 100644 src/auth/providers.ts create mode 100644 src/auth/store.ts create mode 100644 src/routes/auth.ts diff --git a/.env.example b/.env.example index 5a484b4..7d63b58 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,27 @@ -# replace with your own values +# Replace with your own values. PORT=3000 -CORS_ORIGIN=https://shellular.dev \ No newline at end of file +HOST=0.0.0.0 +NODE_ENV=dev +CORS_ORIGIN=https://shellular.dev +CONTACT_EMAIL=team@shellular.dev + +# OAuth +# Public API origin used when building provider callback URLs. +AUTH_PUBLIC_BASE_URL=https://api.shellular.dev +# Deep link used after OAuth completes. The mobile app listens for this URL. +AUTH_APP_CALLBACK_URL=shellular://auth-callback + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# GitHub OAuth +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Sign in with Apple +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md new file mode 100644 index 0000000..ea9730c --- /dev/null +++ b/docs/oauth-flow.md @@ -0,0 +1,126 @@ +# Shellular OAuth Flow + +Shellular requires app users to sign in before onboarding, host pairing, or app WebSocket access. The CLI host registration flow remains unauthenticated for now. + +## Configuration + +Copy `.env.example` to `.env` and configure the provider credentials you want to enable. A provider is shown by `GET /auth/providers` only when all required values for that provider are present. + +Required base settings: + +- `NODE_ENV`: `dev` or `prod`. +- `AUTH_PUBLIC_BASE_URL`: public server origin used for OAuth callback URLs, for example `https://api.shellular.dev`. +- `AUTH_APP_CALLBACK_URL`: app deep link used after OAuth completes, usually `shellular://auth-callback`. +- `CORS_ORIGIN`: app/web origin allowed to call the API. + +Provider settings: + +- Google: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. +- GitHub: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`. +- Apple: `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`. + +For Apple, `APPLE_PRIVATE_KEY` should be the PKCS#8 private key from Apple Developer. It can be stored as a single-line value with escaped newlines (`\n`) or as a normal multiline PEM if your environment loader supports it. + +Provider redirect URLs to register: + +- Google: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/google/callback` +- GitHub: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/github/callback` +- Apple: `{AUTH_PUBLIC_BASE_URL}/auth/oauth/apple/callback` + +## Login Sequence + +```mermaid +sequenceDiagram + participant App + participant Server + participant Provider as OAuth Provider + participant SQLite + + App->>Server: GET /auth/providers + Server-->>App: Enabled providers + App->>Server: POST /auth/oauth/:provider/start + Server->>SQLite: Store state hash and PKCE verifier if needed + Server-->>App: authorizationUrl + App->>Provider: Open authorizationUrl + Provider->>Server: Callback with code and state + Server->>SQLite: Consume state + Server->>Provider: Exchange code for provider tokens/profile + Server->>SQLite: Upsert user and linked OAuth account + Server->>SQLite: Store one-time exchange code hash + Server-->>App: Redirect shellular://auth-callback?code=... + App->>Server: POST /auth/exchange + Server->>SQLite: Consume exchange code + Server->>SQLite: Create session, access token hash, refresh token hash + Server-->>App: User, access token, refresh token +``` + +The app receives only a short-lived exchange code through the custom URL scheme. Access and refresh tokens are returned only through the direct `/auth/exchange` API call. + +## Token Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Unauthenticated + Unauthenticated --> OAuthBrowser: User taps provider + OAuthBrowser --> ExchangeCode: Provider redirects to app callback + ExchangeCode --> Authenticated: /auth/exchange succeeds + Authenticated --> Refreshing: Access token near expiry + Refreshing --> Authenticated: /auth/refresh rotates refresh token + Refreshing --> Unauthenticated: Refresh token invalid or idle > 30 days + Authenticated --> Unauthenticated: Logout revokes session +``` + +Access tokens expire after 15 minutes. Refresh tokens rotate on every refresh and expire after 30 days of inactivity. Server-side token storage uses SHA-256 hashes of opaque random tokens, so raw tokens are never stored in SQLite. + +## WebSocket Enforcement + +```mermaid +flowchart TD + A[App wants to connect to /app WebSocket] --> B[App refreshes access token if needed] + B --> C[App opens /app?authToken=ACCESS_TOKEN&hostId=...] + C --> D{Server validates access token} + D -- Invalid or expired --> E[Reject upgrade with 403] + D -- Valid --> F[Validate client info and host availability] + F --> G[Request CLI approval] + G --> H[Join relay session] +``` + +Only the app WebSocket path requires OAuth. The `/cli` WebSocket path and `/host/register` flow are intentionally unchanged. + +## Account Linking + +During normal sign-in, OAuth identities are still resolved by verified email: + +- If an OAuth provider returns an existing verified email, the provider account is attached to that Shellular user. +- If the email is new, the server creates a new Shellular user. +- If no verified email is returned, login fails with an app-facing error. + +The first OAuth account linked to a Shellular user is the primary account. The primary account anchors the user's Shellular email, display name, and avatar, and it cannot be unlinked. + +```mermaid +sequenceDiagram + participant App + participant Server + participant Provider as OAuth Provider + participant SQLite + + App->>Server: POST /auth/oauth/:provider/link/start with access token + Server->>SQLite: Reject if provider already linked + Server->>SQLite: Store link state hash and current user id + Server-->>App: authorizationUrl + App->>Provider: Open authorizationUrl + Provider->>Server: Callback with code and state + Server->>SQLite: Consume link state + Server->>Provider: Exchange code for verified profile + Server->>SQLite: Store one-time link code hash + Server-->>App: Redirect shellular://auth-callback?linkCode=... + App->>Server: POST /auth/oauth/link/exchange with access token + Server->>SQLite: Consume link code and attach provider + Server-->>App: Updated user with linkedAccounts +``` + +Secondary provider emails may differ from the primary email because the user proves control of the provider during OAuth. A secondary OAuth identity cannot be linked if it is already attached to another Shellular user. Users may unlink secondary providers with `DELETE /auth/oauth/accounts/:provider`; unlinking affects future sign-in options only and does not revoke the current Shellular session. + +## Logout And Revocation + +`POST /auth/logout` revokes the active session and all access/refresh tokens for that session. The app also removes the native refresh token from secure storage and returns to the login gate. diff --git a/package.json b/package.json index 0b29244..1213d22 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "type": "commonjs", "dependencies": { "@shellular/protocol": "^0.0.12", + "@oslojs/encoding": "^1.1.0", + "arctic": "^3.7.0", "better-sqlite3": "^12.10.0", "dotenv": "^17.4.1", "express": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e9f87f..bf05472 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,15 @@ importers: .: dependencies: + '@oslojs/encoding': + specifier: ^1.1.0 + version: 1.1.0 '@shellular/protocol': specifier: ^0.0.12 version: 0.0.12 + arctic: + specifier: ^3.7.0 + version: 3.7.0 better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -276,6 +282,24 @@ packages: cpu: [x64] os: [win32] + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@shellular/protocol@0.0.12': resolution: {integrity: sha512-RKwdjk0vjSF3vCS6QEpRnHp9T9lKZsgjCeppvpZHl+80h1uG1/ukzY25w9oEjtshiFi4oOYvD9HsRLyWOgve3Q==} @@ -319,6 +343,9 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -880,6 +907,25 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + '@shellular/protocol@0.0.12': dependencies: '@agentclientprotocol/sdk': 0.22.1(zod@4.3.6) @@ -939,6 +985,12 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + base64-js@1.5.1: {} better-sqlite3@12.10.0: diff --git a/readme.md b/readme.md index ec68abb..e83b5a2 100644 --- a/readme.md +++ b/readme.md @@ -29,6 +29,10 @@ Server listens on `0.0.0.0:3000` by default. pnpm start ``` +## OAuth Login + +See [docs/oauth-flow.md](docs/oauth-flow.md) for provider setup, token lifecycle, and the app/WebSocket authentication flow. + ## License AGPL-3.0-only diff --git a/src/auth/crypto.ts b/src/auth/crypto.ts new file mode 100644 index 0000000..f921fdc --- /dev/null +++ b/src/auth/crypto.ts @@ -0,0 +1,9 @@ +import { createHash, randomBytes } from "node:crypto"; + +export function createToken(prefix: string): string { + return `${prefix}_${randomBytes(32).toString("base64url")}`; +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} diff --git a/src/auth/providers.ts b/src/auth/providers.ts new file mode 100644 index 0000000..01ef6d4 --- /dev/null +++ b/src/auth/providers.ts @@ -0,0 +1,278 @@ +import * as arctic from "arctic"; + +import { env } from "@/env"; +import { BadRequestError } from "@/error/http"; +import { + type AuthProvider, + consumeLoginState, + type LoginState, + type ProviderProfile, + saveLoginState, +} from "./store"; + +const PROVIDERS = ["google", "github", "apple"] as const; + +type ProviderStatus = { + id: AuthProvider; + enabled: boolean; +}; + +type AppleUser = { + name?: { + firstName?: string; + lastName?: string; + }; + email?: string; +}; + +export function listProviders(): ProviderStatus[] { + return PROVIDERS.map((id) => ({ id, enabled: isProviderEnabled(id) })); +} + +export function isProviderEnabled(provider: AuthProvider): boolean { + switch (provider) { + case "google": + return Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); + case "github": + return Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET); + case "apple": + return Boolean( + env.APPLE_CLIENT_ID && + env.APPLE_TEAM_ID && + env.APPLE_KEY_ID && + env.APPLE_PRIVATE_KEY, + ); + } +} + +export function assertProvider(value: string): AuthProvider { + if (PROVIDERS.includes(value as AuthProvider)) { + return value as AuthProvider; + } + throw new BadRequestError("Unsupported OAuth provider."); +} + +export function createAuthorizationUrl(provider: AuthProvider): string { + return createOAuthAuthorizationUrl(provider); +} + +export function createLinkAuthorizationUrl( + provider: AuthProvider, + userId: string, +): string { + return createOAuthAuthorizationUrl(provider, { purpose: "link", userId }); +} + +function createOAuthAuthorizationUrl( + provider: AuthProvider, + options: { purpose?: LoginState["purpose"]; userId?: string } = {}, +): string { + ensureProviderEnabled(provider); + const state = arctic.generateState(); + + if (provider === "google") { + const codeVerifier = arctic.generateCodeVerifier(); + saveLoginState(state, provider, { + codeVerifier, + purpose: options.purpose, + userId: options.userId, + }); + return getGoogle() + .createAuthorizationURL(state, codeVerifier, [ + "openid", + "profile", + "email", + ]) + .toString(); + } + + saveLoginState(state, provider, { + purpose: options.purpose, + userId: options.userId, + }); + if (provider === "github") { + return getGitHub() + .createAuthorizationURL(state, ["read:user", "user:email"]) + .toString(); + } + + const url = getApple().createAuthorizationURL(state, ["name", "email"]); + url.searchParams.set("response_mode", "form_post"); + return url.toString(); +} + +type ProviderCallbackResult = { + profile: ProviderProfile; + loginState: LoginState; +}; + +export async function getProfileFromCallback( + provider: AuthProvider, + code: string, + state: string, + appleUser?: string, +): Promise { + ensureProviderEnabled(provider); + const loginState = consumeLoginState(state, provider); + + if (provider === "google") { + if (!loginState.codeVerifier) { + throw new BadRequestError("Invalid sign-in request."); + } + const tokens = await getGoogle().validateAuthorizationCode( + code, + loginState.codeVerifier, + ); + const claims = arctic.decodeIdToken(tokens.idToken()) as Record< + string, + unknown + >; + return { + loginState, + profile: { + provider, + providerAccountId: String(claims.sub ?? ""), + email: String(claims.email ?? ""), + emailVerified: claims.email_verified === true, + name: asString(claims.name), + avatarUrl: asString(claims.picture), + }, + }; + } + + if (provider === "github") { + const tokens = await getGitHub().validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + const [userResp, emailResp] = await Promise.all([ + fetch("https://api.github.com/user", { + headers: githubHeaders(accessToken), + }), + fetch("https://api.github.com/user/emails", { + headers: githubHeaders(accessToken), + }), + ]); + + if (!userResp.ok || !emailResp.ok) { + throw new BadRequestError("GitHub did not return account details."); + } + + const user = (await userResp.json()) as Record; + const emails = (await emailResp.json()) as Array>; + const primary = + emails.find( + (email) => email.primary === true && email.verified === true, + ) ?? emails.find((email) => email.verified === true); + + return { + loginState, + profile: { + provider, + providerAccountId: String(user.id ?? ""), + email: String(primary?.email ?? ""), + emailVerified: Boolean(primary), + name: asString(user.name) ?? asString(user.login), + avatarUrl: asString(user.avatar_url), + }, + }; + } + + const tokens = await getApple().validateAuthorizationCode(code); + const claims = arctic.decodeIdToken(tokens.idToken()) as Record< + string, + unknown + >; + const parsedAppleUser = parseAppleUser(appleUser); + const name = [ + parsedAppleUser?.name?.firstName, + parsedAppleUser?.name?.lastName, + ] + .filter(Boolean) + .join(" "); + + return { + loginState, + profile: { + provider, + providerAccountId: String(claims.sub ?? ""), + email: String(claims.email ?? parsedAppleUser?.email ?? ""), + emailVerified: + claims.email_verified === true || claims.email_verified === "true", + name: name || null, + avatarUrl: null, + }, + }; +} + +function getGoogle() { + return new arctic.Google( + required(env.GOOGLE_CLIENT_ID, "GOOGLE_CLIENT_ID"), + required(env.GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET"), + callbackUrl("google"), + ); +} + +function getGitHub() { + return new arctic.GitHub( + required(env.GITHUB_CLIENT_ID, "GITHUB_CLIENT_ID"), + required(env.GITHUB_CLIENT_SECRET, "GITHUB_CLIENT_SECRET"), + callbackUrl("github"), + ); +} + +function getApple() { + return new arctic.Apple( + required(env.APPLE_CLIENT_ID, "APPLE_CLIENT_ID"), + required(env.APPLE_TEAM_ID, "APPLE_TEAM_ID"), + required(env.APPLE_KEY_ID, "APPLE_KEY_ID"), + parseApplePrivateKey(required(env.APPLE_PRIVATE_KEY, "APPLE_PRIVATE_KEY")), + callbackUrl("apple"), + ); +} + +function callbackUrl(provider: AuthProvider): string { + return new URL( + `/auth/oauth/${provider}/callback`, + env.AUTH_PUBLIC_BASE_URL, + ).toString(); +} + +function ensureProviderEnabled(provider: AuthProvider): void { + if (!isProviderEnabled(provider)) { + throw new BadRequestError("This sign-in provider is not configured."); + } +} + +function required(value: string | undefined, name: string): string { + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function parseApplePrivateKey(value: string): Uint8Array { + const normalized = value.replace(/\\n/g, "\n"); + const base64 = normalized + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace(/\s/g, ""); + return new Uint8Array(Buffer.from(base64, "base64")); +} + +function parseAppleUser(value?: string): AppleUser | null { + if (!value) return null; + try { + return JSON.parse(value) as AppleUser; + } catch { + return null; + } +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function githubHeaders(accessToken: string): HeadersInit { + return { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "Shellular", + }; +} diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..ffdfe64 --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,569 @@ +import { nanoid } from "nanoid"; +import { db } from "@/db"; +import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; +import { createToken, hashToken } from "./crypto"; + +export type AuthProvider = "google" | "github" | "apple"; + +export type AuthLinkedAccount = { + provider: AuthProvider; + email: string; + isPrimary: boolean; + linkedAt: number; +}; + +export type AuthUser = { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; + linkedAccounts: AuthLinkedAccount[]; +}; + +export type LoginState = { + provider: AuthProvider; + purpose: "signin" | "link"; + userId: string | null; + codeVerifier: string | null; + expiresAt: number; +}; + +type TokenPair = { + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + refreshTokenExpiresAt: number; + user: AuthUser; +}; + +export type ProviderProfile = { + provider: AuthProvider; + providerAccountId: string; + email: string; + emailVerified: boolean; + name?: string | null; + avatarUrl?: string | null; +}; + +const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000; +const REFRESH_TOKEN_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const LOGIN_STATE_TTL_MS = 10 * 60 * 1000; +const EXCHANGE_CODE_TTL_MS = 5 * 60 * 1000; +const LINK_CODE_TTL_MS = 5 * 60 * 1000; + +export function saveLoginState( + state: string, + provider: AuthProvider, + options: { + codeVerifier?: string; + purpose?: LoginState["purpose"]; + userId?: string; + } = {}, +) { + const now = Date.now(); + db.prepare( + "INSERT INTO oauth_login_states (stateHash, provider, purpose, userId, codeVerifier, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run( + hashToken(state), + provider, + options.purpose ?? "signin", + options.userId ?? null, + options.codeVerifier ?? null, + now, + now + LOGIN_STATE_TTL_MS, + ); +} + +export function consumeLoginState( + state: string, + provider: AuthProvider, +): LoginState { + const stateHash = hashToken(state); + const row = db + .prepare( + "SELECT provider, purpose, userId, codeVerifier, expiresAt FROM oauth_login_states WHERE stateHash = ?", + ) + .get(stateHash) as LoginState | undefined; + + db.prepare("DELETE FROM oauth_login_states WHERE stateHash = ?").run( + stateHash, + ); + + if (!row || row.provider !== provider || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This sign-in request expired. Please try again.", + ); + } + + return row; +} + +export function assertProviderCanBeLinked( + userId: string, + provider: AuthProvider, +): void { + const existing = db + .prepare( + "SELECT provider FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(userId, provider) as { provider: AuthProvider } | undefined; + if (existing) { + throw new ConflictError("This provider is already linked."); + } +} + +export function upsertUserFromProvider(profile: ProviderProfile): AuthUser { + const email = verifiedProfileEmail(profile); + + const now = Date.now(); + const account = db + .prepare( + "SELECT userId, isPrimary FROM oauth_accounts WHERE provider = ? AND providerAccountId = ?", + ) + .get(profile.provider, profile.providerAccountId) as + | { userId: string; isPrimary: number } + | undefined; + + let user = account ? getUserById(account.userId) : getUserByEmail(email); + if (!user) { + const id = `u_${nanoid(18)}`; + db.prepare( + "INSERT INTO users (id, email, name, avatarUrl, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run(id, email, profile.name ?? null, profile.avatarUrl ?? null, now, now); + user = getUserById(id); + } else if (!account || account.isPrimary) { + db.prepare( + "UPDATE users SET name = COALESCE(?, name), avatarUrl = COALESCE(?, avatarUrl), updatedAt = ? WHERE id = ?", + ).run(profile.name ?? null, profile.avatarUrl ?? null, now, user.id); + user = getUserById(user.id); + } + + if (!user) { + throw new Error("Failed to create authenticated user"); + } + + if (!account) { + const existingProviderForUser = db + .prepare( + "SELECT providerAccountId FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(user.id, profile.provider) as + | { providerAccountId: string } + | undefined; + if (existingProviderForUser) { + throw new ConflictError("This provider is already linked."); + } + } + + const isPrimary = account?.isPrimary ?? (hasPrimaryAccount(user.id) ? 0 : 1); + db.prepare( + `INSERT INTO oauth_accounts (provider, providerAccountId, userId, email, isPrimary, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, providerAccountId) + DO UPDATE SET userId = excluded.userId, email = excluded.email, updatedAt = excluded.updatedAt`, + ).run( + profile.provider, + profile.providerAccountId, + user.id, + email, + isPrimary, + now, + now, + ); + + return getUserById(user.id) ?? user; +} + +export function createLinkCode( + userId: string, + profile: ProviderProfile, +): string { + const email = verifiedProfileEmail(profile); + const code = createToken("lnk"); + const now = Date.now(); + db.prepare( + `INSERT INTO auth_link_codes + (codeHash, userId, provider, providerAccountId, email, createdAt, expiresAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + hashToken(code), + userId, + profile.provider, + profile.providerAccountId, + email, + now, + now + LINK_CODE_TTL_MS, + ); + return code; +} + +export function exchangeLinkCodeForAccount( + code: string, + userId: string, +): AuthUser { + const codeHash = hashToken(code); + const row = db + .prepare( + `SELECT userId, provider, providerAccountId, email, expiresAt, usedAt + FROM auth_link_codes + WHERE codeHash = ?`, + ) + .get(codeHash) as + | { + userId: string; + provider: AuthProvider; + providerAccountId: string; + email: string; + expiresAt: number; + usedAt: number | null; + } + | undefined; + + if (!row || row.usedAt || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This account-linking request expired. Please try again.", + ); + } + + if (row.userId !== userId) { + throw new ForbiddenError("This account-linking request is not yours."); + } + + db.prepare("UPDATE auth_link_codes SET usedAt = ? WHERE codeHash = ?").run( + Date.now(), + codeHash, + ); + + return linkProviderAccount(userId, { + provider: row.provider, + providerAccountId: row.providerAccountId, + email: row.email, + emailVerified: true, + }); +} + +export function unlinkProviderAccount( + userId: string, + provider: AuthProvider, +): AuthUser { + const row = db + .prepare( + "SELECT isPrimary FROM oauth_accounts WHERE userId = ? AND provider = ?", + ) + .get(userId, provider) as { isPrimary: number } | undefined; + if (!row) { + throw new BadRequestError("This provider is not linked."); + } + if (row.isPrimary) { + throw new ForbiddenError("Your primary account cannot be unlinked."); + } + + db.prepare( + "DELETE FROM oauth_accounts WHERE userId = ? AND provider = ?", + ).run(userId, provider); + const user = getUserById(userId); + if (!user) throw new ForbiddenError("Your account is no longer available."); + return user; +} + +function linkProviderAccount( + userId: string, + profile: ProviderProfile, +): AuthUser { + const email = verifiedProfileEmail(profile); + const user = getUserById(userId); + if (!user) throw new ForbiddenError("Your account is no longer available."); + + const linkedIdentity = db + .prepare( + "SELECT userId FROM oauth_accounts WHERE provider = ? AND providerAccountId = ?", + ) + .get(profile.provider, profile.providerAccountId) as + | { userId: string } + | undefined; + if (linkedIdentity?.userId === userId) { + throw new ConflictError("This provider is already linked."); + } + if (linkedIdentity) { + throw new ConflictError( + "This OAuth account is already linked to another Shellular user.", + ); + } + + assertProviderCanBeLinked(userId, profile.provider); + const now = Date.now(); + db.prepare( + `INSERT INTO oauth_accounts + (provider, providerAccountId, userId, email, isPrimary, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 0, ?, ?)`, + ).run(profile.provider, profile.providerAccountId, userId, email, now, now); + + const nextUser = getUserById(userId); + if (!nextUser) + throw new ForbiddenError("Your account is no longer available."); + return nextUser; +} + +export function createExchangeCode(userId: string): string { + const code = createToken("exc"); + const now = Date.now(); + db.prepare( + "INSERT INTO auth_exchange_codes (codeHash, userId, createdAt, expiresAt) VALUES (?, ?, ?, ?)", + ).run(hashToken(code), userId, now, now + EXCHANGE_CODE_TTL_MS); + return code; +} + +export function exchangeCodeForTokens(code: string): TokenPair { + const codeHash = hashToken(code); + const row = db + .prepare( + "SELECT userId, expiresAt, usedAt FROM auth_exchange_codes WHERE codeHash = ?", + ) + .get(codeHash) as + | { userId: string; expiresAt: number; usedAt: number | null } + | undefined; + + if (!row || row.usedAt || row.expiresAt < Date.now()) { + throw new BadRequestError( + "This sign-in link expired. Please sign in again.", + ); + } + + db.prepare( + "UPDATE auth_exchange_codes SET usedAt = ? WHERE codeHash = ?", + ).run(Date.now(), codeHash); + + return createSession(row.userId); +} + +export function refreshToken(refreshToken: string): TokenPair { + const now = Date.now(); + const tokenHash = hashToken(refreshToken); + const token = db + .prepare( + `SELECT tokenHash, sessionId, userId, expiresAt, revokedAt + FROM auth_refresh_tokens WHERE tokenHash = ?`, + ) + .get(tokenHash) as + | { + tokenHash: string; + sessionId: string; + userId: string; + expiresAt: number; + revokedAt: number | null; + } + | undefined; + + if (!token || token.revokedAt || token.expiresAt < now) { + throw new ForbiddenError("Your session expired. Please sign in again."); + } + + const session = getLiveSession(token.sessionId); + if (!session) { + throw new ForbiddenError("Your session expired. Please sign in again."); + } + + const user = getUserById(token.userId); + if (!user) { + throw new ForbiddenError("Your account is no longer available."); + } + + const nextRefreshToken = createToken("rfr"); + const nextRefreshHash = hashToken(nextRefreshToken); + db.prepare( + "UPDATE auth_refresh_tokens SET revokedAt = ?, replacedByHash = ? WHERE tokenHash = ?", + ).run(now, nextRefreshHash, tokenHash); + db.prepare( + "INSERT INTO auth_refresh_tokens (tokenHash, sessionId, userId, createdAt, lastUsedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run( + nextRefreshHash, + session.id, + user.id, + now, + now, + now + REFRESH_TOKEN_IDLE_TTL_MS, + ); + touchSession(session.id, now + REFRESH_TOKEN_IDLE_TTL_MS); + + const access = createAccessToken(session.id, user.id); + return { + ...access, + refreshToken: nextRefreshToken, + refreshTokenExpiresAt: now + REFRESH_TOKEN_IDLE_TTL_MS, + user, + }; +} + +export function validateAccessToken(accessToken: string): AuthUser | null { + const now = Date.now(); + const row = db + .prepare( + `SELECT sessionId, userId, expiresAt, revokedAt + FROM auth_access_tokens WHERE tokenHash = ?`, + ) + .get(hashToken(accessToken)) as + | { + sessionId: string; + userId: string; + expiresAt: number; + revokedAt: number | null; + } + | undefined; + + if ( + !row || + row.revokedAt || + row.expiresAt < now || + !getLiveSession(row.sessionId) + ) { + return null; + } + + touchSession(row.sessionId); + return getUserById(row.userId) ?? null; +} + +export function revokeSessionByRefreshToken(refreshToken: string): void { + const row = db + .prepare("SELECT sessionId FROM auth_refresh_tokens WHERE tokenHash = ?") + .get(hashToken(refreshToken)) as { sessionId: string } | undefined; + if (row) revokeSession(row.sessionId); +} + +export function revokeSessionByAccessToken(accessToken: string): void { + const row = db + .prepare("SELECT sessionId FROM auth_access_tokens WHERE tokenHash = ?") + .get(hashToken(accessToken)) as { sessionId: string } | undefined; + if (row) revokeSession(row.sessionId); +} + +function createSession(userId: string): TokenPair { + const user = getUserById(userId); + if (!user) { + throw new ForbiddenError("Your account is no longer available."); + } + + const now = Date.now(); + const sessionId = `ses_${nanoid(24)}`; + const refreshTokenValue = createToken("rfr"); + const refreshTokenHash = hashToken(refreshTokenValue); + const refreshExpiresAt = now + REFRESH_TOKEN_IDLE_TTL_MS; + + db.prepare( + "INSERT INTO auth_sessions (id, userId, createdAt, lastSeenAt, expiresAt) VALUES (?, ?, ?, ?, ?)", + ).run(sessionId, userId, now, now, refreshExpiresAt); + db.prepare( + "INSERT INTO auth_refresh_tokens (tokenHash, sessionId, userId, createdAt, lastUsedAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?)", + ).run(refreshTokenHash, sessionId, userId, now, now, refreshExpiresAt); + + const access = createAccessToken(sessionId, userId); + return { + ...access, + refreshToken: refreshTokenValue, + refreshTokenExpiresAt: refreshExpiresAt, + user, + }; +} + +function createAccessToken(sessionId: string, userId: string) { + const accessToken = createToken("acc"); + const now = Date.now(); + const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS; + db.prepare( + "INSERT INTO auth_access_tokens (tokenHash, sessionId, userId, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?)", + ).run(hashToken(accessToken), sessionId, userId, now, accessTokenExpiresAt); + return { accessToken, accessTokenExpiresAt }; +} + +function getLiveSession(sessionId: string) { + const now = Date.now(); + return db + .prepare( + "SELECT id FROM auth_sessions WHERE id = ? AND revokedAt IS NULL AND expiresAt > ?", + ) + .get(sessionId, now) as { id: string } | undefined; +} + +function touchSession(sessionId: string, expiresAt?: number): void { + const now = Date.now(); + db.prepare( + "UPDATE auth_sessions SET lastSeenAt = ?, expiresAt = COALESCE(?, expiresAt) WHERE id = ?", + ).run(now, expiresAt ?? null, sessionId); +} + +function revokeSession(sessionId: string): void { + const now = Date.now(); + db.prepare("UPDATE auth_sessions SET revokedAt = ? WHERE id = ?").run( + now, + sessionId, + ); + db.prepare( + "UPDATE auth_access_tokens SET revokedAt = ? WHERE sessionId = ? AND revokedAt IS NULL", + ).run(now, sessionId); + db.prepare( + "UPDATE auth_refresh_tokens SET revokedAt = ? WHERE sessionId = ? AND revokedAt IS NULL", + ).run(now, sessionId); +} + +function getUserById(id: string): AuthUser | undefined { + const user = db + .prepare("SELECT id, email, name, avatarUrl FROM users WHERE id = ?") + .get(id) as Omit | undefined; + return user ? { ...user, linkedAccounts: getLinkedAccounts(id) } : undefined; +} + +function getUserByEmail(email: string): AuthUser | undefined { + const user = db + .prepare("SELECT id, email, name, avatarUrl FROM users WHERE email = ?") + .get(email) as Omit | undefined; + return user + ? { ...user, linkedAccounts: getLinkedAccounts(user.id) } + : undefined; +} + +function getLinkedAccounts(userId: string): AuthLinkedAccount[] { + const rows = db + .prepare( + `SELECT provider, email, isPrimary, createdAt + FROM oauth_accounts + WHERE userId = ? + ORDER BY isPrimary DESC, createdAt ASC, provider ASC`, + ) + .all(userId) as Array<{ + provider: AuthProvider; + email: string; + isPrimary: number; + createdAt: number; + }>; + return rows.map((row) => ({ + provider: row.provider, + email: row.email, + isPrimary: Boolean(row.isPrimary), + linkedAt: row.createdAt, + })); +} + +function hasPrimaryAccount(userId: string): boolean { + const row = db + .prepare( + "SELECT provider FROM oauth_accounts WHERE userId = ? AND isPrimary = 1 LIMIT 1", + ) + .get(userId) as { provider: AuthProvider } | undefined; + return Boolean(row); +} + +function verifiedProfileEmail(profile: ProviderProfile): string { + if (!profile.emailVerified) { + throw new ForbiddenError( + "Your OAuth provider did not return a verified email address.", + ); + } + + const email = profile.email.trim().toLowerCase(); + if (!email) { + throw new ForbiddenError( + "Your OAuth provider did not return an email address.", + ); + } + return email; +} diff --git a/src/db/index.ts b/src/db/index.ts index f6d9780..8963cb1 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -29,6 +29,7 @@ if (isLeader) { const start = Date.now(); const initSql = readFileSync(initSQLPath, "utf-8"); db.exec(initSql); + runAuthMigrations(); const end = Date.now(); logger.info(`📀✅ SQLite ready at ${dbPath} in ${end - start}ms`); @@ -37,3 +38,94 @@ if (isLeader) { `📀⏭️ Skipping initialization SQL on non-leader instance (NODE_APP_INSTANCE=${pm2Instance})`, ); } + +function runAuthMigrations(): void { + ensureColumn( + "oauth_accounts", + "isPrimary", + "ALTER TABLE oauth_accounts ADD COLUMN isPrimary INTEGER NOT NULL DEFAULT 0", + ); + ensureColumn( + "oauth_login_states", + "purpose", + "ALTER TABLE oauth_login_states ADD COLUMN purpose TEXT NOT NULL DEFAULT 'signin'", + ); + ensureColumn( + "oauth_login_states", + "userId", + "ALTER TABLE oauth_login_states ADD COLUMN userId TEXT", + ); + db.exec(` + CREATE TABLE IF NOT EXISTS auth_link_codes ( + codeHash TEXT PRIMARY KEY, + userId TEXT NOT NULL, + provider TEXT NOT NULL, + providerAccountId TEXT NOT NULL, + email TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + usedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + `); + backfillPrimaryOAuthAccounts(); + ensureUserProviderIndex(); +} + +function ensureColumn(table: string, column: string, alterSql: string): void { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name: string; + }>; + if (!columns.some((row) => row.name === column)) { + db.exec(alterSql); + } +} + +function backfillPrimaryOAuthAccounts(): void { + const users = db + .prepare("SELECT DISTINCT userId FROM oauth_accounts") + .all() as Array<{ userId: string }>; + + const accountsForUser = db.prepare( + `SELECT rowid, isPrimary + FROM oauth_accounts + WHERE userId = ? + ORDER BY createdAt ASC, updatedAt ASC, provider ASC, providerAccountId ASC`, + ); + const updatePrimary = db.prepare( + "UPDATE oauth_accounts SET isPrimary = ? WHERE rowid = ?", + ); + + for (const { userId } of users) { + const accounts = accountsForUser.all(userId) as Array<{ + rowid: number; + isPrimary: number; + }>; + if (accounts.length === 0) continue; + const primary = accounts[0]; + for (const account of accounts) { + updatePrimary.run(account.rowid === primary.rowid ? 1 : 0, account.rowid); + } + } +} + +function ensureUserProviderIndex(): void { + const duplicates = db + .prepare( + `SELECT userId, provider, COUNT(*) AS count + FROM oauth_accounts + GROUP BY userId, provider + HAVING count > 1`, + ) + .all(); + if (duplicates.length > 0) { + logger.error( + "Skipping oauth_accounts user/provider unique index because duplicates exist", + duplicates, + ); + return; + } + db.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts (userId, provider)", + ); +} diff --git a/src/db/init.sql b/src/db/init.sql index fb414f9..df54e12 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -24,4 +24,99 @@ CREATE TABLE IF NOT EXISTS clients ( appVersion TEXT NOT NULL, -- static createdAt INTEGER NOT NULL -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + avatarUrl TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS oauth_accounts ( + provider TEXT NOT NULL, + providerAccountId TEXT NOT NULL, + userId TEXT NOT NULL, + email TEXT NOT NULL, + isPrimary INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + PRIMARY KEY (provider, providerAccountId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_userId ON oauth_accounts (userId); +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_email ON oauth_accounts (email); + +CREATE TABLE IF NOT EXISTS oauth_login_states ( + stateHash TEXT PRIMARY KEY, + provider TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'signin', + userId TEXT, + codeVerifier TEXT, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_exchange_codes ( + codeHash TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + usedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS auth_link_codes ( + codeHash TEXT PRIMARY KEY, + userId TEXT NOT NULL, + provider TEXT NOT NULL, + providerAccountId TEXT NOT NULL, + email TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + usedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_userId ON auth_sessions (userId); + +CREATE TABLE IF NOT EXISTS auth_access_tokens ( + tokenHash TEXT PRIMARY KEY, + sessionId TEXT NOT NULL, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + FOREIGN KEY (sessionId) REFERENCES auth_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_access_tokens_sessionId ON auth_access_tokens (sessionId); + +CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( + tokenHash TEXT PRIMARY KEY, + sessionId TEXT NOT NULL, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + lastUsedAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + revokedAt INTEGER, + replacedByHash TEXT, + FOREIGN KEY (sessionId) REFERENCES auth_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_sessionId ON auth_refresh_tokens (sessionId); diff --git a/src/env.ts b/src/env.ts index 62a4a39..4e48d55 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,16 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().min(1).default("*"), NODE_ENV: z.enum(["dev", "prod"]), CONTACT_EMAIL: z.email().default("team@shellular.dev"), + AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), + AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), + APPLE_CLIENT_ID: z.string().optional(), + APPLE_TEAM_ID: z.string().optional(), + APPLE_KEY_ID: z.string().optional(), + APPLE_PRIVATE_KEY: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/src/main.ts b/src/main.ts index 4560c29..f6a327d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { env } from "@/env"; import { HttpError } from "@/error/http"; import { logger } from "@/logger"; import cors from "@/middleware/cors"; +import { router as authRouter } from "@/routes/auth"; import { router as hostRouter } from "@/routes/host"; import { printRoutes } from "@/utils/express"; import { initWebSocketRelay } from "@/websocket/index"; @@ -29,6 +30,7 @@ app.set("trust proxy", 1); // trust first proxy (if behind a proxy like nginx or app.use(cors); app.use(express.json()); +app.use(authRouter); app.use(hostRouter); app.use((req, res, next) => { diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index c8d5ed0..84367ad 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -7,7 +7,7 @@ export default function cors(req: Request, res: Response, next: NextFunction) { res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.header( "Access-Control-Allow-Headers", - "Origin, Content-Type, Accept, x-auth-token", + "Origin, Content-Type, Accept, Authorization, x-auth-token", ); res.header("Vary", "Origin"); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..1ba943e --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,217 @@ +import express, { Router } from "express"; +import { rateLimit } from "express-rate-limit"; +import { z } from "zod"; +import { + assertProvider, + createAuthorizationUrl, + createLinkAuthorizationUrl, + getProfileFromCallback, + listProviders, +} from "@/auth/providers"; +import { + assertProviderCanBeLinked, + createExchangeCode, + createLinkCode, + exchangeCodeForTokens, + exchangeLinkCodeForAccount, + type LoginState, + type ProviderProfile, + refreshToken, + revokeSessionByAccessToken, + revokeSessionByRefreshToken, + unlinkProviderAccount, + upsertUserFromProvider, + validateAccessToken, +} from "@/auth/store"; +import { env } from "@/env"; +import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; + +export const router = Router(); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 60, + standardHeaders: false, + legacyHeaders: false, +}); + +const OAuthStartSchema = z.object({ + provider: z.string(), +}); + +const OAuthCallbackSchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +const ExchangeSchema = z.object({ + code: z.string().min(1), +}); + +const RefreshSchema = z.object({ + refreshToken: z.string().min(1), +}); + +router.get("/auth/providers", (_req, res) => { + res.json({ success: true, data: { providers: listProviders() } }); +}); + +router.post("/auth/oauth/:provider/start", authLimiter, (req, res) => { + const { provider: rawProvider } = OAuthStartSchema.parse(req.params); + const provider = assertProvider(rawProvider); + const authorizationUrl = createAuthorizationUrl(provider); + res.json({ success: true, data: { authorizationUrl } }); +}); + +router.post("/auth/oauth/:provider/link/start", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const { provider: rawProvider } = OAuthStartSchema.parse(req.params); + const provider = assertProvider(rawProvider); + assertProviderCanBeLinked(user.id, provider); + const authorizationUrl = createLinkAuthorizationUrl(provider, user.id); + res.json({ success: true, data: { authorizationUrl } }); +}); + +router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { + const provider = assertProvider(String(req.params.provider)); + if (provider === "apple") { + throw new BadRequestError("Apple sign-in callback must use POST."); + } + + const parsed = OAuthCallbackSchema.safeParse(req.query); + if (!parsed.success) { + res.redirect(callbackUrl({ error: "invalid_callback" })); + return; + } + + try { + const result = await getProfileFromCallback( + provider, + parsed.data.code, + parsed.data.state, + ); + res.redirect(callbackUrl(completeOAuthCallback(result))); + } catch (error) { + res.redirect(callbackUrl({ error: callbackError(error) })); + } +}); + +router.post( + "/auth/oauth/apple/callback", + authLimiter, + express.urlencoded({ extended: false }), + async (req, res) => { + const parsed = OAuthCallbackSchema.safeParse(req.body); + if (!parsed.success) { + res.redirect(callbackUrl({ error: "invalid_callback" })); + return; + } + + try { + const result = await getProfileFromCallback( + "apple", + parsed.data.code, + parsed.data.state, + typeof req.body.user === "string" ? req.body.user : undefined, + ); + res.redirect(callbackUrl(completeOAuthCallback(result))); + } catch (error) { + res.redirect(callbackUrl({ error: callbackError(error) })); + } + }, +); + +router.post("/auth/exchange", authLimiter, (req, res) => { + const { code } = ExchangeSchema.parse(req.body); + res.json({ success: true, data: exchangeCodeForTokens(code) }); +}); + +router.post("/auth/oauth/link/exchange", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const { code } = ExchangeSchema.parse(req.body); + res.json({ + success: true, + data: { user: exchangeLinkCodeForAccount(code, user.id) }, + }); +}); + +router.post("/auth/refresh", authLimiter, (req, res) => { + const { refreshToken: token } = RefreshSchema.parse(req.body); + res.json({ success: true, data: refreshToken(token) }); +}); + +router.get("/auth/me", (req, res) => { + const user = requireAuthUser(req); + res.json({ success: true, data: { user } }); +}); + +router.post("/auth/logout", (req, res) => { + const accessToken = getBearerToken(req); + if (accessToken) { + revokeSessionByAccessToken(accessToken); + } + const refresh = RefreshSchema.safeParse(req.body); + if (refresh.success) { + revokeSessionByRefreshToken(refresh.data.refreshToken); + } + res.json({ success: true }); +}); + +router.delete("/auth/oauth/accounts/:provider", authLimiter, (req, res) => { + const user = requireAuthUser(req); + const provider = assertProvider(String(req.params.provider)); + res.json({ + success: true, + data: { user: unlinkProviderAccount(user.id, provider) }, + }); +}); + +export function requireAuthUser(req: express.Request) { + const token = getBearerToken(req); + if (!token) throw new ForbiddenError("Authentication required."); + const user = validateAccessToken(token); + if (!user) throw new ForbiddenError("Authentication required."); + return user; +} + +function getBearerToken(req: express.Request): string | null { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) return null; + return header.slice("Bearer ".length).trim() || null; +} + +function callbackUrl(params: Record): string { + const url = new URL(env.AUTH_APP_CALLBACK_URL); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +function completeOAuthCallback(result: { + profile: ProviderProfile; + loginState: LoginState; +}): Record { + if (result.loginState.purpose === "link") { + if (!result.loginState.userId) { + throw new BadRequestError("Invalid account-linking request."); + } + return { + linkCode: createLinkCode(result.loginState.userId, result.profile), + }; + } + + const user = upsertUserFromProvider(result.profile); + return { code: createExchangeCode(user.id) }; +} + +function callbackError(error: unknown): string { + if ( + error instanceof BadRequestError || + error instanceof ForbiddenError || + error instanceof ConflictError + ) { + return error.message; + } + return "oauth_failed"; +} diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 238f9bc..00eb950 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -4,6 +4,7 @@ import type { Duplex } from "node:stream"; import { ClientInfoSchema } from "@shellular/protocol"; import { z } from "zod"; +import { validateAccessToken } from "@/auth/store"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; import { env } from "@/env"; @@ -17,6 +18,10 @@ const HostQuerySchema = z.object({ hostId: z.string(), }); +const AppAuthQuerySchema = z.object({ + authToken: z.string().min(1), +}); + const cliWsServer = initCliWebSocket(); const appWsServer = initAppWebSocket(); @@ -73,6 +78,17 @@ async function handleUpgradeRequest( return; } + const authParsed = AppAuthQuerySchema.safeParse(query); + if ( + !authParsed.success || + !validateAccessToken(authParsed.data.authToken) + ) { + logger.warn("Rejecting app websocket: missing or invalid auth token"); + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + // async approval before upgrade is fine const parsed = ClientInfoSchema.safeParse(query); From 477f0eb51ef29657947873696e3f3a744095c175 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sun, 28 Jun 2026 02:13:09 +0530 Subject: [PATCH 02/20] feat: issue app websocket tickets --- .env.example | 2 +- .gitignore | 3 +- docs/oauth-flow.md | 4 +- readme.md | 22 ++++++ src/auth/providers.ts | 23 ++++++- src/auth/ws-ticket.ts | 100 +++++++++++++++++++++++++++ src/db/index.ts | 23 +++++++ src/db/init.sql | 19 ++++++ src/db/user-history.ts | 150 +++++++++++++++++++++++++++++++++++++++++ src/env.ts | 2 +- src/routes/auth.ts | 38 +++++++++++ src/websocket/index.ts | 53 +++++++-------- 12 files changed, 405 insertions(+), 34 deletions(-) create mode 100644 src/auth/ws-ticket.ts create mode 100644 src/db/user-history.ts diff --git a/.env.example b/.env.example index 7d63b58..b8c7cf0 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ HOST=0.0.0.0 NODE_ENV=dev CORS_ORIGIN=https://shellular.dev CONTACT_EMAIL=team@shellular.dev +WS_TOKEN_SECRET=replace-with-at-least-32-random-characters # OAuth # Public API origin used when building provider callback URLs. @@ -24,4 +25,3 @@ GITHUB_CLIENT_SECRET= APPLE_CLIENT_ID= APPLE_TEAM_ID= APPLE_KEY_ID= -APPLE_PRIVATE_KEY= diff --git a/.gitignore b/.gitignore index fff61fc..2a63efb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ mermaid/ *.local -credentials.json \ No newline at end of file +credentials.json +apple_key.p8 \ No newline at end of file diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index ea9730c..7a7dbc9 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -17,9 +17,9 @@ Provider settings: - Google: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. - GitHub: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`. -- Apple: `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`. +- Apple: `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, plus the `server/apple_key.p8` file. -For Apple, `APPLE_PRIVATE_KEY` should be the PKCS#8 private key from Apple Developer. It can be stored as a single-line value with escaped newlines (`\n`) or as a normal multiline PEM if your environment loader supports it. +For Apple, the private key is the PKCS#8 `.p8` file from Apple Developer. Place it at `server/apple_key.p8`; this file is ignored by git and read directly by the server. Provider redirect URLs to register: diff --git a/readme.md b/readme.md index e83b5a2..dc8a42a 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,12 @@ WebSocket relay server for [Shellular](https://shellular.dev) — relays message pnpm install ``` +### Environment + +Create a local `.env` from `.env.example`. The server requires `WS_TOKEN_SECRET` with at least 32 characters for signing short-lived app WebSocket tickets. + +OAuth provider settings live in `.env`. Apple Sign in with Apple uses `APPLE_CLIENT_ID`, `APPLE_TEAM_ID`, and `APPLE_KEY_ID`, and reads the private key from the ignored `apple_key.p8` file in the server directory. + ### Run (watch mode) ```bash @@ -33,6 +39,22 @@ pnpm start See [docs/oauth-flow.md](docs/oauth-flow.md) for provider setup, token lifecycle, and the app/WebSocket authentication flow. +## App WebSocket Authentication + +The app no longer sends access tokens or device metadata in the `/app` WebSocket URL. Instead: + +1. The app refreshes its access token. +2. The app sends `POST /auth/ws-token` with `Authorization: Bearer ` and client metadata in the JSON body. +3. The server validates the user token, `ClientInfoSchema`, host existence, and known-client identity. +4. The server returns a signed app WebSocket ticket that expires after 30 seconds. +5. The app opens `/app?wsToken=`. + +The `/cli` WebSocket and `/host/register` flow are unchanged. + +## User History + +Successful app joins are recorded in `user_connection_history` for read-only account history. `GET /auth/history` returns the authenticated user's host and device history for display in the app. This data is intended for visibility, history, and analytics only; it is not used for host sync or E2EE key sync. + ## License AGPL-3.0-only diff --git a/src/auth/providers.ts b/src/auth/providers.ts index 01ef6d4..f03ef33 100644 --- a/src/auth/providers.ts +++ b/src/auth/providers.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import * as arctic from "arctic"; import { env } from "@/env"; @@ -11,6 +13,7 @@ import { } from "./store"; const PROVIDERS = ["google", "github", "apple"] as const; +const APPLE_PRIVATE_KEY_FILE = "apple_key.p8"; type ProviderStatus = { id: AuthProvider; @@ -40,7 +43,7 @@ export function isProviderEnabled(provider: AuthProvider): boolean { env.APPLE_CLIENT_ID && env.APPLE_TEAM_ID && env.APPLE_KEY_ID && - env.APPLE_PRIVATE_KEY, + hasApplePrivateKey(), ); } } @@ -224,7 +227,7 @@ function getApple() { required(env.APPLE_CLIENT_ID, "APPLE_CLIENT_ID"), required(env.APPLE_TEAM_ID, "APPLE_TEAM_ID"), required(env.APPLE_KEY_ID, "APPLE_KEY_ID"), - parseApplePrivateKey(required(env.APPLE_PRIVATE_KEY, "APPLE_PRIVATE_KEY")), + parseApplePrivateKey(getApplePrivateKey()), callbackUrl("apple"), ); } @@ -247,6 +250,22 @@ function required(value: string | undefined, name: string): string { return value; } +function getApplePrivateKey(): string { + const path = applePrivateKeyFilePath(); + if (!existsSync(path)) { + throw new Error(`Apple private key file not found: ${path}`); + } + return readFileSync(path, "utf8"); +} + +function hasApplePrivateKey(): boolean { + return existsSync(applePrivateKeyFilePath()); +} + +function applePrivateKeyFilePath(): string { + return resolve(__dirname, "..", "..", APPLE_PRIVATE_KEY_FILE); +} + function parseApplePrivateKey(value: string): Uint8Array { const normalized = value.replace(/\\n/g, "\n"); const base64 = normalized diff --git a/src/auth/ws-ticket.ts b/src/auth/ws-ticket.ts new file mode 100644 index 0000000..4058d55 --- /dev/null +++ b/src/auth/ws-ticket.ts @@ -0,0 +1,100 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; +import { z } from "zod"; +import { env } from "@/env"; + +export const APP_WEBSOCKET_TOKEN_TTL_SECONDS = 30; + +const TOKEN_HEADER = { alg: "HS256", typ: "JWT" }; +// Tickets are not stored or consumed server-side in this pass; replay is limited +// to the short TTL. +const TOKEN_SECRET = env.WS_TOKEN_SECRET; + +const AppWebSocketTokenPayloadSchema = ClientInfoSchema.extend({ + userId: z.string().min(1), + iat: z.number().int(), + exp: z.number().int(), + jti: z.string().min(1), +}); + +export type AppWebSocketTokenPayload = z.infer< + typeof AppWebSocketTokenPayloadSchema +>; + +export function createAppWebSocketToken( + userId: string, + clientInfo: ClientInfo, +): string { + const iat = Math.floor(Date.now() / 1000); + const payload: AppWebSocketTokenPayload = { + ...clientInfo, + userId, + iat, + exp: iat + APP_WEBSOCKET_TOKEN_TTL_SECONDS, + jti: randomBytes(16).toString("base64url"), + }; + + const encodedHeader = encodeJson(TOKEN_HEADER); + const encodedPayload = encodeJson(payload); + const signingInput = `${encodedHeader}.${encodedPayload}`; + return `${signingInput}.${sign(signingInput)}`; +} + +export function verifyAppWebSocketToken( + token: string, +): AppWebSocketTokenPayload | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const [encodedHeader, encodedPayload, signature] = parts; + const signingInput = `${encodedHeader}.${encodedPayload}`; + if (!constantTimeEqual(signature, sign(signingInput))) return null; + + const header = decodeJson(encodedHeader); + if ( + !header || + header.alg !== TOKEN_HEADER.alg || + header.typ !== TOKEN_HEADER.typ + ) { + return null; + } + + const payload = decodeJson(encodedPayload); + const parsed = AppWebSocketTokenPayloadSchema.safeParse(payload); + if (!parsed.success) return null; + + const now = Math.floor(Date.now() / 1000); + if (parsed.data.exp <= now) return null; + + return parsed.data; +} + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeJson(value: string): Record | null { + try { + const decoded = Buffer.from(value, "base64url").toString("utf8"); + const parsed = JSON.parse(decoded); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function sign(value: string): string { + return createHmac("sha256", TOKEN_SECRET).update(value).digest("base64url"); +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return ( + leftBuffer.length === rightBuffer.length && + timingSafeEqual(leftBuffer, rightBuffer) + ); +} diff --git a/src/db/index.ts b/src/db/index.ts index 8963cb1..4bb0593 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -68,6 +68,7 @@ function runAuthMigrations(): void { FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE ); `); + ensureUserConnectionHistoryTable(); backfillPrimaryOAuthAccounts(); ensureUserProviderIndex(); } @@ -109,6 +110,28 @@ function backfillPrimaryOAuthAccounts(): void { } } +function ensureUserConnectionHistoryTable(): void { + db.exec(` + CREATE TABLE IF NOT EXISTS user_connection_history ( + userId TEXT NOT NULL, + hostId TEXT NOT NULL, + clientId TEXT NOT NULL, + appVersion TEXT NOT NULL, + platform TEXT NOT NULL, + deviceModel TEXT NOT NULL, + deviceIsEmulator INTEGER NOT NULL, + deviceManufacturer TEXT NOT NULL, + firstSeenAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + connectionCount INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (userId, hostId, clientId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_user_connection_history_user_lastSeen + ON user_connection_history (userId, lastSeenAt); + `); +} + function ensureUserProviderIndex(): void { const duplicates = db .prepare( diff --git a/src/db/init.sql b/src/db/init.sql index df54e12..4e4cb2d 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -120,3 +120,22 @@ CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( ); CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_sessionId ON auth_refresh_tokens (sessionId); + +CREATE TABLE IF NOT EXISTS user_connection_history ( + userId TEXT NOT NULL, + hostId TEXT NOT NULL, + clientId TEXT NOT NULL, + appVersion TEXT NOT NULL, + platform TEXT NOT NULL, + deviceModel TEXT NOT NULL, + deviceIsEmulator INTEGER NOT NULL, + deviceManufacturer TEXT NOT NULL, + firstSeenAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + connectionCount INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (userId, hostId, clientId), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_connection_history_user_lastSeen + ON user_connection_history (userId, lastSeenAt); diff --git a/src/db/user-history.ts b/src/db/user-history.ts new file mode 100644 index 0000000..5257ace --- /dev/null +++ b/src/db/user-history.ts @@ -0,0 +1,150 @@ +import type { ClientInfo } from "@shellular/protocol"; + +import { db } from "./index"; + +export type UserHostHistory = { + hostId: string; + machineId: string | null; + platform: string | null; + firstSeenAt: number; + lastSeenAt: number; + connectionCount: number; +}; + +export type UserDeviceHistory = { + clientId: string; + lastHostId: string; + appVersion: string; + platform: ClientInfo["platform"]; + deviceModel: string; + deviceIsEmulator: boolean; + deviceManufacturer: string; + firstSeenAt: number; + lastSeenAt: number; + connectionCount: number; +}; + +export type UserConnectionHistory = { + hosts: UserHostHistory[]; + devices: UserDeviceHistory[]; +}; + +type UserConnectionHistoryRow = { + hostId: string; + clientId: string; + appVersion: string; + platform: ClientInfo["platform"]; + deviceModel: string; + deviceIsEmulator: number; + deviceManufacturer: string; + firstSeenAt: number; + lastSeenAt: number; + connectionCount: number; + hostMachineId: string | null; + hostPlatform: string | null; +}; + +export function recordUserConnectionHistory( + userId: string, + clientInfo: ClientInfo, +): void { + const now = Date.now(); + db.prepare( + `INSERT INTO user_connection_history + (userId, hostId, clientId, appVersion, platform, deviceModel, deviceIsEmulator, deviceManufacturer, firstSeenAt, lastSeenAt, connectionCount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + ON CONFLICT(userId, hostId, clientId) + DO UPDATE SET + appVersion = excluded.appVersion, + platform = excluded.platform, + deviceModel = excluded.deviceModel, + deviceIsEmulator = excluded.deviceIsEmulator, + deviceManufacturer = excluded.deviceManufacturer, + lastSeenAt = excluded.lastSeenAt, + connectionCount = user_connection_history.connectionCount + 1`, + ).run( + userId, + clientInfo.hostId, + clientInfo.clientId, + clientInfo.appVersion, + clientInfo.platform, + clientInfo.deviceModel, + clientInfo.deviceIsEmulator ? 1 : 0, + clientInfo.deviceManufacturer, + now, + now, + ); +} + +export function listUserConnectionHistory( + userId: string, +): UserConnectionHistory { + const rows = db + .prepare( + `SELECT + history.hostId, + history.clientId, + history.appVersion, + history.platform, + history.deviceModel, + history.deviceIsEmulator, + history.deviceManufacturer, + history.firstSeenAt, + history.lastSeenAt, + history.connectionCount, + hosts.machineId AS hostMachineId, + hosts.platform AS hostPlatform + FROM user_connection_history history + LEFT JOIN hosts ON hosts.id = history.hostId + WHERE history.userId = ? + ORDER BY history.lastSeenAt DESC + LIMIT 500`, + ) + .all(userId) as UserConnectionHistoryRow[]; + + const hosts = new Map(); + const devices = new Map(); + + for (const row of rows) { + const host = hosts.get(row.hostId); + if (host) { + host.firstSeenAt = Math.min(host.firstSeenAt, row.firstSeenAt); + host.lastSeenAt = Math.max(host.lastSeenAt, row.lastSeenAt); + host.connectionCount += row.connectionCount; + } else { + hosts.set(row.hostId, { + hostId: row.hostId, + machineId: row.hostMachineId, + platform: row.hostPlatform, + firstSeenAt: row.firstSeenAt, + lastSeenAt: row.lastSeenAt, + connectionCount: row.connectionCount, + }); + } + + const device = devices.get(row.clientId); + if (device) { + device.firstSeenAt = Math.min(device.firstSeenAt, row.firstSeenAt); + device.lastSeenAt = Math.max(device.lastSeenAt, row.lastSeenAt); + device.connectionCount += row.connectionCount; + } else { + devices.set(row.clientId, { + clientId: row.clientId, + lastHostId: row.hostId, + appVersion: row.appVersion, + platform: row.platform, + deviceModel: row.deviceModel, + deviceIsEmulator: row.deviceIsEmulator === 1, + deviceManufacturer: row.deviceManufacturer, + firstSeenAt: row.firstSeenAt, + lastSeenAt: row.lastSeenAt, + connectionCount: row.connectionCount, + }); + } + } + + return { + hosts: [...hosts.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt), + devices: [...devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt), + }; +} diff --git a/src/env.ts b/src/env.ts index 4e48d55..61a87dc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,7 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().min(1).default("*"), NODE_ENV: z.enum(["dev", "prod"]), CONTACT_EMAIL: z.email().default("team@shellular.dev"), + WS_TOKEN_SECRET: z.string().min(32), AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), GOOGLE_CLIENT_ID: z.string().optional(), @@ -18,7 +19,6 @@ const envSchema = z.object({ APPLE_CLIENT_ID: z.string().optional(), APPLE_TEAM_ID: z.string().optional(), APPLE_KEY_ID: z.string().optional(), - APPLE_PRIVATE_KEY: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 1ba943e..4389950 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,3 +1,4 @@ +import { ClientInfoSchema } from "@shellular/protocol"; import express, { Router } from "express"; import { rateLimit } from "express-rate-limit"; import { z } from "zod"; @@ -23,6 +24,13 @@ import { upsertUserFromProvider, validateAccessToken, } from "@/auth/store"; +import { + APP_WEBSOCKET_TOKEN_TTL_SECONDS, + createAppWebSocketToken, +} from "@/auth/ws-ticket"; +import { getClient, verifyClient } from "@/db/client"; +import { getHost } from "@/db/host"; +import { listUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; @@ -145,6 +153,36 @@ router.get("/auth/me", (req, res) => { res.json({ success: true, data: { user } }); }); +router.get("/auth/history", (req, res) => { + const user = requireAuthUser(req); + res.json({ + success: true, + data: { history: listUserConnectionHistory(user.id) }, + }); +}); + +router.post("/auth/ws-token", (req, res) => { + const user = requireAuthUser(req); + const clientInfo = ClientInfoSchema.parse(req.body); + + if (!getHost(clientInfo.hostId)) { + throw new BadRequestError("Host is not available."); + } + + const existingClient = getClient(clientInfo.clientId); + if (existingClient && !verifyClient(clientInfo)) { + throw new BadRequestError("Client verification failed."); + } + + res.json({ + success: true, + data: { + wsToken: createAppWebSocketToken(user.id, clientInfo), + expiresIn: APP_WEBSOCKET_TOKEN_TTL_SECONDS, + }, + }); +}); + router.post("/auth/logout", (req, res) => { const accessToken = getBearerToken(req); if (accessToken) { diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 00eb950..9387200 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,12 +1,12 @@ import type http from "node:http"; import type { Duplex } from "node:stream"; -import { ClientInfoSchema } from "@shellular/protocol"; import { z } from "zod"; -import { validateAccessToken } from "@/auth/store"; +import { verifyAppWebSocketToken } from "@/auth/ws-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; +import { recordUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { logger } from "@/logger"; import { getActiveSessionForHost, joinSession } from "./sessions"; @@ -18,9 +18,11 @@ const HostQuerySchema = z.object({ hostId: z.string(), }); -const AppAuthQuerySchema = z.object({ - authToken: z.string().min(1), -}); +const AppAuthQuerySchema = z + .object({ + wsToken: z.string().min(1), + }) + .strict(); const cliWsServer = initCliWebSocket(); const appWsServer = initAppWebSocket(); @@ -79,28 +81,24 @@ async function handleUpgradeRequest( } const authParsed = AppAuthQuerySchema.safeParse(query); - if ( - !authParsed.success || - !validateAccessToken(authParsed.data.authToken) - ) { - logger.warn("Rejecting app websocket: missing or invalid auth token"); + if (!authParsed.success) { + logger.warn("Rejecting app websocket: missing or invalid wsToken query"); socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } - // async approval before upgrade is fine - const parsed = ClientInfoSchema.safeParse(query); + const clientInfo = verifyAppWebSocketToken(authParsed.data.wsToken); + if (!clientInfo) { + logger.warn("Rejecting app websocket: invalid or expired wsToken"); + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + // async approval before upgrade is fine appWsServer.handleUpgrade(request, socket, head, async (ws) => { - if (!parsed.success) { - const { code, reason } = CloseCodeAndReason.INVALID_QUERY; - logger.info("Rejecting app websocket: invalid query"); - closeWsWithError(ws, code, reason); - return; - } - - const { hostId } = parsed.data; + const { hostId } = clientInfo; const host = getHost(hostId); if (!host) { logger.info(`Rejecting app websocket: host ${hostId} doesn't exist`); @@ -109,10 +107,10 @@ async function handleUpgradeRequest( return; } - const existingClient = getClient(parsed.data.clientId); - if (existingClient && !verifyClient(parsed.data)) { + const existingClient = getClient(clientInfo.clientId); + if (existingClient && !verifyClient(clientInfo)) { logger.info( - `Rejecting app websocket: client verification failed for clientId=${parsed.data.clientId}`, + `Rejecting app websocket: client verification failed for clientId=${clientInfo.clientId}`, ); const { code, reason } = CloseCodeAndReason.INVALID_QUERY; closeWsWithError(ws, code, reason); @@ -129,25 +127,26 @@ async function handleUpgradeRequest( return; } - const failure = await requestClientApprovalFromHost(session, parsed.data); + const failure = await requestClientApprovalFromHost(session, clientInfo); if (failure) { logger.info( - `Rejecting app websocket: approval denied for hostId=${hostId} clientId=${parsed.data.clientId} reason=${failure.reason}`, + `Rejecting app websocket: approval denied for hostId=${hostId} clientId=${clientInfo.clientId} reason=${failure.reason}`, ); closeWsWithError(ws, failure.code, failure.reason); return; } - const joined = joinSession(session.id, ws, parsed.data); + const joined = joinSession(session.id, ws, clientInfo); if (!joined) { const { code, reason } = CloseCodeAndReason.SESSION_JOIN_FAILED; logger.info( - `Rejecting app websocket: join failed for hostId=${hostId} clientId=${parsed.data.clientId}`, + `Rejecting app websocket: join failed for hostId=${hostId} clientId=${clientInfo.clientId}`, ); closeWsWithError(ws, code, reason); return; } + recordUserConnectionHistory(clientInfo.userId, clientInfo); appWsServer.emit("connection", ws, request); }); From 323eedb7c9d1e6873560bad047c8ad061ca70098 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Sun, 28 Jun 2026 03:03:04 +0530 Subject: [PATCH 03/20] chore: update docs --- docs/oauth-flow.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index 7a7dbc9..274cb29 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -77,12 +77,15 @@ Access tokens expire after 15 minutes. Refresh tokens rotate on every refresh an ```mermaid flowchart TD A[App wants to connect to /app WebSocket] --> B[App refreshes access token if needed] - B --> C[App opens /app?authToken=ACCESS_TOKEN&hostId=...] - C --> D{Server validates access token} - D -- Invalid or expired --> E[Reject upgrade with 403] - D -- Valid --> F[Validate client info and host availability] - F --> G[Request CLI approval] - G --> H[Join relay session] + B --> C[App posts client info to /auth/ws-token with Authorization bearer access token] + C --> D{Server validates access token, client info, host availability, and known client identity} + D -- Invalid or expired --> E[Reject token request] + D -- Valid --> F[Server returns short-lived wsToken] + F --> G[App opens /app?wsToken=...] + G --> H{Server validates wsToken} + H -- Invalid or expired --> I[Reject upgrade with 403] + H -- Valid --> J[Request CLI approval] + J --> K[Join relay session] ``` Only the app WebSocket path requires OAuth. The `/cli` WebSocket path and `/host/register` flow are intentionally unchanged. From 354a2890bc6ceac1601d61591852df76fa460068 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Mon, 29 Jun 2026 18:55:09 +0530 Subject: [PATCH 04/20] fix: preserve OAuth callback scheme on server errors --- docs/oauth-flow.md | 2 ++ src/auth/providers.ts | 22 +++++++++--- src/auth/store.ts | 26 ++++++++++++-- src/db/index.ts | 5 +++ src/db/init.sql | 1 + src/routes/auth.ts | 80 ++++++++++++++++++++++++++++++++++++++----- 6 files changed, 122 insertions(+), 14 deletions(-) diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index 274cb29..fefc0cc 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -56,6 +56,8 @@ sequenceDiagram The app receives only a short-lived exchange code through the custom URL scheme. Access and refresh tokens are returned only through the direct `/auth/exchange` API call. +Android debug builds send `shellular-dev://auth-callback` when starting OAuth so they can coexist with the production app without Android showing an app chooser. The server stores the requested app callback URL with the OAuth state and falls back to `AUTH_APP_CALLBACK_URL` for older clients or invalid callbacks. + ## Token Lifecycle ```mermaid diff --git a/src/auth/providers.ts b/src/auth/providers.ts index f03ef33..75bf8ee 100644 --- a/src/auth/providers.ts +++ b/src/auth/providers.ts @@ -55,20 +55,32 @@ export function assertProvider(value: string): AuthProvider { throw new BadRequestError("Unsupported OAuth provider."); } -export function createAuthorizationUrl(provider: AuthProvider): string { - return createOAuthAuthorizationUrl(provider); +export function createAuthorizationUrl( + provider: AuthProvider, + options: { callbackUrl?: string } = {}, +): string { + return createOAuthAuthorizationUrl(provider, options); } export function createLinkAuthorizationUrl( provider: AuthProvider, userId: string, + options: { callbackUrl?: string } = {}, ): string { - return createOAuthAuthorizationUrl(provider, { purpose: "link", userId }); + return createOAuthAuthorizationUrl(provider, { + ...options, + purpose: "link", + userId, + }); } function createOAuthAuthorizationUrl( provider: AuthProvider, - options: { purpose?: LoginState["purpose"]; userId?: string } = {}, + options: { + callbackUrl?: string; + purpose?: LoginState["purpose"]; + userId?: string; + } = {}, ): string { ensureProviderEnabled(provider); const state = arctic.generateState(); @@ -77,6 +89,7 @@ function createOAuthAuthorizationUrl( const codeVerifier = arctic.generateCodeVerifier(); saveLoginState(state, provider, { codeVerifier, + callbackUrl: options.callbackUrl, purpose: options.purpose, userId: options.userId, }); @@ -90,6 +103,7 @@ function createOAuthAuthorizationUrl( } saveLoginState(state, provider, { + callbackUrl: options.callbackUrl, purpose: options.purpose, userId: options.userId, }); diff --git a/src/auth/store.ts b/src/auth/store.ts index ffdfe64..4f7d3cb 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -25,6 +25,7 @@ export type LoginState = { purpose: "signin" | "link"; userId: string | null; codeVerifier: string | null; + callbackUrl: string | null; expiresAt: number; }; @@ -56,19 +57,21 @@ export function saveLoginState( provider: AuthProvider, options: { codeVerifier?: string; + callbackUrl?: string; purpose?: LoginState["purpose"]; userId?: string; } = {}, ) { const now = Date.now(); db.prepare( - "INSERT INTO oauth_login_states (stateHash, provider, purpose, userId, codeVerifier, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO oauth_login_states (stateHash, provider, purpose, userId, codeVerifier, callbackUrl, createdAt, expiresAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ).run( hashToken(state), provider, options.purpose ?? "signin", options.userId ?? null, options.codeVerifier ?? null, + options.callbackUrl ?? null, now, now + LOGIN_STATE_TTL_MS, ); @@ -81,7 +84,7 @@ export function consumeLoginState( const stateHash = hashToken(state); const row = db .prepare( - "SELECT provider, purpose, userId, codeVerifier, expiresAt FROM oauth_login_states WHERE stateHash = ?", + "SELECT provider, purpose, userId, codeVerifier, callbackUrl, expiresAt FROM oauth_login_states WHERE stateHash = ?", ) .get(stateHash) as LoginState | undefined; @@ -98,6 +101,25 @@ export function consumeLoginState( return row; } +export function getLoginStateCallbackUrl( + state: string, + provider: AuthProvider, +): string | null { + const row = db + .prepare( + "SELECT provider, callbackUrl, expiresAt FROM oauth_login_states WHERE stateHash = ?", + ) + .get(hashToken(state)) as + | Pick + | undefined; + + if (!row || row.provider !== provider || row.expiresAt < Date.now()) { + return null; + } + + return row.callbackUrl; +} + export function assertProviderCanBeLinked( userId: string, provider: AuthProvider, diff --git a/src/db/index.ts b/src/db/index.ts index 4bb0593..2f81004 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -55,6 +55,11 @@ function runAuthMigrations(): void { "userId", "ALTER TABLE oauth_login_states ADD COLUMN userId TEXT", ); + ensureColumn( + "oauth_login_states", + "callbackUrl", + "ALTER TABLE oauth_login_states ADD COLUMN callbackUrl TEXT", + ); db.exec(` CREATE TABLE IF NOT EXISTS auth_link_codes ( codeHash TEXT PRIMARY KEY, diff --git a/src/db/init.sql b/src/db/init.sql index 4e4cb2d..0db2661 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -56,6 +56,7 @@ CREATE TABLE IF NOT EXISTS oauth_login_states ( purpose TEXT NOT NULL DEFAULT 'signin', userId TEXT, codeVerifier TEXT, + callbackUrl TEXT, createdAt INTEGER NOT NULL, expiresAt INTEGER NOT NULL ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4389950..4748627 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -15,6 +15,7 @@ import { createLinkCode, exchangeCodeForTokens, exchangeLinkCodeForAccount, + getLoginStateCallbackUrl, type LoginState, type ProviderProfile, refreshToken, @@ -47,6 +48,10 @@ const OAuthStartSchema = z.object({ provider: z.string(), }); +const OAuthStartBodySchema = z.object({ + callbackUrl: z.string().optional(), +}); + const OAuthCallbackSchema = z.object({ code: z.string().min(1), state: z.string().min(1), @@ -67,7 +72,9 @@ router.get("/auth/providers", (_req, res) => { router.post("/auth/oauth/:provider/start", authLimiter, (req, res) => { const { provider: rawProvider } = OAuthStartSchema.parse(req.params); const provider = assertProvider(rawProvider); - const authorizationUrl = createAuthorizationUrl(provider); + const authorizationUrl = createAuthorizationUrl(provider, { + callbackUrl: appCallbackUrl(req.body), + }); res.json({ success: true, data: { authorizationUrl } }); }); @@ -76,7 +83,9 @@ router.post("/auth/oauth/:provider/link/start", authLimiter, (req, res) => { const { provider: rawProvider } = OAuthStartSchema.parse(req.params); const provider = assertProvider(rawProvider); assertProviderCanBeLinked(user.id, provider); - const authorizationUrl = createLinkAuthorizationUrl(provider, user.id); + const authorizationUrl = createLinkAuthorizationUrl(provider, user.id, { + callbackUrl: appCallbackUrl(req.body), + }); res.json({ success: true, data: { authorizationUrl } }); }); @@ -92,15 +101,22 @@ router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { return; } + const appCallbackUrl = getLoginStateCallbackUrl( + parsed.data.state, + provider, + ); + try { const result = await getProfileFromCallback( provider, parsed.data.code, parsed.data.state, ); - res.redirect(callbackUrl(completeOAuthCallback(result))); + res.redirect( + callbackUrl(completeOAuthCallback(result), result.loginState.callbackUrl), + ); } catch (error) { - res.redirect(callbackUrl({ error: callbackError(error) })); + res.redirect(callbackUrl({ error: callbackError(error) }, appCallbackUrl)); } }); @@ -115,6 +131,11 @@ router.post( return; } + const appCallbackUrl = getLoginStateCallbackUrl( + parsed.data.state, + "apple", + ); + try { const result = await getProfileFromCallback( "apple", @@ -122,9 +143,16 @@ router.post( parsed.data.state, typeof req.body.user === "string" ? req.body.user : undefined, ); - res.redirect(callbackUrl(completeOAuthCallback(result))); + res.redirect( + callbackUrl( + completeOAuthCallback(result), + result.loginState.callbackUrl, + ), + ); } catch (error) { - res.redirect(callbackUrl({ error: callbackError(error) })); + res.redirect( + callbackUrl({ error: callbackError(error) }, appCallbackUrl), + ); } }, ); @@ -218,8 +246,44 @@ function getBearerToken(req: express.Request): string | null { return header.slice("Bearer ".length).trim() || null; } -function callbackUrl(params: Record): string { - const url = new URL(env.AUTH_APP_CALLBACK_URL); +function appCallbackUrl(body: unknown): string { + const { callbackUrl: requestedCallbackUrl } = OAuthStartBodySchema.parse( + body ?? {}, + ); + if (!requestedCallbackUrl) return env.AUTH_APP_CALLBACK_URL; + + let url: URL; + try { + url = new URL(requestedCallbackUrl); + } catch { + throw new BadRequestError("Invalid app callback URL."); + } + + if (url.host !== "auth-callback") { + throw new BadRequestError("Invalid app callback URL."); + } + + const defaultScheme = new URL(env.AUTH_APP_CALLBACK_URL).protocol; + const allowedSchemes = new Set([ + defaultScheme, + "shellular:", + "shellular-dev:", + "foxbiz:", + ]); + if (!allowedSchemes.has(url.protocol)) { + throw new BadRequestError("Invalid app callback URL."); + } + + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function callbackUrl( + params: Record, + appCallbackUrl?: string | null, +): string { + const url = new URL(appCallbackUrl ?? env.AUTH_APP_CALLBACK_URL); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } From 1f223edaa3a22162d7f9600a45cec078ebf7cfe0 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Thu, 2 Jul 2026 01:59:51 +0530 Subject: [PATCH 05/20] feat: support browser cookie auth sessions --- docs/oauth-flow.md | 8 +- src/auth/store.ts | 6 +- src/main.ts | 2 + src/middleware/cors.ts | 7 +- src/routes/auth.ts | 265 ++++++++++++++++++++++++++++++++++---- src/routes/utils.ts | 116 +++++++++++++++++ src/websocket/index.ts | 6 +- src/websocket/sessions.ts | 7 + 8 files changed, 386 insertions(+), 31 deletions(-) create mode 100644 src/routes/utils.ts diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index fefc0cc..1a9b486 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -47,17 +47,19 @@ sequenceDiagram Server->>Provider: Exchange code for provider tokens/profile Server->>SQLite: Upsert user and linked OAuth account Server->>SQLite: Store one-time exchange code hash - Server-->>App: Redirect shellular://auth-callback?code=... + Server-->>App: Redirect app callback with code App->>Server: POST /auth/exchange Server->>SQLite: Consume exchange code Server->>SQLite: Create session, access token hash, refresh token hash Server-->>App: User, access token, refresh token ``` -The app receives only a short-lived exchange code through the custom URL scheme. Access and refresh tokens are returned only through the direct `/auth/exchange` API call. +The app receives only a short-lived exchange code through the callback URL. Access and refresh tokens are returned only through the direct `/auth/exchange` API call. Android debug builds send `shellular-dev://auth-callback` when starting OAuth so they can coexist with the production app without Android showing an app chooser. The server stores the requested app callback URL with the OAuth state and falls back to `AUTH_APP_CALLBACK_URL` for older clients or invalid callbacks. +Browser builds cannot receive custom URL schemes, so they send a same-origin callback URL with `shellularAuthCallback=1`, for example `https://app.shellular.dev/?shellularAuthCallback=1`. For browser sign-in, the server completes the OAuth callback itself, creates the Shellular session, sets HttpOnly Secure SameSite=None cookies on the API origin, and redirects the popup back to the app callback. The original browser tab then refreshes `/auth/me` with `credentials: include`. The popup callback still posts a lightweight completion signal and closes, but it no longer carries access or refresh tokens. + ## Token Lifecycle ```mermaid @@ -118,7 +120,7 @@ sequenceDiagram Server->>SQLite: Consume link state Server->>Provider: Exchange code for verified profile Server->>SQLite: Store one-time link code hash - Server-->>App: Redirect shellular://auth-callback?linkCode=... + Server-->>App: Redirect app callback with linkCode App->>Server: POST /auth/oauth/link/exchange with access token Server->>SQLite: Consume link code and attach provider Server-->>App: Updated user with linkedAccounts diff --git a/src/auth/store.ts b/src/auth/store.ts index 4f7d3cb..7760b20 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -29,7 +29,7 @@ export type LoginState = { expiresAt: number; }; -type TokenPair = { +export type TokenPair = { accessToken: string; accessTokenExpiresAt: number; refreshToken: string; @@ -288,7 +288,7 @@ export function unlinkProviderAccount( return user; } -function linkProviderAccount( +export function linkProviderAccount( userId: string, profile: ProviderProfile, ): AuthUser { @@ -459,7 +459,7 @@ export function revokeSessionByAccessToken(accessToken: string): void { if (row) revokeSession(row.sessionId); } -function createSession(userId: string): TokenPair { +export function createSession(userId: string): TokenPair { const user = getUserById(userId); if (!user) { throw new ForbiddenError("Your account is no longer available."); diff --git a/src/main.ts b/src/main.ts index 9af3b4a..dd36af6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { logger } from "@/logger"; import cors from "@/middleware/cors"; import { router as authRouter } from "@/routes/auth"; import { router as hostRouter } from "@/routes/host"; +import { router as utilsRouter } from "@/routes/utils"; import { printRoutes } from "@/utils/express"; import { initWebSocketRelay } from "@/websocket/index"; import { getSessionStats } from "@/websocket/sessions"; @@ -45,6 +46,7 @@ app.use(express.json()); app.use(authRouter); app.use(hostRouter); +app.use(utilsRouter); app.use((req, res, next) => { const start = Date.now(); diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index 84367ad..105c3b9 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -3,7 +3,12 @@ import type { NextFunction, Request, Response } from "express"; import { env } from "@/env"; export default function cors(req: Request, res: Response, next: NextFunction) { - res.header("Access-Control-Allow-Origin", env.CORS_ORIGIN); + const requestOrigin = req.headers.origin; + res.header( + "Access-Control-Allow-Origin", + env.NODE_ENV === "dev" && requestOrigin ? requestOrigin : env.CORS_ORIGIN, + ); + res.header("Access-Control-Allow-Credentials", "true"); res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); res.header( "Access-Control-Allow-Headers", diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 4748627..f6ca68f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,4 +1,4 @@ -import { ClientInfoSchema } from "@shellular/protocol"; +import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; import express, { Router } from "express"; import { rateLimit } from "express-rate-limit"; import { z } from "zod"; @@ -10,17 +10,21 @@ import { listProviders, } from "@/auth/providers"; import { + type AuthUser, assertProviderCanBeLinked, createExchangeCode, createLinkCode, + createSession, exchangeCodeForTokens, exchangeLinkCodeForAccount, getLoginStateCallbackUrl, type LoginState, + linkProviderAccount, type ProviderProfile, refreshToken, revokeSessionByAccessToken, revokeSessionByRefreshToken, + type TokenPair, unlinkProviderAccount, upsertUserFromProvider, validateAccessToken, @@ -65,6 +69,9 @@ const RefreshSchema = z.object({ refreshToken: z.string().min(1), }); +const AUTH_ACCESS_COOKIE = "__Host-shellular_access"; +const AUTH_REFRESH_COOKIE = "__Host-shellular_refresh"; + router.get("/auth/providers", (_req, res) => { res.json({ success: true, data: { providers: listProviders() } }); }); @@ -73,7 +80,7 @@ router.post("/auth/oauth/:provider/start", authLimiter, (req, res) => { const { provider: rawProvider } = OAuthStartSchema.parse(req.params); const provider = assertProvider(rawProvider); const authorizationUrl = createAuthorizationUrl(provider, { - callbackUrl: appCallbackUrl(req.body), + callbackUrl: appCallbackUrl(req), }); res.json({ success: true, data: { authorizationUrl } }); }); @@ -84,7 +91,7 @@ router.post("/auth/oauth/:provider/link/start", authLimiter, (req, res) => { const provider = assertProvider(rawProvider); assertProviderCanBeLinked(user.id, provider); const authorizationUrl = createLinkAuthorizationUrl(provider, user.id, { - callbackUrl: appCallbackUrl(req.body), + callbackUrl: appCallbackUrl(req), }); res.json({ success: true, data: { authorizationUrl } }); }); @@ -101,10 +108,7 @@ router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { return; } - const appCallbackUrl = getLoginStateCallbackUrl( - parsed.data.state, - provider, - ); + const appCallbackUrl = getLoginStateCallbackUrl(parsed.data.state, provider); try { const result = await getProfileFromCallback( @@ -112,6 +116,15 @@ router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { parsed.data.code, parsed.data.state, ); + if (isBrowserCallbackUrl(result.loginState.callbackUrl)) { + res.redirect( + callbackUrl( + completeBrowserOAuthCallback(res, result), + result.loginState.callbackUrl, + ), + ); + return; + } res.redirect( callbackUrl(completeOAuthCallback(result), result.loginState.callbackUrl), ); @@ -131,10 +144,7 @@ router.post( return; } - const appCallbackUrl = getLoginStateCallbackUrl( - parsed.data.state, - "apple", - ); + const appCallbackUrl = getLoginStateCallbackUrl(parsed.data.state, "apple"); try { const result = await getProfileFromCallback( @@ -143,6 +153,15 @@ router.post( parsed.data.state, typeof req.body.user === "string" ? req.body.user : undefined, ); + if (isBrowserCallbackUrl(result.loginState.callbackUrl)) { + res.redirect( + callbackUrl( + completeBrowserOAuthCallback(res, result), + result.loginState.callbackUrl, + ), + ); + return; + } res.redirect( callbackUrl( completeOAuthCallback(result), @@ -159,7 +178,10 @@ router.post( router.post("/auth/exchange", authLimiter, (req, res) => { const { code } = ExchangeSchema.parse(req.body); - res.json({ success: true, data: exchangeCodeForTokens(code) }); + res.json({ + success: true, + data: tokenPairResponse(req, exchangeCodeForTokens(code)), + }); }); router.post("/auth/oauth/link/exchange", authLimiter, (req, res) => { @@ -167,18 +189,31 @@ router.post("/auth/oauth/link/exchange", authLimiter, (req, res) => { const { code } = ExchangeSchema.parse(req.body); res.json({ success: true, - data: { user: exchangeLinkCodeForAccount(code, user.id) }, + data: { + user: authUserResponse(req, exchangeLinkCodeForAccount(code, user.id)), + }, }); }); router.post("/auth/refresh", authLimiter, (req, res) => { - const { refreshToken: token } = RefreshSchema.parse(req.body); - res.json({ success: true, data: refreshToken(token) }); + const parsed = RefreshSchema.safeParse(req.body); + const token = parsed.success + ? parsed.data.refreshToken + : getCookie(req, AUTH_REFRESH_COOKIE); + if (!token) + throw new ForbiddenError("Your session expired. Please sign in again."); + const data = refreshToken(token); + if (!parsed.success) { + setAuthCookies(res, data); + res.json({ success: true, data: browserSessionData(req, data) }); + return; + } + res.json({ success: true, data: tokenPairResponse(req, data) }); }); router.get("/auth/me", (req, res) => { const user = requireAuthUser(req); - res.json({ success: true, data: { user } }); + res.json({ success: true, data: { user: authUserResponse(req, user) } }); }); router.get("/auth/history", (req, res) => { @@ -191,14 +226,19 @@ router.get("/auth/history", (req, res) => { router.post("/auth/ws-token", (req, res) => { const user = requireAuthUser(req); - const clientInfo = ClientInfoSchema.parse(req.body); + const requestedClientInfo = ClientInfoSchema.parse(req.body); + const clientInfo = effectiveClientInfo(user.id, requestedClientInfo); if (!getHost(clientInfo.hostId)) { throw new BadRequestError("Host is not available."); } const existingClient = getClient(clientInfo.clientId); - if (existingClient && !verifyClient(clientInfo)) { + if ( + existingClient && + !isBrowserClientInfo(clientInfo) && + !verifyClient(clientInfo) + ) { throw new BadRequestError("Client verification failed."); } @@ -207,12 +247,13 @@ router.post("/auth/ws-token", (req, res) => { data: { wsToken: createAppWebSocketToken(user.id, clientInfo), expiresIn: APP_WEBSOCKET_TOKEN_TTL_SECONDS, + clientId: clientInfo.clientId, }, }); }); router.post("/auth/logout", (req, res) => { - const accessToken = getBearerToken(req); + const accessToken = getBearerToken(req) ?? getCookie(req, AUTH_ACCESS_COOKIE); if (accessToken) { revokeSessionByAccessToken(accessToken); } @@ -220,6 +261,11 @@ router.post("/auth/logout", (req, res) => { if (refresh.success) { revokeSessionByRefreshToken(refresh.data.refreshToken); } + const refreshCookie = getCookie(req, AUTH_REFRESH_COOKIE); + if (refreshCookie) { + revokeSessionByRefreshToken(refreshCookie); + } + clearAuthCookies(res); res.json({ success: true }); }); @@ -228,12 +274,14 @@ router.delete("/auth/oauth/accounts/:provider", authLimiter, (req, res) => { const provider = assertProvider(String(req.params.provider)); res.json({ success: true, - data: { user: unlinkProviderAccount(user.id, provider) }, + data: { + user: authUserResponse(req, unlinkProviderAccount(user.id, provider)), + }, }); }); export function requireAuthUser(req: express.Request) { - const token = getBearerToken(req); + const token = getBearerToken(req) ?? getCookie(req, AUTH_ACCESS_COOKIE); if (!token) throw new ForbiddenError("Authentication required."); const user = validateAccessToken(token); if (!user) throw new ForbiddenError("Authentication required."); @@ -246,9 +294,27 @@ function getBearerToken(req: express.Request): string | null { return header.slice("Bearer ".length).trim() || null; } -function appCallbackUrl(body: unknown): string { +function effectiveClientInfo( + userId: string, + clientInfo: ClientInfo, +): ClientInfo { + if (!isBrowserClientInfo(clientInfo)) { + return clientInfo; + } + + return { + ...clientInfo, + clientId: userId, + }; +} + +function isBrowserClientInfo(clientInfo: ClientInfo): boolean { + return clientInfo.platform === "browser"; +} + +function appCallbackUrl(req: express.Request): string { const { callbackUrl: requestedCallbackUrl } = OAuthStartBodySchema.parse( - body ?? {}, + req.body ?? {}, ); if (!requestedCallbackUrl) return env.AUTH_APP_CALLBACK_URL; @@ -259,6 +325,11 @@ function appCallbackUrl(body: unknown): string { throw new BadRequestError("Invalid app callback URL."); } + if (isBrowserAppCallbackUrl(req, url)) { + url.hash = ""; + return url.toString(); + } + if (url.host !== "auth-callback") { throw new BadRequestError("Invalid app callback URL."); } @@ -279,6 +350,55 @@ function appCallbackUrl(body: unknown): string { return url.toString(); } +function isBrowserAppCallbackUrl(req: express.Request, url: URL): boolean { + if (!isBrowserCallbackUrl(url.toString())) return false; + + const requestOrigin = req.headers.origin; + if (!requestOrigin) { + return false; + } + + let origin: URL; + try { + origin = new URL(requestOrigin); + } catch { + return false; + } + + if (origin.origin !== url.origin) { + return false; + } + if (env.NODE_ENV === "dev") { + return true; + } + + return isTrustedWebAppOrigin(url); +} + +function isTrustedWebAppOrigin(url: URL): boolean { + if (url.protocol !== "https:") { + return false; + } + return ( + url.hostname === "shellular.dev" || url.hostname.endsWith(".shellular.dev") + ); +} + +function isBrowserCallbackUrl(callbackUrl: string | null | undefined): boolean { + if (!callbackUrl) return false; + try { + const url = new URL(callbackUrl); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password && + url.searchParams.get("shellularAuthCallback") === "1" + ); + } catch { + return false; + } +} + function callbackUrl( params: Record, appCallbackUrl?: string | null, @@ -307,6 +427,105 @@ function completeOAuthCallback(result: { return { code: createExchangeCode(user.id) }; } +function completeBrowserOAuthCallback( + res: express.Response, + result: { + profile: ProviderProfile; + loginState: LoginState; + }, +): Record { + if (result.loginState.purpose === "link") { + if (!result.loginState.userId) { + throw new BadRequestError("Invalid account-linking request."); + } + linkProviderAccount(result.loginState.userId, result.profile); + return { linked: "1" }; + } + + const user = upsertUserFromProvider(result.profile); + setAuthCookies(res, createSession(user.id)); + return { authenticated: "1" }; +} + +function setAuthCookies(res: express.Response, tokenPair: TokenPair): void { + res.cookie(AUTH_ACCESS_COOKIE, tokenPair.accessToken, { + ...authCookieOptions(), + expires: new Date(tokenPair.accessTokenExpiresAt), + }); + res.cookie(AUTH_REFRESH_COOKIE, tokenPair.refreshToken, { + ...authCookieOptions(), + expires: new Date(tokenPair.refreshTokenExpiresAt), + }); +} + +function tokenPairResponse( + req: express.Request, + tokenPair: TokenPair, +): TokenPair { + return { + ...tokenPair, + user: authUserResponse(req, tokenPair.user), + }; +} + +function browserSessionData(req: express.Request, tokenPair: TokenPair) { + return { + accessTokenExpiresAt: tokenPair.accessTokenExpiresAt, + refreshTokenExpiresAt: tokenPair.refreshTokenExpiresAt, + user: authUserResponse(req, tokenPair.user), + }; +} + +function authUserResponse(req: express.Request, user: AuthUser): AuthUser { + return { + ...user, + avatarUrl: proxiedImageUrl(req, user.avatarUrl), + }; +} + +function proxiedImageUrl( + req: express.Request, + imageUrl: string | null, +): string | null { + if (!imageUrl) return null; + try { + const url = new URL( + "/utils/image-proxy", + `${req.protocol}://${req.get("host")}`, + ); + url.searchParams.set("url", imageUrl); + return url.toString(); + } catch { + return imageUrl; + } +} + +function clearAuthCookies(res: express.Response): void { + res.clearCookie(AUTH_ACCESS_COOKIE, authCookieOptions()); + res.clearCookie(AUTH_REFRESH_COOKIE, authCookieOptions()); +} + +function authCookieOptions(): express.CookieOptions { + return { + httpOnly: true, + path: "/", + sameSite: env.NODE_ENV === "dev" ? "none" : "lax", + secure: true, + }; +} + +function getCookie(req: express.Request, name: string): string | null { + const header = req.headers.cookie; + if (!header) return null; + for (const cookie of header.split(";")) { + const [rawName, ...rawValue] = cookie.trim().split("="); + if (rawName === name) { + return decodeURIComponent(rawValue.join("=")); + } + } + return null; +} + function callbackError(error: unknown): string { if ( error instanceof BadRequestError || diff --git a/src/routes/utils.ts b/src/routes/utils.ts new file mode 100644 index 0000000..877ffa0 --- /dev/null +++ b/src/routes/utils.ts @@ -0,0 +1,116 @@ +import { Router } from "express"; +import { z } from "zod"; +import { BadRequestError } from "@/error/http"; + +export const router = Router(); + +const ImageProxyQuerySchema = z.object({ + url: z.string().min(1), +}); + +const TRUSTED_IMAGE_HOSTS = new Set([ + "lh3.googleusercontent.com", + "avatars.githubusercontent.com", + "github.com", +]); +const TRUSTED_IMAGE_TYPES = new Set([ + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]); +const MAX_IMAGE_BYTES = 1024 * 1024; +const MAX_IMAGE_REDIRECTS = 3; + +router.get("/utils/image-proxy", async (req, res) => { + const { url: rawUrl } = ImageProxyQuerySchema.parse(req.query); + const url = parseTrustedImageUrl(rawUrl); + + const response = await fetchTrustedImage(url); + + if (!response.ok) { + throw new BadRequestError("Image could not be loaded."); + } + + const contentType = (response.headers.get("content-type") ?? "") + .split(";")[0] + .trim() + .toLowerCase(); + if (!TRUSTED_IMAGE_TYPES.has(contentType)) { + throw new BadRequestError("URL is not an image."); + } + + const contentLength = Number(response.headers.get("content-length") ?? "0"); + if (contentLength > MAX_IMAGE_BYTES) { + throw new BadRequestError("Image is too large."); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_IMAGE_BYTES) { + throw new BadRequestError("Image is too large."); + } + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", String(bytes.byteLength)); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader( + "Cache-Control", + "public, max-age=86400, stale-while-revalidate=604800", + ); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + res.end(Buffer.from(bytes)); +}); + +async function fetchTrustedImage(rawUrl: string): Promise { + let url = rawUrl; + for ( + let redirectCount = 0; + redirectCount <= MAX_IMAGE_REDIRECTS; + redirectCount++ + ) { + const response = await fetch(url, { + headers: { + Accept: "image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8", + "User-Agent": "Shellular image proxy", + }, + redirect: "manual", + }); + + if (!isRedirectResponse(response)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + throw new BadRequestError("Image redirect is invalid."); + } + + url = parseTrustedImageUrl(new URL(location, url).toString()); + } + + throw new BadRequestError("Image redirected too many times."); +} + +function isRedirectResponse(response: Response): boolean { + return response.status >= 300 && response.status < 400; +} + +function parseTrustedImageUrl(rawUrl: string): string { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new BadRequestError("Invalid image URL."); + } + + if (url.protocol !== "https:") { + throw new BadRequestError("Image URL must use HTTPS."); + } + if (!TRUSTED_IMAGE_HOSTS.has(url.hostname)) { + throw new BadRequestError("Image host is not allowed."); + } + + url.hash = ""; + return url.toString(); +} diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 9387200..3bbddf9 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -108,7 +108,11 @@ async function handleUpgradeRequest( } const existingClient = getClient(clientInfo.clientId); - if (existingClient && !verifyClient(clientInfo)) { + if ( + existingClient && + clientInfo.platform !== "browser" && + !verifyClient(clientInfo) + ) { logger.info( `Rejecting app websocket: client verification failed for clientId=${clientInfo.clientId}`, ); diff --git a/src/websocket/sessions.ts b/src/websocket/sessions.ts index 844c7ab..e889cc6 100644 --- a/src/websocket/sessions.ts +++ b/src/websocket/sessions.ts @@ -75,6 +75,13 @@ export function joinSession( info: clientInfo, }; + const existingClient = session.clients.get(clientInfo.clientId); + if (existingClient && existingClient.ws !== clientWs) { + socketToSession.delete(existingClient.ws); + const { code, reason } = CloseCodeAndReason.CLIENT_REPLACED; + existingClient.ws.close(code, reason); + } + connections.clients.add(clientInfo.clientId); session.clients.set(clientInfo.clientId, clientInfoWithWs); socketToSession.set(clientWs, { From 7122377cb97d7b75546d02d5bac0918f2079bde8 Mon Sep 17 00:00:00 2001 From: Ajit Kumar Date: Thu, 2 Jul 2026 02:26:29 +0530 Subject: [PATCH 06/20] fix: keep websocket ticket auth for browser clients Allow legacy unauthenticated /app websocket query auth for old native clients, but require browser clients to use wsToken-backed websocket tickets. --- src/websocket/index.ts | 57 +++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 3bbddf9..63201d3 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,9 +1,13 @@ import type http from "node:http"; import type { Duplex } from "node:stream"; +import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; import { z } from "zod"; -import { verifyAppWebSocketToken } from "@/auth/ws-ticket"; +import { + type AppWebSocketTokenPayload, + verifyAppWebSocketToken, +} from "@/auth/ws-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; import { recordUserConnectionHistory } from "@/db/user-history"; @@ -24,6 +28,10 @@ const AppAuthQuerySchema = z }) .strict(); +type AppConnectionAuth = + | { clientInfo: AppWebSocketTokenPayload; userId: string; legacy: false } + | { clientInfo: ClientInfo; userId: null; legacy: true }; + const cliWsServer = initCliWebSocket(); const appWsServer = initAppWebSocket(); @@ -80,21 +88,15 @@ async function handleUpgradeRequest( return; } - const authParsed = AppAuthQuerySchema.safeParse(query); - if (!authParsed.success) { - logger.warn("Rejecting app websocket: missing or invalid wsToken query"); + const auth = parseAppConnectionAuth(query); + if (!auth) { + logger.warn("Rejecting app websocket: missing or invalid app auth query"); socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } - const clientInfo = verifyAppWebSocketToken(authParsed.data.wsToken); - if (!clientInfo) { - logger.warn("Rejecting app websocket: invalid or expired wsToken"); - socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); - socket.destroy(); - return; - } + const { clientInfo } = auth; // async approval before upgrade is fine appWsServer.handleUpgrade(request, socket, head, async (ws) => { @@ -150,7 +152,9 @@ async function handleUpgradeRequest( return; } - recordUserConnectionHistory(clientInfo.userId, clientInfo); + if (auth.userId) { + recordUserConnectionHistory(auth.userId, clientInfo); + } appWsServer.emit("connection", ws, request); }); @@ -162,6 +166,35 @@ async function handleUpgradeRequest( socket.destroy(); } +function parseAppConnectionAuth( + query: Record, +): AppConnectionAuth | null { + if ("wsToken" in query) { + const authParsed = AppAuthQuerySchema.safeParse(query); + if (!authParsed.success) return null; + + const clientInfo = verifyAppWebSocketToken(authParsed.data.wsToken); + if (!clientInfo) { + logger.warn("Rejecting app websocket: invalid or expired wsToken"); + return null; + } + + return { clientInfo, userId: clientInfo.userId, legacy: false }; + } + + const legacyParsed = ClientInfoSchema.safeParse(query); + if (!legacyParsed.success) return null; + if (legacyParsed.data.platform === "browser") { + logger.warn("Rejecting legacy browser websocket without wsToken"); + return null; + } + + logger.warn( + `Accepting legacy unauthenticated app websocket for clientId=${legacyParsed.data.clientId} hostId=${legacyParsed.data.hostId}`, + ); + return { clientInfo: legacyParsed.data, userId: null, legacy: true }; +} + const APP_PROTOCOL = "shellular:"; const WEB_PROTOCOLS = new Set(["https:", "wss:"]); From af048bef4b1257e25481f967db3fb83182abad44 Mon Sep 17 00:00:00 2001 From: Biraj Date: Wed, 8 Jul 2026 11:15:04 -0700 Subject: [PATCH 07/20] fix: update .gitignore to ignore all .p8 files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2a63efb..e703ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ mermaid/ *.local credentials.json -apple_key.p8 \ No newline at end of file +*.p8 \ No newline at end of file From f6ccd61c9a214f426d956db7a5f128c14b02a204 Mon Sep 17 00:00:00 2001 From: Biraj Date: Thu, 9 Jul 2026 15:12:05 -0700 Subject: [PATCH 08/20] refactor --- docs/oauth-flow.md | 2 +- readme.md | 2 +- src/auth/{ws-ticket.ts => ws-app-ticket.ts} | 1 + src/config.ts | 13 ++++++ src/main.ts | 45 ++++++++++----------- src/routes/auth.ts | 36 ++++++++++------- src/routes/host.ts | 10 ++++- src/routes/notices.ts | 8 +++- src/routes/utils.ts | 8 +++- src/types.ts | 10 +++++ src/utils/express.ts | 31 ++++++++++++-- src/websocket/index.ts | 2 +- 12 files changed, 119 insertions(+), 49 deletions(-) rename src/auth/{ws-ticket.ts => ws-app-ticket.ts} (99%) create mode 100644 src/types.ts diff --git a/docs/oauth-flow.md b/docs/oauth-flow.md index 1a9b486..89d6d57 100644 --- a/docs/oauth-flow.md +++ b/docs/oauth-flow.md @@ -81,7 +81,7 @@ Access tokens expire after 15 minutes. Refresh tokens rotate on every refresh an ```mermaid flowchart TD A[App wants to connect to /app WebSocket] --> B[App refreshes access token if needed] - B --> C[App posts client info to /auth/ws-token with Authorization bearer access token] + B --> C[App posts client info to /auth/ws-app-token with Authorization bearer access token] C --> D{Server validates access token, client info, host availability, and known client identity} D -- Invalid or expired --> E[Reject token request] D -- Valid --> F[Server returns short-lived wsToken] diff --git a/readme.md b/readme.md index ae52362..ef4f7e9 100644 --- a/readme.md +++ b/readme.md @@ -44,7 +44,7 @@ See [docs/oauth-flow.md](docs/oauth-flow.md) for provider setup, token lifecycle The app no longer sends access tokens or device metadata in the `/app` WebSocket URL. Instead: 1. The app refreshes its access token. -2. The app sends `POST /auth/ws-token` with `Authorization: Bearer ` and client metadata in the JSON body. +2. The app sends `POST /auth/ws-app-token` with `Authorization: Bearer ` and client metadata in the JSON body. 3. The server validates the user token, `ClientInfoSchema`, host existence, and known-client identity. 4. The server returns a signed app WebSocket ticket that expires after 30 seconds. 5. The app opens `/app?wsToken=`. diff --git a/src/auth/ws-ticket.ts b/src/auth/ws-app-ticket.ts similarity index 99% rename from src/auth/ws-ticket.ts rename to src/auth/ws-app-ticket.ts index 4058d55..495a998 100644 --- a/src/auth/ws-ticket.ts +++ b/src/auth/ws-app-ticket.ts @@ -2,6 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; import { z } from "zod"; + import { env } from "@/env"; export const APP_WEBSOCKET_TOKEN_TTL_SECONDS = 30; diff --git a/src/config.ts b/src/config.ts index 458b386..25e954a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { existsSync, mkdirSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; @@ -11,6 +12,18 @@ export const DB_FILE = join(APP_DIR, DB_FILE_NAME); // relative this file : server/src/config.ts -> server/ export const PROJECT_ROOT_DIR = join(__dirname, ".."); +export const GIT_COMMIT = (() => { + try { + const sha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + const message = execSync("git log -1 --pretty=%s", { + encoding: "utf8", + }).trim(); + return { sha, message }; + } catch { + return { sha: "unknown", message: "unknown" }; + } +})(); + export const initConfig = () => { if (!existsSync(APP_DIR)) { mkdirSync(APP_DIR, { recursive: true }); diff --git a/src/main.ts b/src/main.ts index 536d291..78a9fed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,19 @@ -import { execSync } from "node:child_process"; import { createServer } from "node:http"; import express from "express"; import { z } from "zod"; -import { initConfig } from "@/config"; + +import { GIT_COMMIT, initConfig } from "@/config"; import { env } from "@/env"; import { HttpError } from "@/error/http"; import { logger } from "@/logger"; import cors from "@/middleware/cors"; import { initNotices } from "@/notices"; -import { router as authRouter } from "@/routes/auth"; -import { router as hostRouter } from "@/routes/host"; -import { router as noticesRouter } from "@/routes/notices"; -import { router as utilsRouter } from "@/routes/utils"; +import authRoutes from "@/routes/auth"; +import hostRoutes from "@/routes/host"; +import noticesRoutes from "@/routes/notices"; +import utilsRoutes from "@/routes/utils"; +import type { RouteModule } from "@/types"; import { printRoutes } from "@/utils/express"; import { initWebSocketRelay } from "@/websocket/index"; import { getSessionStats } from "@/websocket/sessions"; @@ -27,18 +28,6 @@ process.on("unhandledRejection", (reason) => { initConfig(); -const GIT_COMMIT = (() => { - try { - const sha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); - const message = execSync("git log -1 --pretty=%s", { - encoding: "utf8", - }).trim(); - return { sha, message }; - } catch { - return { sha: "unknown", message: "unknown" }; - } -})(); - const app = express(); app.set("trust proxy", 1); // trust first proxy (if behind a proxy like nginx or cloudflare) @@ -46,10 +35,20 @@ app.set("trust proxy", 1); // trust first proxy (if behind a proxy like nginx or app.use(cors); app.use(express.json()); -app.use(authRouter); -app.use(hostRouter); -app.use(utilsRouter); -app.use(noticesRouter); +const routes: RouteModule[] = [ + authRoutes, + hostRoutes, + utilsRoutes, + noticesRoutes, +]; + +for (const { prefix, router } of routes) { + if (prefix) { + app.use(prefix, router); + } else { + app.use(router); + } +} app.use((req, res, next) => { const start = Date.now(); @@ -143,4 +142,4 @@ server.listen(env.PORT, env.HOST, () => { logger.info(`Server is running on port ${env.PORT}`); }); -printRoutes(app); +printRoutes(app, routes); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index f6ca68f..9df851d 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -32,14 +32,20 @@ import { import { APP_WEBSOCKET_TOKEN_TTL_SECONDS, createAppWebSocketToken, -} from "@/auth/ws-ticket"; +} from "@/auth/ws-app-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; import { listUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = "/auth"; + +export default { + router, + prefix: ROUTE_PREFIX, +}; const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, @@ -72,11 +78,11 @@ const RefreshSchema = z.object({ const AUTH_ACCESS_COOKIE = "__Host-shellular_access"; const AUTH_REFRESH_COOKIE = "__Host-shellular_refresh"; -router.get("/auth/providers", (_req, res) => { +router.get("/providers", (_req, res) => { res.json({ success: true, data: { providers: listProviders() } }); }); -router.post("/auth/oauth/:provider/start", authLimiter, (req, res) => { +router.post("/oauth/:provider/start", authLimiter, (req, res) => { const { provider: rawProvider } = OAuthStartSchema.parse(req.params); const provider = assertProvider(rawProvider); const authorizationUrl = createAuthorizationUrl(provider, { @@ -85,7 +91,7 @@ router.post("/auth/oauth/:provider/start", authLimiter, (req, res) => { res.json({ success: true, data: { authorizationUrl } }); }); -router.post("/auth/oauth/:provider/link/start", authLimiter, (req, res) => { +router.post("/oauth/:provider/link/start", authLimiter, (req, res) => { const user = requireAuthUser(req); const { provider: rawProvider } = OAuthStartSchema.parse(req.params); const provider = assertProvider(rawProvider); @@ -96,7 +102,7 @@ router.post("/auth/oauth/:provider/link/start", authLimiter, (req, res) => { res.json({ success: true, data: { authorizationUrl } }); }); -router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { +router.get("/oauth/:provider/callback", authLimiter, async (req, res) => { const provider = assertProvider(String(req.params.provider)); if (provider === "apple") { throw new BadRequestError("Apple sign-in callback must use POST."); @@ -134,7 +140,7 @@ router.get("/auth/oauth/:provider/callback", authLimiter, async (req, res) => { }); router.post( - "/auth/oauth/apple/callback", + "/oauth/apple/callback", authLimiter, express.urlencoded({ extended: false }), async (req, res) => { @@ -176,7 +182,7 @@ router.post( }, ); -router.post("/auth/exchange", authLimiter, (req, res) => { +router.post("/exchange", authLimiter, (req, res) => { const { code } = ExchangeSchema.parse(req.body); res.json({ success: true, @@ -184,7 +190,7 @@ router.post("/auth/exchange", authLimiter, (req, res) => { }); }); -router.post("/auth/oauth/link/exchange", authLimiter, (req, res) => { +router.post("/oauth/link/exchange", authLimiter, (req, res) => { const user = requireAuthUser(req); const { code } = ExchangeSchema.parse(req.body); res.json({ @@ -195,7 +201,7 @@ router.post("/auth/oauth/link/exchange", authLimiter, (req, res) => { }); }); -router.post("/auth/refresh", authLimiter, (req, res) => { +router.post("/refresh", authLimiter, (req, res) => { const parsed = RefreshSchema.safeParse(req.body); const token = parsed.success ? parsed.data.refreshToken @@ -211,12 +217,12 @@ router.post("/auth/refresh", authLimiter, (req, res) => { res.json({ success: true, data: tokenPairResponse(req, data) }); }); -router.get("/auth/me", (req, res) => { +router.get("/me", (req, res) => { const user = requireAuthUser(req); res.json({ success: true, data: { user: authUserResponse(req, user) } }); }); -router.get("/auth/history", (req, res) => { +router.get("/history", (req, res) => { const user = requireAuthUser(req); res.json({ success: true, @@ -224,7 +230,7 @@ router.get("/auth/history", (req, res) => { }); }); -router.post("/auth/ws-token", (req, res) => { +router.post("/ws-app-token", (req, res) => { const user = requireAuthUser(req); const requestedClientInfo = ClientInfoSchema.parse(req.body); const clientInfo = effectiveClientInfo(user.id, requestedClientInfo); @@ -252,7 +258,7 @@ router.post("/auth/ws-token", (req, res) => { }); }); -router.post("/auth/logout", (req, res) => { +router.post("/logout", (req, res) => { const accessToken = getBearerToken(req) ?? getCookie(req, AUTH_ACCESS_COOKIE); if (accessToken) { revokeSessionByAccessToken(accessToken); @@ -269,7 +275,7 @@ router.post("/auth/logout", (req, res) => { res.json({ success: true }); }); -router.delete("/auth/oauth/accounts/:provider", authLimiter, (req, res) => { +router.delete("/oauth/accounts/:provider", authLimiter, (req, res) => { const user = requireAuthUser(req); const provider = assertProvider(String(req.params.provider)); res.json({ diff --git a/src/routes/host.ts b/src/routes/host.ts index be3a59c..1d0543b 100644 --- a/src/routes/host.ts +++ b/src/routes/host.ts @@ -8,7 +8,13 @@ import { ForbiddenError, TooManyRequestsError } from "@/error/http"; import { logger } from "@/logger"; import { userAgentFilter } from "@/middleware/user-agent-filter"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = "/host"; + +export default { + router, + prefix: ROUTE_PREFIX, +}; const registerLimiter = rateLimit({ windowMs: 24 * 60 * 60 * 1000, // 24 hours @@ -49,7 +55,7 @@ const HostRegisterReqSchema = z.object({ }); router.post( - "/host/register", + "/register", userAgentFilter([/^shellular\/\d+\.\d+\.\d+$/]), registerLimiter, (req, res) => { diff --git a/src/routes/notices.ts b/src/routes/notices.ts index 6a91134..87fc75d 100644 --- a/src/routes/notices.ts +++ b/src/routes/notices.ts @@ -2,7 +2,13 @@ import { Router } from "express"; import { getNotices } from "@/notices"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = ""; + +export default { + router, + prefix: ROUTE_PREFIX, +}; // Public: the app fetches this to decide whether to show a notice popup. // Cached in memory server-side; safe to hit frequently. diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 877ffa0..3e4599b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -2,7 +2,13 @@ import { Router } from "express"; import { z } from "zod"; import { BadRequestError } from "@/error/http"; -export const router = Router(); +const router = Router(); +const ROUTE_PREFIX = ""; + +export default { + router, + prefix: ROUTE_PREFIX, +}; const ImageProxyQuerySchema = z.object({ url: z.string().min(1), diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9723a78 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,10 @@ +import type express from "express"; + +/** + * A router together with the path it is mounted at. + * A convention we'll follow to export default all router modules from their respective files. + */ +export type RouteModule = { + router: express.Router; + prefix: string; +}; diff --git a/src/utils/express.ts b/src/utils/express.ts index fd2b999..7cf21ba 100644 --- a/src/utils/express.ts +++ b/src/utils/express.ts @@ -4,9 +4,28 @@ import type express from "express"; import { logger } from "@/logger"; +import type { RouteModule } from "@/types"; + +function joinPaths(prefix: string, path: string): string { + if (!prefix) return path; + if (path === "/") return prefix; + return `${prefix}${path}`; +} + +/** + * Function to print all registered routes in a pretty format. + * @param app - The Express application instance. + * @param routes - The route modules mounted on the app. Explicitly Needed because prefix info is not available against a router. + */ +export function printRoutes( + app: ReturnType, + routes: readonly RouteModule[] = [], +): void { + const prefixByRouter = new Map(); + for (const { router, prefix } of routes) { + prefixByRouter.set(router, prefix); + } -// Function to print all registered routes in a pretty format -export function printRoutes(app: ReturnType): void { logger.info("Registered Routes:"); logger.info("─".repeat(60)); @@ -28,7 +47,10 @@ export function printRoutes(app: ReturnType): void { } routeMap.get(path)!.push(...methods); } else if (middleware.name === "router") { - // Routes from mounted routers + // Routes from mounted routers. Routers mounted at the root have no + // prefix, so they fall back to "". + const prefix = prefixByRouter.get(middleware.handle) ?? ""; + (middleware.handle as any).stack.forEach((handler: any) => { if (handler.route) { const methods = Object.keys(handler.route.methods) @@ -39,7 +61,8 @@ export function printRoutes(app: ReturnType): void { ? handler.route.path : [handler.route.path]; - paths.forEach((path: string) => { + paths.forEach((routePath: string) => { + const path = joinPaths(prefix, routePath); if (!routeMap.has(path)) { routeMap.set(path, []); } diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 63201d3..24d7557 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { type AppWebSocketTokenPayload, verifyAppWebSocketToken, -} from "@/auth/ws-ticket"; +} from "@/auth/ws-app-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; import { recordUserConnectionHistory } from "@/db/user-history"; From e9b768e767e8c8cdaf142e8af0c1fd028a0eb34c Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 19:53:56 -0700 Subject: [PATCH 09/20] feat: use new AuthedClientInfo from protocol --- src/auth/ws-app-ticket.ts | 17 +++++++++-------- src/routes/auth.ts | 36 +++++++++++++++++++++++------------- src/websocket/index.ts | 10 +++++++--- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/auth/ws-app-ticket.ts b/src/auth/ws-app-ticket.ts index 495a998..b1fad3c 100644 --- a/src/auth/ws-app-ticket.ts +++ b/src/auth/ws-app-ticket.ts @@ -1,6 +1,9 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; +import { + type AuthedClientInfo, + AuthedClientInfoSchema, +} from "@shellular/protocol"; import { z } from "zod"; import { env } from "@/env"; @@ -12,8 +15,10 @@ const TOKEN_HEADER = { alg: "HS256", typ: "JWT" }; // to the short TTL. const TOKEN_SECRET = env.WS_TOKEN_SECRET; -const AppWebSocketTokenPayloadSchema = ClientInfoSchema.extend({ - userId: z.string().min(1), +// The authenticated account rides along inside `user` (see AuthedClientInfo) +// rather than as a sibling `userId` claim, so the identity the CLI is shown is +// the same one the token was signed for — they cannot drift apart. +const AppWebSocketTokenPayloadSchema = AuthedClientInfoSchema.extend({ iat: z.number().int(), exp: z.number().int(), jti: z.string().min(1), @@ -23,14 +28,10 @@ export type AppWebSocketTokenPayload = z.infer< typeof AppWebSocketTokenPayloadSchema >; -export function createAppWebSocketToken( - userId: string, - clientInfo: ClientInfo, -): string { +export function createAppWebSocketToken(clientInfo: AuthedClientInfo): string { const iat = Math.floor(Date.now() / 1000); const payload: AppWebSocketTokenPayload = { ...clientInfo, - userId, iat, exp: iat + APP_WEBSOCKET_TOKEN_TTL_SECONDS, jti: randomBytes(16).toString("base64url"), diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 9df851d..66b0f51 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,4 +1,8 @@ -import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; +import { + type AuthedClientInfo, + type ClientInfoRequest, + ClientInfoRequestSchema, +} from "@shellular/protocol"; import express, { Router } from "express"; import { rateLimit } from "express-rate-limit"; import { z } from "zod"; @@ -232,8 +236,10 @@ router.get("/history", (req, res) => { router.post("/ws-app-token", (req, res) => { const user = requireAuthUser(req); - const requestedClientInfo = ClientInfoSchema.parse(req.body); - const clientInfo = effectiveClientInfo(user.id, requestedClientInfo); + // Parsed with the request schema, which omits `user`: identity is asserted by + // the session, not by the caller's payload. + const requestedClientInfo = ClientInfoRequestSchema.parse(req.body); + const clientInfo = effectiveClientInfo(user, requestedClientInfo); if (!getHost(clientInfo.hostId)) { throw new BadRequestError("Host is not available."); @@ -251,7 +257,7 @@ router.post("/ws-app-token", (req, res) => { res.json({ success: true, data: { - wsToken: createAppWebSocketToken(user.id, clientInfo), + wsToken: createAppWebSocketToken(clientInfo), expiresIn: APP_WEBSOCKET_TOKEN_TTL_SECONDS, clientId: clientInfo.clientId, }, @@ -300,21 +306,25 @@ function getBearerToken(req: express.Request): string | null { return header.slice("Bearer ".length).trim() || null; } +/** + * Binds the authenticated account to the client's self-reported device info. + * `user` is always taken from the session, so a spoofed value in the request + * body cannot reach the websocket token or the CLI's approval prompt. + */ function effectiveClientInfo( - userId: string, - clientInfo: ClientInfo, -): ClientInfo { - if (!isBrowserClientInfo(clientInfo)) { - return clientInfo; - } - + user: AuthUser, + clientInfo: ClientInfoRequest, +): AuthedClientInfo { return { ...clientInfo, - clientId: userId, + // Browser clients have no stable per-install id, so the account id doubles + // as the client id — one browser client per user. + clientId: isBrowserClientInfo(clientInfo) ? user.id : clientInfo.clientId, + user: { id: user.id, email: user.email }, }; } -function isBrowserClientInfo(clientInfo: ClientInfo): boolean { +function isBrowserClientInfo(clientInfo: ClientInfoRequest): boolean { return clientInfo.platform === "browser"; } diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 24d7557..e23d3e0 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,7 +1,7 @@ import type http from "node:http"; import type { Duplex } from "node:stream"; -import { type ClientInfo, ClientInfoSchema } from "@shellular/protocol"; +import { type ClientInfo, ClientInfoRequestSchema } from "@shellular/protocol"; import { z } from "zod"; import { @@ -30,6 +30,8 @@ const AppAuthQuerySchema = z type AppConnectionAuth = | { clientInfo: AppWebSocketTokenPayload; userId: string; legacy: false } + // Legacy clients predate the wsToken handshake, so no identity was ever + // proven for them; `clientInfo.user` is correspondingly absent. | { clientInfo: ClientInfo; userId: null; legacy: true }; const cliWsServer = initCliWebSocket(); @@ -179,10 +181,12 @@ function parseAppConnectionAuth( return null; } - return { clientInfo, userId: clientInfo.userId, legacy: false }; + return { clientInfo, userId: clientInfo.user.id, legacy: false }; } - const legacyParsed = ClientInfoSchema.safeParse(query); + // Legacy path: identity is unproven, so strip any `user` a caller tried to + // smuggle in through the query string rather than forwarding it to the CLI. + const legacyParsed = ClientInfoRequestSchema.safeParse(query); if (!legacyParsed.success) return null; if (legacyParsed.data.platform === "browser") { logger.warn("Rejecting legacy browser websocket without wsToken"); From dc54812415054c9b4604a0ded5d5ce9a9c53d3bc Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 20:06:53 -0700 Subject: [PATCH 10/20] chore: update protocol --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 08b2ee5..a3ff902 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "type": "commonjs", "dependencies": { "@oslojs/encoding": "^1.1.0", - "@shellular/protocol": "^0.0.25", + "@shellular/protocol": "^0.0.26", "arctic": "^3.7.0", "better-sqlite3": "^12.10.0", "dotenv": "^17.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a524823..4dcb0cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 '@shellular/protocol': - specifier: ^0.0.25 - version: 0.0.25 + specifier: ^0.0.26 + version: 0.0.26 arctic: specifier: ^3.7.0 version: 3.7.0 @@ -300,8 +300,8 @@ packages: '@oslojs/jwt@0.2.0': resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} - '@shellular/protocol@0.0.25': - resolution: {integrity: sha512-rsRlj2V+Jy8HqHrQqlxPiOA75+IsuGiedjI3UsKmWRgTvvrosz7ZTjKmk1KP1JfI6Qf8QJgxW639g+/giLZt+g==} + '@shellular/protocol@0.0.26': + resolution: {integrity: sha512-97HZ6fVVVr1BK8uxVQpzHRkDRL4uo2naThYoMDghJPmnMwgSzCElC0e8WzLS+7VW/2x+zlzSFyi67B/1PIkhuA==} '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -926,7 +926,7 @@ snapshots: dependencies: '@oslojs/encoding': 0.4.1 - '@shellular/protocol@0.0.25': + '@shellular/protocol@0.0.26': dependencies: '@agentclientprotocol/sdk': 0.26.0(zod@4.3.6) zod: 4.3.6 From a8eb3fa8ca3e4d95faf5cc30cf3493e8fb094650 Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 21:11:42 -0700 Subject: [PATCH 11/20] feat: integrate PostHog for analytics --- package.json | 1 + pnpm-lock.yaml | 28 +++++ src/env.ts | 2 + src/main.ts | 14 ++- src/posthog.ts | 211 ++++++++++++++++++++++++++++++++++++++ src/websocket/index.ts | 13 ++- src/websocket/sessions.ts | 5 + src/websocket/ws-cli.ts | 2 + 8 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 src/posthog.ts diff --git a/package.json b/package.json index a3ff902..2317800 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.2", "nanoid": "^5.1.7", + "posthog-node": "^5.40.0", "ws": "^8.21.0", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dcb0cc..a02cd2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: nanoid: specifier: ^5.1.7 version: 5.1.7 + posthog-node: + specifier: ^5.40.0 + version: 5.40.0 ws: specifier: ^8.21.0 version: 8.21.0(bufferutil@4.1.0) @@ -300,6 +303,12 @@ packages: '@oslojs/jwt@0.2.0': resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@posthog/core@1.40.1': + resolution: {integrity: sha512-jXuMtZCwA7AMpYlo1wjm6GlC58YBlz/ZxpDIsF9hroUfqYoPPDmQbsQb4iiZAS43+o/kwloxdKJIlE0IiwQ5HQ==} + + '@posthog/types@1.393.0': + resolution: {integrity: sha512-vzWeEJZ7ERQhFRoQYaP5jzN1JvIu46UJyHXsuv+dTGW2r3sMgREOhNxXLZjmFHwZ8/FOHQoyqqQmXTCXZSfMSg==} + '@shellular/protocol@0.0.26': resolution: {integrity: sha512-97HZ6fVVVr1BK8uxVQpzHRkDRL4uo2naThYoMDghJPmnMwgSzCElC0e8WzLS+7VW/2x+zlzSFyi67B/1PIkhuA==} @@ -633,6 +642,15 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + posthog-node@5.40.0: + resolution: {integrity: sha512-DrLfHuauO0W6qruF80iqr5JdmLysef74XzOB4eh36oRLRhxCySLraTqsi2Pj161LZnp9/JNdRDxwT8ei8VK2YA==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -926,6 +944,12 @@ snapshots: dependencies: '@oslojs/encoding': 0.4.1 + '@posthog/core@1.40.1': + dependencies: + '@posthog/types': 1.393.0 + + '@posthog/types@1.393.0': {} + '@shellular/protocol@0.0.26': dependencies: '@agentclientprotocol/sdk': 0.26.0(zod@4.3.6) @@ -1292,6 +1316,10 @@ snapshots: path-to-regexp@8.4.2: {} + posthog-node@5.40.0: + dependencies: + '@posthog/core': 1.40.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 diff --git a/src/env.ts b/src/env.ts index 61a87dc..7f9dd45 100644 --- a/src/env.ts +++ b/src/env.ts @@ -10,6 +10,8 @@ const envSchema = z.object({ NODE_ENV: z.enum(["dev", "prod"]), CONTACT_EMAIL: z.email().default("team@shellular.dev"), WS_TOKEN_SECRET: z.string().min(32), + POSTHOG_KEY: z.string().min(1), + POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), GOOGLE_CLIENT_ID: z.string().optional(), diff --git a/src/main.ts b/src/main.ts index 78a9fed..4d03b85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { HttpError } from "@/error/http"; import { logger } from "@/logger"; import cors from "@/middleware/cors"; import { initNotices } from "@/notices"; +import { shutdownPostHog, startHostHeartbeatForPosthog } from "@/posthog"; import authRoutes from "@/routes/auth"; import hostRoutes from "@/routes/host"; import noticesRoutes from "@/routes/notices"; @@ -16,7 +17,7 @@ import utilsRoutes from "@/routes/utils"; import type { RouteModule } from "@/types"; import { printRoutes } from "@/utils/express"; import { initWebSocketRelay } from "@/websocket/index"; -import { getSessionStats } from "@/websocket/sessions"; +import { getActiveHostSessions, getSessionStats } from "@/websocket/sessions"; process.on("uncaughtException", (err) => { logger.error("Uncaught exception:", err); @@ -137,9 +138,20 @@ app.use((_req, res) => { const server = createServer(app); initWebSocketRelay(server); initNotices(); +startHostHeartbeatForPosthog(() => + getActiveHostSessions().map((session) => session.hostInfo), +); server.listen(env.PORT, env.HOST, () => { logger.info(`Server is running on port ${env.PORT}`); }); +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + logger.info(`Received ${signal}, shutting down`); + server.close(); + shutdownPostHog().finally(() => process.exit(0)); + }); +} + printRoutes(app, routes); diff --git a/src/posthog.ts b/src/posthog.ts new file mode 100644 index 0000000..f071990 --- /dev/null +++ b/src/posthog.ts @@ -0,0 +1,211 @@ +import type { ClientInfo, HostInfo } from "@shellular/protocol"; +import { PostHog } from "posthog-node"; + +import { env } from "@/env"; +import { logger } from "@/logger"; + +/** + * Shared PostHog client for product analytics (DAU, retention, etc.). + * + * We capture from the server rather than the client because + * - server is the only place we have a *proven* userId + * - Server-side capture also can't be blocked or spoofed by the app + * - Also don't have to worry about Posthog's session replay thing for user privacy (ik it can be disabled but still) + */ +export const posthog = new PostHog(env.POSTHOG_KEY, { + host: env.POSTHOG_HOST, +}); + +posthog.on("error", (err) => { + // Analytics must never take down or interfere with the relay. + logger.warn("PostHog error:", err); +}); + +// Only real (prod) traffic should reach PostHog — dev runs would otherwise +// pollute DAU/retention with our own testing. Routing every event through this +// one gate means a newly added event can't forget to opt out of dev capture. +const analyticsEnabled = env.NODE_ENV === "prod"; + +function capture(event: Parameters[0]): void { + if (!analyticsEnabled) { + return; + } + + posthog.capture(event); +} + +/** + * Every analytics event we capture, at a glance. Keyed by side of the relay + * (`app` = mobile/web client, `cli` = host). Use these constants at call sites + * instead of inline strings so event names stay consistent. + */ +const Events = { + app: { + connectionEstablished: "app_connection_established", + legacyConnectionEstablished: "legacy_app_connection_established", + }, + cli: { + connectionEstablished: "cli_connection_established", + hostActive: "cli_host_active", + }, +} as const; + +function appConnectionProperties(clientInfo: ClientInfo, hostPlatform: string) { + return { + // `source` labels which side of the relay the event came from so app and + // CLI analytics are never conflated. + source: "app" as const, + hostId: clientInfo.hostId, + clientId: clientInfo.clientId, + platform: clientInfo.platform, + appVersion: clientInfo.appVersion, + deviceIsEmulator: clientInfo.deviceIsEmulator, + hostPlatform, + }; +} + +export function captureAppConnection( + userId: string, + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: userId, + event: Events.app.connectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: true, + // Person profile props so users are filterable in PostHog. + $set: { + lastPlatform: clientInfo.platform, + lastAppVersion: clientInfo.appVersion, + }, + }, + }); +} + +/** + * Legacy app connections have no proven userId (old app versions predating the + * login, or forks that stripped login). We key these on the stable + * clientId so a distinct install counts once, and keep them under a separate + * event so they never inflate authenticated DAU/retention. A current appVersion + * arriving here is a signal of a login-stripped rebuild rather than an old app. + */ +export function captureLegacyAppConnection( + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: clientInfo.clientId, + event: Events.app.legacyConnectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: false, + // Mark the person profile so legacy installs are filterable but never + // silently merged with a real authenticated user. + $set: { isLegacy: true }, + }, + }); +} + +function hostProperties(hostInfo: HostInfo) { + return { + source: "cli" as const, + hostId: hostInfo.id, + // machineId lets you dedupe by physical machine in PostHog even though + // events are keyed on the per-registration hostId. + machineId: hostInfo.machineId, + platform: hostInfo.platform, + cliVersion: hostInfo.cliVersion, + }; +} + +/** Fired once when a host (CLI) establishes its relay connection. */ +export function captureCliConnection(hostInfo: HostInfo): void { + capture({ + distinctId: hostInfo.id, + event: Events.cli.connectionEstablished, + properties: { + ...hostProperties(hostInfo), + $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, + }, + }); +} + +/** + * Daily-active heartbeat for a persistent host connection. A host can stay + * connected for days off a single `cli_connection_established`, which would + * undercount it as a DAU. Emitting one `cli_host_active` per host per UTC day + * makes long-lived hosts count as active every day they're up. Callers must + * dedupe per day (see the heartbeat loop) so this never fires more than once + * per host per day. + */ +function captureCliHostActive(hostInfo: HostInfo): void { + capture({ + distinctId: hostInfo.id, + event: Events.cli.hostActive, + properties: hostProperties(hostInfo), + }); +} + +const HEARTBEAT_SWEEP_MS = 6 * 60 * 60 * 1000; // every 6 hours + +// Tracks the last UTC day (YYYY-MM-DD) we emitted cli_host_active for a hostId, +// so a persistent host is counted active at most once per day. +const lastHostActiveDay = new Map(); +let heartbeatTimer: NodeJS.Timeout | null = null; + +function utcDay(now = new Date()): string { + return now.toISOString().slice(0, 10); +} + +/** + * Start the daily host-active heartbeat. Sweeps connected hosts every 6 hours + * and emits one `cli_host_active` per host per UTC day so long-lived host + * connections are counted as daily-active on every day they stay up, not just + * their connect day. The sweep interval only governs how soon after the UTC day + * boundary a persistent host is re-counted; the per-day dedup keeps emissions to + * once per host per day regardless. + * + * `getHosts` is injected to avoid a circular import with the sessions module. + */ +export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { + if (heartbeatTimer) { + return; + } + + const sweep = () => { + const day = utcDay(); + for (const hostInfo of getHosts()) { + if (lastHostActiveDay.get(hostInfo.id) === day) { + // if a host has already been counted for the day, then let's skip it + continue; + } + + lastHostActiveDay.set(hostInfo.id, day); + captureCliHostActive(hostInfo); + } + // Drop entries for days that have rolled over so the map can't grow + // unbounded as hosts churn. + for (const [hostId, seenDay] of lastHostActiveDay) { + if (seenDay !== day) { + lastHostActiveDay.delete(hostId); + } + } + }; + + heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); + heartbeatTimer.unref?.(); +} + +/** + * Flush any buffered events and stop the client. Call on graceful shutdown so + * batched events aren't lost when the process exits. + */ +export async function shutdownPostHog(): Promise { + try { + await posthog.shutdown(); + } catch (err) { + logger.warn("PostHog shutdown failed:", err); + } +} diff --git a/src/websocket/index.ts b/src/websocket/index.ts index e23d3e0..27e9658 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -1,7 +1,10 @@ import type http from "node:http"; import type { Duplex } from "node:stream"; -import { type ClientInfo, ClientInfoRequestSchema } from "@shellular/protocol"; +import { + type ClientInfoRequest, + ClientInfoRequestSchema, +} from "@shellular/protocol"; import { z } from "zod"; import { @@ -13,6 +16,7 @@ import { getHost } from "@/db/host"; import { recordUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { logger } from "@/logger"; +import { captureAppConnection, captureLegacyAppConnection } from "@/posthog"; import { getActiveSessionForHost, joinSession } from "./sessions"; import { CloseCodeAndReason, closeWsWithError } from "./shared"; import { initAppWebSocket, requestClientApprovalFromHost } from "./ws-app"; @@ -31,8 +35,8 @@ const AppAuthQuerySchema = z type AppConnectionAuth = | { clientInfo: AppWebSocketTokenPayload; userId: string; legacy: false } // Legacy clients predate the wsToken handshake, so no identity was ever - // proven for them; `clientInfo.user` is correspondingly absent. - | { clientInfo: ClientInfo; userId: null; legacy: true }; + // proven for them + | { clientInfo: ClientInfoRequest; userId: null; legacy: true }; const cliWsServer = initCliWebSocket(); const appWsServer = initAppWebSocket(); @@ -156,6 +160,9 @@ async function handleUpgradeRequest( if (auth.userId) { recordUserConnectionHistory(auth.userId, clientInfo); + captureAppConnection(auth.userId, clientInfo, host.platform); + } else { + captureLegacyAppConnection(clientInfo, host.platform); } appWsServer.emit("connection", ws, request); }); diff --git a/src/websocket/sessions.ts b/src/websocket/sessions.ts index 1d1ddfe..ed6e5a4 100644 --- a/src/websocket/sessions.ts +++ b/src/websocket/sessions.ts @@ -124,6 +124,11 @@ export function getActiveSessionForHost(hostId: string): Session | null { return sessionsByHostId.get(hostId) ?? null; } +/** All currently-connected host sessions, for periodic sweeps (e.g. analytics). */ +export function getActiveHostSessions(): Session[] { + return [...sessionsByHostId.values()]; +} + export function getSessionStats() { return { hosts: connections.hosts.size, diff --git a/src/websocket/ws-cli.ts b/src/websocket/ws-cli.ts index 6505e93..36fb994 100644 --- a/src/websocket/ws-cli.ts +++ b/src/websocket/ws-cli.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { getHost, verifyHost } from "@/db/host"; import { logger } from "@/logger"; +import { captureCliConnection } from "@/posthog"; import { type HostToClientMsg, HostToClientMsgSchema } from "./protocol"; import { createSession, @@ -206,6 +207,7 @@ function handleAuth(ws: WebSocket, msg: SessionHostMsg): Session { } const session = createSession(hostId, ws, msg.data); + captureCliConnection(session.hostInfo); // complete handshake with the host (CLI) const hostedId = `server_${nanoid(8)}`; From 7819dfa7f676c5170a9a8ce677335f3e7e762614 Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 21:12:24 -0700 Subject: [PATCH 12/20] chore: add PostHog key to .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index b8c7cf0..e07a9b5 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ GITHUB_CLIENT_SECRET= APPLE_CLIENT_ID= APPLE_TEAM_ID= APPLE_KEY_ID= + +# PostHog +POSTHOG_KEY= From 29eeb3b154f09658d69c066680251ba71af9a2ed Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 21:53:03 -0700 Subject: [PATCH 13/20] make posthog optional --- src/env.ts | 40 ++++---- src/posthog.ts | 245 +++++++++++++++++++++++++------------------------ 2 files changed, 146 insertions(+), 139 deletions(-) diff --git a/src/env.ts b/src/env.ts index 7f9dd45..f8284f1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,23 +4,29 @@ import { z } from "zod"; dotenv.config(); const envSchema = z.object({ - HOST: z.string().default("0.0.0.0"), - PORT: z.string().transform(Number).default(6969), - CORS_ORIGIN: z.string().min(1).default("*"), - NODE_ENV: z.enum(["dev", "prod"]), - CONTACT_EMAIL: z.email().default("team@shellular.dev"), - WS_TOKEN_SECRET: z.string().min(32), - POSTHOG_KEY: z.string().min(1), - POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), - AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), - AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), - GITHUB_CLIENT_ID: z.string().optional(), - GITHUB_CLIENT_SECRET: z.string().optional(), - APPLE_CLIENT_ID: z.string().optional(), - APPLE_TEAM_ID: z.string().optional(), - APPLE_KEY_ID: z.string().optional(), + HOST: z.string().default("0.0.0.0"), + PORT: z.string().transform(Number).default(6969), + CORS_ORIGIN: z.string().min(1).default("*"), + NODE_ENV: z.enum(["dev", "prod"]), + CONTACT_EMAIL: z.email().default("team@shellular.dev"), + WS_TOKEN_SECRET: z.string().min(32), + POSTHOG_KEY: z.string().min(1).optional(), + POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), + AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), + AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), + APPLE_CLIENT_ID: z.string().optional(), + APPLE_TEAM_ID: z.string().optional(), + APPLE_KEY_ID: z.string().optional(), }); export const env = envSchema.parse(process.env); + +// using console.log instead of local logger to ensure we don't import any local modules in this file +const nodeEnvLog = `--- NODE_ENV: ${env.NODE_ENV} ---`; +console.log("-".repeat(nodeEnvLog.length)); +console.log(nodeEnvLog); +console.log("-".repeat(nodeEnvLog.length)); diff --git a/src/posthog.ts b/src/posthog.ts index f071990..f13d0d3 100644 --- a/src/posthog.ts +++ b/src/posthog.ts @@ -11,27 +11,30 @@ import { logger } from "@/logger"; * - server is the only place we have a *proven* userId * - Server-side capture also can't be blocked or spoofed by the app * - Also don't have to worry about Posthog's session replay thing for user privacy (ik it can be disabled but still) + * + * Analytics is enabled only when a PostHog key is configured *and* we're in + * prod: the key is optional (self-hosters/forks/local runs can omit it), and + * even with a key set, dev runs must not pollute DAU/retention with our own + * testing. With no key, `posthog` stays null and every capture no-ops. */ -export const posthog = new PostHog(env.POSTHOG_KEY, { - host: env.POSTHOG_HOST, -}); +const analyticsEnabled = env.POSTHOG_KEY !== undefined && env.NODE_ENV === "prod"; +logger.info(`🐽 PostHog analytics ${analyticsEnabled ? "enabled 📊" : "disabled"}`); -posthog.on("error", (err) => { - // Analytics must never take down or interfere with the relay. - logger.warn("PostHog error:", err); -}); +const client: PostHog | null = + analyticsEnabled && env.POSTHOG_KEY ? new PostHog(env.POSTHOG_KEY, { host: env.POSTHOG_HOST }) : null; -// Only real (prod) traffic should reach PostHog — dev runs would otherwise -// pollute DAU/retention with our own testing. Routing every event through this -// one gate means a newly added event can't forget to opt out of dev capture. -const analyticsEnabled = env.NODE_ENV === "prod"; +client?.on("error", (err) => { + // Analytics must never take down or interfere with the relay. + logger.warn("PostHog error:", err); +}); -function capture(event: Parameters[0]): void { - if (!analyticsEnabled) { - return; - } +function capture(event: Parameters[0]): void { + // null client (no key / non-prod) means analytics is off — nothing to do. + if (!client) { + return; + } - posthog.capture(event); + client.capture(event); } /** @@ -40,48 +43,44 @@ function capture(event: Parameters[0]): void { * instead of inline strings so event names stay consistent. */ const Events = { - app: { - connectionEstablished: "app_connection_established", - legacyConnectionEstablished: "legacy_app_connection_established", - }, - cli: { - connectionEstablished: "cli_connection_established", - hostActive: "cli_host_active", - }, + app: { + connectionEstablished: "app_connection_established", + legacyConnectionEstablished: "legacy_app_connection_established", + }, + cli: { + connectionEstablished: "cli_connection_established", + hostActive: "cli_host_active", + }, } as const; function appConnectionProperties(clientInfo: ClientInfo, hostPlatform: string) { - return { - // `source` labels which side of the relay the event came from so app and - // CLI analytics are never conflated. - source: "app" as const, - hostId: clientInfo.hostId, - clientId: clientInfo.clientId, - platform: clientInfo.platform, - appVersion: clientInfo.appVersion, - deviceIsEmulator: clientInfo.deviceIsEmulator, - hostPlatform, - }; + return { + // `source` labels which side of the relay the event came from so app and + // CLI analytics are never conflated. + source: "app" as const, + hostId: clientInfo.hostId, + clientId: clientInfo.clientId, + platform: clientInfo.platform, + appVersion: clientInfo.appVersion, + deviceIsEmulator: clientInfo.deviceIsEmulator, + hostPlatform, + }; } -export function captureAppConnection( - userId: string, - clientInfo: ClientInfo, - hostPlatform: string, -): void { - capture({ - distinctId: userId, - event: Events.app.connectionEstablished, - properties: { - ...appConnectionProperties(clientInfo, hostPlatform), - authenticated: true, - // Person profile props so users are filterable in PostHog. - $set: { - lastPlatform: clientInfo.platform, - lastAppVersion: clientInfo.appVersion, - }, - }, - }); +export function captureAppConnection(userId: string, clientInfo: ClientInfo, hostPlatform: string): void { + capture({ + distinctId: userId, + event: Events.app.connectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: true, + // Person profile props so users are filterable in PostHog. + $set: { + lastPlatform: clientInfo.platform, + lastAppVersion: clientInfo.appVersion, + }, + }, + }); } /** @@ -91,45 +90,42 @@ export function captureAppConnection( * event so they never inflate authenticated DAU/retention. A current appVersion * arriving here is a signal of a login-stripped rebuild rather than an old app. */ -export function captureLegacyAppConnection( - clientInfo: ClientInfo, - hostPlatform: string, -): void { - capture({ - distinctId: clientInfo.clientId, - event: Events.app.legacyConnectionEstablished, - properties: { - ...appConnectionProperties(clientInfo, hostPlatform), - authenticated: false, - // Mark the person profile so legacy installs are filterable but never - // silently merged with a real authenticated user. - $set: { isLegacy: true }, - }, - }); +export function captureLegacyAppConnection(clientInfo: ClientInfo, hostPlatform: string): void { + capture({ + distinctId: clientInfo.clientId, + event: Events.app.legacyConnectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: false, + // Mark the person profile so legacy installs are filterable but never + // silently merged with a real authenticated user. + $set: { isLegacy: true }, + }, + }); } function hostProperties(hostInfo: HostInfo) { - return { - source: "cli" as const, - hostId: hostInfo.id, - // machineId lets you dedupe by physical machine in PostHog even though - // events are keyed on the per-registration hostId. - machineId: hostInfo.machineId, - platform: hostInfo.platform, - cliVersion: hostInfo.cliVersion, - }; + return { + source: "cli" as const, + hostId: hostInfo.id, + // machineId lets you dedupe by physical machine in PostHog even though + // events are keyed on the per-registration hostId. + machineId: hostInfo.machineId, + platform: hostInfo.platform, + cliVersion: hostInfo.cliVersion, + }; } /** Fired once when a host (CLI) establishes its relay connection. */ export function captureCliConnection(hostInfo: HostInfo): void { - capture({ - distinctId: hostInfo.id, - event: Events.cli.connectionEstablished, - properties: { - ...hostProperties(hostInfo), - $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, - }, - }); + capture({ + distinctId: hostInfo.id, + event: Events.cli.connectionEstablished, + properties: { + ...hostProperties(hostInfo), + $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, + }, + }); } /** @@ -141,11 +137,11 @@ export function captureCliConnection(hostInfo: HostInfo): void { * per host per day. */ function captureCliHostActive(hostInfo: HostInfo): void { - capture({ - distinctId: hostInfo.id, - event: Events.cli.hostActive, - properties: hostProperties(hostInfo), - }); + capture({ + distinctId: hostInfo.id, + event: Events.cli.hostActive, + properties: hostProperties(hostInfo), + }); } const HEARTBEAT_SWEEP_MS = 6 * 60 * 60 * 1000; // every 6 hours @@ -156,7 +152,7 @@ const lastHostActiveDay = new Map(); let heartbeatTimer: NodeJS.Timeout | null = null; function utcDay(now = new Date()): string { - return now.toISOString().slice(0, 10); + return now.toISOString().slice(0, 10); } /** @@ -170,32 +166,33 @@ function utcDay(now = new Date()): string { * `getHosts` is injected to avoid a circular import with the sessions module. */ export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { - if (heartbeatTimer) { - return; - } - - const sweep = () => { - const day = utcDay(); - for (const hostInfo of getHosts()) { - if (lastHostActiveDay.get(hostInfo.id) === day) { - // if a host has already been counted for the day, then let's skip it - continue; - } - - lastHostActiveDay.set(hostInfo.id, day); - captureCliHostActive(hostInfo); - } - // Drop entries for days that have rolled over so the map can't grow - // unbounded as hosts churn. - for (const [hostId, seenDay] of lastHostActiveDay) { - if (seenDay !== day) { - lastHostActiveDay.delete(hostId); - } - } - }; - - heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); - heartbeatTimer.unref?.(); + // No point sweeping hosts to emit events that would no-op at the gate. + if (!analyticsEnabled || heartbeatTimer) { + return; + } + + const sweep = () => { + const day = utcDay(); + for (const hostInfo of getHosts()) { + if (lastHostActiveDay.get(hostInfo.id) === day) { + // if a host has already been counted for the day, then let's skip it + continue; + } + + lastHostActiveDay.set(hostInfo.id, day); + captureCliHostActive(hostInfo); + } + // Drop entries for days that have rolled over so the map can't grow + // unbounded as hosts churn. + for (const [hostId, seenDay] of lastHostActiveDay) { + if (seenDay !== day) { + lastHostActiveDay.delete(hostId); + } + } + }; + + heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); + heartbeatTimer.unref?.(); } /** @@ -203,9 +200,13 @@ export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { * batched events aren't lost when the process exits. */ export async function shutdownPostHog(): Promise { - try { - await posthog.shutdown(); - } catch (err) { - logger.warn("PostHog shutdown failed:", err); - } + if (!client) { + return; + } + + try { + await client.shutdown(); + } catch (err) { + logger.warn("PostHog shutdown failed:", err); + } } From 874f1bfd526b323301acef6036bfe05ca498a9a1 Mon Sep 17 00:00:00 2001 From: Biraj Date: Fri, 10 Jul 2026 21:56:18 -0700 Subject: [PATCH 14/20] f --- src/posthog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/posthog.ts b/src/posthog.ts index f13d0d3..fb967f1 100644 --- a/src/posthog.ts +++ b/src/posthog.ts @@ -191,6 +191,7 @@ export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { } }; + logger.info("Starting PostHog host-active heartbeat sweep"); heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); heartbeatTimer.unref?.(); } From af653e3eb36332833f07ac87d698809297537656 Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 20:55:02 -0700 Subject: [PATCH 15/20] refactor: remove user connection history functionality and related imports --- src/db/index.ts | 23 ------- src/db/user-history.ts | 150 ----------------------------------------- src/routes/auth.ts | 9 --- src/websocket/index.ts | 2 - 4 files changed, 184 deletions(-) delete mode 100644 src/db/user-history.ts diff --git a/src/db/index.ts b/src/db/index.ts index 2f81004..c3f02c4 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -73,7 +73,6 @@ function runAuthMigrations(): void { FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE ); `); - ensureUserConnectionHistoryTable(); backfillPrimaryOAuthAccounts(); ensureUserProviderIndex(); } @@ -115,28 +114,6 @@ function backfillPrimaryOAuthAccounts(): void { } } -function ensureUserConnectionHistoryTable(): void { - db.exec(` - CREATE TABLE IF NOT EXISTS user_connection_history ( - userId TEXT NOT NULL, - hostId TEXT NOT NULL, - clientId TEXT NOT NULL, - appVersion TEXT NOT NULL, - platform TEXT NOT NULL, - deviceModel TEXT NOT NULL, - deviceIsEmulator INTEGER NOT NULL, - deviceManufacturer TEXT NOT NULL, - firstSeenAt INTEGER NOT NULL, - lastSeenAt INTEGER NOT NULL, - connectionCount INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (userId, hostId, clientId), - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_user_connection_history_user_lastSeen - ON user_connection_history (userId, lastSeenAt); - `); -} - function ensureUserProviderIndex(): void { const duplicates = db .prepare( diff --git a/src/db/user-history.ts b/src/db/user-history.ts deleted file mode 100644 index 5257ace..0000000 --- a/src/db/user-history.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { ClientInfo } from "@shellular/protocol"; - -import { db } from "./index"; - -export type UserHostHistory = { - hostId: string; - machineId: string | null; - platform: string | null; - firstSeenAt: number; - lastSeenAt: number; - connectionCount: number; -}; - -export type UserDeviceHistory = { - clientId: string; - lastHostId: string; - appVersion: string; - platform: ClientInfo["platform"]; - deviceModel: string; - deviceIsEmulator: boolean; - deviceManufacturer: string; - firstSeenAt: number; - lastSeenAt: number; - connectionCount: number; -}; - -export type UserConnectionHistory = { - hosts: UserHostHistory[]; - devices: UserDeviceHistory[]; -}; - -type UserConnectionHistoryRow = { - hostId: string; - clientId: string; - appVersion: string; - platform: ClientInfo["platform"]; - deviceModel: string; - deviceIsEmulator: number; - deviceManufacturer: string; - firstSeenAt: number; - lastSeenAt: number; - connectionCount: number; - hostMachineId: string | null; - hostPlatform: string | null; -}; - -export function recordUserConnectionHistory( - userId: string, - clientInfo: ClientInfo, -): void { - const now = Date.now(); - db.prepare( - `INSERT INTO user_connection_history - (userId, hostId, clientId, appVersion, platform, deviceModel, deviceIsEmulator, deviceManufacturer, firstSeenAt, lastSeenAt, connectionCount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) - ON CONFLICT(userId, hostId, clientId) - DO UPDATE SET - appVersion = excluded.appVersion, - platform = excluded.platform, - deviceModel = excluded.deviceModel, - deviceIsEmulator = excluded.deviceIsEmulator, - deviceManufacturer = excluded.deviceManufacturer, - lastSeenAt = excluded.lastSeenAt, - connectionCount = user_connection_history.connectionCount + 1`, - ).run( - userId, - clientInfo.hostId, - clientInfo.clientId, - clientInfo.appVersion, - clientInfo.platform, - clientInfo.deviceModel, - clientInfo.deviceIsEmulator ? 1 : 0, - clientInfo.deviceManufacturer, - now, - now, - ); -} - -export function listUserConnectionHistory( - userId: string, -): UserConnectionHistory { - const rows = db - .prepare( - `SELECT - history.hostId, - history.clientId, - history.appVersion, - history.platform, - history.deviceModel, - history.deviceIsEmulator, - history.deviceManufacturer, - history.firstSeenAt, - history.lastSeenAt, - history.connectionCount, - hosts.machineId AS hostMachineId, - hosts.platform AS hostPlatform - FROM user_connection_history history - LEFT JOIN hosts ON hosts.id = history.hostId - WHERE history.userId = ? - ORDER BY history.lastSeenAt DESC - LIMIT 500`, - ) - .all(userId) as UserConnectionHistoryRow[]; - - const hosts = new Map(); - const devices = new Map(); - - for (const row of rows) { - const host = hosts.get(row.hostId); - if (host) { - host.firstSeenAt = Math.min(host.firstSeenAt, row.firstSeenAt); - host.lastSeenAt = Math.max(host.lastSeenAt, row.lastSeenAt); - host.connectionCount += row.connectionCount; - } else { - hosts.set(row.hostId, { - hostId: row.hostId, - machineId: row.hostMachineId, - platform: row.hostPlatform, - firstSeenAt: row.firstSeenAt, - lastSeenAt: row.lastSeenAt, - connectionCount: row.connectionCount, - }); - } - - const device = devices.get(row.clientId); - if (device) { - device.firstSeenAt = Math.min(device.firstSeenAt, row.firstSeenAt); - device.lastSeenAt = Math.max(device.lastSeenAt, row.lastSeenAt); - device.connectionCount += row.connectionCount; - } else { - devices.set(row.clientId, { - clientId: row.clientId, - lastHostId: row.hostId, - appVersion: row.appVersion, - platform: row.platform, - deviceModel: row.deviceModel, - deviceIsEmulator: row.deviceIsEmulator === 1, - deviceManufacturer: row.deviceManufacturer, - firstSeenAt: row.firstSeenAt, - lastSeenAt: row.lastSeenAt, - connectionCount: row.connectionCount, - }); - } - } - - return { - hosts: [...hosts.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt), - devices: [...devices.values()].sort((a, b) => b.lastSeenAt - a.lastSeenAt), - }; -} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 66b0f51..0eefc54 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -39,7 +39,6 @@ import { } from "@/auth/ws-app-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; -import { listUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { BadRequestError, ConflictError, ForbiddenError } from "@/error/http"; @@ -226,14 +225,6 @@ router.get("/me", (req, res) => { res.json({ success: true, data: { user: authUserResponse(req, user) } }); }); -router.get("/history", (req, res) => { - const user = requireAuthUser(req); - res.json({ - success: true, - data: { history: listUserConnectionHistory(user.id) }, - }); -}); - router.post("/ws-app-token", (req, res) => { const user = requireAuthUser(req); // Parsed with the request schema, which omits `user`: identity is asserted by diff --git a/src/websocket/index.ts b/src/websocket/index.ts index 27e9658..a54a0f4 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -13,7 +13,6 @@ import { } from "@/auth/ws-app-ticket"; import { getClient, verifyClient } from "@/db/client"; import { getHost } from "@/db/host"; -import { recordUserConnectionHistory } from "@/db/user-history"; import { env } from "@/env"; import { logger } from "@/logger"; import { captureAppConnection, captureLegacyAppConnection } from "@/posthog"; @@ -159,7 +158,6 @@ async function handleUpgradeRequest( } if (auth.userId) { - recordUserConnectionHistory(auth.userId, clientInfo); captureAppConnection(auth.userId, clientInfo, host.platform); } else { captureLegacyAppConnection(clientInfo, host.platform); From 60900d2e1c06075ef014a4b1bb5aef81194c2a4b Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 20:55:07 -0700 Subject: [PATCH 16/20] chore: format --- src/env.ts | 34 +++---- src/posthog.ts | 242 ++++++++++++++++++++++++++----------------------- 2 files changed, 144 insertions(+), 132 deletions(-) diff --git a/src/env.ts b/src/env.ts index f8284f1..258e4d8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,23 +4,23 @@ import { z } from "zod"; dotenv.config(); const envSchema = z.object({ - HOST: z.string().default("0.0.0.0"), - PORT: z.string().transform(Number).default(6969), - CORS_ORIGIN: z.string().min(1).default("*"), - NODE_ENV: z.enum(["dev", "prod"]), - CONTACT_EMAIL: z.email().default("team@shellular.dev"), - WS_TOKEN_SECRET: z.string().min(32), - POSTHOG_KEY: z.string().min(1).optional(), - POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), - AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), - AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), - GOOGLE_CLIENT_ID: z.string().optional(), - GOOGLE_CLIENT_SECRET: z.string().optional(), - GITHUB_CLIENT_ID: z.string().optional(), - GITHUB_CLIENT_SECRET: z.string().optional(), - APPLE_CLIENT_ID: z.string().optional(), - APPLE_TEAM_ID: z.string().optional(), - APPLE_KEY_ID: z.string().optional(), + HOST: z.string().default("0.0.0.0"), + PORT: z.string().transform(Number).default(6969), + CORS_ORIGIN: z.string().min(1).default("*"), + NODE_ENV: z.enum(["dev", "prod"]), + CONTACT_EMAIL: z.email().default("team@shellular.dev"), + WS_TOKEN_SECRET: z.string().min(32), + POSTHOG_KEY: z.string().min(1).optional(), + POSTHOG_HOST: z.url().default("https://us.i.posthog.com"), + AUTH_PUBLIC_BASE_URL: z.url().default("https://api.shellular.dev"), + AUTH_APP_CALLBACK_URL: z.string().default("shellular://auth-callback"), + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), + APPLE_CLIENT_ID: z.string().optional(), + APPLE_TEAM_ID: z.string().optional(), + APPLE_KEY_ID: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/src/posthog.ts b/src/posthog.ts index fb967f1..0aa1fa0 100644 --- a/src/posthog.ts +++ b/src/posthog.ts @@ -17,24 +17,29 @@ import { logger } from "@/logger"; * even with a key set, dev runs must not pollute DAU/retention with our own * testing. With no key, `posthog` stays null and every capture no-ops. */ -const analyticsEnabled = env.POSTHOG_KEY !== undefined && env.NODE_ENV === "prod"; -logger.info(`🐽 PostHog analytics ${analyticsEnabled ? "enabled 📊" : "disabled"}`); +const analyticsEnabled = + env.POSTHOG_KEY !== undefined && env.NODE_ENV === "prod"; +logger.info( + `🐽 PostHog analytics ${analyticsEnabled ? "enabled 📊" : "disabled"}`, +); const client: PostHog | null = - analyticsEnabled && env.POSTHOG_KEY ? new PostHog(env.POSTHOG_KEY, { host: env.POSTHOG_HOST }) : null; + analyticsEnabled && env.POSTHOG_KEY + ? new PostHog(env.POSTHOG_KEY, { host: env.POSTHOG_HOST }) + : null; client?.on("error", (err) => { - // Analytics must never take down or interfere with the relay. - logger.warn("PostHog error:", err); + // Analytics must never take down or interfere with the relay. + logger.warn("PostHog error:", err); }); function capture(event: Parameters[0]): void { - // null client (no key / non-prod) means analytics is off — nothing to do. - if (!client) { - return; - } + // null client (no key / non-prod) means analytics is off — nothing to do. + if (!client) { + return; + } - client.capture(event); + client.capture(event); } /** @@ -43,44 +48,48 @@ function capture(event: Parameters[0]): void { * instead of inline strings so event names stay consistent. */ const Events = { - app: { - connectionEstablished: "app_connection_established", - legacyConnectionEstablished: "legacy_app_connection_established", - }, - cli: { - connectionEstablished: "cli_connection_established", - hostActive: "cli_host_active", - }, + app: { + connectionEstablished: "app_connection_established", + legacyConnectionEstablished: "legacy_app_connection_established", + }, + cli: { + connectionEstablished: "cli_connection_established", + hostActive: "cli_host_active", + }, } as const; function appConnectionProperties(clientInfo: ClientInfo, hostPlatform: string) { - return { - // `source` labels which side of the relay the event came from so app and - // CLI analytics are never conflated. - source: "app" as const, - hostId: clientInfo.hostId, - clientId: clientInfo.clientId, - platform: clientInfo.platform, - appVersion: clientInfo.appVersion, - deviceIsEmulator: clientInfo.deviceIsEmulator, - hostPlatform, - }; + return { + // `source` labels which side of the relay the event came from so app and + // CLI analytics are never conflated. + source: "app" as const, + hostId: clientInfo.hostId, + clientId: clientInfo.clientId, + platform: clientInfo.platform, + appVersion: clientInfo.appVersion, + deviceIsEmulator: clientInfo.deviceIsEmulator, + hostPlatform, + }; } -export function captureAppConnection(userId: string, clientInfo: ClientInfo, hostPlatform: string): void { - capture({ - distinctId: userId, - event: Events.app.connectionEstablished, - properties: { - ...appConnectionProperties(clientInfo, hostPlatform), - authenticated: true, - // Person profile props so users are filterable in PostHog. - $set: { - lastPlatform: clientInfo.platform, - lastAppVersion: clientInfo.appVersion, - }, - }, - }); +export function captureAppConnection( + userId: string, + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: userId, + event: Events.app.connectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: true, + // Person profile props so users are filterable in PostHog. + $set: { + lastPlatform: clientInfo.platform, + lastAppVersion: clientInfo.appVersion, + }, + }, + }); } /** @@ -90,42 +99,45 @@ export function captureAppConnection(userId: string, clientInfo: ClientInfo, hos * event so they never inflate authenticated DAU/retention. A current appVersion * arriving here is a signal of a login-stripped rebuild rather than an old app. */ -export function captureLegacyAppConnection(clientInfo: ClientInfo, hostPlatform: string): void { - capture({ - distinctId: clientInfo.clientId, - event: Events.app.legacyConnectionEstablished, - properties: { - ...appConnectionProperties(clientInfo, hostPlatform), - authenticated: false, - // Mark the person profile so legacy installs are filterable but never - // silently merged with a real authenticated user. - $set: { isLegacy: true }, - }, - }); +export function captureLegacyAppConnection( + clientInfo: ClientInfo, + hostPlatform: string, +): void { + capture({ + distinctId: clientInfo.clientId, + event: Events.app.legacyConnectionEstablished, + properties: { + ...appConnectionProperties(clientInfo, hostPlatform), + authenticated: false, + // Mark the person profile so legacy installs are filterable but never + // silently merged with a real authenticated user. + $set: { isLegacy: true }, + }, + }); } function hostProperties(hostInfo: HostInfo) { - return { - source: "cli" as const, - hostId: hostInfo.id, - // machineId lets you dedupe by physical machine in PostHog even though - // events are keyed on the per-registration hostId. - machineId: hostInfo.machineId, - platform: hostInfo.platform, - cliVersion: hostInfo.cliVersion, - }; + return { + source: "cli" as const, + hostId: hostInfo.id, + // machineId lets you dedupe by physical machine in PostHog even though + // events are keyed on the per-registration hostId. + machineId: hostInfo.machineId, + platform: hostInfo.platform, + cliVersion: hostInfo.cliVersion, + }; } /** Fired once when a host (CLI) establishes its relay connection. */ export function captureCliConnection(hostInfo: HostInfo): void { - capture({ - distinctId: hostInfo.id, - event: Events.cli.connectionEstablished, - properties: { - ...hostProperties(hostInfo), - $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, - }, - }); + capture({ + distinctId: hostInfo.id, + event: Events.cli.connectionEstablished, + properties: { + ...hostProperties(hostInfo), + $set: { lastCliVersion: hostInfo.cliVersion, isHost: true }, + }, + }); } /** @@ -137,11 +149,11 @@ export function captureCliConnection(hostInfo: HostInfo): void { * per host per day. */ function captureCliHostActive(hostInfo: HostInfo): void { - capture({ - distinctId: hostInfo.id, - event: Events.cli.hostActive, - properties: hostProperties(hostInfo), - }); + capture({ + distinctId: hostInfo.id, + event: Events.cli.hostActive, + properties: hostProperties(hostInfo), + }); } const HEARTBEAT_SWEEP_MS = 6 * 60 * 60 * 1000; // every 6 hours @@ -152,7 +164,7 @@ const lastHostActiveDay = new Map(); let heartbeatTimer: NodeJS.Timeout | null = null; function utcDay(now = new Date()): string { - return now.toISOString().slice(0, 10); + return now.toISOString().slice(0, 10); } /** @@ -166,34 +178,34 @@ function utcDay(now = new Date()): string { * `getHosts` is injected to avoid a circular import with the sessions module. */ export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { - // No point sweeping hosts to emit events that would no-op at the gate. - if (!analyticsEnabled || heartbeatTimer) { - return; - } - - const sweep = () => { - const day = utcDay(); - for (const hostInfo of getHosts()) { - if (lastHostActiveDay.get(hostInfo.id) === day) { - // if a host has already been counted for the day, then let's skip it - continue; - } - - lastHostActiveDay.set(hostInfo.id, day); - captureCliHostActive(hostInfo); - } - // Drop entries for days that have rolled over so the map can't grow - // unbounded as hosts churn. - for (const [hostId, seenDay] of lastHostActiveDay) { - if (seenDay !== day) { - lastHostActiveDay.delete(hostId); - } - } - }; - - logger.info("Starting PostHog host-active heartbeat sweep"); - heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); - heartbeatTimer.unref?.(); + // No point sweeping hosts to emit events that would no-op at the gate. + if (!analyticsEnabled || heartbeatTimer) { + return; + } + + const sweep = () => { + const day = utcDay(); + for (const hostInfo of getHosts()) { + if (lastHostActiveDay.get(hostInfo.id) === day) { + // if a host has already been counted for the day, then let's skip it + continue; + } + + lastHostActiveDay.set(hostInfo.id, day); + captureCliHostActive(hostInfo); + } + // Drop entries for days that have rolled over so the map can't grow + // unbounded as hosts churn. + for (const [hostId, seenDay] of lastHostActiveDay) { + if (seenDay !== day) { + lastHostActiveDay.delete(hostId); + } + } + }; + + logger.info("Starting PostHog host-active heartbeat sweep"); + heartbeatTimer = setInterval(sweep, HEARTBEAT_SWEEP_MS); + heartbeatTimer.unref?.(); } /** @@ -201,13 +213,13 @@ export function startHostHeartbeatForPosthog(getHosts: () => HostInfo[]): void { * batched events aren't lost when the process exits. */ export async function shutdownPostHog(): Promise { - if (!client) { - return; - } - - try { - await client.shutdown(); - } catch (err) { - logger.warn("PostHog shutdown failed:", err); - } + if (!client) { + return; + } + + try { + await client.shutdown(); + } catch (err) { + logger.warn("PostHog shutdown failed:", err); + } } From 9c46541235d5d4bc91fa0f0e89b50c5532e3f80b Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 21:01:48 -0700 Subject: [PATCH 17/20] remove user_connection_history table --- readme.md | 4 ---- src/db/init.sql | 22 ++-------------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/readme.md b/readme.md index ef4f7e9..44a5569 100644 --- a/readme.md +++ b/readme.md @@ -51,10 +51,6 @@ The app no longer sends access tokens or device metadata in the `/app` WebSocket The `/cli` WebSocket and `/host/register` flow are unchanged. -## User History - -Successful app joins are recorded in `user_connection_history` for read-only account history. `GET /auth/history` returns the authenticated user's host and device history for display in the app. This data is intended for visibility, history, and analytics only; it is not used for host sync or E2EE key sync. - ## App Notices Short, dismiss-once messages shown as a popup on the app home screen (e.g. maintenance heads-ups). Notices are served from a plain JSON file so they can be pushed to already-installed apps without an app update. diff --git a/src/db/init.sql b/src/db/init.sql index 0db2661..dd92fb6 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS oauth_accounts ( ); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_userId ON oauth_accounts (userId); + CREATE INDEX IF NOT EXISTS idx_oauth_accounts_email ON oauth_accounts (email); CREATE TABLE IF NOT EXISTS oauth_login_states ( @@ -120,23 +121,4 @@ CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_sessionId ON auth_refresh_tokens (sessionId); - -CREATE TABLE IF NOT EXISTS user_connection_history ( - userId TEXT NOT NULL, - hostId TEXT NOT NULL, - clientId TEXT NOT NULL, - appVersion TEXT NOT NULL, - platform TEXT NOT NULL, - deviceModel TEXT NOT NULL, - deviceIsEmulator INTEGER NOT NULL, - deviceManufacturer TEXT NOT NULL, - firstSeenAt INTEGER NOT NULL, - lastSeenAt INTEGER NOT NULL, - connectionCount INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (userId, hostId, clientId), - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_user_connection_history_user_lastSeen - ON user_connection_history (userId, lastSeenAt); +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_sessionId ON auth_refresh_tokens (sessionId); \ No newline at end of file From 7d5ce7e07ad5ba0678e01ed5c4a0eb593e4dc87e Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 21:20:07 -0700 Subject: [PATCH 18/20] refactor: remove unnecessary auth migrations --- src/db/index.ts | 96 ------------------------------------------------- src/db/init.sql | 2 ++ 2 files changed, 2 insertions(+), 96 deletions(-) diff --git a/src/db/index.ts b/src/db/index.ts index c3f02c4..bc96e50 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -29,7 +29,6 @@ if (isLeader) { const start = Date.now(); const initSql = readFileSync(initSQLPath, "utf-8"); db.exec(initSql); - runAuthMigrations(); const end = Date.now(); logger.info(`📀✅ SQLite ready at ${dbPath} in ${end - start}ms`); @@ -39,98 +38,3 @@ if (isLeader) { ); } -function runAuthMigrations(): void { - ensureColumn( - "oauth_accounts", - "isPrimary", - "ALTER TABLE oauth_accounts ADD COLUMN isPrimary INTEGER NOT NULL DEFAULT 0", - ); - ensureColumn( - "oauth_login_states", - "purpose", - "ALTER TABLE oauth_login_states ADD COLUMN purpose TEXT NOT NULL DEFAULT 'signin'", - ); - ensureColumn( - "oauth_login_states", - "userId", - "ALTER TABLE oauth_login_states ADD COLUMN userId TEXT", - ); - ensureColumn( - "oauth_login_states", - "callbackUrl", - "ALTER TABLE oauth_login_states ADD COLUMN callbackUrl TEXT", - ); - db.exec(` - CREATE TABLE IF NOT EXISTS auth_link_codes ( - codeHash TEXT PRIMARY KEY, - userId TEXT NOT NULL, - provider TEXT NOT NULL, - providerAccountId TEXT NOT NULL, - email TEXT NOT NULL, - createdAt INTEGER NOT NULL, - expiresAt INTEGER NOT NULL, - usedAt INTEGER, - FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE - ); - `); - backfillPrimaryOAuthAccounts(); - ensureUserProviderIndex(); -} - -function ensureColumn(table: string, column: string, alterSql: string): void { - const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ - name: string; - }>; - if (!columns.some((row) => row.name === column)) { - db.exec(alterSql); - } -} - -function backfillPrimaryOAuthAccounts(): void { - const users = db - .prepare("SELECT DISTINCT userId FROM oauth_accounts") - .all() as Array<{ userId: string }>; - - const accountsForUser = db.prepare( - `SELECT rowid, isPrimary - FROM oauth_accounts - WHERE userId = ? - ORDER BY createdAt ASC, updatedAt ASC, provider ASC, providerAccountId ASC`, - ); - const updatePrimary = db.prepare( - "UPDATE oauth_accounts SET isPrimary = ? WHERE rowid = ?", - ); - - for (const { userId } of users) { - const accounts = accountsForUser.all(userId) as Array<{ - rowid: number; - isPrimary: number; - }>; - if (accounts.length === 0) continue; - const primary = accounts[0]; - for (const account of accounts) { - updatePrimary.run(account.rowid === primary.rowid ? 1 : 0, account.rowid); - } - } -} - -function ensureUserProviderIndex(): void { - const duplicates = db - .prepare( - `SELECT userId, provider, COUNT(*) AS count - FROM oauth_accounts - GROUP BY userId, provider - HAVING count > 1`, - ) - .all(); - if (duplicates.length > 0) { - logger.error( - "Skipping oauth_accounts user/provider unique index because duplicates exist", - duplicates, - ); - return; - } - db.exec( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts (userId, provider)", - ); -} diff --git a/src/db/init.sql b/src/db/init.sql index dd92fb6..0813747 100644 --- a/src/db/init.sql +++ b/src/db/init.sql @@ -51,6 +51,8 @@ CREATE INDEX IF NOT EXISTS idx_oauth_accounts_userId ON oauth_accounts (userId); CREATE INDEX IF NOT EXISTS idx_oauth_accounts_email ON oauth_accounts (email); +CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider ON oauth_accounts (userId, provider); + CREATE TABLE IF NOT EXISTS oauth_login_states ( stateHash TEXT PRIMARY KEY, provider TEXT NOT NULL, From 9eabe22bbb90bbd595c24265129f2145bfb55465 Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 21:22:12 -0700 Subject: [PATCH 19/20] format --- src/db/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/db/index.ts b/src/db/index.ts index bc96e50..f6d9780 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -37,4 +37,3 @@ if (isLeader) { `📀⏭️ Skipping initialization SQL on non-leader instance (NODE_APP_INSTANCE=${pm2Instance})`, ); } - From d888f712d1f893b47eef9efd3174cc69912fa052 Mon Sep 17 00:00:00 2001 From: Biraj Date: Sat, 11 Jul 2026 22:19:50 -0700 Subject: [PATCH 20/20] remove foxbiz --- src/routes/auth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 0eefc54..ef91210 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -346,7 +346,6 @@ function appCallbackUrl(req: express.Request): string { defaultScheme, "shellular:", "shellular-dev:", - "foxbiz:", ]); if (!allowedSchemes.has(url.protocol)) { throw new BadRequestError("Invalid app callback URL.");