feat: add APN relay MVP and experimental push bridge
parent
8ac2fbbd12
commit
f276a8db42
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
|
export function hash(input: string) {
|
||||||
|
return createHash("sha256").update(input).digest("hex")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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;
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "@tsconfig/bun/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"noUncheckedIndexedAccess": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue