Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@capacitor/browser": "^8.0.3",
"@capacitor/core": "^8.3.0",
"@capacitor/ios": "^8.3.0",
"@capacitor/local-notifications": "^8.0.1",
"capacitor-secure-storage-plugin": "^0.13.0"
},
"devDependencies": {
Expand Down
94 changes: 89 additions & 5 deletions apps/mobile/src/mobile-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { App } from "@capacitor/app";
import { Browser } from "@capacitor/browser";
import type { MobileBridge, MobilePairingState } from "@okcode/contracts";
import type {
MobileBridge,
MobileConnectionState,
MobileNotificationEvent,
MobilePairingState,
} from "@okcode/contracts";
import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";

import { parseMobilePairingInput } from "./mobilePairing";
import {
fireNotification as fireLocalNotification,
registerNotifications as registerLocalNotifications,
setupNotificationTapHandler,
} from "./notifications";

const STORAGE_KEYS = {
serverUrl: "okcode.mobile.serverUrl",
token: "okcode.mobile.authToken",
} as const;

const listeners = new Set<(state: MobilePairingState) => void>();
// ── Pairing state ────────────────────────────────────────────────────

const pairingListeners = new Set<(state: MobilePairingState) => void>();

let pairingState: MobilePairingState = {
paired: false,
Expand Down Expand Up @@ -39,7 +51,7 @@ async function removeSecureValue(key: string): Promise<void> {
}

function emitPairingState(): void {
for (const listener of listeners) {
for (const listener of pairingListeners) {
try {
listener(pairingState);
} catch {
Expand Down Expand Up @@ -161,6 +173,58 @@ async function clearPairing(): Promise<MobilePairingState> {
);
}

// ── Connection state ─────────────────────────────────────────────────

const connectionListeners = new Set<(state: MobileConnectionState) => void>();
let connectionState: MobileConnectionState = "disconnected";

function setConnectionState(nextState: MobileConnectionState): void {
if (connectionState === nextState) return;
connectionState = nextState;
for (const listener of connectionListeners) {
try {
listener(connectionState);
} catch {
// Swallow listener errors.
}
}
}

// The web app's WsTransport emits state changes. The bridge listens to a
// custom event that the web layer fires so the native side can track it.
if (typeof window !== "undefined") {
window.addEventListener("okcode:transport-state", ((event: CustomEvent<string>) => {
const state = event.detail;
switch (state) {
case "open":
setConnectionState("connected");
break;
case "connecting":
setConnectionState("connecting");
break;
case "reconnecting":
setConnectionState("reconnecting");
break;
case "closed":
case "disposed":
setConnectionState("disconnected");
break;
}
}) as EventListener);
}

// ── Notification tap handler ─────────────────────────────────────────

setupNotificationTapHandler((threadId) => {
if (threadId && typeof window !== "undefined") {
// Navigate to the thread when a notification is tapped.
// The web app listens for this custom event and handles navigation.
window.dispatchEvent(new CustomEvent("okcode:notification-tap", { detail: { threadId } }));
}
});

// ── Bridge export ────────────────────────────────────────────────────

const mobileBridge: MobileBridge = {
getWsUrl: () => websocketUrl,
getPairingState: async () => {
Expand All @@ -184,12 +248,32 @@ const mobileBridge: MobileBridge = {
}
},
onPairingState: (listener) => {
listeners.add(listener);
pairingListeners.add(listener);
listener(pairingState);
return () => {
listeners.delete(listener);
pairingListeners.delete(listener);
};
},

// ── Phase 3 additions ──────────────────────────────────────────

getConnectionState: () => connectionState,

onConnectionState: (listener) => {
connectionListeners.add(listener);
listener(connectionState);
return () => {
connectionListeners.delete(listener);
};
},

registerNotifications: async () => {
return registerLocalNotifications();
},

fireNotification: async (event: MobileNotificationEvent) => {
return fireLocalNotification(event);
},
};

Object.defineProperty(window, "mobileBridge", {
Expand Down
108 changes: 108 additions & 0 deletions apps/mobile/src/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Local notification support for the mobile companion app.
*
* Uses Capacitor Local Notifications to alert the user when attention-requiring
* events arrive while the app is backgrounded.
*/
import { LocalNotifications } from "@capacitor/local-notifications";
import type { MobileNotificationEvent } from "@okcode/contracts";

let registered = false;
let nextNotificationId = 1;

const CATEGORY_CHANNEL_MAP: Record<MobileNotificationEvent["category"], string> = {
"approval-requested": "attention",
"user-input-requested": "attention",
"turn-completed": "status",
"session-error": "alerts",
};

/**
* Request notification permissions and create notification channels.
* Returns true if permission was granted.
*/
export async function registerNotifications(): Promise<boolean> {
if (registered) return true;

try {
const permission = await LocalNotifications.requestPermissions();
if (permission.display !== "granted") {
return false;
}

// Create Android notification channels (no-op on iOS)
await LocalNotifications.createChannel({
id: "attention",
name: "Attention Required",
description: "Approval requests and user input needed",
importance: 5, // Max importance
sound: "default",
vibration: true,
});

await LocalNotifications.createChannel({
id: "status",
name: "Status Updates",
description: "Turn completions and session updates",
importance: 3, // Default importance
sound: "default",
vibration: false,
});

await LocalNotifications.createChannel({
id: "alerts",
name: "Alerts",
description: "Errors and critical session events",
importance: 4, // High importance
sound: "default",
vibration: true,
});

registered = true;
return true;
} catch {
return false;
}
}

/**
* Fire a local notification for a mobile notification event.
*/
export async function fireNotification(event: MobileNotificationEvent): Promise<void> {
if (!registered) {
const ok = await registerNotifications();
if (!ok) return;
}

const channelId = CATEGORY_CHANNEL_MAP[event.category] ?? "attention";
const id = nextNotificationId++;

await LocalNotifications.schedule({
notifications: [
{
id,
title: event.title,
body: event.body,
channelId,
extra: {
eventId: event.id,
category: event.category,
threadId: event.threadId,
occurredAt: event.occurredAt,
},
// Show immediately
schedule: { at: new Date(Date.now() + 100) },
},
],
});
}

/**
* Set up listener for notification taps to enable deep navigation.
*/
export function setupNotificationTapHandler(onTap: (threadId: string | undefined) => void): void {
void LocalNotifications.addListener("localNotificationActionPerformed", (action) => {
const threadId = action.notification.extra?.threadId as string | undefined;
onTap(threadId);
});
}
118 changes: 118 additions & 0 deletions apps/server/src/tokenManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";

import { TokenManager } from "./tokenManager";

describe("TokenManager", () => {
describe("validate", () => {
it("accepts any token when auth is disabled", () => {
const manager = new TokenManager(undefined);
expect(manager.validate(null)).toBe(true);
expect(manager.validate("anything")).toBe(true);
});

it("accepts the initial token", () => {
const manager = new TokenManager("secret-token");
expect(manager.validate("secret-token")).toBe(true);
});

it("rejects an incorrect token", () => {
const manager = new TokenManager("secret-token");
expect(manager.validate("wrong-token")).toBe(false);
});

it("rejects null token when auth is enabled", () => {
const manager = new TokenManager("secret-token");
expect(manager.validate(null)).toBe(false);
});
});

describe("generatePairingToken", () => {
it("creates a short-lived token that validates", () => {
const manager = new TokenManager("initial");
const record = manager.generatePairingToken({ ttlSeconds: 60 });
expect(record.kind).toBe("short-lived");
expect(record.expiresAt).not.toBeNull();
expect(manager.validate(record.tokenValue)).toBe(true);
});

it("defaults to 5 minute TTL", () => {
const manager = new TokenManager("initial");
const record = manager.generatePairingToken();
const expiresAt = new Date(record.expiresAt!).getTime();
const createdAt = new Date(record.createdAt).getTime();
// Allow 1 second tolerance
expect(expiresAt - createdAt).toBeGreaterThanOrEqual(299_000);
expect(expiresAt - createdAt).toBeLessThanOrEqual(301_000);
});
});

describe("rotate", () => {
it("issues a new token and returns the previous token ID", () => {
const manager = new TokenManager("initial");
const tokens = manager.list();
const initialTokenId = tokens[0]!.tokenId;

const result = manager.rotate();
expect(result.previousTokenId).toBe(initialTokenId);
expect(result.newRecord.kind).toBe("long-lived");
expect(manager.validate(result.newRecord.tokenValue)).toBe(true);
});

it("old token is still valid during grace period", () => {
const manager = new TokenManager("initial");
manager.rotate();
// The old token should still work during the 30s grace period
expect(manager.validate("initial")).toBe(true);
});
});

describe("revoke", () => {
it("immediately invalidates a token", () => {
const manager = new TokenManager("initial");
const record = manager.generatePairingToken();
expect(manager.validate(record.tokenValue)).toBe(true);

manager.revoke(record.tokenId);
expect(manager.validate(record.tokenValue)).toBe(false);
});

it("returns false for unknown token ID", () => {
const manager = new TokenManager("initial");
expect(manager.revoke("nonexistent")).toBe(false);
});
});

describe("list", () => {
it("lists all tokens without exposing values", () => {
const manager = new TokenManager("initial");
manager.generatePairingToken({ label: "test-pairing" });

const tokens = manager.list();
expect(tokens).toHaveLength(2);
expect(tokens.some((t) => t.kind === "long-lived")).toBe(true);
expect(tokens.some((t) => t.kind === "short-lived")).toBe(true);
// Values should not be present in list output
for (const token of tokens) {
expect(token).not.toHaveProperty("tokenValue");
}
});
});

describe("getPrimaryTokenValue", () => {
it("returns the primary token value", () => {
const manager = new TokenManager("initial");
expect(manager.getPrimaryTokenValue()).toBe("initial");
});

it("returns null when auth is disabled", () => {
const manager = new TokenManager(undefined);
expect(manager.getPrimaryTokenValue()).toBeNull();
});

it("returns the new value after rotation", () => {
const manager = new TokenManager("initial");
const { newRecord } = manager.rotate();
expect(manager.getPrimaryTokenValue()).toBe(newRecord.tokenValue);
});
});
});
Loading
Loading