From f276a8db42bff6092f8aa3c61840fdaa0cbf00e9 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sat, 28 Mar 2026 13:28:24 -0400 Subject: [PATCH] feat: add APN relay MVP and experimental push bridge --- packages/apn-relay/.env.example | 11 + packages/apn-relay/Dockerfile | 14 + packages/apn-relay/README.md | 46 +++ packages/apn-relay/drizzle.config.ts | 17 ++ packages/apn-relay/package.json | 26 ++ packages/apn-relay/src/apns.ts | 148 ++++++++++ packages/apn-relay/src/db.ts | 11 + packages/apn-relay/src/env.ts | 14 + packages/apn-relay/src/hash.ts | 5 + packages/apn-relay/src/index.ts | 275 ++++++++++++++++++ packages/apn-relay/src/schema.sql.ts | 35 +++ packages/apn-relay/src/setup.ts | 34 +++ packages/apn-relay/tsconfig.json | 8 + packages/opencode/src/cli/cmd/serve.ts | 34 ++- packages/opencode/src/server/push-relay.ts | 252 ++++++++++++++++ .../src/server/routes/experimental.ts | 94 ++++++ specs/apn-relay-mvp-layout.md | 173 +++++++++++ 17 files changed, 1196 insertions(+), 1 deletion(-) create mode 100644 packages/apn-relay/.env.example create mode 100644 packages/apn-relay/Dockerfile create mode 100644 packages/apn-relay/README.md create mode 100644 packages/apn-relay/drizzle.config.ts create mode 100644 packages/apn-relay/package.json create mode 100644 packages/apn-relay/src/apns.ts create mode 100644 packages/apn-relay/src/db.ts create mode 100644 packages/apn-relay/src/env.ts create mode 100644 packages/apn-relay/src/hash.ts create mode 100644 packages/apn-relay/src/index.ts create mode 100644 packages/apn-relay/src/schema.sql.ts create mode 100644 packages/apn-relay/src/setup.ts create mode 100644 packages/apn-relay/tsconfig.json create mode 100644 packages/opencode/src/server/push-relay.ts create mode 100644 specs/apn-relay-mvp-layout.md diff --git a/packages/apn-relay/.env.example b/packages/apn-relay/.env.example new file mode 100644 index 0000000000..82a410cb3a --- /dev/null +++ b/packages/apn-relay/.env.example @@ -0,0 +1,11 @@ +PORT=8787 + +DATABASE_HOST= +DATABASE_USERNAME= +DATABASE_PASSWORD= +DATABASE_NAME=main + +APNS_TEAM_ID= +APNS_KEY_ID= +APNS_PRIVATE_KEY= +APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice diff --git a/packages/apn-relay/Dockerfile b/packages/apn-relay/Dockerfile new file mode 100644 index 0000000000..3f389f7461 --- /dev/null +++ b/packages/apn-relay/Dockerfile @@ -0,0 +1,14 @@ +FROM oven/bun:1.3.11-alpine + +WORKDIR /app + +COPY package.json ./ +COPY tsconfig.json ./ +COPY drizzle.config.ts ./ +RUN bun install --production + +COPY src ./src + +EXPOSE 8787 + +CMD ["bun", "run", "src/index.ts"] diff --git a/packages/apn-relay/README.md b/packages/apn-relay/README.md new file mode 100644 index 0000000000..54a6e5993b --- /dev/null +++ b/packages/apn-relay/README.md @@ -0,0 +1,46 @@ +# APN Relay + +Minimal APNs relay for OpenCode mobile background notifications. + +## What it does + +- Registers iOS device tokens for a shared secret. +- Receives OpenCode event posts (`complete`, `permission`, `error`). +- Sends APNs notifications to mapped devices. +- Stores delivery rows in PlanetScale. + +## Routes + +- `GET /health` +- `GET /` (simple dashboard) +- `POST /v1/device/register` +- `POST /v1/device/unregister` +- `POST /v1/event` + +## Environment + +Use `.env.example` as a starting point. + +- `DATABASE_HOST` +- `DATABASE_USERNAME` +- `DATABASE_PASSWORD` +- `APNS_TEAM_ID` +- `APNS_KEY_ID` +- `APNS_PRIVATE_KEY` +- `APNS_DEFAULT_BUNDLE_ID` + +## Run locally + +```bash +bun install +bun run src/index.ts +``` + +## Docker + +Build from this directory: + +```bash +docker build -t apn-relay . +docker run --rm -p 8787:8787 --env-file .env apn-relay +``` diff --git a/packages/apn-relay/drizzle.config.ts b/packages/apn-relay/drizzle.config.ts new file mode 100644 index 0000000000..6e6f4647db --- /dev/null +++ b/packages/apn-relay/drizzle.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + out: "./migration", + strict: true, + schema: ["./src/**/*.sql.ts"], + dialect: "mysql", + dbCredentials: { + host: process.env.DATABASE_HOST ?? "", + user: process.env.DATABASE_USERNAME ?? "", + password: process.env.DATABASE_PASSWORD ?? "", + database: process.env.DATABASE_NAME ?? "main", + ssl: { + rejectUnauthorized: false, + }, + }, +}) diff --git a/packages/apn-relay/package.json b/packages/apn-relay/package.json new file mode 100644 index 0000000000..7f24260676 --- /dev/null +++ b/packages/apn-relay/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/apn-relay", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "dev": "bun run src/index.ts", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@planetscale/database": "1.19.0", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "jose": "6.0.11", + "zod": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "drizzle-kit": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/apn-relay/src/apns.ts b/packages/apn-relay/src/apns.ts new file mode 100644 index 0000000000..e0256175fa --- /dev/null +++ b/packages/apn-relay/src/apns.ts @@ -0,0 +1,148 @@ +import { connect } from "node:http2" +import { SignJWT, importPKCS8 } from "jose" +import { env } from "./env" + +export type PushEnv = "sandbox" | "production" + +type PushInput = { + token: string + bundle: string + env: PushEnv + title: string + body: string + data: Record +} + +type PushResult = { + ok: boolean + code: number + error?: string +} + +let jwt = "" +let exp = 0 +let pk: Awaited> | undefined + +function host(input: PushEnv) { + if (input === "sandbox") return "api.sandbox.push.apple.com" + return "api.push.apple.com" +} + +function key() { + if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n") + return env.APNS_PRIVATE_KEY +} + +async function sign() { + if (!pk) pk = await importPKCS8(key(), "ES256") + const now = Math.floor(Date.now() / 1000) + if (jwt && now < exp) return jwt + jwt = await new SignJWT({}) + .setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID }) + .setIssuer(env.APNS_TEAM_ID) + .setIssuedAt(now) + .sign(pk) + exp = now + 50 * 60 + return jwt +} + +function post(input: { + host: string + token: string + auth: string + bundle: string + payload: string +}): Promise<{ code: number; body: string }> { + return new Promise((resolve, reject) => { + const cli = connect(`https://${input.host}`) + let done = false + let code = 0 + let body = "" + + const stop = (fn: () => void) => { + if (done) return + done = true + fn() + } + + cli.on("error", (err) => { + stop(() => reject(err)) + cli.close() + }) + + const req = cli.request({ + ":method": "POST", + ":path": `/3/device/${input.token}`, + authorization: `bearer ${input.auth}`, + "apns-topic": input.bundle, + "apns-push-type": "alert", + "apns-priority": "10", + "content-type": "application/json", + }) + + req.setEncoding("utf8") + req.on("response", (headers) => { + code = Number(headers[":status"] ?? 0) + }) + req.on("data", (chunk) => { + body += chunk + }) + req.on("end", () => { + stop(() => resolve({ code, body })) + cli.close() + }) + req.on("error", (err) => { + stop(() => reject(err)) + cli.close() + }) + req.end(input.payload) + }) +} + +export async function send(input: PushInput): Promise { + const auth = await sign().catch((err) => { + return `error:${String(err)}` + }) + if (auth.startsWith("error:")) { + return { + ok: false, + code: 0, + error: auth, + } + } + + const payload = JSON.stringify({ + aps: { + alert: { + title: input.title, + body: input.body, + }, + sound: "default", + }, + ...input.data, + }) + + const out = await post({ + host: host(input.env), + token: input.token, + auth, + bundle: input.bundle, + payload, + }).catch((err) => ({ + code: 0, + body: String(err), + })) + + if (out.code === 200) { + return { + ok: true, + code: 200, + } + } + + return { + ok: false, + code: out.code, + error: out.body, + } +} diff --git a/packages/apn-relay/src/db.ts b/packages/apn-relay/src/db.ts new file mode 100644 index 0000000000..0035abf818 --- /dev/null +++ b/packages/apn-relay/src/db.ts @@ -0,0 +1,11 @@ +import { Client } from "@planetscale/database" +import { drizzle } from "drizzle-orm/planetscale-serverless" +import { env } from "./env" + +const client = new Client({ + host: env.DATABASE_HOST, + username: env.DATABASE_USERNAME, + password: env.DATABASE_PASSWORD, +}) + +export const db = drizzle(client) diff --git a/packages/apn-relay/src/env.ts b/packages/apn-relay/src/env.ts new file mode 100644 index 0000000000..2825bc6ae1 --- /dev/null +++ b/packages/apn-relay/src/env.ts @@ -0,0 +1,14 @@ +import { z } from "zod" + +const schema = z.object({ + PORT: z.coerce.number().int().positive().default(8787), + DATABASE_HOST: z.string().min(1), + DATABASE_USERNAME: z.string().min(1), + DATABASE_PASSWORD: z.string().min(1), + APNS_TEAM_ID: z.string().min(1), + APNS_KEY_ID: z.string().min(1), + APNS_PRIVATE_KEY: z.string().min(1), + APNS_DEFAULT_BUNDLE_ID: z.string().min(1), +}) + +export const env = schema.parse(process.env) diff --git a/packages/apn-relay/src/hash.ts b/packages/apn-relay/src/hash.ts new file mode 100644 index 0000000000..9f3dcb2d9b --- /dev/null +++ b/packages/apn-relay/src/hash.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function hash(input: string) { + return createHash("sha256").update(input).digest("hex") +} diff --git a/packages/apn-relay/src/index.ts b/packages/apn-relay/src/index.ts new file mode 100644 index 0000000000..314c6f9813 --- /dev/null +++ b/packages/apn-relay/src/index.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "node:crypto" +import { and, desc, eq, sql } from "drizzle-orm" +import { Hono } from "hono" +import { z } from "zod" +import { send } from "./apns" +import { db } from "./db" +import { env } from "./env" +import { hash } from "./hash" +import { delivery_log, device_registration } from "./schema.sql" +import { setup } from "./setup" + +const reg = z.object({ + secret: z.string().min(1), + deviceToken: z.string().min(1), + bundleId: z.string().min(1).optional(), + apnsEnv: z.enum(["sandbox", "production"]).default("production"), +}) + +const unreg = z.object({ + secret: z.string().min(1), + deviceToken: z.string().min(1), +}) + +const evt = z.object({ + secret: z.string().min(1), + eventType: z.enum(["complete", "permission", "error"]), + sessionID: z.string().min(1), + title: z.string().min(1).optional(), + body: z.string().min(1).optional(), +}) + +function title(input: z.infer["eventType"]) { + if (input === "complete") return "Session complete" + if (input === "permission") return "Action needed" + return "Session error" +} + +function body(input: z.infer["eventType"]) { + if (input === "complete") return "OpenCode finished your session." + if (input === "permission") return "OpenCode needs your permission decision." + return "OpenCode reported an error for your session." +} + +const app = new Hono() + +app.onError((err, c) => { + return c.json( + { + ok: false, + error: err.message, + }, + 500, + ) +}) + +app.notFound((c) => { + return c.json( + { + ok: false, + error: "Not found", + }, + 404, + ) +}) + +app.get("/health", async (c) => { + const [a] = await db.select({ value: sql`count(*)` }).from(device_registration) + const [b] = await db.select({ value: sql`count(*)` }).from(delivery_log) + return c.json({ + ok: true, + devices: Number(a?.value ?? 0), + deliveries: Number(b?.value ?? 0), + }) +}) + +app.get("/", async (c) => { + const [a] = await db.select({ value: sql`count(*)` }).from(device_registration) + const [b] = await db.select({ value: sql`count(*)` }).from(delivery_log) + const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20) + + const html = ` + + + + + APN Relay + + + +

APN Relay

+

MVP dashboard

+
+
+
Registered devices
+
${Number(a?.value ?? 0)}
+
+
+
Delivery log rows
+
${Number(b?.value ?? 0)}
+
+
+ + + + + + + + + + + + ${rows + .map( + (row) => ` + + + + + + `, + ) + .join("")} + +
timeeventsessionstatuserror
${new Date(row.created_at).toISOString()}${row.event_type}${row.session_id}${row.status}${row.error ?? ""}
+ +` + + return c.html(html) +}) + +app.post("/v1/device/register", async (c) => { + const raw = await c.req.json().catch(() => undefined) + const check = reg.safeParse(raw) + if (!check.success) { + return c.json( + { + ok: false, + error: "Invalid request body", + }, + 400, + ) + } + + const now = Date.now() + const key = hash(check.data.secret) + const row = { + id: randomUUID(), + secret_hash: key, + device_token: check.data.deviceToken, + bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID, + apns_env: check.data.apnsEnv, + created_at: now, + updated_at: now, + } + + await db + .insert(device_registration) + .values(row) + .onDuplicateKeyUpdate({ + set: { + bundle_id: row.bundle_id, + apns_env: row.apns_env, + updated_at: now, + }, + }) + + return c.json({ ok: true }) +}) + +app.post("/v1/device/unregister", async (c) => { + const raw = await c.req.json().catch(() => undefined) + const check = unreg.safeParse(raw) + if (!check.success) { + return c.json( + { + ok: false, + error: "Invalid request body", + }, + 400, + ) + } + + await db + .delete(device_registration) + .where( + and( + eq(device_registration.secret_hash, hash(check.data.secret)), + eq(device_registration.device_token, check.data.deviceToken), + ), + ) + + return c.json({ ok: true }) +}) + +app.post("/v1/event", async (c) => { + const raw = await c.req.json().catch(() => undefined) + const check = evt.safeParse(raw) + if (!check.success) { + return c.json( + { + ok: false, + error: "Invalid request body", + }, + 400, + ) + } + + const key = hash(check.data.secret) + const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key)) + if (!list.length) { + return c.json({ + ok: true, + sent: 0, + failed: 0, + }) + } + + const out = await Promise.all( + list.map((row) => + send({ + token: row.device_token, + bundle: row.bundle_id, + env: row.apns_env === "sandbox" ? "sandbox" : "production", + title: check.data.title ?? title(check.data.eventType), + body: check.data.body ?? body(check.data.eventType), + data: { + eventType: check.data.eventType, + sessionID: check.data.sessionID, + }, + }), + ), + ) + + const now = Date.now() + await db.insert(delivery_log).values( + out.map((item) => ({ + id: randomUUID(), + secret_hash: key, + event_type: check.data.eventType, + session_id: check.data.sessionID, + status: item.ok ? "sent" : "failed", + error: item.error, + created_at: now, + })), + ) + + const sent = out.filter((item) => item.ok).length + return c.json({ + ok: true, + sent, + failed: out.length - sent, + }) +}) + +await setup() + +if (import.meta.main) { + Bun.serve({ + port: env.PORT, + fetch: app.fetch, + }) + console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`) +} + +export default app diff --git a/packages/apn-relay/src/schema.sql.ts b/packages/apn-relay/src/schema.sql.ts new file mode 100644 index 0000000000..2447611b6b --- /dev/null +++ b/packages/apn-relay/src/schema.sql.ts @@ -0,0 +1,35 @@ +import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" + +export const device_registration = mysqlTable( + "device_registration", + { + id: varchar("id", { length: 36 }).primaryKey(), + secret_hash: varchar("secret_hash", { length: 64 }).notNull(), + device_token: varchar("device_token", { length: 255 }).notNull(), + bundle_id: varchar("bundle_id", { length: 255 }).notNull(), + apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"), + created_at: bigint("created_at", { mode: "number" }).notNull(), + updated_at: bigint("updated_at", { mode: "number" }).notNull(), + }, + (table) => [ + uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token), + index("device_registration_secret_hash_idx").on(table.secret_hash), + ], +) + +export const delivery_log = mysqlTable( + "delivery_log", + { + id: varchar("id", { length: 36 }).primaryKey(), + secret_hash: varchar("secret_hash", { length: 64 }).notNull(), + event_type: varchar("event_type", { length: 32 }).notNull(), + session_id: varchar("session_id", { length: 255 }).notNull(), + status: varchar("status", { length: 16 }).notNull(), + error: varchar("error", { length: 1024 }), + created_at: bigint("created_at", { mode: "number" }).notNull(), + }, + (table) => [ + index("delivery_log_secret_hash_idx").on(table.secret_hash), + index("delivery_log_created_at_idx").on(table.created_at), + ], +) diff --git a/packages/apn-relay/src/setup.ts b/packages/apn-relay/src/setup.ts new file mode 100644 index 0000000000..eb50c4b9d1 --- /dev/null +++ b/packages/apn-relay/src/setup.ts @@ -0,0 +1,34 @@ +import { sql } from "drizzle-orm" +import { db } from "./db" + +export async function setup() { + await db.execute(sql` + CREATE TABLE IF NOT EXISTS device_registration ( + id varchar(36) NOT NULL, + secret_hash varchar(64) NOT NULL, + device_token varchar(255) NOT NULL, + bundle_id varchar(255) NOT NULL, + apns_env varchar(16) NOT NULL DEFAULT 'production', + created_at bigint NOT NULL, + updated_at bigint NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token), + KEY device_registration_secret_hash_idx (secret_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `) + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS delivery_log ( + id varchar(36) NOT NULL, + secret_hash varchar(64) NOT NULL, + event_type varchar(32) NOT NULL, + session_id varchar(255) NOT NULL, + status varchar(16) NOT NULL, + error varchar(1024) NULL, + created_at bigint NOT NULL, + PRIMARY KEY (id), + KEY delivery_log_secret_hash_idx (secret_hash), + KEY delivery_log_created_at_idx (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `) +} diff --git a/packages/apn-relay/tsconfig.json b/packages/apn-relay/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/apn-relay/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index ab51fe8c3e..3d06cef4a8 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,10 +5,20 @@ import { Flag } from "../../flag/flag" import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" +import { PushRelay } from "../../server/push-relay" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + withNetworkOptions(yargs) + .option("relay-url", { + type: "string", + describe: "experimental APN relay URL", + }) + .option("relay-secret", { + type: "string", + describe: "experimental APN relay secret", + }), describe: "starts a headless opencode server", handler: async (args) => { if (!Flag.OPENCODE_SERVER_PASSWORD) { @@ -18,6 +28,28 @@ export const ServeCommand = cmd({ const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + const relayURL = ( + args["relay-url"] ?? + process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? + "https://relay.opencode.ai" + ).trim() + const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() + if (relayURL && relaySecret) { + const host = server.hostname ?? opts.hostname + const port = server.port || opts.port || 4096 + const pair = PushRelay.start({ + relayURL, + relaySecret, + hostname: host, + port, + }) + if (pair) { + console.log("experimental push relay enabled") + console.log("qr payload") + console.log(JSON.stringify(pair, null, 2)) + } + } + await new Promise(() => {}) await server.stop() }, diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts new file mode 100644 index 0000000000..20f9234df2 --- /dev/null +++ b/packages/opencode/src/server/push-relay.ts @@ -0,0 +1,252 @@ +import os from "node:os" +import { Bus } from "@/bus" +import { Log } from "@/util/log" + +type Type = "complete" | "permission" | "error" + +type Pair = { + v: 1 + relayURL: string + relaySecret: string + hosts: string[] +} + +type Input = { + relayURL: string + relaySecret: string + hostname: string + port: number +} + +type State = { + relayURL: string + relaySecret: string + pair: Pair + stop: () => void + seen: Map + gc: number +} + +type Event = { + type: string + properties: unknown +} + +const log = Log.create({ service: "push-relay" }) + +let state: State | undefined + +function obj(input: unknown): input is Record { + return typeof input === "object" && input !== null +} + +function str(input: unknown) { + return typeof input === "string" && input.length > 0 ? input : undefined +} + +function norm(input: string) { + return input.replace(/\/+$/, "") +} + +function list(hostname: string, port: number) { + const urls = new Set() + const add = (host: string) => { + if (!host) return + if (host === "0.0.0.0") return + if (host === "::") return + urls.add(`http://${host}:${port}`) + } + + add(hostname) + add("127.0.0.1") + + const nets = Object.values(os.networkInterfaces()) + .flatMap((item) => item ?? []) + .filter((item) => item.family === "IPv4" && !item.internal) + .map((item) => item.address) + + nets.forEach(add) + + return [...urls] +} + +function map(event: Event): { type: Type; sessionID: string } | undefined { + if (!obj(event.properties)) return + + if (event.type === "permission.asked") { + const sessionID = str(event.properties.sessionID) + if (!sessionID) return + return { type: "permission", sessionID } + } + + if (event.type === "session.error") { + const sessionID = str(event.properties.sessionID) + if (!sessionID) return + return { type: "error", sessionID } + } + + if (event.type === "session.idle") { + const sessionID = str(event.properties.sessionID) + if (!sessionID) return + return { type: "complete", sessionID } + } + + if (event.type !== "session.status") return + const sessionID = str(event.properties.sessionID) + if (!sessionID) return + if (!obj(event.properties.status)) return + if (event.properties.status.type !== "idle") return + return { type: "complete", sessionID } +} + +function dedupe(input: { type: Type; sessionID: string }) { + if (input.type !== "complete") return false + const next = state + if (!next) return false + const now = Date.now() + + if (next.seen.size > 2048 || now - next.gc > 60_000) { + next.gc = now + for (const [key, time] of next.seen) { + if (now - time > 60_000) { + next.seen.delete(key) + } + } + const drop = next.seen.size - 2048 + if (drop > 0) { + let i = 0 + for (const key of next.seen.keys()) { + next.seen.delete(key) + i += 1 + if (i >= drop) break + } + } + } + + const key = `${input.type}:${input.sessionID}` + const prev = next.seen.get(key) + next.seen.set(key, now) + if (!prev) return false + return now - prev < 5_000 +} + +function post(input: { type: Type; sessionID: string }) { + const next = state + if (!next) return false + if (dedupe(input)) return true + + void fetch(`${next.relayURL}/v1/event`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + secret: next.relaySecret, + eventType: input.type, + sessionID: input.sessionID, + }), + }) + .then(async (res) => { + if (res.ok) return + const error = await res.text().catch(() => "") + log.warn("relay post failed", { + status: res.status, + type: input.type, + sessionID: input.sessionID, + error, + }) + }) + .catch((error) => { + log.warn("relay post failed", { + type: input.type, + sessionID: input.sessionID, + error: String(error), + }) + }) + + return true +} + +export namespace PushRelay { + export function start(input: Input) { + const relayURL = norm(input.relayURL.trim()) + const relaySecret = input.relaySecret.trim() + if (!relayURL) return + if (!relaySecret) return + + stop() + + const pair: Pair = { + v: 1, + relayURL, + relaySecret, + hosts: list(input.hostname, input.port), + } + + let unsub: (() => void) | undefined + try { + unsub = Bus.subscribeAll((event) => { + const next = map(event) + if (!next) return + post(next) + }) + } catch (error) { + log.warn("failed to subscribe", { + error: String(error), + }) + return + } + if (!unsub) return + + state = { + relayURL, + relaySecret, + pair, + stop: unsub, + seen: new Map(), + gc: 0, + } + + log.info("enabled", { + relayURL, + hosts: pair.hosts, + }) + + return pair + } + + export function stop() { + const next = state + if (!next) return + state = undefined + next.stop() + } + + export function status() { + const next = state + if (!next) { + return { + enabled: false, + relaySecretSet: false, + } as const + } + return { + enabled: true, + relaySecretSet: next.relaySecret.length > 0, + } as const + } + + export function pair() { + return state?.pair + } + + export function test(input: { type: Type; sessionID: string }) { + return post(input) + } + + export function auth(input: string) { + const next = state + if (!next) return false + return next.relaySecret === input + } +} diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index a41b21a1fe..3c99ac5fee 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -12,6 +12,7 @@ import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" import { WorkspaceRoutes } from "./workspace" +import { PushRelay } from "../push-relay" export const ExperimentalRoutes = lazy(() => new Hono() @@ -267,5 +268,98 @@ export const ExperimentalRoutes = lazy(() => async (c) => { return c.json(await MCP.resources()) }, + ) + .get( + "/push", + describeRoute({ + summary: "Get push relay status", + description: "Get experimental push relay runtime status for this server.", + operationId: "experimental.push.status", + responses: { + 200: { + description: "Push relay status", + content: { + "application/json": { + schema: resolver( + z.object({ + enabled: z.boolean(), + relaySecretSet: z.boolean(), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + return c.json(PushRelay.status()) + }, + ) + .post( + "/push/test", + describeRoute({ + summary: "Send test push event", + description: "Send a test push event through the experimental APN relay integration.", + operationId: "experimental.push.test", + responses: { + 200: { + description: "Test event accepted", + content: { + "application/json": { + schema: resolver( + z.object({ + ok: z.boolean(), + enabled: z.boolean(), + }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + secret: z.string(), + sessionID: z.string().optional(), + eventType: z.enum(["complete", "permission", "error"]).optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const status = PushRelay.status() + if (!status.enabled) { + return c.json( + { + data: { enabled: false }, + errors: [{ message: "Push relay is not enabled" }], + success: false, + }, + 400, + ) + } + + if (!PushRelay.auth(body.secret)) { + return c.json( + { + data: { enabled: true }, + errors: [{ message: "Invalid push relay secret" }], + success: false, + }, + 400, + ) + } + + const ok = PushRelay.test({ + type: body.eventType ?? "permission", + sessionID: body.sessionID ?? `test-${Date.now()}`, + }) + + return c.json({ + ok, + enabled: true, + }) + }, ), ) diff --git a/specs/apn-relay-mvp-layout.md b/specs/apn-relay-mvp-layout.md new file mode 100644 index 0000000000..17339160de --- /dev/null +++ b/specs/apn-relay-mvp-layout.md @@ -0,0 +1,173 @@ +# APN Relay MVP Layout + +This is the minimum setup to get reliable mobile background notifications working with OpenCode. + +## Part 1: APN Relay spec and routes + +### Goal + +- Receive event posts from OpenCode. +- Look up device tokens by shared secret. +- Send APNs notifications to iOS devices. +- Keep the service small and easy to run in Docker. + +### Stack + +- Runtime: Bun +- Web framework: Hono +- Database: PlanetScale MySQL (via Drizzle ORM) +- Deployment artifact: Docker image (`packages/apn-relay`) + +### Minimal data model + +- `device_registration` + - `id` + - `secret_hash` (hash of shared secret) + - `device_token` (APNs token) + - `bundle_id` + - `apns_env` (`sandbox` or `production`) + - `created_at` + - `updated_at` +- `delivery_log` (optional but recommended) + - `id` + - `secret_hash` + - `event_type` + - `session_id` + - `status` (`sent` or `failed`) + - `error` + - `created_at` + +### API routes + +#### `GET /health` + +- Response: `{ ok: true }` + +#### `POST /v1/device/register` + +- Purpose: upsert device token for a shared secret. +- Body: + - `secret` (string) + - `deviceToken` (string) + - `bundleId` (string) + - `apnsEnv` (`sandbox` or `production`) +- Response: `{ ok: true }` + +#### `POST /v1/device/unregister` + +- Purpose: remove token mapping for a shared secret. +- Body: + - `secret` (string) + - `deviceToken` (string) +- Response: `{ ok: true }` + +#### `POST /v1/event` + +- Purpose: receive event from OpenCode and push to all devices for that secret. +- Body: + - `secret` (string) + - `eventType` (`complete` or `permission` or `error`) + - `sessionID` (string) + - `title` (optional string) + - `body` (optional string) +- Response: + - `{ ok: true, sent: number, failed: number }` + +### APNs behavior for MVP + +- Use APNs auth key (`.p8`) with JWT auth. +- Default to user-visible alert pushes for reliability. + - `apns-push-type: alert` + - `apns-priority: 10` +- Payload includes `eventType` and `sessionID` in `data`. +- Keep advanced silent/background tuning out of scope for MVP. + +### Env vars + +- `PORT` +- `DATABASE_URL` +- `APNS_TEAM_ID` +- `APNS_KEY_ID` +- `APNS_PRIVATE_KEY` +- `APNS_DEFAULT_BUNDLE_ID` (fallback) + +## Part 2: Mobile app setup (`packages/mobile-voice`) + +### Goal + +- Pair app with OpenCode server using QR data. +- Register APNs token in relay using shared secret. +- Keep existing foreground SSE behavior. +- Receive APNs when app is backgrounded or terminated. + +### Pairing flow (simple) + +1. User runs OpenCode serve with relay enabled. +2. OpenCode prints a QR code that includes: + - `hosts` (array of server URLs) + - `relayURL` + - `relaySecret` +3. User scans QR in mobile app. +4. App saves `relaySecret` in secure storage and server profile metadata. + +### Token registration flow + +1. App gets APNs token (`Notifications.getDevicePushTokenAsync()`). +2. App calls `POST {relayURL}/v1/device/register` with secret and token. +3. App re-registers on token change and on app startup. + +### Prompt and monitoring flow + +1. App sends prompt to OpenCode (`POST /session/:id/prompt_async`). +2. If app stays foregrounded, existing SSE monitor still updates UI quickly. +3. If app goes backgrounded, APNs notification from relay carries state updates. + +### Mobile changes + +- Replace Expo push relay integration with APNs relay integration. +- Keep local notification behavior for handling incoming payload data. +- Store `relaySecret` with secure storage, not plain AsyncStorage. +- Remove session-specific monitor start/stop calls for MVP. + +## Part 3: OpenCode serve setup and modifications (`packages/opencode`) + +### Goal + +- Watch all sessions for the current OpenCode server. +- Detect target events in OpenCode server. +- Forward those events to APN relay using shared secret. + +### Serve config and terminal UX + +- Add serve options: + - `--relay-url` + - `--relay-secret` (optional; generate random if missing) +- Default relay URL: `https://relay.opencode.ai` +- If relay is configured, print QR payload in terminal: + - `hosts` (local LAN and configured host, including Tailscale IP when present) + - `relayURL` + - `relaySecret` + +### New experimental routes + +- No required monitor routes for MVP. +- Optional debug route: + - `POST /experimental/push/test` + - Purpose: force-send a test event to relay to validate config. + +### Event forwarding behavior + +- Subscribe to existing OpenCode events. +- For all sessions under the running OpenCode server: + - On `permission.asked` -> send `eventType=permission` + - On `session.error` -> send `eventType=error` + - On `session.status` idle (or `session.idle`) -> send `eventType=complete` +- Include `sessionID` in every relay request so the mobile app can label the event. +- Best effort posting only for MVP (log failures, no complex retry queue yet). + +### Out of scope for this MVP + +- Certificate-based trust between OpenCode and relay. +- Complex key rotation UX. +- Multi-tenant dashboard auth model. +- Guaranteed delivery semantics.