-
Notifications
You must be signed in to change notification settings - Fork 50
Add mobile sync API routes #1777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string | null>; | ||||||||||||||||||||||||||||||||||||||||
| ingest?: (args: { | ||||||||||||||||||||||||||||||||||||||||
| accessToken: string; | ||||||||||||||||||||||||||||||||||||||||
| event: string; | ||||||||||||||||||||||||||||||||||||||||
| properties?: Record<string, unknown>; | ||||||||||||||||||||||||||||||||||||||||
| }) => Promise<void>; | ||||||||||||||||||||||||||||||||||||||||
| }) { | ||||||||||||||||||||||||||||||||||||||||
| 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<string, unknown>; | ||||||||||||||||||||||||||||||||||||||||
| }) => { | ||||||||||||||||||||||||||||||||||||||||
| // Stub: log and accept. Forward to PostHog later. | ||||||||||||||||||||||||||||||||||||||||
| console.log("mobile-analytics:", args.event, args.properties ?? {}); | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The default stub's inner type annotation is
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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<string, unknown> | undefined, | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return c.json({ accepted: true }); | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return router; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export const mobileAnalyticsRouter = createMobileAnalyticsRouter(); | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(), | ||||||||||
| }); | ||||||||||
|
Comment on lines
+17
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify whether directConnect is handled in Convex HTTP handler
rg -n "directConnect|directPort|directTlsPins|ticketSecret" packages/convex/Repository: manaflow-ai/manaflow Length of output: 46 🏁 Script executed: #!/bin/bash
# Examine the schema file at apps/www/lib/routes/mobile-heartbeat.route.ts
echo "=== Checking schema file ==="
cat -n apps/www/lib/routes/mobile-heartbeat.route.ts | head -70Repository: manaflow-ai/manaflow Length of output: 2709 🏁 Script executed: #!/bin/bash
# Check if the Convex mobile_http.ts file exists and examine it
echo "=== Checking Convex handler ==="
if [ -f "packages/convex/convex/mobile_http.ts" ]; then
cat -n "packages/convex/convex/mobile_http.ts" | head -80
else
# Try to find mobile_http file
find packages/convex -name "*mobile*" -type f 2>/dev/null
fiRepository: manaflow-ai/manaflow Length of output: 2138 🏁 Script executed: #!/bin/bash
# Search for where the heartbeat data is being used in the codebase
echo "=== Searching for heartbeat references ==="
rg -n "HeartbeatDirectConnect" --type ts --type tsx -iRepository: manaflow-ai/manaflow Length of output: 151 🏁 Script executed: #!/bin/bash
# Check the specific lines mentioned in "Also applies to"
echo "=== Checking line 32 (directConnect in HeartbeatBody) ==="
sed -n '30,35p' apps/www/lib/routes/mobile-heartbeat.route.ts
echo "=== Checking line 61 (directConnect in function signature) ==="
sed -n '59,63p' apps/www/lib/routes/mobile-heartbeat.route.ts
echo "=== Checking line 149 ==="
sed -n '147,151p' apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 810 🏁 Script executed: #!/bin/bash
# Check if directConnect is being sent to Convex in the publishMobileHeartbeatToConvex function
echo "=== Checking how directConnect is passed to Convex ==="
sed -n '69,90p' apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 716 🏁 Script executed: #!/bin/bash
# Check full context around line 149
echo "=== Context around line 149 ==="
sed -n '140,160p' apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 700 🏁 Script executed: #!/bin/bash
# Check if the Convex handler actually handles directConnect despite my initial grep
echo "=== Full Convex handler file ==="
wc -l packages/convex/convex/mobile_http.tsRepository: manaflow-ai/manaflow Length of output: 137 🏁 Script executed: #!/bin/bash
# Get more context to understand what function is being called around line 149
echo "=== Context for line 149 (function being called) ==="
sed -n '130,160p' apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 1008 🏁 Script executed: #!/bin/bash
# Check if there's any handling of directConnect in the mutations
echo "=== Check mobileMachines.upsertHeartbeatInternal signature ==="
rg -n "upsertHeartbeatInternal" packages/convex/ -A 10Repository: manaflow-ai/manaflow Length of output: 1788 🏁 Script executed: #!/bin/bash
# Check the actual mutation definition to see if it accepts directConnect
echo "=== Check full upsertHeartbeatInternal mutation ==="
sed -n '54,90p' packages/convex/convex/mobileMachines.tsRepository: manaflow-ai/manaflow Length of output: 1190 🏁 Script executed: #!/bin/bash
# Check the schema of mobileMachine to see if it has directConnect fields
echo "=== Check mobileMachine schema ==="
rg -n "directConnect|directPort|directTlsPins|ticketSecret" packages/convex/convex/mobileMachines.tsRepository: manaflow-ai/manaflow Length of output: 98 🏁 Script executed: #!/bin/bash
# Verify the complete data flow - check what the Convex HTTP handler actually receives vs what it uses
echo "=== Verify Convex handler receives directConnect ==="
sed -n '4,45p' packages/convex/convex/mobile_http.tsRepository: manaflow-ai/manaflow Length of output: 1298 🏁 Script executed: #!/bin/bash
# Check if there are any other handlers or recent updates
echo "=== Check all HTTP handlers in Convex ==="
find packages/convex -name "*http*" -type fRepository: manaflow-ai/manaflow Length of output: 768
The client sends Either:
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| 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(), | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Changing from There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Changing Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify the Convex mutation's status validator
rg -n "status:" packages/convex/convex/mobileMachines.ts -A5Repository: manaflow-ai/manaflow Length of output: 599 🏁 Script executed: #!/bin/bash
# Check the mobile-heartbeat.route.ts file around line 29 and relevant schema definitions
rg -n "status.*z\." apps/www/lib/routes/mobile-heartbeat.route.ts -B2 -A2Repository: manaflow-ai/manaflow Length of output: 274 🏁 Script executed: #!/bin/bash
# Check if directConnect is in the HeartbeatBody schema and how it's used
rg -n "directConnect\|HeartbeatBody" apps/www/lib/routes/mobile-heartbeat.route.ts -B1 -A1Repository: manaflow-ai/manaflow Length of output: 46 🏁 Script executed: #!/bin/bash
# Check the Convex HTTP handler to see if directConnect fields are extracted
rg -n "directConnect\|mobile_http" packages/convex/convex/mobile_http.ts -B2 -A2Repository: manaflow-ai/manaflow Length of output: 46 🏁 Script executed: #!/bin/bash
# Get the full schema definition around line 29
sed -n '15,50p' apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 1065 🏁 Script executed: #!/bin/bash
# Search for HeartbeatDirectConnect or directConnect more broadly
rg -i "directconnect|directport|directtls" apps/www/lib/routes/mobile-heartbeat.route.tsRepository: manaflow-ai/manaflow Length of output: 324 🏁 Script executed: #!/bin/bash
# Check what Convex expects in the mobile_http handler
cat packages/convex/convex/mobile_http.tsRepository: manaflow-ai/manaflow Length of output: 1693 Relaxing The Convex HTTP handler ( Additionally, the Suggested fix- status: z.string(),
+ status: z.enum(["online", "offline", "unknown"]),To preserve the 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Relaxing Prompt for AI agents
Suggested change
|
||||||||||
| 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<typeof HeartbeatDirectConnect>; | ||||||||||
| workspaces: Array<z.infer<typeof HeartbeatWorkspace>>; | ||||||||||
| }) { | ||||||||||
| 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, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof createMobilePushRouter>[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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Avoid logging raw analytics
properties; they can contain sensitive user data and should not be emitted verbatim to server logs.Prompt for AI agents