From ede4d7e63584cd9c7fd028f6e6ec69ae24675bf7 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 17:05:48 +0800 Subject: [PATCH 1/2] feat(push): real APNs delivery (token auth), inert without a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the APNs path end-to-end so it's ready the moment an Apple push key is provided — no more "would notify" stub when configured: - apnsConfigFromEnv() reads LISA_APNS_KEY_ID/_TEAM_ID/_KEY (.p8 PEM or path) /_TOPIC/_ENV; returns null (→ stub log) when unset. - buildApnsJwt() signs the ES256 provider JWT (raw IEEE-P1363, cached ~50min). - buildApnsPayload() builds aps.alert + a `link` custom key (the deep-link twin of the ntfy Click). - sendApns() POSTs /3/device/ over HTTP/2 with the apns-* headers; the poster is injectable for tests. PushBridge now delivers via APNs when configured, else logs the stub. Verified: npm run typecheck && npm run build && npm test -> 817 pass / 1 skip, incl. new tests that sign+verify the ES256 JWT with a throwaway P-256 key and assert the request shaping. Live delivery still needs a real key + device token (the external dependency). Co-Authored-By: Claude Opus 4.8 --- src/web/push.test.ts | 60 +++++++++++++++++++++ src/web/push.ts | 121 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/web/push.test.ts b/src/web/push.test.ts index de391f5..07a4c46 100644 --- a/src/web/push.test.ts +++ b/src/web/push.test.ts @@ -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-")); @@ -15,6 +16,10 @@ const { agentPushEvents, agentDeepLink, sendNtfy, + apnsConfigFromEnv, + buildApnsJwt, + buildApnsPayload, + sendApns, PushBridge, registerPush, unregisterPush, @@ -161,3 +166,58 @@ 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/ 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; 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.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); + }); +}); diff --git a/src/web/push.ts b/src/web/push.ts index 300fd9c..cf440fd 100644 --- a/src/web/push.ts +++ b/src/web/push.ts @@ -7,8 +7,11 @@ * 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. */ @@ -16,6 +19,7 @@ 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 { @@ -179,6 +183,111 @@ 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, 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 { + 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; + body: string; +}) => Promise<{ status: number }>; + +/** Real APNs POST over HTTP/2. Resolves status 0 on a connection error. */ +const realApnsPost: ApnsPoster = (o) => + new Promise((resolve) => { + const client = http2.connect(`https://${o.host}`); + let status = 0; + client.on("error", () => resolve({ status: 0 })); + const req = client.request({ ":method": "POST", ":path": o.path, ...o.headers }); + req.setEncoding("utf8"); + req.on("response", (h) => { status = Number(h[":status"]) || 0; }); + req.on("data", () => {}); + req.on("end", () => { client.close(); resolve({ status }); }); + req.on("error", () => { try { client.close(); } catch { /* */ } resolve({ status: 0 }); }); + req.end(o.body); + }); + +let cachedApnsJwt: { token: string; at: number } | 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 { + if (!cachedApnsJwt || nowSec - cachedApnsJwt.at > 50 * 60) { + cachedApnsJwt = { token: buildApnsJwt(cfg, nowSec), at: nowSec }; + } + 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", + }, + body: JSON.stringify(buildApnsPayload(ev)), + }); + return res.status === 200; +} + // ── bridge: hub/idle events → throttled per-subscription delivery ────────── export interface PushBridgeOpts { subs?: () => PushSubscription[]; @@ -234,7 +343,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}`); } } } From 8007f0b07672bd0b2d04821416058b612f231756 Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 19 Jun 2026 18:51:34 +0800 Subject: [PATCH 2/2] fix(push): address pre-merge review (APNs robustness, %20 encoding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - realApnsPost: settle exactly once (a `settled` guard kills the client.on("error")/req.on("error") double-resolve race), always close the HTTP/2 client, and add a 10s request timeout so a hung connection can't leak a never-settled promise. - sendApns JWT cache is now keyed by config identity (keyId/teamId), not just time — a rotated/second key can't reuse a stale token (wrong kid/iss → 403). - Add apns-expiration (high → "0" deliver-now-or-drop; default → 1h) so a transient operational alert isn't stored and delivered stale. - agentDeepLink / buildPairUrl emit %20 for spaces instead of "+", which iOS URLComponents reads literally — so a device label / value round-trips. Verified: typecheck + build clean; npm test -> 818 pass / 1 skip (+ space- encoding and apns-expiration assertions). Co-Authored-By: Claude Opus 4.8 --- src/cli/pair.ts | 5 +++-- src/web/push.test.ts | 9 +++++++++ src/web/push.ts | 35 ++++++++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/cli/pair.ts b/src/cli/pair.ts index c87330d..361fdca 100644 --- a/src/cli/pair.ts +++ b/src/cli/pair.ts @@ -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 { diff --git a/src/web/push.test.ts b/src/web/push.test.ts index 07a4c46..dc161b5 100644 --- a/src/web/push.test.ts +++ b/src/web/push.test.ts @@ -83,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", () => { @@ -214,6 +222,7 @@ describe("APNs", () => { 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" }, diff --git a/src/web/push.ts b/src/web/push.ts index cf440fd..9359348 100644 --- a/src/web/push.ts +++ b/src/web/push.ts @@ -122,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 { @@ -245,22 +247,33 @@ export type ApnsPoster = (o: { body: string; }) => Promise<{ status: number }>; -/** Real APNs POST over HTTP/2. Resolves status 0 on a connection error. */ +/** 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; - client.on("error", () => resolve({ 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", () => { client.close(); resolve({ status }); }); - req.on("error", () => { try { client.close(); } catch { /* */ } resolve({ status: 0 }); }); + req.on("end", () => done(status)); + req.on("error", () => done(0)); req.end(o.body); }); -let cachedApnsJwt: { token: string; at: number } | null = null; +// 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. */ @@ -271,8 +284,9 @@ export async function sendApns( post: ApnsPoster = realApnsPost, nowSec: number = Math.floor(Date.now() / 1000), ): Promise { - if (!cachedApnsJwt || nowSec - cachedApnsJwt.at > 50 * 60) { - cachedApnsJwt = { token: buildApnsJwt(cfg, nowSec), at: nowSec }; + 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, @@ -282,6 +296,9 @@ export async function sendApns( "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)), });