diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index f4efbf7..b02216b 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -86,6 +86,13 @@ export const CAPABILITIES = { * showing one that errors on click. */ BLACKBOARD: "blackboard", + /** + * Push via NATIVE device tokens (APNs/FCM) rather than Expo. Advertised by + * the daemon when the `native` or `relay` transport is configured; a client + * seeing this registers its native `getDevicePushTokenAsync` token (vs the + * Expo token it sends for `PUSH`). + */ + PUSH_NATIVE: "push.native", } as const; export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; diff --git a/src/config.ts b/src/config.ts index ea10cb0..bc1f2ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -651,15 +651,47 @@ const PipelineSchema = z * tool args) to the configured transport so the session owner's registered * devices are alerted off-LAN. `transport: "none"` (default) disables push. */ +/** APNs token-auth credentials for embedded (`native`) mode. */ +const ApnsSchema = z.object({ + keyId: z.string(), + teamId: z.string(), + bundleId: z.string(), + /** PEM contents of the `.p8` key (multi-line — lives in config.json). */ + p8: z.string(), + /** Use the APNs sandbox host (development builds). */ + sandbox: z.boolean().default(false), +}); + +/** FCM v1 service-account credentials for embedded (`native`) mode. */ +const FcmSchema = z.object({ + projectId: z.string(), + clientEmail: z.string(), + /** PEM contents of the service-account private key. */ + privateKey: z.string(), +}); + const PushSchema = z .object({ - /** Delivery transport. "expo" routes through Expo's push service (which - * relays to APNs/FCM); "none" disables push. A self-hosted content-blind - * relay transport swaps in behind this seam later. */ - transport: z.enum(["expo", "none"]).default("none"), - /** Expo access token (Bearer) for the push API. Optional — Expo accepts - * unauthenticated sends, but a token enables receipts + higher limits. */ + /** + * Delivery transport: + * - "expo" — Expo's push service relays to APNs/FCM (a third party in + * the path; simplest to set up). + * - "native" — the daemon holds APNs/FCM creds and sends DIRECTLY (no + * third party). Fits when the daemon operator is the app publisher. + * - "relay" — the daemon POSTs a content-blind wake-up to a self-hosted + * relay that sends via APNs/FCM (no creds in the daemon). + * - "none" — push disabled (default). + */ + transport: z.enum(["expo", "native", "relay", "none"]).default("none"), + /** Expo access token (Bearer) — `expo` transport only. */ expoAccessToken: z.string().optional(), + /** APNs creds — `native` transport (iOS). */ + apns: ApnsSchema.optional(), + /** FCM creds — `native` transport (Android). */ + fcm: FcmSchema.optional(), + /** Relay base URL + shared bearer token — `relay` transport (no local creds). */ + relayUrl: z.string().optional(), + relayToken: z.string().optional(), }) .default({ transport: "none" }); @@ -887,8 +919,12 @@ export interface CodeoidConfig { * minimal; loadConfig always populates it (schema default: transport "none"). */ push?: { - transport: "expo" | "none"; + transport: "expo" | "native" | "relay" | "none"; expoAccessToken?: string; + apns?: { keyId: string; teamId: string; bundleId: string; p8: string; sandbox: boolean }; + fcm?: { projectId: string; clientEmail: string; privateKey: string }; + relayUrl?: string; + relayToken?: string; }; /** * Embed trust — origins permitted to frame the web UI and pre-authenticate @@ -969,6 +1005,9 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ // config.json (e.g. CODEOID_PUSH_TRANSPORT=expo). { env: "CODEOID_PUSH_TRANSPORT", path: "push.transport", kind: "string" }, { env: "CODEOID_EXPO_ACCESS_TOKEN", path: "push.expoAccessToken", kind: "string" }, + // Relay-mode endpoint — per-invocation without touching config.json. + { env: "CODEOID_PUSH_RELAY_URL", path: "push.relayUrl", kind: "string" }, + { env: "CODEOID_PUSH_RELAY_TOKEN", path: "push.relayToken", kind: "string" }, { env: "CODEOID_TURN_STALL_TIMEOUT_MS", path: "session.turnStallTimeoutMs", kind: "int" }, { env: "CODEOID_MCP_TOOL_TIMEOUT_MS", path: "session.mcpToolTimeoutMs", kind: "int" }, // Embed-SSO trusted framing origins (comma-separated). Each is an exact diff --git a/src/daemon/push/index.ts b/src/daemon/push/index.ts index fc70381..9c95c1e 100644 --- a/src/daemon/push/index.ts +++ b/src/daemon/push/index.ts @@ -1,4 +1,6 @@ export { PushService, createPushTransport } from "./service.js"; export type { PushConfig, SessionOwner } from "./service.js"; export { ExpoPushTransport } from "./expo.js"; +export { NativePushTransport } from "./native.js"; +export { RelayPushTransport } from "./relay.js"; export type { PushNotification, PushTarget, PushTransport } from "./types.js"; diff --git a/src/daemon/push/native.ts b/src/daemon/push/native.ts new file mode 100644 index 0000000..2d559a6 --- /dev/null +++ b/src/daemon/push/native.ts @@ -0,0 +1,43 @@ +/** + * EMBEDDED mode (`transport: "native"`): the daemon holds the APNs/FCM creds + * and delivers directly via push-core's PushSender — no third party, no relay. + * The right fit when the daemon operator IS the app publisher (personal / + * self-host-your-own-app). For multi-user / hosted, `RelayPushTransport` POSTs + * to a relay that runs the SAME PushSender. + * + * Dead tokens reported by APNs/FCM are pruned via the `onUnregistered` hook so + * the registry stays clean. Best-effort — never throws onto the status path. + */ +import type { PushResult, PushSender } from "../../push-core/index.js"; +import type { PushNotification, PushTarget, PushTransport } from "./types.js"; + +export class NativePushTransport implements PushTransport { + readonly name = "native"; + + constructor( + private readonly sender: PushSender, + /** Called with a token APNs/FCM reports as dead, so the caller can prune it. */ + private readonly onUnregistered?: (token: string) => void, + ) {} + + async send(targets: PushTarget[], note: PushNotification): Promise { + if (targets.length === 0) return; + let results: PushResult[]; + try { + results = await this.sender.send( + targets.map((t) => ({ token: t.token, platform: t.platform })), + { sessionId: note.sessionId, kind: note.kind }, + ); + } catch (err) { + console.error("[codeoid/push] native send failed:", err); + return; + } + for (const r of results) { + if (r.unregistered) { + this.onUnregistered?.(r.token); + } else if (!r.ok) { + console.error(`[codeoid/push] delivery failed (${r.token.slice(0, 8)}…): ${r.error}`); + } + } + } +} diff --git a/src/daemon/push/relay.ts b/src/daemon/push/relay.ts new file mode 100644 index 0000000..bccb8b2 --- /dev/null +++ b/src/daemon/push/relay.ts @@ -0,0 +1,42 @@ +/** + * RELAY mode (`transport: "relay"`): POST a content-blind wake-up to a + * standalone relay service that holds the APNs/FCM creds and does the actual + * send (via the same push-core PushSender). In this mode the daemon holds NO + * push credentials — only the relay URL + a shared bearer token. + * + * The relay service itself is a thin HTTP wrapper around push-core, added when + * the multi-user / hosted path is needed; this transport is the daemon half of + * that seam. Best-effort — never throws onto the status path. + */ +import type { PushNotification, PushTarget, PushTransport } from "./types.js"; + +const POST_TIMEOUT_MS = 10_000; + +export class RelayPushTransport implements PushTransport { + readonly name = "relay"; + + constructor( + private readonly relayUrl: string, + private readonly relayToken: string, + ) {} + + async send(targets: PushTarget[], note: PushNotification): Promise { + if (targets.length === 0) return; + try { + const res = await fetch(new URL("/push", this.relayUrl), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${this.relayToken}`, + }, + // Content-blind: opaque device tokens + { sessionId, kind }. The relay + // never sees session content. + body: JSON.stringify({ targets, note }), + signal: AbortSignal.timeout(POST_TIMEOUT_MS), + }); + if (!res.ok) console.error(`[codeoid/push] relay POST ${res.status}`); + } catch (err) { + console.error("[codeoid/push] relay POST error:", err); + } + } +} diff --git a/src/daemon/push/service.ts b/src/daemon/push/service.ts index 4fcc59c..ed33598 100644 --- a/src/daemon/push/service.ts +++ b/src/daemon/push/service.ts @@ -6,14 +6,23 @@ * tenant-scoped by account/project — so only the human who owns the session is * alerted, and never across tenants. */ +import { createPushSender, type ApnsCreds, type FcmCreds } from "../../push-core/index.js"; import type { Store } from "../store.js"; import { ExpoPushTransport } from "./expo.js"; +import { NativePushTransport } from "./native.js"; +import { RelayPushTransport } from "./relay.js"; import type { PushNotification, PushTransport } from "./types.js"; /** Config shape this module needs (a subset of CodeoidConfig["push"]). */ export interface PushConfig { - transport: "expo" | "none"; + transport: "expo" | "native" | "relay" | "none"; expoAccessToken?: string; + /** Embedded (native) mode — APNs/FCM creds the daemon sends with directly. */ + apns?: ApnsCreds; + fcm?: FcmCreds; + /** Relay mode — where to POST content-blind wake-ups (no creds in the daemon). */ + relayUrl?: string; + relayToken?: string; } const noopTransport: PushTransport = { @@ -21,11 +30,32 @@ const noopTransport: PushTransport = { async send() {}, }; -/** Build the transport for the daemon's push config. */ -export function createPushTransport(config: PushConfig | undefined): PushTransport { - if (!config || config.transport === "none") return noopTransport; - if (config.transport === "expo") return new ExpoPushTransport(config.expoAccessToken); - return noopTransport; +/** + * Build the transport for the daemon's push config. `onUnregistered` is invoked + * with dead device tokens (native mode) so the caller can prune the registry. + */ +export function createPushTransport( + config: PushConfig | undefined, + onUnregistered?: (token: string) => void, +): PushTransport { + if (!config) return noopTransport; + switch (config.transport) { + case "expo": + return new ExpoPushTransport(config.expoAccessToken); + case "native": + return new NativePushTransport( + createPushSender({ apns: config.apns, fcm: config.fcm }), + onUnregistered, + ); + case "relay": + if (!config.relayUrl || !config.relayToken) { + console.error("[codeoid/push] transport=relay needs relayUrl + relayToken; push disabled"); + return noopTransport; + } + return new RelayPushTransport(config.relayUrl, config.relayToken); + default: + return noopTransport; + } } /** Owner identity + tenant of a session — the push routing key. */ diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 8f06693..463e2b2 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -203,9 +203,18 @@ export class DaemonServer { constructor(config: DaemonConfig) { this.#config = config; - const pushOn = (config.fullConfig?.push?.transport ?? "none") !== "none"; - this.#advertisedCapabilities = pushOn - ? [...SERVER_CAPABILITIES, CAPABILITIES.PUSH] + // Advertise the capability matching the token type this transport needs: + // `expo` wants Expo push tokens (PUSH); `native`/`relay` send via APNs/FCM + // and want native device tokens (PUSH_NATIVE). `none` advertises neither. + const transport = config.fullConfig?.push?.transport ?? "none"; + const pushCapability = + transport === "expo" + ? CAPABILITIES.PUSH + : transport === "native" || transport === "relay" + ? CAPABILITIES.PUSH_NATIVE + : null; + this.#advertisedCapabilities = pushCapability + ? [...SERVER_CAPABILITIES, pushCapability] : SERVER_CAPABILITIES; this.#store = new Store(config.dbPath); this.#transcriptStore = new TranscriptStore(config.transcriptDir); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index b0062ce..12128f5 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -325,7 +325,10 @@ export class SessionManager { this.#makeDispatcherHost(), opts?.config?.dispatch, ); - this.#pushService = new PushService(store, createPushTransport(opts?.config?.push)); + this.#pushService = new PushService( + store, + createPushTransport(opts?.config?.push, (token) => store.pruneDeadToken(token)), + ); // SDLC pipeline (docs/sdlc-pipeline.md) — off by default; when enabled, the // manager shares the daemon DB (one connection) and rehydrates non-terminal // pipelines on construction (resume). The runner drives prompt/slash phases diff --git a/src/daemon/store.ts b/src/daemon/store.ts index d187963..007990c 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -518,6 +518,15 @@ export class Store { return rows; } + /** + * Delete a device token that APNs/FCM reported dead (uninstalled / rotated) — + * regardless of owner, since a dead token is globally invalid. Keeps the + * registry from accumulating tokens that will never deliver again. + */ + pruneDeadToken(token: string): void { + this.#db.prepare("DELETE FROM push_registrations WHERE token = ?").run(token); + } + // ── Sessions ────────────────────────────────────────────────────────── createSession(session: SessionInfo & { accountId: string; projectId: string }): void { diff --git a/src/push-core/apns.ts b/src/push-core/apns.ts new file mode 100644 index 0000000..c7cceff --- /dev/null +++ b/src/push-core/apns.ts @@ -0,0 +1,124 @@ +/** + * APNs channel — token-based (`.p8`) auth over HTTP/2 (the only transport APNs + * accepts). Content-blind: the alert copy is generic and the custom keys carry + * only the opaque `sessionId` + `kind`. + * + * The provider JWT is signed once and reused (~50 min; APNs allows up to 60); + * the HTTP/2 session is reused across sends and lazily re-established if it + * drops. `send()` never throws — it resolves a `PushResult` so a delivery + * failure can't escape onto the daemon's status path. + */ +import http2 from "node:http2"; + +import { signEs256 } from "./jwt.js"; +import { ALERT_BODY, ALERT_TITLE, type ApnsCreds, type Device, type PushMessage, type PushChannel, type PushResult } from "./types.js"; + +const APNS_HOST_PROD = "https://api.push.apple.com"; +const APNS_HOST_SANDBOX = "https://api.sandbox.push.apple.com"; +const JWT_REUSE_SECONDS = 3000; // < 60 min, APNs's provider-token lifetime +const SEND_TIMEOUT_MS = 10_000; + +/** Pure classification of an APNs response — unit-tested without a real socket. */ +export function classifyApnsResponse( + status: number, + body: string, +): { ok: boolean; unregistered: boolean; reason: string } { + if (status === 200) return { ok: true, unregistered: false, reason: "" }; + let reason = ""; + try { + reason = (JSON.parse(body) as { reason?: string }).reason ?? ""; + } catch { + // non-JSON error body — fall through with an empty reason + } + // 410 Gone, or BadDeviceToken / Unregistered = the token is dead, prune it. + const unregistered = status === 410 || reason === "Unregistered" || reason === "BadDeviceToken"; + return { ok: false, unregistered, reason: reason || `HTTP ${status}` }; +} + +/** The content-blind APNs payload for an approval wake-up. */ +export function apnsPayload(msg: PushMessage): string { + return JSON.stringify({ + aps: { alert: { title: ALERT_TITLE, body: ALERT_BODY }, sound: "default" }, + // Opaque routing data only — no session content. + sessionId: msg.sessionId, + kind: msg.kind, + }); +} + +export class ApnsClient implements PushChannel { + readonly #creds: ApnsCreds; + readonly #host: string; + readonly #connect: typeof http2.connect; + #jwt = ""; + #jwtIat = 0; + #session: http2.ClientHttp2Session | null = null; + + /** `connect` is injectable so the HTTP/2 send path is unit-testable without + * a real APNs socket; it defaults to node:http2's connect. */ + constructor(creds: ApnsCreds, connect: typeof http2.connect = http2.connect) { + this.#creds = creds; + this.#host = creds.sandbox ? APNS_HOST_SANDBOX : APNS_HOST_PROD; + this.#connect = connect; + } + + #token(): string { + const now = Math.floor(Date.now() / 1000); + if (this.#jwt && now - this.#jwtIat < JWT_REUSE_SECONDS) return this.#jwt; + this.#jwt = signEs256({ iss: this.#creds.teamId, iat: now }, this.#creds.keyId, this.#creds.p8); + this.#jwtIat = now; + return this.#jwt; + } + + #getSession(): http2.ClientHttp2Session { + if (this.#session && !this.#session.closed && !this.#session.destroyed) return this.#session; + const session = this.#connect(this.#host); + // Swallow session-level errors; the next send lazily reconnects. + session.on("error", () => {}); + this.#session = session; + return session; + } + + send(device: Device, msg: PushMessage): Promise { + return new Promise((resolve) => { + const done = (r: Omit) => resolve({ token: device.token, ...r }); + let req: http2.ClientHttp2Stream; + try { + req = this.#getSession().request({ + [http2.constants.HTTP2_HEADER_METHOD]: "POST", + [http2.constants.HTTP2_HEADER_PATH]: `/3/device/${device.token}`, + authorization: `bearer ${this.#token()}`, + "apns-topic": this.#creds.bundleId, + "apns-push-type": "alert", + "apns-priority": "10", + }); + } catch (err) { + done({ ok: false, error: err instanceof Error ? err.message : String(err) }); + return; + } + let status = 0; + let data = ""; + req.setEncoding("utf8"); + req.on("response", (h) => { + status = Number(h[http2.constants.HTTP2_HEADER_STATUS]) || 0; + }); + req.on("data", (chunk) => { + data += chunk; + }); + req.on("end", () => { + const c = classifyApnsResponse(status, data); + done({ ok: c.ok, unregistered: c.unregistered, error: c.ok ? undefined : c.reason }); + }); + req.on("error", (err) => done({ ok: false, error: err.message })); + req.setTimeout(SEND_TIMEOUT_MS, () => { + req.close(); + done({ ok: false, error: "timeout" }); + }); + req.end(apnsPayload(msg)); + }); + } + + close(): void { + this.#session?.close(); + this.#session = null; + } +} diff --git a/src/push-core/fcm.ts b/src/push-core/fcm.ts new file mode 100644 index 0000000..6e83fef --- /dev/null +++ b/src/push-core/fcm.ts @@ -0,0 +1,99 @@ +/** + * FCM v1 channel — a service-account JWT exchanged for a short-lived OAuth + * access token (cached), then a REST POST per message. Content-blind: generic + * `notification` copy + opaque `data` (sessionId, kind). + * + * `send()` never throws — it resolves a `PushResult`. + */ +import { signRs256 } from "./jwt.js"; +import { ALERT_BODY, ALERT_TITLE, type Device, type FcmCreds, type PushMessage, type PushChannel, type PushResult } from "./types.js"; + +const OAUTH_URL = "https://oauth2.googleapis.com/token"; +const OAUTH_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"; +const SEND_TIMEOUT_MS = 10_000; + +/** The content-blind FCM v1 message body for an approval wake-up. */ +export function fcmMessageBody(token: string, msg: PushMessage): string { + return JSON.stringify({ + message: { + token, + notification: { title: ALERT_TITLE, body: ALERT_BODY }, + // Opaque routing data only — no session content. + data: { sessionId: msg.sessionId, kind: msg.kind }, + android: { priority: "high" }, + }, + }); +} + +/** Pure classification of an FCM error response — unit-tested without a network. */ +export function classifyFcmError(status: number, body: string): { unregistered: boolean; reason: string } { + let reason = ""; + try { + reason = (JSON.parse(body) as { error?: { status?: string } }).error?.status ?? ""; + } catch { + // non-JSON — fall through + } + // A dead FCM token surfaces as 404 NOT_FOUND or an UNREGISTERED status. + const unregistered = status === 404 || reason === "UNREGISTERED" || reason === "NOT_FOUND"; + return { unregistered, reason: reason || `HTTP ${status}` }; +} + +export class FcmClient implements PushChannel { + readonly #creds: FcmCreds; + #accessToken = ""; + #expiresAt = 0; + + constructor(creds: FcmCreds) { + this.#creds = creds; + } + + async #getAccessToken(): Promise { + const now = Math.floor(Date.now() / 1000); + if (this.#accessToken && now < this.#expiresAt - 60) return this.#accessToken; + const jwt = signRs256( + { + iss: this.#creds.clientEmail, + scope: OAUTH_SCOPE, + aud: OAUTH_URL, + iat: now, + exp: now + 3600, + }, + this.#creds.privateKey, + ); + const res = await fetch(OAUTH_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }).toString(), + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`FCM OAuth ${res.status}`); + const body = (await res.json()) as { access_token?: string; expires_in?: number }; + if (!body.access_token) throw new Error("FCM OAuth: no access_token"); + this.#accessToken = body.access_token; + this.#expiresAt = now + (body.expires_in ?? 3600); + return this.#accessToken; + } + + async send(device: Device, msg: PushMessage): Promise { + try { + const accessToken = await this.#getAccessToken(); + const res = await fetch( + `https://fcm.googleapis.com/v1/projects/${this.#creds.projectId}/messages:send`, + { + method: "POST", + headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" }, + body: fcmMessageBody(device.token, msg), + signal: AbortSignal.timeout(SEND_TIMEOUT_MS), + }, + ); + if (res.ok) return { token: device.token, ok: true }; + const c = classifyFcmError(res.status, await res.text().catch(() => "")); + return { token: device.token, ok: false, unregistered: c.unregistered, error: c.reason }; + } catch (err) { + return { token: device.token, ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } +} diff --git a/src/push-core/index.ts b/src/push-core/index.ts new file mode 100644 index 0000000..04ef764 --- /dev/null +++ b/src/push-core/index.ts @@ -0,0 +1,13 @@ +export { PushSender, createPushSender } from "./sender.js"; +export { ApnsClient, apnsPayload, classifyApnsResponse } from "./apns.js"; +export { FcmClient, fcmMessageBody, classifyFcmError } from "./fcm.js"; +export { signEs256, signRs256 } from "./jwt.js"; +export type { + ApnsCreds, + Device, + FcmCreds, + Platform, + PushChannel, + PushMessage, + PushResult, +} from "./types.js"; diff --git a/src/push-core/jwt.ts b/src/push-core/jwt.ts new file mode 100644 index 0000000..98c977a --- /dev/null +++ b/src/push-core/jwt.ts @@ -0,0 +1,35 @@ +/** + * Minimal JWT signing for the push providers — ES256 for APNs (a `.p8` EC key) + * and RS256 for FCM's OAuth2 assertion (a service-account RSA key). Kept tiny + * and dependency-free (node:crypto only) so push-core stays portable. + */ +import { sign } from "node:crypto"; + +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString("base64url"); +} + +function encode(header: object, claims: object): string { + return `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`; +} + +/** + * ES256 (ECDSA P-256 + SHA-256) JWT for APNs token auth. `dsaEncoding: + * "ieee-p1363"` yields the raw r‖s signature JOSE requires (not DER). + */ +export function signEs256( + claims: Record, + keyId: string, + p8Pem: string, +): string { + const input = encode({ alg: "ES256", kid: keyId }, claims); + const sig = sign("sha256", Buffer.from(input), { key: p8Pem, dsaEncoding: "ieee-p1363" }); + return `${input}.${b64url(sig)}`; +} + +/** RS256 (RSA + SHA-256) JWT for the FCM OAuth2 jwt-bearer grant. */ +export function signRs256(claims: Record, privateKeyPem: string): string { + const input = encode({ alg: "RS256", typ: "JWT" }, claims); + const sig = sign("RSA-SHA256", Buffer.from(input), privateKeyPem); + return `${input}.${b64url(sig)}`; +} diff --git a/src/push-core/sender.ts b/src/push-core/sender.ts new file mode 100644 index 0000000..b3f87a6 --- /dev/null +++ b/src/push-core/sender.ts @@ -0,0 +1,52 @@ +/** + * PushSender — routes a content-blind message to the right per-platform channel + * (iOS→APNs, Android→FCM). The channels are injected so routing/fan-out is + * unit-testable with fakes; `createPushSender` builds the real ones from creds. + * + * This is the single piece BOTH delivery modes share — the daemon's embedded + * `NativePushTransport` and (later) the standalone relay both call it, so the + * credential-bearing APNs/FCM logic lives exactly once. + */ +import { ApnsClient } from "./apns.js"; +import { FcmClient } from "./fcm.js"; +import type { ApnsCreds, Device, FcmCreds, PushChannel, PushMessage, PushResult } from "./types.js"; + +export class PushSender { + readonly #ios?: PushChannel; + readonly #android?: PushChannel; + + constructor(channels: { ios?: PushChannel; android?: PushChannel }) { + this.#ios = channels.ios; + this.#android = channels.android; + } + + /** Deliver to every device; one PushResult per device (per-token feedback). */ + async send(devices: Device[], msg: PushMessage): Promise { + return Promise.all(devices.map((d) => this.#one(d, msg))); + } + + #one(device: Device, msg: PushMessage): Promise { + const channel = device.platform === "ios" ? this.#ios : this.#android; + if (!channel) { + return Promise.resolve({ + token: device.token, + ok: false, + error: `no ${device.platform} channel configured`, + }); + } + return channel.send(device, msg); + } + + close(): void { + this.#ios?.close?.(); + this.#android?.close?.(); + } +} + +/** Build a PushSender from raw credentials (a channel per configured platform). */ +export function createPushSender(creds: { apns?: ApnsCreds; fcm?: FcmCreds }): PushSender { + return new PushSender({ + ios: creds.apns ? new ApnsClient(creds.apns) : undefined, + android: creds.fcm ? new FcmClient(creds.fcm) : undefined, + }); +} diff --git a/src/push-core/types.ts b/src/push-core/types.ts new file mode 100644 index 0000000..cb475f3 --- /dev/null +++ b/src/push-core/types.ts @@ -0,0 +1,71 @@ +/** + * push-core — the shared APNs/FCM sending core. + * + * This module is dependency-light on purpose (only node:crypto / node:http2 / + * fetch, no daemon imports) so BOTH delivery modes reuse it with zero + * duplication of the credential-bearing, protocol-heavy sending logic: + * + * - EMBEDDED (daemon `transport: "native"`): the daemon holds the creds and + * calls `PushSender` directly. + * - RELAY (daemon `transport: "relay"`): the daemon POSTs a content-blind + * wake-up to a standalone relay service, which calls the SAME `PushSender`. + * + * Content-blindness holds either way: a `PushMessage` carries only an opaque + * session id + a kind — never a tool name, args, or description. + */ + +export type Platform = "ios" | "android"; + +/** A device to deliver to (opaque native APNs/FCM token + its platform). */ +export interface Device { + token: string; + platform: Platform; +} + +/** The content-blind payload — opaque ids only. */ +export interface PushMessage { + sessionId: string; + kind: "approval"; +} + +/** Apple Push Notification service credentials (token-based auth, a `.p8` key). */ +export interface ApnsCreds { + /** The `.p8` key id (10 chars) from the Apple Developer portal. */ + keyId: string; + /** The Apple Developer Team id (10 chars). */ + teamId: string; + /** The app bundle id — becomes the `apns-topic`. */ + bundleId: string; + /** PEM contents of the `.p8` private key (`-----BEGIN PRIVATE KEY-----…`). */ + p8: string; + /** Send to the APNs sandbox host (development builds). Default false (production). */ + sandbox?: boolean; +} + +/** Firebase Cloud Messaging v1 credentials (a service account). */ +export interface FcmCreds { + projectId: string; + clientEmail: string; + /** PEM contents of the service-account private key. */ + privateKey: string; +} + +/** Per-token delivery result. `unregistered` means the caller should prune it. */ +export interface PushResult { + token: string; + ok: boolean; + /** The token is dead (uninstalled / rotated) — delete it from the registry. */ + unregistered?: boolean; + error?: string; +} + +/** A per-platform delivery channel — implemented by ApnsClient / FcmClient, and + * trivially fakeable in tests. */ +export interface PushChannel { + send(device: Device, msg: PushMessage): Promise; + close?(): void; +} + +/** Content-blind alert copy, shared by both channels — no session content. */ +export const ALERT_TITLE = "codeoid"; +export const ALERT_BODY = "A session needs your approval"; diff --git a/src/tests/push-core.test.ts b/src/tests/push-core.test.ts new file mode 100644 index 0000000..5817ea8 --- /dev/null +++ b/src/tests/push-core.test.ts @@ -0,0 +1,301 @@ +/** + * push-core — the shared APNs/FCM sender. The load-bearing property is + * CONTENT-BLINDNESS: every payload carries only opaque ids + generic copy. + * Crypto is verified against the public key; the APNs HTTP/2 send is driven + * through an injected fake connect; FCM is driven through a mocked fetch. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { generateKeyPairSync, verify } from "node:crypto"; +import type http2 from "node:http2"; +import { + ApnsClient, + apnsPayload, + classifyApnsResponse, + classifyFcmError, + createPushSender, + FcmClient, + fcmMessageBody, + PushSender, + signEs256, + signRs256, + type PushChannel, + type PushMessage, +} from "../push-core/index.js"; + +const MSG: PushMessage = { sessionId: "sess-opaque-123", kind: "approval" }; + +function ecPem(): string { + return generateKeyPairSync("ec", { + namedCurve: "P-256", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey; +} + +// ── JWT ────────────────────────────────────────────────────────────────────── + +describe("jwt", () => { + test("ES256 (APNs .p8) signs a JWT that verifies against the public key", () => { + const { privateKey, publicKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const jwt = signEs256({ iss: "TEAM", iat: 1_700_000_000 }, "KEY10", privateKey); + const [h, c, s] = jwt.split("."); + expect(JSON.parse(Buffer.from(h, "base64url").toString())).toEqual({ alg: "ES256", kid: "KEY10" }); + expect(JSON.parse(Buffer.from(c, "base64url").toString())).toEqual({ iss: "TEAM", iat: 1_700_000_000 }); + const ok = verify( + "sha256", + Buffer.from(`${h}.${c}`), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(s, "base64url"), + ); + expect(ok).toBe(true); + }); + + test("RS256 (FCM service account) signs a JWT that verifies", () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }); + const jwt = signRs256({ iss: "sa@x.iam", iat: 1, exp: 2 }, privateKey); + const [h, c, s] = jwt.split("."); + expect(verify("RSA-SHA256", Buffer.from(`${h}.${c}`), publicKey, Buffer.from(s, "base64url"))).toBe(true); + }); +}); + +// ── APNs ───────────────────────────────────────────────────────────────────── + +describe("APNs payload + response classification", () => { + test("payload is content-blind — opaque ids + generic copy only", () => { + const p = JSON.parse(apnsPayload(MSG)); + expect(p.aps.alert).toEqual({ title: "codeoid", body: "A session needs your approval" }); + expect(p.sessionId).toBe("sess-opaque-123"); + expect(p.kind).toBe("approval"); + expect(apnsPayload(MSG)).not.toContain("Bash"); + }); + + test("classify 200 → ok", () => { + expect(classifyApnsResponse(200, "")).toEqual({ ok: true, unregistered: false, reason: "" }); + }); + test("classify 410 Unregistered → unregistered", () => { + expect(classifyApnsResponse(410, JSON.stringify({ reason: "Unregistered" }))).toEqual({ + ok: false, + unregistered: true, + reason: "Unregistered", + }); + }); + test("classify BadDeviceToken → unregistered", () => { + expect(classifyApnsResponse(400, JSON.stringify({ reason: "BadDeviceToken" })).unregistered).toBe(true); + }); + test("classify other 4xx → failure, not unregistered", () => { + const r = classifyApnsResponse(403, JSON.stringify({ reason: "ExpiredProviderToken" })); + expect(r).toEqual({ ok: false, unregistered: false, reason: "ExpiredProviderToken" }); + }); + test("classify non-JSON body → HTTP status reason", () => { + expect(classifyApnsResponse(503, "upstream boom").reason).toBe("HTTP 503"); + }); +}); + +/** Minimal fake of node:http2 connect capturing the request headers + payload. */ +function fakeHttp2(status: number, body = "") { + const requests: Array<{ headers: Record; payload: string }> = []; + const connect = ((_authority: string) => { + const session = new EventEmitter() as EventEmitter & Record; + session.closed = false; + session.destroyed = false; + session.close = () => { + session.closed = true; + }; + session.request = (headers: Record) => { + const stream = new EventEmitter() as EventEmitter & Record; + stream.setEncoding = () => {}; + stream.setTimeout = () => {}; + stream.close = () => {}; + stream.end = (payload: string) => { + requests.push({ headers, payload }); + queueMicrotask(() => { + stream.emit("response", { ":status": status }); + if (body) stream.emit("data", body); + stream.emit("end"); + }); + }; + return stream; + }; + return session; + }) as unknown as typeof http2.connect; + return { connect, requests }; +} + +describe("ApnsClient.send (injected HTTP/2)", () => { + test("success — content-blind headers + payload", async () => { + const { connect, requests } = fakeHttp2(200); + const client = new ApnsClient( + { keyId: "K", teamId: "T", bundleId: "ai.codeoid.mobile", p8: ecPem() }, + connect, + ); + const res = await client.send({ token: "devtok", platform: "ios" }, MSG); + expect(res).toEqual({ token: "devtok", ok: true, unregistered: false, error: undefined }); + expect(requests).toHaveLength(1); + const req = requests[0]; + expect(req.headers[":path"]).toBe("/3/device/devtok"); + expect(req.headers["apns-topic"]).toBe("ai.codeoid.mobile"); + expect(req.headers["apns-push-type"]).toBe("alert"); + expect(String(req.headers.authorization)).toMatch(/^bearer /); + expect(JSON.parse(req.payload).sessionId).toBe("sess-opaque-123"); + }); + + test("410 → ok:false, unregistered:true", async () => { + const { connect } = fakeHttp2(410, JSON.stringify({ reason: "Unregistered" })); + const client = new ApnsClient({ keyId: "K", teamId: "T", bundleId: "b", p8: ecPem() }, connect); + const res = await client.send({ token: "dead", platform: "ios" }, MSG); + expect(res.ok).toBe(false); + expect(res.unregistered).toBe(true); + }); + + test("reuses the session across sends (one connect for two)", async () => { + let connects = 0; + const { connect } = fakeHttp2(200); + const counting = ((authority: string) => { + connects++; + return connect(authority); + }) as unknown as typeof http2.connect; + const client = new ApnsClient({ keyId: "K", teamId: "T", bundleId: "b", p8: ecPem() }, counting); + await client.send({ token: "a", platform: "ios" }, MSG); + await client.send({ token: "b", platform: "ios" }, MSG); + expect(connects).toBe(1); + client.close(); + }); +}); + +// ── FCM ────────────────────────────────────────────────────────────────────── + +describe("FCM", () => { + let origFetch: typeof fetch; + let calls: Array<{ url: string; init: RequestInit }>; + const creds = { + projectId: "proj", + clientEmail: "sa@x.iam", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + }; + + beforeEach(() => { + calls = []; + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + function mock(handler: (url: string) => Response) { + globalThis.fetch = (async (url: unknown, init: unknown) => { + calls.push({ url: String(url), init: init as RequestInit }); + return handler(String(url)); + }) as unknown as typeof fetch; + } + const oauthOk = (url: string) => + url.includes("oauth2") + ? new Response(JSON.stringify({ access_token: "AT", expires_in: 3600 }), { status: 200 }) + : new Response("{}", { status: 200 }); + + test("message body is content-blind", () => { + const b = JSON.parse(fcmMessageBody("tok", MSG)); + expect(b.message.token).toBe("tok"); + expect(b.message.data).toEqual({ sessionId: "sess-opaque-123", kind: "approval" }); + expect(b.message.notification).toEqual({ title: "codeoid", body: "A session needs your approval" }); + expect(fcmMessageBody("tok", MSG)).not.toContain("Bash"); + }); + + test("classifyFcmError: 404 / UNREGISTERED / NOT_FOUND → unregistered", () => { + expect(classifyFcmError(404, "").unregistered).toBe(true); + expect(classifyFcmError(400, JSON.stringify({ error: { status: "UNREGISTERED" } })).unregistered).toBe(true); + expect(classifyFcmError(500, JSON.stringify({ error: { status: "INTERNAL" } })).unregistered).toBe(false); + }); + + test("send: OAuth then POST with Bearer + content-blind body", async () => { + mock(oauthOk); + const res = await new FcmClient(creds).send({ token: "tok", platform: "android" }, MSG); + expect(res).toEqual({ token: "tok", ok: true }); + const send = calls.find((c) => c.url.includes("messages:send")); + expect((send?.init.headers as Record).authorization).toBe("Bearer AT"); + expect(JSON.parse(send?.init.body as string).message.data.sessionId).toBe("sess-opaque-123"); + }); + + test("send: caches the access token (one OAuth for two sends)", async () => { + mock(oauthOk); + const client = new FcmClient(creds); + await client.send({ token: "a", platform: "android" }, MSG); + await client.send({ token: "b", platform: "android" }, MSG); + expect(calls.filter((c) => c.url.includes("oauth2"))).toHaveLength(1); + }); + + test("send: 404 → unregistered", async () => { + mock((url) => + url.includes("oauth2") + ? new Response(JSON.stringify({ access_token: "AT", expires_in: 3600 }), { status: 200 }) + : new Response("{}", { status: 404 }), + ); + const res = await new FcmClient(creds).send({ token: "dead", platform: "android" }, MSG); + expect(res.ok).toBe(false); + expect(res.unregistered).toBe(true); + }); + + test("send: OAuth failure → ok:false (swallowed)", async () => { + mock(() => new Response("nope", { status: 401 })); + const res = await new FcmClient(creds).send({ token: "t", platform: "android" }, MSG); + expect(res.ok).toBe(false); + }); +}); + +// ── PushSender routing ─────────────────────────────────────────────────────── + +describe("PushSender", () => { + function fake() { + const sent: Array<{ token: string; msg: PushMessage }> = []; + const ch: PushChannel = { + async send(device, msg) { + sent.push({ token: device.token, msg }); + return { token: device.token, ok: true }; + }, + }; + return { ch, sent }; + } + + test("routes ios→apns channel, android→fcm channel, passing the content-blind note", async () => { + const ios = fake(); + const android = fake(); + const sender = new PushSender({ ios: ios.ch, android: android.ch }); + const results = await sender.send( + [ + { token: "i", platform: "ios" }, + { token: "a", platform: "android" }, + ], + MSG, + ); + expect(results.every((r) => r.ok)).toBe(true); + expect(ios.sent.map((s) => s.token)).toEqual(["i"]); + expect(android.sent.map((s) => s.token)).toEqual(["a"]); + expect(ios.sent[0].msg).toEqual(MSG); + }); + + test("missing channel → ok:false, never throws", async () => { + const [r] = await new PushSender({}).send([{ token: "x", platform: "ios" }], MSG); + expect(r.ok).toBe(false); + expect(r.error).toContain("no ios channel"); + }); + + test("createPushSender builds only the configured channels", async () => { + const sender = createPushSender({ apns: { keyId: "K", teamId: "T", bundleId: "b", p8: ecPem() } }); + expect(sender).toBeInstanceOf(PushSender); + // No fcm channel → android delivery fails gracefully (no network touched). + const [r] = await sender.send([{ token: "x", platform: "android" }], MSG); + expect(r.ok).toBe(false); + }); +}); diff --git a/src/tests/push-native-relay.test.ts b/src/tests/push-native-relay.test.ts new file mode 100644 index 0000000..e1ac646 --- /dev/null +++ b/src/tests/push-native-relay.test.ts @@ -0,0 +1,139 @@ +/** + * The daemon-side transports that wrap push-core: NativePushTransport + * (embedded — maps targets→devices, prunes dead tokens) and RelayPushTransport + * (POSTs a content-blind wake-up), plus the createPushTransport factory and the + * store's dead-token prune. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPushTransport, NativePushTransport, RelayPushTransport } from "../daemon/push/index.js"; +import { Store } from "../daemon/store.js"; +import type { PushMessage, PushResult, PushSender } from "../push-core/index.js"; + +const NOTE = { sessionId: "s-1", kind: "approval" as const }; + +function fakeSender(results: PushResult[]) { + const calls: Array<{ devices: unknown; msg: PushMessage }> = []; + const sender = { + async send(devices: unknown, msg: PushMessage) { + calls.push({ devices, msg }); + return results; + }, + close() {}, + } as unknown as PushSender; + return { sender, calls }; +} + +describe("NativePushTransport", () => { + test("maps targets→devices and passes the content-blind note", async () => { + const { sender, calls } = fakeSender([{ token: "t", ok: true }]); + await new NativePushTransport(sender).send([{ token: "t", platform: "ios" }], NOTE); + expect(calls[0].devices).toEqual([{ token: "t", platform: "ios" }]); + expect(calls[0].msg).toEqual({ sessionId: "s-1", kind: "approval" }); + }); + + test("prunes only the unregistered tokens via the callback", async () => { + const { sender } = fakeSender([ + { token: "dead", ok: false, unregistered: true }, + { token: "live", ok: true }, + ]); + const pruned: string[] = []; + await new NativePushTransport(sender, (t) => pruned.push(t)).send( + [ + { token: "dead", platform: "ios" }, + { token: "live", platform: "android" }, + ], + NOTE, + ); + expect(pruned).toEqual(["dead"]); + }); + + test("empty targets → sender not called", async () => { + const { sender, calls } = fakeSender([]); + await new NativePushTransport(sender).send([], NOTE); + expect(calls).toHaveLength(0); + }); +}); + +describe("RelayPushTransport", () => { + let origFetch: typeof fetch; + let calls: Array<{ url: string; init: RequestInit }>; + + beforeEach(() => { + calls = []; + origFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = origFetch; + }); + + test("POSTs content-blind {targets, note} with Bearer auth to /push", async () => { + globalThis.fetch = (async (url: unknown, init: unknown) => { + calls.push({ url: String(url), init: init as RequestInit }); + return new Response("ok", { status: 200 }); + }) as unknown as typeof fetch; + + await new RelayPushTransport("https://relay.example", "reltok").send( + [{ token: "t", platform: "ios" }], + NOTE, + ); + expect(calls[0].url).toBe("https://relay.example/push"); + expect((calls[0].init.headers as Record).authorization).toBe("Bearer reltok"); + expect(JSON.parse(calls[0].init.body as string)).toEqual({ + targets: [{ token: "t", platform: "ios" }], + note: { sessionId: "s-1", kind: "approval" }, + }); + }); + + test("empty targets → no POST", async () => { + globalThis.fetch = (async () => { + calls.push({ url: "x", init: {} }); + return new Response(""); + }) as unknown as typeof fetch; + await new RelayPushTransport("https://r", "t").send([], NOTE); + expect(calls).toHaveLength(0); + }); +}); + +describe("createPushTransport", () => { + test("undefined / none → noop", () => { + expect(createPushTransport(undefined).name).toBe("none"); + expect(createPushTransport({ transport: "none" }).name).toBe("none"); + }); + test("expo → expo", () => { + expect(createPushTransport({ transport: "expo" }).name).toBe("expo"); + }); + test("native → native", () => { + expect(createPushTransport({ transport: "native" }).name).toBe("native"); + }); + test("relay with url+token → relay", () => { + expect( + createPushTransport({ transport: "relay", relayUrl: "https://r", relayToken: "t" }).name, + ).toBe("relay"); + }); + test("relay missing url/token → noop (fail safe)", () => { + expect(createPushTransport({ transport: "relay" }).name).toBe("none"); + }); +}); + +describe("store.pruneDeadToken", () => { + let tmp: string; + let store: Store; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-push-prune-")); + store = new Store(join(tmp, "codeoid.db")); + }); + afterEach(() => { + store.close(); + rmSync(tmp, { recursive: true, force: true }); + }); + + test("deletes a token regardless of owner", () => { + store.registerPush("t1", "ios", "user:a", "acc", "proj"); + store.pruneDeadToken("t1"); + expect(store.listPushForOwner("user:a", "acc", "proj")).toEqual([]); + }); +});