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
7 changes: 7 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
53 changes: 46 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/daemon/push/index.ts
Original file line number Diff line number Diff line change
@@ -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";
43 changes: 43 additions & 0 deletions src/daemon/push/native.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
}
}
}
42 changes: 42 additions & 0 deletions src/daemon/push/relay.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
42 changes: 36 additions & 6 deletions src/daemon/push/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,56 @@
* 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 = {
name: "none",
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. */
Expand Down
15 changes: 12 additions & 3 deletions src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/daemon/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading