Skip to content
Closed
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
5 changes: 3 additions & 2 deletions src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export function detectLanHost(

/** Build the `lisa-pair://` deep-link the phone scans/pastes. Pure. */
export function buildPairUrl(host: string, port: number, token: string, name: string): string {
const q = new URLSearchParams({ host, port: String(port), token, name });
return `lisa-pair://v1?${q.toString()}`;
// %20 (not "+") for spaces so the device label round-trips through iOS URLComponents.
const q = new URLSearchParams({ host, port: String(port), token, name }).toString().replace(/\+/g, "%20");
return `lisa-pair://v1?${q}`;
}

export async function runPairCommand(argv: string[]): Promise<number> {
Expand Down
69 changes: 69 additions & 0 deletions src/web/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import type { AgentSession } from "../integrations/types.js";

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-push-"));
Expand All @@ -15,6 +16,10 @@ const {
agentPushEvents,
agentDeepLink,
sendNtfy,
apnsConfigFromEnv,
buildApnsJwt,
buildApnsPayload,
sendApns,
PushBridge,
registerPush,
unregisterPush,
Expand Down Expand Up @@ -78,6 +83,14 @@ describe("agentPushEvents (pure trigger)", () => {
assert.equal(u.searchParams.get("agent"), "codex");
assert.equal(u.searchParams.get("id"), "s9");
});
test("agentDeepLink encodes spaces as %20 (not +) so iOS round-trips them", () => {
const link = agentDeepLink("claude code", "s 9");
assert.ok(!link.includes("+"), "no + encoding");
assert.match(link, /%20/);
const u = new URL(link);
assert.equal(u.searchParams.get("agent"), "claude code");
assert.equal(u.searchParams.get("id"), "s 9");
});
});

describe("sendNtfy", () => {
Expand Down Expand Up @@ -161,3 +174,59 @@ describe("push storage", () => {
assert.equal(unregisterPush("nope"), false);
});
});

describe("APNs", () => {
// Throwaway P-256 key so we can sign + verify without a real Apple key.
const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
const pem = kp.privateKey.export({ type: "pkcs8", format: "pem" }) as string;

test("apnsConfigFromEnv: null without env; populated + host by env", () => {
assert.equal(apnsConfigFromEnv({} as NodeJS.ProcessEnv), null);
const cfg = apnsConfigFromEnv({
LISA_APNS_KEY_ID: "K1", LISA_APNS_TEAM_ID: "T1", LISA_APNS_KEY: pem, LISA_APNS_ENV: "production",
} as unknown as NodeJS.ProcessEnv);
assert.equal(cfg?.keyId, "K1");
assert.equal(cfg?.topic, "ai.meetlisa.pocket");
assert.equal(cfg?.host, "api.push.apple.com");
});

test("buildApnsJwt: ES256 header/claims + a verifiable signature", () => {
const jwt = buildApnsJwt({ keyId: "K1", teamId: "T1", key: pem }, 1000);
const [h, c, s] = jwt.split(".");
assert.equal(JSON.parse(Buffer.from(h!, "base64url").toString()).alg, "ES256");
assert.equal(JSON.parse(Buffer.from(h!, "base64url").toString()).kid, "K1");
const claims = JSON.parse(Buffer.from(c!, "base64url").toString());
assert.equal(claims.iss, "T1");
assert.equal(claims.iat, 1000);
const verifier = crypto.createVerify("SHA256");
verifier.update(`${h}.${c}`);
assert.equal(verifier.verify({ key: kp.publicKey, dsaEncoding: "ieee-p1363" }, Buffer.from(s!, "base64url")), true);
});

test("buildApnsPayload: aps.alert + optional deep-link", () => {
const p = buildApnsPayload({ title: "T", body: "B", click: "lisapocket://session?id=s" });
assert.deepEqual((p.aps as { alert: unknown }).alert, { title: "T", body: "B" });
assert.equal(p.link, "lisapocket://session?id=s");
assert.equal(buildApnsPayload({ title: "T", body: "B" }).link, undefined);
});

test("sendApns: POSTs /3/device/<token> with apns headers; 200→true, 4xx→false", async () => {
const cfg = { keyId: "K1", teamId: "T1", key: pem, topic: "ai.meetlisa.pocket", host: "api.sandbox.push.apple.com" };
let captured: { host: string; path: string; headers: Record<string, string>; body: string } | null = null;
const ok = await sendApns(
cfg, "devtoken", { title: "T", body: "B", priority: "high", click: "lisapocket://x" },
async (o) => { captured = o; return { status: 200 }; }, 1000,
);
assert.equal(ok, true);
assert.equal(captured!.path, "/3/device/devtoken");
assert.equal(captured!.headers["apns-topic"], "ai.meetlisa.pocket");
assert.equal(captured!.headers["apns-push-type"], "alert");
assert.equal(captured!.headers["apns-priority"], "10");
assert.equal(captured!.headers["apns-expiration"], "0"); // high-priority → deliver-now-or-drop
assert.match(captured!.headers.authorization, /^bearer /);

const bad = await sendApns(cfg, "devtoken", { title: "T", body: "B", priority: "default" },
async () => ({ status: 400 }), 1000);
assert.equal(bad, false);
});
});
142 changes: 137 additions & 5 deletions src/web/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@
* Transport is pluggable:
* - `ntfy` works today with zero Apple infra — a plain HTTP POST to a topic on
* ntfy.sh (or your self-hosted server); the topic name is the shared secret.
* - `apns` is the iOS-native path and needs an Apple push key, so it's a
* documented stub here (logs "would notify").
* - `apns` is the iOS-native path (token-based auth, HTTP/2). It's fully wired
* here but inert until you provide an Apple push key via env:
* LISA_APNS_KEY_ID, LISA_APNS_TEAM_ID, LISA_APNS_KEY (.p8 PEM or a path),
* LISA_APNS_TOPIC (default ai.meetlisa.pocket), LISA_APNS_ENV=production.
* Without those, apnsConfigFromEnv() returns null and delivery logs a stub.
* Payloads carry low-sensitivity metadata only (agent / project / state / reason)
* — never prompts, replies, full commands, or terminal output.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import http2 from "node:http2";
import type { AgentSession } from "../integrations/types.js";

export interface PushPrefs {
Expand Down Expand Up @@ -118,8 +122,10 @@ export const POCKET_SCHEME = "lisapocket";
/** Deep-link that opens Lisa Pocket at a specific roster session. Pure. The app
* routes on host="session" + the agent/id query (Lisa Pocket's URL handler). */
export function agentDeepLink(agent: string, sessionId: string): string {
const q = new URLSearchParams({ agent, id: sessionId });
return `${POCKET_SCHEME}://session?${q.toString()}`;
// URLSearchParams encodes spaces as "+", which iOS URLComponents reads literally
// (not as a space); use %20 so values round-trip on the app side.
const q = new URLSearchParams({ agent, id: sessionId }).toString().replace(/\+/g, "%20");
return `${POCKET_SCHEME}://session?${q}`;
}

export interface PushEvent {
Expand Down Expand Up @@ -179,6 +185,126 @@ export async function sendNtfy(
}
}

// ── APNs transport (token-based auth; needs an Apple key, else a no-op stub) ──
export interface ApnsConfig {
keyId: string;
teamId: string;
/** The .p8 auth-key contents (PEM). */
key: string;
/** Bundle id, e.g. ai.meetlisa.pocket. */
topic: string;
/** api.push.apple.com (prod) or api.sandbox.push.apple.com (dev). */
host: string;
}

/**
* APNs config from env, or null (→ stub). LISA_APNS_KEY is the .p8 contents (PEM)
* or a path to it; LISA_APNS_ENV=production switches to the prod host.
*/
export function apnsConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ApnsConfig | null {
const keyId = env.LISA_APNS_KEY_ID;
const teamId = env.LISA_APNS_TEAM_ID;
const raw = env.LISA_APNS_KEY;
if (!keyId || !teamId || !raw) return null;
let key = raw;
if (!raw.includes("BEGIN")) {
try { key = fs.readFileSync(raw, "utf8"); } catch { return null; }
}
const topic = env.LISA_APNS_TOPIC || "ai.meetlisa.pocket";
const host = env.LISA_APNS_ENV === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
return { keyId, teamId, key, topic, host };
}

function b64url(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

/** Build a signed ES256 provider JWT for APNs. Pure given (cfg, nowSec). */
export function buildApnsJwt(cfg: Pick<ApnsConfig, "keyId" | "teamId" | "key">, nowSec: number): string {
const header = b64url(Buffer.from(JSON.stringify({ alg: "ES256", kid: cfg.keyId })));
const claims = b64url(Buffer.from(JSON.stringify({ iss: cfg.teamId, iat: nowSec })));
const signingInput = `${header}.${claims}`;
const signer = crypto.createSign("SHA256");
signer.update(signingInput);
// ES256 JWTs use raw R||S (IEEE-P1363), not the DER that sign() defaults to.
const sig = signer.sign({ key: crypto.createPrivateKey(cfg.key), dsaEncoding: "ieee-p1363" });
return `${signingInput}.${b64url(sig)}`;
}

/** Build the APNs JSON payload from a push event. Pure. */
export function buildApnsPayload(ev: { title: string; body: string; click?: string }): Record<string, unknown> {
return {
aps: { alert: { title: ev.title, body: ev.body }, sound: "default" },
// Custom key the app reads on tap to deep-link (mirrors the ntfy Click URL).
...(ev.click ? { link: ev.click } : {}),
};
}

export type ApnsPoster = (o: {
host: string;
path: string;
headers: Record<string, string>;
body: string;
}) => Promise<{ status: number }>;

/** Real APNs POST over HTTP/2. Settles exactly once and always closes the client;
* resolves status 0 on a connection error or a 10s timeout. */
const realApnsPost: ApnsPoster = (o) =>
new Promise((resolve) => {
const client = http2.connect(`https://${o.host}`);
let status = 0;
let settled = false;
const done = (s: number) => {
if (settled) return;
settled = true;
try { client.close(); } catch { /* already closing */ }
resolve({ status: s });
};
client.on("error", () => done(0));
const req = client.request({ ":method": "POST", ":path": o.path, ...o.headers });
req.setEncoding("utf8");
req.setTimeout(10_000, () => done(0)); // don't leak a hung connection
req.on("response", (h) => { status = Number(h[":status"]) || 0; });
req.on("data", () => {});
req.on("end", () => done(status));
req.on("error", () => done(0));
req.end(o.body);
});

// JWT cache keyed by config identity (not just time) — a different key/team must
// not reuse a stale token (wrong kid/iss → APNs 403).
let cachedApnsJwt: { token: string; at: number; id: string } | null = null;

/** Send one APNs notification. The provider JWT is cached ~50min (Apple rate-
* limits regeneration). `post` is injectable for tests. */
export async function sendApns(
cfg: ApnsConfig,
deviceToken: string,
ev: { title: string; body: string; priority: "high" | "default"; click?: string },
post: ApnsPoster = realApnsPost,
nowSec: number = Math.floor(Date.now() / 1000),
): Promise<boolean> {
const cacheId = `${cfg.keyId}/${cfg.teamId}`;
if (!cachedApnsJwt || cachedApnsJwt.id !== cacheId || nowSec - cachedApnsJwt.at > 50 * 60) {
cachedApnsJwt = { token: buildApnsJwt(cfg, nowSec), at: nowSec, id: cacheId };
}
const res = await post({
host: cfg.host,
path: `/3/device/${deviceToken}`,
headers: {
authorization: `bearer ${cachedApnsJwt.token}`,
"apns-topic": cfg.topic,
"apns-push-type": "alert",
"apns-priority": ev.priority === "high" ? "10" : "5",
// Operational alerts are time-sensitive — don't let APNs store & deliver
// a stale one later if the device is offline.
"apns-expiration": ev.priority === "high" ? "0" : String(nowSec + 3600),
},
body: JSON.stringify(buildApnsPayload(ev)),
});
return res.status === 200;
}

// ── bridge: hub/idle events → throttled per-subscription delivery ──────────
export interface PushBridgeOpts {
subs?: () => PushSubscription[];
Expand Down Expand Up @@ -234,7 +360,13 @@ export class PushBridge {
const ok = await sendNtfy(sub.server ?? "https://ntfy.sh", sub.target, ev);
if (!ok) this.log(`[push] ntfy send failed for ${sub.id}`);
} else {
this.log(`[push] apns not wired (needs an Apple push key) — would notify ${sub.id}: ${ev.title}`);
const cfg = apnsConfigFromEnv();
if (!cfg) {
this.log(`[push] apns not configured (set LISA_APNS_KEY/_KEY_ID/_TEAM_ID) — would notify ${sub.id}: ${ev.title}`);
return;
}
const ok = await sendApns(cfg, sub.target, ev);
if (!ok) this.log(`[push] apns send failed for ${sub.id}`);
}
}
}