feat: add APN relay MVP and experimental push bridge

pull/19547/head
Ryan Vogel 2026-03-28 13:28:24 -04:00
parent 8ac2fbbd12
commit f276a8db42
17 changed files with 1196 additions and 1 deletions

View File

@ -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

View File

@ -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"]

View File

@ -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
```

View File

@ -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,
},
},
})

View File

@ -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:"
}
}

View File

@ -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<string, unknown>
}
type PushResult = {
ok: boolean
code: number
error?: string
}
let jwt = ""
let exp = 0
let pk: Awaited<ReturnType<typeof importPKCS8>> | 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<PushResult> {
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,
}
}

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,5 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}

View File

@ -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<typeof evt>["eventType"]) {
if (input === "complete") return "Session complete"
if (input === "permission") return "Action needed"
return "Session error"
}
function body(input: z.infer<typeof evt>["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<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`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<number>`count(*)` }).from(device_registration)
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>APN Relay</title>
<style>
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
h1 { margin: 0 0 12px 0; }
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
.muted { color: #6b7280; font-size: 12px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>APN Relay</h1>
<p class="muted">MVP dashboard</p>
<div class="stats">
<div class="card">
<div class="muted">Registered devices</div>
<div>${Number(a?.value ?? 0)}</div>
</div>
<div class="card">
<div class="muted">Delivery log rows</div>
<div>${Number(b?.value ?? 0)}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>time</th>
<th>event</th>
<th>session</th>
<th>status</th>
<th>error</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `<tr>
<td>${new Date(row.created_at).toISOString()}</td>
<td>${row.event_type}</td>
<td>${row.session_id}</td>
<td>${row.status}</td>
<td>${row.error ?? ""}</td>
</tr>`,
)
.join("")}
</tbody>
</table>
</body>
</html>`
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

View File

@ -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),
],
)

View File

@ -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;
`)
}

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}

View File

@ -5,10 +5,20 @@ import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace" import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project/project" import { Project } from "../../project/project"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay"
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", 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", describe: "starts a headless opencode server",
handler: async (args) => { handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) { if (!Flag.OPENCODE_SERVER_PASSWORD) {
@ -18,6 +28,28 @@ export const ServeCommand = cmd({
const server = Server.listen(opts) const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) 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 new Promise(() => {})
await server.stop() await server.stop()
}, },

View File

@ -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<string, number>
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<string, unknown> {
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<string>()
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
}
}

View File

@ -12,6 +12,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace" import { WorkspaceRoutes } from "./workspace"
import { PushRelay } from "../push-relay"
export const ExperimentalRoutes = lazy(() => export const ExperimentalRoutes = lazy(() =>
new Hono() new Hono()
@ -267,5 +268,98 @@ export const ExperimentalRoutes = lazy(() =>
async (c) => { async (c) => {
return c.json(await MCP.resources()) 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,
})
},
), ),
) )

View File

@ -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.