diff --git a/apps/www/lib/hono-app.ts b/apps/www/lib/hono-app.ts index 7ab7c1c680..8429bb1400 100644 --- a/apps/www/lib/hono-app.ts +++ b/apps/www/lib/hono-app.ts @@ -22,8 +22,11 @@ import { githubOAuthTokenRouter, healthRouter, morphRouter, + mobileAnalyticsRouter, mobileHeartbeatRouter, mobileMachineSessionRouter, + mobilePushRouter, + mobileWorkspaceReadRouter, sandboxesRouter, teamsRouter, usersRouter, @@ -138,8 +141,11 @@ app.route("/", githubPrsFileContentsBatchRouter); app.route("/", githubInstallStateRouter); app.route("/", githubOAuthTokenRouter); app.route("/", githubBranchesRouter); +app.route("/", mobileAnalyticsRouter); app.route("/", mobileMachineSessionRouter); app.route("/", mobileHeartbeatRouter); +app.route("/", mobilePushRouter); +app.route("/", mobileWorkspaceReadRouter); app.route("/", morphRouter); app.route("/", iframePreflightRouter); app.route("/", environmentsRouter); diff --git a/apps/www/lib/routes/index.ts b/apps/www/lib/routes/index.ts index e3b9c8bafd..8f1986802e 100644 --- a/apps/www/lib/routes/index.ts +++ b/apps/www/lib/routes/index.ts @@ -17,8 +17,11 @@ export { githubPrsRouter } from "./github.prs.route"; export { githubInstallStateRouter } from "./github.install-state.route"; export { healthRouter } from "./health.route"; export { iframePreflightRouter } from "./iframe-preflight.route"; +export { mobileAnalyticsRouter } from "./mobile-analytics.route"; export { mobileHeartbeatRouter } from "./mobile-heartbeat.route"; export { mobileMachineSessionRouter } from "./mobile-machine-session.route"; +export { mobilePushRouter } from "./mobile-push.route"; +export { mobileWorkspaceReadRouter } from "./mobile-workspace-read.route"; export { morphRouter } from "./morph.route"; export { sandboxesRouter } from "./sandboxes.route"; export { teamsRouter } from "./teams.route"; diff --git a/apps/www/lib/routes/mobile-analytics.route.test.ts b/apps/www/lib/routes/mobile-analytics.route.test.ts new file mode 100644 index 0000000000..ec3ce4997e --- /dev/null +++ b/apps/www/lib/routes/mobile-analytics.route.test.ts @@ -0,0 +1,56 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; +import { createMobileAnalyticsRouter } from "./mobile-analytics.route"; + +describe("mobileAnalyticsRouter", () => { + it("accepts an analytics event", async () => { + const ingest = vi.fn(async () => {}); + const app = new OpenAPIHono(); + app.route( + "/", + createMobileAnalyticsRouter({ + resolveAccessToken: async () => "access-token-123", + ingest, + }), + ); + + const response = await app.request("/mobile/analytics", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + event: "mobile_workspace_opened", + properties: { workspaceId: "ws_1", platform: "ios" }, + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { accepted: boolean }; + expect(body.accepted).toBe(true); + expect(ingest).toHaveBeenCalledTimes(1); + const firstCall = (ingest.mock.calls as unknown[][])[0]; + expect(firstCall?.[0]).toMatchObject({ + accessToken: "access-token-123", + event: "mobile_workspace_opened", + }); + }); + + it("rejects unauthenticated requests", async () => { + const app = new OpenAPIHono(); + app.route( + "/", + createMobileAnalyticsRouter({ + resolveAccessToken: async () => null, + }), + ); + + const response = await app.request("/mobile/analytics", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + event: "mobile_workspace_opened", + }), + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/www/lib/routes/mobile-analytics.route.ts b/apps/www/lib/routes/mobile-analytics.route.ts new file mode 100644 index 0000000000..62e08f6e94 --- /dev/null +++ b/apps/www/lib/routes/mobile-analytics.route.ts @@ -0,0 +1,90 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +const AnalyticsBody = z + .object({ + event: z.string(), + properties: z.record(z.string(), z.any()).optional(), + }) + .openapi("MobileAnalyticsBody"); + +const AnalyticsResponse = z + .object({ + accepted: z.boolean(), + }) + .openapi("MobileAnalyticsResponse"); + +export function createMobileAnalyticsRouter(options?: { + resolveAccessToken?: (req: Request) => Promise; + ingest?: (args: { + accessToken: string; + event: string; + properties?: Record; + }) => Promise; +}) { + const router = new OpenAPIHono(); + + const resolveAccessToken = + options?.resolveAccessToken ?? + (async (req: Request) => { + const { getAccessTokenFromRequest } = await import("@/lib/utils/auth"); + return getAccessTokenFromRequest(req); + }); + const ingest = + options?.ingest ?? + (async (args: { + event: string; + properties?: Record; + }) => { + // Stub: log and accept. Forward to PostHog later. + console.log("mobile-analytics:", args.event, args.properties ?? {}); + }); + + router.openapi( + createRoute({ + method: "post", + path: "/mobile/analytics", + summary: "Forward mobile analytics events", + tags: ["Mobile"], + request: { + body: { + content: { + "application/json": { + schema: AnalyticsBody, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "Event accepted", + content: { + "application/json": { + schema: AnalyticsResponse, + }, + }, + }, + 401: { description: "Unauthorized" }, + }, + }), + async (c) => { + const accessToken = await resolveAccessToken(c.req.raw); + if (!accessToken) { + return c.text("Unauthorized", 401); + } + + const body = c.req.valid("json"); + await ingest({ + accessToken, + event: body.event, + properties: body.properties as Record | undefined, + }); + + return c.json({ accepted: true }); + }, + ); + + return router; +} + +export const mobileAnalyticsRouter = createMobileAnalyticsRouter(); diff --git a/apps/www/lib/routes/mobile-heartbeat.route.ts b/apps/www/lib/routes/mobile-heartbeat.route.ts index bff54c3a2e..4a3b977af3 100644 --- a/apps/www/lib/routes/mobile-heartbeat.route.ts +++ b/apps/www/lib/routes/mobile-heartbeat.route.ts @@ -14,15 +14,22 @@ const HeartbeatWorkspace = z.object({ lastEventAt: z.number().optional(), }); +const HeartbeatDirectConnect = z.object({ + directPort: z.number(), + directTlsPins: z.array(z.string()), + ticketSecret: z.string(), +}); + const HeartbeatBody = z .object({ machineId: z.string(), displayName: z.string(), tailscaleHostname: z.string().optional(), tailscaleIPs: z.array(z.string()), - status: z.enum(["online", "offline", "unknown"]), + status: z.string(), lastSeenAt: z.number().optional(), lastWorkspaceSyncAt: z.number().optional(), + directConnect: HeartbeatDirectConnect.optional(), workspaces: z.array(HeartbeatWorkspace), }) .openapi("MobileHeartbeatBody"); @@ -48,9 +55,10 @@ export async function publishMobileHeartbeatToConvex(args: { displayName: string; tailscaleHostname?: string; tailscaleIPs: string[]; - status: "online" | "offline" | "unknown"; + status: string; lastSeenAt: number; lastWorkspaceSyncAt?: number; + directConnect?: z.infer; workspaces: Array>; }) { const { convexUrl, deployKey } = getConvexHeartbeatConfig(); @@ -138,6 +146,7 @@ export function createMobileHeartbeatRouter(options?: { status: body.status, lastSeenAt: body.lastSeenAt ?? now(), lastWorkspaceSyncAt: body.lastWorkspaceSyncAt ?? now(), + directConnect: body.directConnect, workspaces: body.workspaces, }); diff --git a/apps/www/lib/routes/mobile-push.route.test.ts b/apps/www/lib/routes/mobile-push.route.test.ts new file mode 100644 index 0000000000..f95aa93bf0 --- /dev/null +++ b/apps/www/lib/routes/mobile-push.route.test.ts @@ -0,0 +1,88 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; +import { createMobilePushRouter } from "./mobile-push.route"; + +describe("mobilePushRouter", () => { + function makeApp(overrides?: Parameters[0]) { + const app = new OpenAPIHono(); + app.route( + "/", + createMobilePushRouter({ + resolveAccessToken: async () => "access-token-123", + ...overrides, + }), + ); + return app; + } + + it("registers a push token", async () => { + const registerToken = vi.fn(async () => {}); + const app = makeApp({ registerToken }); + + const response = await app.request("/mobile/push/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: "apns-device-token-abc", + environment: "development", + platform: "ios", + bundleId: "dev.cmux.app", + deviceId: "device-xyz", + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(registerToken).toHaveBeenCalledTimes(1); + }); + + it("removes a push token", async () => { + const removeToken = vi.fn(async () => {}); + const app = makeApp({ removeToken }); + + const response = await app.request("/mobile/push/remove", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: "apns-device-token-abc" }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(removeToken).toHaveBeenCalledTimes(1); + }); + + it("sends a test push", async () => { + const sendTestPush = vi.fn(async () => 0); + const app = makeApp({ sendTestPush }); + + const response = await app.request("/mobile/push/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Hello", body: "World" }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { scheduledCount: number }; + expect(body.scheduledCount).toBe(0); + expect(sendTestPush).toHaveBeenCalledTimes(1); + }); + + it("rejects unauthenticated requests", async () => { + const app = makeApp({ resolveAccessToken: async () => null }); + + const response = await app.request("/mobile/push/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: "apns-device-token-abc", + environment: "production", + platform: "ios", + bundleId: "dev.cmux.app", + }), + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/www/lib/routes/mobile-push.route.ts b/apps/www/lib/routes/mobile-push.route.ts new file mode 100644 index 0000000000..e6cc45fa1f --- /dev/null +++ b/apps/www/lib/routes/mobile-push.route.ts @@ -0,0 +1,243 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +const PushRegisterBody = z + .object({ + token: z.string(), + environment: z.enum(["development", "production"]), + platform: z.string(), + bundleId: z.string(), + deviceId: z.string().optional(), + }) + .openapi("MobilePushRegisterBody"); + +const PushRemoveBody = z + .object({ + token: z.string(), + }) + .openapi("MobilePushRemoveBody"); + +const PushTestBody = z + .object({ + title: z.string(), + body: z.string(), + }) + .openapi("MobilePushTestBody"); + +const OKResponse = z + .object({ + ok: z.boolean(), + }) + .openapi("MobilePushOKResponse"); + +const PushTestResponse = z + .object({ + scheduledCount: z.number(), + }) + .openapi("MobilePushTestResponse"); + +export function createMobilePushRouter(options?: { + resolveAccessToken?: (req: Request) => Promise; + registerToken?: (args: { + accessToken: string; + token: string; + environment: "development" | "production"; + platform: string; + bundleId: string; + deviceId?: string; + }) => Promise; + removeToken?: (args: { + accessToken: string; + token: string; + }) => Promise; + sendTestPush?: (args: { + accessToken: string; + title: string; + body: string; + }) => Promise; +}) { + const router = new OpenAPIHono(); + + const resolveAccessToken = + options?.resolveAccessToken ?? + (async (req: Request) => { + const { getAccessTokenFromRequest } = await import("@/lib/utils/auth"); + return getAccessTokenFromRequest(req); + }); + const registerToken = + options?.registerToken ?? + (async (args: { + token: string; + environment: string; + platform: string; + bundleId: string; + deviceId?: string; + }) => { + console.log("mobile-push: register token", { + environment: args.environment, + platform: args.platform, + bundleId: args.bundleId, + deviceId: args.deviceId, + tokenPrefix: args.token.slice(0, 8), + }); + }); + const removeToken = + options?.removeToken ?? + (async (args: { token: string }) => { + console.log("mobile-push: remove token", { + tokenPrefix: args.token.slice(0, 8), + }); + }); + const sendTestPush = + options?.sendTestPush ?? + (async (args: { title: string; body: string }) => { + console.log("mobile-push: test push requested", { + title: args.title, + body: args.body, + }); + return 0; + }); + + // POST /mobile/push/register + router.openapi( + createRoute({ + method: "post", + path: "/mobile/push/register", + summary: "Register a push notification token", + tags: ["Mobile"], + request: { + body: { + content: { + "application/json": { + schema: PushRegisterBody, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "Token registered", + content: { + "application/json": { + schema: OKResponse, + }, + }, + }, + 401: { description: "Unauthorized" }, + }, + }), + async (c) => { + const accessToken = await resolveAccessToken(c.req.raw); + if (!accessToken) { + return c.text("Unauthorized", 401); + } + + const body = c.req.valid("json"); + await registerToken({ + accessToken, + token: body.token, + environment: body.environment, + platform: body.platform, + bundleId: body.bundleId, + deviceId: body.deviceId, + }); + + return c.json({ ok: true }); + }, + ); + + // POST /mobile/push/remove + router.openapi( + createRoute({ + method: "post", + path: "/mobile/push/remove", + summary: "Remove a push notification token", + tags: ["Mobile"], + request: { + body: { + content: { + "application/json": { + schema: PushRemoveBody, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "Token removed", + content: { + "application/json": { + schema: OKResponse, + }, + }, + }, + 401: { description: "Unauthorized" }, + }, + }), + async (c) => { + const accessToken = await resolveAccessToken(c.req.raw); + if (!accessToken) { + return c.text("Unauthorized", 401); + } + + const body = c.req.valid("json"); + await removeToken({ + accessToken, + token: body.token, + }); + + return c.json({ ok: true }); + }, + ); + + // POST /mobile/push/test + router.openapi( + createRoute({ + method: "post", + path: "/mobile/push/test", + summary: "Send a test push notification", + tags: ["Mobile"], + request: { + body: { + content: { + "application/json": { + schema: PushTestBody, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "Test push scheduled", + content: { + "application/json": { + schema: PushTestResponse, + }, + }, + }, + 401: { description: "Unauthorized" }, + }, + }), + async (c) => { + const accessToken = await resolveAccessToken(c.req.raw); + if (!accessToken) { + return c.text("Unauthorized", 401); + } + + const body = c.req.valid("json"); + const scheduledCount = await sendTestPush({ + accessToken, + title: body.title, + body: body.body, + }); + + return c.json({ scheduledCount }); + }, + ); + + return router; +} + +export const mobilePushRouter = createMobilePushRouter(); diff --git a/apps/www/lib/routes/mobile-workspace-read.route.test.ts b/apps/www/lib/routes/mobile-workspace-read.route.test.ts new file mode 100644 index 0000000000..f4870d4b6f --- /dev/null +++ b/apps/www/lib/routes/mobile-workspace-read.route.test.ts @@ -0,0 +1,67 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; +import { createMobileWorkspaceReadRouter } from "./mobile-workspace-read.route"; + +describe("mobileWorkspaceReadRouter", () => { + it("marks a workspace as read", async () => { + const markRead = vi.fn(async () => {}); + const app = new OpenAPIHono(); + app.route( + "/", + createMobileWorkspaceReadRouter({ + resolveAccessToken: async () => "access-token-123", + verifyTeam: async () => ({ uuid: "team_123" }), + markRead, + }), + ); + + const response = await app.request("/mobile/workspaces/mark-read", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + teamSlugOrId: "cmux", + workspaceId: "workspace_abc", + latestEventSeq: 42, + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + expect(markRead).toHaveBeenCalledTimes(1); + const firstCall = (markRead.mock.calls as unknown[][])[0]; + expect(firstCall?.[0]).toMatchObject({ + accessToken: "access-token-123", + teamId: "team_123", + workspaceId: "workspace_abc", + latestEventSeq: 42, + }); + }); + + it("rejects unauthenticated requests", async () => { + const app = new OpenAPIHono(); + app.route( + "/", + createMobileWorkspaceReadRouter({ + resolveAccessToken: async () => null, + verifyTeam: async () => ({ uuid: "team_123" }), + markRead: async () => {}, + }), + ); + + const response = await app.request("/mobile/workspaces/mark-read", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + teamSlugOrId: "cmux", + workspaceId: "workspace_abc", + }), + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/apps/www/lib/routes/mobile-workspace-read.route.ts b/apps/www/lib/routes/mobile-workspace-read.route.ts new file mode 100644 index 0000000000..574f500eff --- /dev/null +++ b/apps/www/lib/routes/mobile-workspace-read.route.ts @@ -0,0 +1,148 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +const MarkReadBody = z + .object({ + teamSlugOrId: z.string(), + workspaceId: z.string(), + latestEventSeq: z.number().optional(), + }) + .openapi("MobileMarkReadBody"); + +const MarkReadResponse = z + .object({ + ok: z.boolean(), + }) + .openapi("MobileMarkReadResponse"); + +export function createMobileWorkspaceReadRouter(options?: { + resolveAccessToken?: (req: Request) => Promise; + verifyTeam?: (args: { + req: Request; + accessToken: string; + teamSlugOrId: string; + }) => Promise<{ uuid: string }>; + markRead?: (args: { + accessToken: string; + teamId: string; + workspaceId: string; + latestEventSeq?: number; + }) => Promise; +}) { + const router = new OpenAPIHono(); + + const resolveAccessToken = + options?.resolveAccessToken ?? + (async (req: Request) => { + const { getAccessTokenFromRequest } = await import("@/lib/utils/auth"); + return getAccessTokenFromRequest(req); + }); + const verifyTeam = + options?.verifyTeam ?? + (async ({ + req, + teamSlugOrId, + }: { + req: Request; + accessToken: string; + teamSlugOrId: string; + }) => { + const { verifyTeamAccess } = await import( + "@/lib/utils/team-verification" + ); + return await verifyTeamAccess({ req, teamSlugOrId }); + }); + const markRead = + options?.markRead ?? + (async (args: { + accessToken: string; + teamId: string; + workspaceId: string; + latestEventSeq?: number; + }) => { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + const deployKey = process.env.CONVEX_DEPLOY_KEY; + if (!convexUrl || !deployKey) { + throw new Error( + "NEXT_PUBLIC_CONVEX_URL and CONVEX_DEPLOY_KEY are required", + ); + } + const endpoint = convexUrl + .replace(".convex.cloud", ".convex.site") + .replace(/\/$/, ""); + // Calls Convex HTTP endpoint to mark the workspace as read. + const response = await fetch( + `${endpoint}/api/mobile/workspaces/mark-read`, + { + method: "POST", + headers: { + authorization: `Bearer ${deployKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + teamSlugOrId: args.teamId, + workspaceId: args.workspaceId, + latestEventSeq: args.latestEventSeq, + }), + }, + ); + if (!response.ok) { + throw new Error(`Convex markRead failed: ${response.status}`); + } + }); + + router.openapi( + createRoute({ + method: "post", + path: "/mobile/workspaces/mark-read", + summary: "Mark a workspace as read", + tags: ["Mobile"], + request: { + body: { + content: { + "application/json": { + schema: MarkReadBody, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "Workspace marked as read", + content: { + "application/json": { + schema: MarkReadResponse, + }, + }, + }, + 401: { description: "Unauthorized" }, + }, + }), + async (c) => { + const accessToken = await resolveAccessToken(c.req.raw); + if (!accessToken) { + return c.text("Unauthorized", 401); + } + + const body = c.req.valid("json"); + const team = await verifyTeam({ + req: c.req.raw, + accessToken, + teamSlugOrId: body.teamSlugOrId, + }); + + await markRead({ + accessToken, + teamId: team.uuid, + workspaceId: body.workspaceId, + latestEventSeq: body.latestEventSeq, + }); + + return c.json({ ok: true }); + }, + ); + + return router; +} + +export const mobileWorkspaceReadRouter = createMobileWorkspaceReadRouter();