Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/www/lib/hono-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ import {
githubOAuthTokenRouter,
healthRouter,
morphRouter,
mobileAnalyticsRouter,
mobileHeartbeatRouter,
mobileMachineSessionRouter,
mobilePushRouter,
mobileWorkspaceReadRouter,
sandboxesRouter,
teamsRouter,
usersRouter,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions apps/www/lib/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
56 changes: 56 additions & 0 deletions apps/www/lib/routes/mobile-analytics.route.test.ts
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);
});
});
90 changes: 90 additions & 0 deletions apps/www/lib/routes/mobile-analytics.route.ts
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 ?? {});
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 3, 2026

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
Check if this issue is valid — if so, understand the root cause and fix it. At apps/www/lib/routes/mobile-analytics.route.ts, line 39:

<comment>Avoid logging raw analytics `properties`; they can contain sensitive user data and should not be emitted verbatim to server logs.</comment>

<file context>
@@ -0,0 +1,90 @@
+      properties?: Record<string, unknown>;
+    }) => {
+      // Stub: log and accept. Forward to PostHog later.
+      console.log("mobile-analytics:", args.event, args.properties ?? {});
+    });
+
</file context>
Fix with Cubic

});
Comment on lines +32 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Default ingest stub type omits accessToken

The default stub's inner type annotation is { event: string; properties?: ... }, dropping accessToken. The actual call (line 77) passes accessToken — it's silently ignored by the stub. When PostHog forwarding is added later, whoever wires it in via the default path will need to remember to add accessToken to the stub signature, or will find the parameter missing. The stub should mirror the full option type to avoid this drift.

Suggested change
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 ?? {});
});
const ingest =
options?.ingest ??
(async (args: {
accessToken: string;
event: string;
properties?: Record<string, unknown>;
}) => {
// 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<string, unknown> | undefined,
});

return c.json({ accepted: true });
},
);

return router;
}

export const mobileAnalyticsRouter = createMobileAnalyticsRouter();
13 changes: 11 additions & 2 deletions apps/www/lib/routes/mobile-heartbeat.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -70

Repository: 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
fi

Repository: 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 -i

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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 10

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: 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 f

Repository: manaflow-ai/manaflow

Length of output: 768


directConnect fields are silently dropped by the Convex backend.

The client sends directConnect (with directPort, directTlsPins, ticketSecret) in the request body to the Convex HTTP endpoint, but the handler at packages/convex/convex/mobile_http.ts does not extract these fields from the request. The type annotation for the request body (lines 9-31) excludes directConnect, and the subsequent mutations (lines 33-43, 45-52) never receive or persist these fields. The data is lost.

Either:

  1. Update the Convex HTTP handler to extract and forward directConnect fields to a mutation, or
  2. Remove these fields from the schema until the backend is ready.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/lib/routes/mobile-heartbeat.route.ts` around lines 17 - 21, The
Convex HTTP handler is not extracting or forwarding the directConnect payload
(HeartbeatDirectConnect/directPort, directTlsPins, ticketSecret), so those
fields are dropped; update the handler in packages/convex/convex/mobile_http.ts
to accept and validate a directConnect object (using HeartbeatDirectConnect
shape), include it in the request-body type/validation and pass it through to
the existing Convex mutation(s) that persist heartbeats (the mutation you call
to store heartbeat data), or alternatively remove directConnect from the client
schema if you choose to not persist it yet; ensure you reference
HeartbeatDirectConnect and the directConnect property names when modifying the
handler and mutation call so the fields are forwarded and stored.


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(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 status widened to unbounded z.string()

Changing from z.enum(["online", "offline", "unknown"]) to z.string() removes all schema-level validation and will generate a less descriptive OpenAPI spec. If new values are needed (e.g., "direct-connect"), consider extending the enum rather than accepting any string, so invalid values are rejected at the API boundary rather than silently forwarded to Convex.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce heartbeat status enum at request boundary

Changing status validation to z.string() allows arbitrary values through this API, but the downstream Convex mutation still validates status as online|offline|unknown (mobileMachines.upsertHeartbeatInternal). Any other value now passes initial validation and fails later during mutation args validation, turning a client input issue into a runtime 5xx path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the Convex mutation's status validator
rg -n "status:" packages/convex/convex/mobileMachines.ts -A5

Repository: 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 -A2

Repository: 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 -A1

Repository: 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 -A2

Repository: 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.ts

Repository: 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.ts

Repository: 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.ts

Repository: manaflow-ai/manaflow

Length of output: 1693


Relaxing status to z.string() will cause runtime errors downstream.

The Convex HTTP handler (packages/convex/convex/mobile_http.ts) expects a strict literal union: status: "online" | "offline" | "unknown". Your route will accept any string value, but non-enum statuses will fail at the Convex layer.

Additionally, the directConnect field accepted by your route schema is never extracted by the HTTP handler and will be silently dropped without being persisted.

Suggested fix
-    status: z.string(),
+    status: z.enum(["online", "offline", "unknown"]),

To preserve the directConnect data, add extraction and forwarding in the Convex HTTP handler, or remove it from the route schema if it's not needed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
status: z.string(),
status: z.enum(["online", "offline", "unknown"]),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/lib/routes/mobile-heartbeat.route.ts` at line 29, The route schema
currently relaxes status to z.string() (see status in mobile-heartbeat.route.ts)
which can pass invalid values into the Convex handler; change status to the
strict literal union ("online" | "offline" | "unknown") so it matches the Convex
HTTP handler expectation in packages/convex/convex/mobile_http.ts, and either
remove the unused directConnect field from the route schema or update the Convex
handler (mobile_http.ts) to extract and forward directConnect alongside other
payload fields so it is persisted rather than silently dropped.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Relaxing status to z.string() removes API-boundary validation while the downstream Convex mutation (upsertHeartbeatInternal) still enforces "online" | "offline" | "unknown". Invalid values now pass Zod validation here but fail at the mutation layer, turning a client input error into a runtime 500. Restore the enum (extending it if new values are needed) so invalid statuses are rejected early.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/www/lib/routes/mobile-heartbeat.route.ts, line 29:

<comment>Relaxing `status` to `z.string()` removes API-boundary validation while the downstream Convex mutation (`upsertHeartbeatInternal`) still enforces `"online" | "offline" | "unknown"`. Invalid values now pass Zod validation here but fail at the mutation layer, turning a client input error into a runtime 500. Restore the enum (extending it if new values are needed) so invalid statuses are rejected early.</comment>

<file context>
@@ -14,15 +14,22 @@ const HeartbeatWorkspace = z.object({
     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(),
</file context>
Suggested change
status: z.string(),
status: z.enum(["online", "offline", "unknown"]),
Fix with Cubic

lastSeenAt: z.number().optional(),
lastWorkspaceSyncAt: z.number().optional(),
directConnect: HeartbeatDirectConnect.optional(),
workspaces: z.array(HeartbeatWorkspace),
})
.openapi("MobileHeartbeatBody");
Expand All @@ -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();
Expand Down Expand Up @@ -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,
});

Expand Down
88 changes: 88 additions & 0 deletions apps/www/lib/routes/mobile-push.route.test.ts
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);
});
});
Loading
Loading