Compare commits

...
1 Commits
Author SHA1 Message Date
Hona e24f71af29 feat(app): add opt-in PWA push notifications 2026-09-04 22:11:45 +00:00
32 changed files with 3202 additions and 6 deletions
+15
View File
@@ -693,11 +693,14 @@
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"web-push": "3.6.7",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/web-push": "3.6.4",
"@typescript/native-preview": "catalog:",
"http_ece": "1.2.0",
},
},
"packages/session-ui": {
@@ -3185,6 +3188,8 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
@@ -3351,6 +3356,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -3445,6 +3452,8 @@
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -4245,6 +4254,8 @@
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@@ -4703,6 +4714,8 @@
"miniflare": ["miniflare@4.20251118.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251118.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-uLSAE/DvOm392fiaig4LOaatxLjM7xzIniFRG5Y3yF9IduOYLLK/pkCPQNCgKQH3ou0YJRHnTN+09LPfqYNTQQ=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -5731,6 +5744,8 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
@@ -0,0 +1,194 @@
import { expect, test } from "@playwright/test"
import { createECDH, randomBytes } from "node:crypto"
import { mockOpenCodeServer } from "../utils/mock-server"
test.use({
channel: "chromium",
permissions: ["notifications"],
contextOptions: { reducedMotion: "reduce" },
colorScheme: "dark",
})
test.beforeEach(async ({ page }) => {
const directory = "/tmp/push-settings"
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_push_settings",
canonical: directory,
name: "Push settings",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
})
test("push requires opt-in and explains denied permission without contacting the server", async ({ page }, info) => {
const requests: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/push")) requests.push(request.url())
})
await page.addInitScript(() => {
Object.defineProperty(Notification, "permission", { get: () => "default" })
Object.defineProperty(Notification, "requestPermission", { value: async () => "denied" })
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Notifications", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText("Push notifications are off for this server on this browser.")
expect(requests).toEqual([])
const enable = settings.getByRole("button", { name: "Enable push", exact: true })
await expect(enable).toBeEnabled()
await enable.focus()
await page.keyboard.press("Enter")
await expect(settings.getByRole("status")).toHaveText(/Notification permission is not granted/)
await expect(settings.getByRole("button", { name: "Disable push", exact: true })).toHaveCount(0)
expect(requests).toEqual([])
await page.screenshot({ path: info.outputPath("push-denied-desktop.png") })
await page.setViewportSize({ width: 390, height: 844 })
await expect(enable).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.screenshot({ path: info.outputPath("push-denied-mobile.png") })
})
test("iOS browser explains installation before requesting permission", async ({ page }, info) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "userAgent", { value: "iPhone" })
Object.defineProperty(Notification, "requestPermission", {
value: () => {
throw new Error("No gesture without installation")
},
})
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Notifications", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText(/On iPhone or iPad, use Add to Home Screen/)
await page.setViewportSize({ width: 390, height: 844 })
const enable = settings.getByRole("button", { name: "Enable push", exact: true })
await enable.click()
await expect(settings.getByRole("status")).toHaveText(/Requires iOS or iPadOS 16.4 or later/)
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.screenshot({ path: info.outputPath("push-install-mobile.png") })
})
test("long server names wrap on mobile and older servers show an actionable error", async ({ page }, info) => {
const name = "remote-development-server-" + "long-name-without-spaces-".repeat(8)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ name, server }) => {
// Test the server capability error independently of headless browser notification policy.
Object.defineProperty(Notification, "permission", { get: () => "granted" })
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: server }, displayName: name }] }),
)
},
{ name, server },
)
await page.route("**/api/push", (route) =>
route.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" } }),
)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Notifications", exact: true }).click()
await expect(settings.getByText(`Notifications from ${name}`, { exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await settings.getByRole("button", { name: "Enable push", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText(
"This server does not support push notifications. Update the server and try again.",
)
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.screenshot({ path: info.outputPath("push-long-server-mobile.png") })
})
test.describe("push opt-in lifecycle", () => {
test("enables, updates preferences, and disables through the real settings controls", async ({ page }, info) => {
const key = createECDH("prime256v1")
key.generateKeys()
const publicKey = key.getPublicKey().toString("base64url")
const keys = { p256dh: publicKey, auth: randomBytes(16).toString("base64url") }
// Keep actual permission, worker installation, UI, and API transport. Replace only the external push service.
await page.addInitScript((keys) => {
let subscription: PushSubscription | null = null
Object.defineProperties(PushManager.prototype, {
getSubscription: { value: async () => subscription },
subscribe: {
value: async (options: PushSubscriptionOptionsInit) => {
subscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/test-browser",
expirationTime: null,
options: {
userVisibleOnly: true,
applicationServerKey: new Uint8Array(options.applicationServerKey as Uint8Array).buffer,
},
getKey: () => null,
toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/test-browser", keys }),
unsubscribe: async () => {
subscription = null
return true
},
}
return subscription
},
},
})
}, keys)
const requests: { method: string; path: string; body: Record<string, unknown> | null }[] = []
await page.route("**/api/push{,/**}", async (route) => {
const request = route.request()
const method = request.method()
if (method === "GET") return route.fulfill({ json: { publicKey } })
requests.push({
method,
path: new URL(request.url()).pathname,
body: method === "PUT" ? request.postDataJSON() : null,
})
await route.fulfill({ status: 204 })
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Notifications", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText("Push notifications are off for this server on this browser.")
expect(requests).toEqual([])
await settings.getByRole("button", { name: "Enable push", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText(
"Push notifications are enabled for this server on this browser.",
)
expect(requests[0]).toMatchObject({
method: "PUT",
path: "/api/push/subscription",
body: { keys, notifications: { agent: true, errors: false } },
})
const id = requests[0].body?.id
await page.screenshot({ path: info.outputPath("push-enabled-desktop.png") })
const errors = settings.locator('[data-action="settings-notifications-errors"]')
await errors.locator('[data-slot="switch-control"]').click()
await expect(errors.getByRole("switch")).toBeChecked()
await expect
.poll(() => requests.at(-1)?.body)
.toMatchObject({
id,
notifications: { agent: true, errors: true },
})
await expect(settings.getByRole("button", { name: "Disable push", exact: true })).toBeEnabled()
await settings.getByRole("button", { name: "Disable push", exact: true }).click()
await expect(settings.getByRole("status")).toHaveText("Push notifications are off for this server on this browser.")
expect(requests.at(-1)).toEqual({ method: "DELETE", path: `/api/push/subscription/${id}`, body: null })
await expect(settings.getByRole("button", { name: "Enable push", exact: true })).toBeEnabled()
})
})
@@ -42,6 +42,7 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
"public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2),
"public/_headers": "/*\n Cache-Control: no-cache",
"public/_redirects": "/* /index.html 200",
"public/push-sw.js": await readFile(new URL("../../public/push-sw.js", import.meta.url)),
}).map(([path, contents]) => writeFile(join(root, path), contents)),
)
await build({
@@ -182,6 +183,36 @@ async function waiting(page: Page) {
.toBe("installed")
}
fixture("per-server push workers coexist without taking over offline pages", async ({ page, context, site }) => {
await install(page, site.url)
await page.evaluate(async () => {
await navigator.serviceWorker.register("/push-sw.js", { scope: "/push/server-a/" })
await navigator.serviceWorker.register("/push-sw.js", { scope: "/push/server-b/" })
})
await expect
.poll(() =>
page.evaluate(async () =>
(await navigator.serviceWorker.getRegistrations())
.filter((registration) => new URL(registration.scope).pathname.startsWith("/push/"))
.map((registration) => ({ scope: new URL(registration.scope).pathname, state: registration.active?.state }))
.sort((a, b) => a.scope.localeCompare(b.scope)),
),
)
.toEqual([
{ scope: "/push/server-a/", state: "activated" },
{ scope: "/push/server-b/", state: "activated" },
])
expect(await page.evaluate(() => new URL(navigator.serviceWorker.controller!.scriptURL).pathname)).toBe("/sw.js")
await page.getByLabel("Draft").fill("Push registration must not reload this draft")
await expect(page.getByLabel("Draft")).toHaveValue("Push registration must not reload this draft")
await context.setOffline(true)
await page.goto(`${site.url}/server/server-a/session/ses_never_visited`)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByRole("button", { name: "Load lazy" }).click()
await expect(page.getByRole("status")).toHaveText("old nested lazy loaded")
expect(await page.evaluate(() => new URL(navigator.serviceWorker.controller!.scriptURL).pathname)).toBe("/sw.js")
})
fixture(
"opens an uncached route offline and executes never-used nested lazy chunks",
async ({ page, context, site }) => {
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { once } from "node:events"
// The stripped-down headless shell disables native notifications. Use full Chromium.
test.use({ channel: "chromium", permissions: ["notifications"] })
test("push wakes a stopped worker after every app page closes", async ({ context }) => {
const script = await readFile(new URL("../../public/push-sw.js", import.meta.url))
const server = createServer((request, response) => {
if (request.url === "/push-sw.js") {
response.writeHead(200, { "content-type": "text/javascript" }).end(script)
return
}
response.writeHead(200, { "content-type": "text/html" }).end("<!doctype html><title>Worker test setup</title>")
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
const origin = `http://127.0.0.1:${address.port}`
const scope = "/push/aHR0cDovL2xvY2FsaG9zdDo0MDk2/"
try {
// An about:blank inspector keeps the CDP connection, not an app page or event stream.
const inspector = await context.newPage()
const cdp = await context.newCDPSession(inspector)
const registrations = new Map<string, string>()
const versions = new Map<string, { versionId: string; runningStatus: string }>()
cdp.on("ServiceWorker.workerRegistrationUpdated", ({ registrations: items }) => {
items.forEach((item) => registrations.set(item.scopeURL, item.registrationId))
})
cdp.on("ServiceWorker.workerVersionUpdated", ({ versions: items }) => {
items.forEach((item) => versions.set(item.registrationId, item))
})
await cdp.send("ServiceWorker.enable")
const page = await context.newPage()
await page.goto(origin)
await page.evaluate(async (scope) => {
const registration = await navigator.serviceWorker.register("/push-sw.js", { scope })
const worker = registration.installing ?? registration.waiting ?? registration.active
if (!worker) throw new Error("Missing push worker")
if (worker.state === "activated") return
await new Promise<void>((resolve) => {
worker.addEventListener("statechange", () => {
if (worker.state === "activated") resolve()
})
})
}, scope)
await expect.poll(() => registrations.get(origin + scope)).toBeDefined()
const registrationId = registrations.get(origin + scope)!
await expect.poll(() => versions.get(registrationId)?.runningStatus).toBe("running")
await page.close()
await cdp.send("ServiceWorker.stopWorker", { versionId: versions.get(registrationId)!.versionId })
await expect.poll(() => versions.get(registrationId)?.runningStatus).toBe("stopped")
expect(context.pages().map((page) => page.url())).toEqual(["about:blank"])
const payload = {
title: "Response ready",
body: "Closed PWA integration test",
url: `${origin}/server/aHR0cDovL2xvY2FsaG9zdDo0MDk2/session/ses_push_test`,
tag: "evt_push_test",
}
// Inject at the browser's decoded-push boundary; this does not verify an external push provider.
await cdp.send("ServiceWorker.deliverPushMessage", { origin, registrationId, data: JSON.stringify(payload) })
await expect.poll(() => versions.get(registrationId)?.runningStatus).toBe("running")
const check = await context.newPage()
await check.goto(origin)
await expect
.poll(() =>
check.evaluate(async (scope) => {
const registration = await navigator.serviceWorker.getRegistration(scope)
const notifications = await registration?.getNotifications()
return notifications?.map((item) => ({
title: item.title,
body: item.body,
tag: item.tag,
data: item.data,
}))
}, scope),
)
.toEqual([{ title: payload.title, body: payload.body, tag: payload.tag, data: { url: payload.url } }])
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
})
+56
View File
@@ -0,0 +1,56 @@
// Push uses a separate, non-page scope. Do not claim clients or change the offline worker.
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()))
function destination(value) {
if (typeof value !== "string" || !URL.canParse(value)) return
const url = new URL(value)
const key = new URL(self.registration.scope).pathname.match(/^\/push\/([^/]+)\/$/)?.[1]
if (!key || url.origin !== self.location.origin || url.username || url.password) return
if (!url.pathname.startsWith(`/server/${key}/session/`)) return
if (!/^[A-Za-z0-9_-]+$/.test(url.pathname.slice(`/server/${key}/session/`.length))) return
if (url.search || url.hash) return
return url
}
self.addEventListener("push", (event) => {
// Every push must display a notification, even with a visible app (userVisibleOnly).
const payload = (() => {
try {
return event.data?.json()
} catch {
return undefined
}
})()
const valid = payload && typeof payload.title === "string" && typeof payload.body === "string"
const url = destination(payload?.url)
event.waitUntil(
self.registration.showNotification(valid ? payload.title : "OpenCode", {
body: valid ? payload.body : "",
tag: typeof payload?.tag === "string" ? payload.tag : undefined,
data: { url: url?.href },
}),
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = destination(event.notification.data?.url)
if (!url) return
event.waitUntil(
(async () => {
const prefix = url.pathname.slice(0, url.pathname.indexOf("/session/")) + "/"
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
const candidates = windows.filter((client) => {
if (!URL.canParse(client.url)) return false
const current = new URL(client.url)
return current.origin === url.origin && current.pathname.startsWith(prefix)
})
const client = candidates.find((client) => client.url === url.href) ?? candidates[0]
if (client) {
const target = client.url === url.href ? client : await client.navigate(url.href)
if (target) return target.focus()
}
return self.clients.openWindow(url.href)
})(),
)
})
+22
View File
@@ -941,6 +941,28 @@ export const dict = {
"settings.appearance.row.projectName.title": "Show project names",
"settings.appearance.row.projectName.description": "Show project names in vertical tabs and the mobile tab drawer",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.notifications.push.title": "Push notifications",
"settings.notifications.push.server": "Notifications from {{server}}",
"settings.notifications.push.description":
"Receive agent responses and errors on this browser even when OpenCode is closed. Push notifications also appear while the app is open. The server must stay running. The notification switches below apply to push too; permissions and sounds require an open app.",
"settings.notifications.push.enable": "Enable push",
"settings.notifications.push.disable": "Disable push",
"settings.notifications.push.updating": "Updating push notifications…",
"settings.notifications.push.enabled": "Push notifications are enabled for this server on this browser.",
"settings.notifications.push.disabled": "Push notifications are off for this server on this browser.",
"settings.notifications.push.insecure": "Open OpenCode over HTTPS or localhost to enable push notifications.",
"settings.notifications.push.install":
"On iPhone or iPad, use Add to Home Screen, open the installed app, then enable push notifications. Requires iOS or iPadOS 16.4 or later.",
"settings.notifications.push.unsupported":
"This browser does not support Web Push. Try a supported browser or installed web app.",
"settings.notifications.push.denied":
"Notification permission is not granted. Allow notifications in your browser or device settings, then enable push again.",
"settings.notifications.push.serverUnsupported":
"This server does not support push notifications. Update the server and try again.",
"settings.notifications.push.subscription":
"The browser subscription could not be verified or updated. Enable push again to reconnect. If disabling failed, retry to finish removing the browser subscription.",
"settings.notifications.push.failed":
"Push notifications could not be updated. Check your server connection and try again. Changes are not confirmed until the update succeeds.",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
"settings.projects.title": "Projects",
@@ -1,10 +1,15 @@
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { useGlobal } from "@/runtime/server/runtime"
import { serverName } from "@/runtime/server/registry"
import { usePlatform } from "@/runtime/platform/platform"
import { InlineServerSelect } from "@/settings/server-select"
import {
createSoundSettingsController,
soundOptions,
@@ -57,6 +62,12 @@ export const SettingsNotifications: Component = () => {
const language = useLanguage()
const settings = useSettings()
const sounds = createSoundSettingsController()
const global = useGlobal()
const platform = usePlatform()
const push = () => {
const server = global.settings.server.selected()
return server ? global.ensureServerCtx(server).notification.push : undefined
}
return (
<>
@@ -72,6 +83,62 @@ export const SettingsNotifications: Component = () => {
</div>
<div class="settings-tab-body">
<Show when={platform.platform === "web" && push()} keyed>
{(push) => (
<div class="settings-section">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h3 class="settings-section-title">{language.t("settings.notifications.push.title")}</h3>
<InlineServerSelect />
</div>
<SettingsList>
<SettingsRow
title={
<span class="break-all">
{language.t("settings.notifications.push.server", {
server: serverName(global.settings.server.selected()),
})}
</span>
}
description={language.t("settings.notifications.push.description")}
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={push.state.busy || !push.ready()}
onClick={() => void (push.state.enabled ? push.disable() : push.enable())}
>
{language.t(
push.state.enabled
? "settings.notifications.push.disable"
: "settings.notifications.push.enable",
)}
</Button>
<Show when={!push.state.enabled && (push.wanted() || push.state.issue === "subscription")}>
<Button
variant="outline"
disabled={push.state.busy || !push.ready()}
onClick={() => void push.disable()}
>
{language.t("settings.notifications.push.disable")}
</Button>
</Show>
</div>
</SettingsRow>
</SettingsList>
<div role="status" class="text-12-regular text-v2-text-text-muted break-words">
{push.state.busy
? language.t("settings.notifications.push.updating")
: push.state.issue
? language.t(`settings.notifications.push.${push.state.issue}`)
: language.t(
push.state.enabled
? "settings.notifications.push.enabled"
: "settings.notifications.push.disabled",
)}
</div>
</div>
)}
</Show>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
@@ -18,6 +18,7 @@ import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { requireServerKey, sessionHref } from "@/shell/routes/session"
import type { ServerScope } from "@/runtime/server/scope"
import { useServer } from "@/runtime/server/current"
import { createServerPush } from "./push-state"
const NotificationBase = {
directory: Schema.optional(Schema.String),
@@ -119,6 +120,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const settings = useSettings()
const language = useLanguage()
const tabs = useTabs()
const push = createServerPush(input)
const empty: Notification[] = []
const [store, setStore, _, ready] = persisted(
@@ -234,7 +236,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
session: sessionID,
})
if (settings.notifications.agent()) {
if (settings.notifications.agent() && !push?.state.enabled) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
)
@@ -262,7 +264,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
if (settings.notifications.errors() && !push?.state.enabled) {
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
)
@@ -287,6 +289,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
return {
ready,
push,
session: {
all(session: string) {
return index.session.all[session] ?? empty
@@ -0,0 +1,62 @@
import { createEffect, onCleanup, untrack } from "solid-js"
import { Schema } from "effect"
import { base64Encode } from "@opencode-ai/util/encode"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import type { ServerSDK } from "@/runtime/server/client"
import type { ServerConnection } from "@/runtime/server/registry"
import { useSettings } from "@/settings/model"
import { sessionHref } from "@/shell/routes/session"
import { browserPush, createPushController } from "./push"
export function createServerPush(input: { sdk: ServerSDK; key: ServerConnection.Key }) {
const platform = usePlatform()
if (platform.platform !== "web") return
const settings = useSettings()
const language = useLanguage()
const [saved, setSaved, _, ready] = persisted(
{ ...Persist.serverGlobal(input.sdk.scope, "push"), sync: true },
Persistence.struct({ id: Schema.String, enabled: Schema.Boolean }),
{ id: "", enabled: false },
)
const preferences = () => ({
notifications: { agent: settings.notifications.agent(), errors: settings.notifications.errors() },
titles: {
agent: language.t("notification.session.responseReady.title"),
errors: language.t("notification.session.error.title"),
},
})
const push = createPushController({
api: () => input.sdk.api.push,
browser: browserPush(),
scope: `/push/${base64Encode(input.key)}/`,
id: () => {
if (!saved.id) setSaved("id", crypto.randomUUID())
return saved.id
},
url: new URL(sessionHref(input.key, ""), location.origin).href,
preferences,
wanted: () => saved.enabled,
save: (enabled) => setSaved("enabled", enabled),
})
createEffect(() => {
if (!ready() || !settings.ready()) return
preferences()
saved.enabled
// A successful PUT confirms persisted intent; intent alone never disables local notifications.
untrack(() => void push.refresh())
})
const visible = () => {
if (document.visibilityState === "visible") void push.refresh()
}
document.addEventListener("visibilitychange", visible)
window.addEventListener("online", visible)
onCleanup(() => {
document.removeEventListener("visibilitychange", visible)
window.removeEventListener("online", visible)
})
return { ...push, wanted: () => saved.enabled, ready }
}
@@ -0,0 +1,136 @@
import { expect, test } from "bun:test"
import { runInNewContext } from "node:vm"
const script = await Bun.file(new URL("../../../public/push-sw.js", import.meta.url)).text()
const url = "https://app.example/server/server-a/session/ses_123"
type WindowClient = { url: string; focus(): Promise<unknown>; navigate(url: string): Promise<WindowClient | null> }
function worker(windows: WindowClient[] = []) {
const handlers = new Map<string, (event: object) => void>()
const shown: { title: string; options: NotificationOptions }[] = []
const opened: string[] = []
const pending: Promise<unknown>[] = []
runInNewContext(script, {
URL,
self: {
location: { origin: "https://app.example" },
registration: {
scope: "https://app.example/push/server-a/",
showNotification: async (title: string, options: NotificationOptions) => {
shown.push({ title, options })
},
},
addEventListener: (name: string, handler: (event: object) => void) => handlers.set(name, handler),
skipWaiting: async () => {},
clients: {
matchAll: async (options: object) => {
expect(options).toEqual({ type: "window", includeUncontrolled: true })
return windows
},
openWindow: async (url: string) => {
opened.push(url)
},
},
},
})
return {
shown,
opened,
async send(name: string, event: object) {
handlers.get(name)!({ ...event, waitUntil: (promise: Promise<unknown>) => pending.push(promise) })
await Promise.all(pending.splice(0))
},
}
}
test("push displays payload even if a window is already open", async () => {
const sw = worker()
await sw.send("push", {
data: { json: () => ({ title: "Response ready", body: "Session title", url, tag: "evt_1" }) },
})
expect(sw.shown).toEqual([
{ title: "Response ready", options: { body: "Session title", tag: "evt_1", data: { url } } },
])
})
test("malformed or empty push is still user-visible, not a silent background fetch", async () => {
const sw = worker()
await sw.send("push", {
data: {
json: () => {
throw new Error("invalid JSON")
},
},
})
await sw.send("push", {})
expect(sw.shown.map((notification) => notification.title)).toEqual(["OpenCode", "OpenCode"])
expect(sw.shown.every((notification) => notification.options.data.url === undefined)).toBe(true)
})
test("worker rejects cross-origin, cross-server and non-session notification URLs", async () => {
const sw = worker()
for (const unsafe of [
"https://evil.example/server/server-a/session/ses_123",
"https://app.example/server/server-b/session/ses_123",
"https://app.example/settings",
"https://app.example/server/server-a/session/../settings",
"https://app.example/server/server-a/session/a%2Fb",
`${url}?token=secret`,
`https://user:password@app.example/server/server-a/session/ses_123`,
]) {
await sw.send("push", { data: { json: () => ({ title: "Done", body: "Title", url: unsafe }) } })
expect(sw.shown.at(-1)?.options.data.url).toBeUndefined()
await sw.send("notificationclick", { notification: { data: { url: unsafe }, close: () => {} } })
}
expect(sw.opened).toEqual([])
})
test("click navigates and focuses an existing same-server window, never another server", async () => {
const calls: string[] = []
const current: WindowClient = {
url: "https://app.example/server/server-a/session/ses_old",
focus: async () => {
calls.push("focus")
},
navigate: async (value) => {
calls.push(value)
return current
},
}
const other: WindowClient = {
url: "https://app.example/server/server-b/session/ses_other",
focus: async () => {
throw new Error("wrong server")
},
navigate: async () => {
throw new Error("wrong server")
},
}
const sw = worker([other, current])
await sw.send("notificationclick", { notification: { data: { url }, close: () => calls.push("close") } })
expect(calls).toEqual(["close", url, "focus"])
expect(sw.opened).toEqual([])
})
test("click prefers the exact session without reloading it", async () => {
const calls: string[] = []
const current: WindowClient = {
url,
focus: async () => {
calls.push("focus")
},
navigate: async () => {
throw new Error("unnecessary navigation")
},
}
const sw = worker([current])
await sw.send("notificationclick", { notification: { data: { url }, close: () => {} } })
expect(calls).toEqual(["focus"])
})
test("click opens the session when the app is closed", async () => {
const sw = worker()
await sw.send("notificationclick", { notification: { data: { url }, close: () => {} } })
expect(sw.opened).toEqual([url])
})
@@ -0,0 +1,303 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer } from "@/runtime/server/api"
import { createPushController, pushSupport, type PushBrowser } from "./push"
const key = new Uint8Array([1, 2, 3]).buffer
function fixture(options: { scope?: string; id?: string; browser?: PushBrowser } = {}) {
const calls: string[] = []
const requests: { path: string; method: string; body: unknown; authorization: string | null }[] = []
const state = {
wanted: false,
permission: "default" as NotificationPermission,
granted: "granted" as NotificationPermission,
status: 204,
getStatus: 200,
deleteStatus: 204,
applicationServerKey: key,
notifications: { agent: true, errors: false },
titles: { agent: "Response ready", errors: "Session failed" },
unsubscribe: true,
subscription: false,
registered: false,
}
const subscription = {
endpoint: `https://push.example/${options.id ?? "browser"}`,
options: {
userVisibleOnly: true,
get applicationServerKey() {
return state.applicationServerKey
},
},
toJSON: () => ({ keys: { p256dh: "p256dh", auth: "auth" } }),
unsubscribe: async () => {
calls.push("browser-unsubscribe")
if (state.unsubscribe) state.subscription = false
return state.unsubscribe
},
}
const registration = {
pushManager: {
getSubscription: async () => (state.subscription ? subscription : null),
subscribe: async (input: PushSubscriptionOptionsInit) => {
calls.push("browser-subscribe")
expect(input.userVisibleOnly).toBe(true)
expect(input.applicationServerKey).toEqual(new Uint8Array(key))
state.subscription = true
state.applicationServerKey = key
return subscription
},
},
}
const browser: PushBrowser = options.browser ?? {
issue: () => undefined,
permission: () => state.permission,
requestPermission: () => {
calls.push("permission")
state.permission = state.granted
return Promise.resolve(state.permission)
},
registration: async (scope) => {
calls.push(`lookup:${scope}`)
return state.registered ? registration : undefined
},
register: async (scope) => {
calls.push(`register:${scope}`)
state.registered = true
return registration
},
}
const api = createApiForServer({
server: { url: "https://server.example", password: "test-password" },
fetch: Object.assign(
async (url: URL | RequestInfo, init?: RequestInit) => {
calls.push(init?.method ?? "GET")
const path = new URL(String(url)).pathname
requests.push({
path,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined,
authorization: new Headers(init?.headers).get("Authorization"),
})
if (init?.method === "GET") return Response.json({ publicKey: "AQID" }, { status: state.getStatus })
return new Response(null, { status: init?.method === "DELETE" ? state.deleteStatus : state.status })
},
{ preconnect() {} },
),
})
const make = () =>
createPushController({
api: () => api.push,
browser,
scope: options.scope ?? "/push/server-a/",
id: () => options.id ?? "browser-id",
url: "https://app.example/server/server-a/session/",
preferences: () => ({ notifications: { ...state.notifications }, titles: { ...state.titles } }),
wanted: () => state.wanted,
save: (enabled) => {
state.wanted = enabled
},
})
return { push: make(), make, state, calls, requests, browser, registration }
}
describe("push subscription lifecycle", () => {
test("requests permission synchronously and registers through authenticated API before enabling", async () => {
const f = fixture()
const enable = f.push.enable()
expect(f.calls).toEqual(["permission"])
expect(f.push.state.enabled).toBe(false)
await enable
expect(f.push.state.enabled).toBe(true)
expect(f.state.wanted).toBe(true)
expect(f.requests.map((request) => [request.path, request.method])).toEqual([
["/api/push", "GET"],
["/api/push/subscription", "PUT"],
])
expect(f.requests.every((request) => request.authorization === `Basic ${btoa("opencode:test-password")}`)).toBe(
true,
)
expect(f.requests[1].body).toEqual({
id: "browser-id",
endpoint: "https://push.example/browser",
keys: { p256dh: "p256dh", auth: "auth" },
url: "https://app.example/server/server-a/session/",
notifications: { agent: true, errors: false },
titles: { agent: "Response ready", errors: "Session failed" },
})
})
test("denied permission never registers or contacts a server", async () => {
const f = fixture()
f.state.granted = "denied"
await f.push.enable()
expect(f.push.state).toMatchObject({ enabled: false, busy: false, issue: "denied" })
expect(f.calls).toEqual(["permission"])
expect(f.state.wanted).toBe(false)
})
test("unsupported browsers never request permission or contact the server", async () => {
const base = fixture()
const f = fixture({ browser: { ...base.browser, issue: () => "unsupported" } })
await f.push.enable()
expect(f.push.state).toMatchObject({ enabled: false, issue: "unsupported" })
expect(f.requests).toEqual([])
expect(base.calls).toEqual([])
})
test("a failed PUT does not enable push or persist opt-in", async () => {
const f = fixture()
f.state.status = 503
await f.push.enable()
expect(f.push.state).toMatchObject({ enabled: false, issue: "failed" })
expect(f.state.wanted).toBe(false)
f.state.status = 204
await f.push.enable()
expect(f.push.state.enabled).toBe(true)
expect(f.calls.filter((call) => call === "browser-subscribe")).toHaveLength(1)
})
test("old servers show unsupported without creating a browser subscription", async () => {
const f = fixture()
f.state.getStatus = 404
await f.push.enable()
expect(f.push.state.issue).toBe("serverUnsupported")
expect(f.state.subscription).toBe(false)
})
test("restore verifies subscription and PUT; stale saved opt-in alone never enables", async () => {
const f = fixture()
f.state.wanted = true
f.state.permission = "granted"
await f.push.refresh()
expect(f.push.state.enabled).toBe(false)
expect(f.push.state.issue).toBe("subscription")
expect(f.calls).not.toContain("permission")
await f.push.enable()
const reopened = f.make()
expect(reopened.state.enabled).toBe(false)
await reopened.refresh()
expect(reopened.state.enabled).toBe(true)
f.state.status = 503
await reopened.refresh()
expect(reopened.state.enabled).toBe(false)
})
test("refresh sends current categories and localized titles without asking permission", async () => {
const f = fixture()
await f.push.enable()
f.state.notifications = { agent: false, errors: true }
f.state.titles = { agent: "Antwort bereit", errors: "Sitzungsfehler" }
await f.push.refresh()
expect(f.requests.at(-1)?.body).toMatchObject({ notifications: f.state.notifications, titles: f.state.titles })
expect(f.calls.filter((call) => call === "permission")).toHaveLength(1)
})
test("a synchronized opt-out restores local notifications without recreating the registration", async () => {
const f = fixture()
await f.push.enable()
f.calls.length = 0
f.state.wanted = false
await f.push.refresh()
expect(f.push.state.enabled).toBe(false)
expect(f.calls).toEqual([])
})
test("disable removes server registration before browser unsubscribe", async () => {
const f = fixture()
await f.push.enable()
f.calls.length = 0
await f.push.disable()
expect(f.calls).toEqual(["DELETE", "lookup:/push/server-a/", "browser-unsubscribe"])
expect(f.requests.at(-1)?.path).toBe("/api/push/subscription/browser-id")
expect(f.push.state.enabled).toBe(false)
expect(f.state.wanted).toBe(false)
})
test("failed DELETE preserves the live subscription and enabled state", async () => {
const f = fixture()
await f.push.enable()
f.state.deleteStatus = 503
await f.push.disable()
expect(f.push.state).toMatchObject({ enabled: true, issue: "failed" })
expect(f.state.wanted).toBe(true)
expect(f.state.subscription).toBe(true)
expect(f.calls).not.toContain("browser-unsubscribe")
})
test("failed browser removal reports the failure even after server deletion", async () => {
const f = fixture()
await f.push.enable()
f.state.unsubscribe = false
await f.push.disable()
expect(f.push.state).toMatchObject({ enabled: false, issue: "subscription" })
expect(f.state.wanted).toBe(false)
f.state.unsubscribe = true
await f.push.disable()
expect(f.state.subscription).toBe(false)
expect(f.push.state.issue).toBeUndefined()
})
test("rotates stale VAPID keys only after deleting the old registration", async () => {
const f = fixture()
await f.push.enable()
f.state.applicationServerKey = new Uint8Array([4, 5, 6]).buffer
f.calls.length = 0
await f.push.refresh()
expect(f.calls).toEqual([
"GET",
"lookup:/push/server-a/",
"DELETE",
"browser-unsubscribe",
"browser-subscribe",
"PUT",
])
expect(f.push.state.enabled).toBe(true)
})
test("does not swallow VAPID rotation failures", async () => {
const f = fixture()
await f.push.enable()
f.state.applicationServerKey = new Uint8Array([4, 5, 6]).buffer
f.state.deleteStatus = 503
await f.push.refresh()
expect(f.push.state).toMatchObject({ enabled: false, issue: "failed" })
expect(f.calls).not.toContain("browser-unsubscribe")
})
test("keeps separate worker registrations for multiple servers", async () => {
const first = fixture({ scope: "/push/server-a/", id: "id-a" })
const second = fixture({ scope: "/push/server-b/", id: "id-b" })
const registrations = new Map([
["/push/server-a/", first.registration],
["/push/server-b/", second.registration],
])
const scopes: string[] = []
const browser: PushBrowser = {
...first.browser,
permission: () => "granted",
register: async (scope) => {
scopes.push(scope)
return registrations.get(scope)!
},
registration: async (scope) => registrations.get(scope),
}
const a = fixture({ scope: "/push/server-a/", id: "id-a", browser })
const b = fixture({ scope: "/push/server-b/", id: "id-b", browser })
await Promise.all([a.push.enable(), b.push.enable()])
expect(scopes).toEqual(["/push/server-a/", "/push/server-b/"])
await a.push.disable()
expect(b.push.state.enabled).toBe(true)
expect(second.state.subscription).toBe(true)
expect(first.state.subscription).toBe(false)
})
})
test("push support requires secure context, browser APIs, and an installed iOS app", () => {
const supported = { secure: true, supported: true, ios: false, installed: false }
expect(pushSupport(supported)).toBeUndefined()
expect(pushSupport({ ...supported, secure: false })).toBe("insecure")
expect(pushSupport({ ...supported, supported: false })).toBe("unsupported")
expect(pushSupport({ ...supported, ios: true, supported: false })).toBe("install")
expect(pushSupport({ ...supported, ios: true, installed: true })).toBeUndefined()
})
@@ -0,0 +1,213 @@
import { createStore } from "solid-js/store"
import type { ServerApi } from "@/runtime/server/api"
import { isStandalone } from "@/runtime/platform/pwa"
export type PushIssue =
| "insecure"
| "install"
| "unsupported"
| "denied"
| "serverUnsupported"
| "subscription"
| "failed"
type Subscription = Pick<PushSubscription, "endpoint" | "options" | "toJSON" | "unsubscribe">
type Registration = {
pushManager: {
getSubscription(): Promise<Subscription | null>
subscribe(options: PushSubscriptionOptionsInit): Promise<Subscription>
}
}
export type PushBrowser = {
issue(): PushIssue | undefined
permission(): NotificationPermission
requestPermission(): Promise<NotificationPermission>
registration(scope: string): Promise<Registration | undefined>
register(scope: string): Promise<Registration>
}
export function pushSupport(input: {
secure: boolean
supported: boolean
ios: boolean
installed: boolean
}): PushIssue | undefined {
if (!input.secure) return "insecure"
if (input.ios && !input.installed) return "install"
if (!input.supported) return "unsupported"
}
export function browserPush(): PushBrowser {
return {
issue: () =>
pushSupport({
secure: window.isSecureContext,
supported: "serviceWorker" in navigator && "PushManager" in window && "Notification" in window,
ios:
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1),
installed: isStandalone(),
}),
permission: () => Notification.permission,
requestPermission: () => Notification.requestPermission(),
registration: async (scope) => {
const registration = await navigator.serviceWorker.getRegistration(scope)
// getRegistration can otherwise return the unrelated root offline worker.
if (registration?.scope === new URL(scope, location.origin).href) return registration
},
register: async (scope) => {
const registration = await navigator.serviceWorker.register("/push-sw.js", { scope })
if (registration.active) return registration
const worker = registration.installing ?? registration.waiting
if (!worker) throw new Error("Push worker was not installed")
await new Promise<void>((resolve, reject) => {
const changed = () => {
if (worker.state !== "activated" && worker.state !== "redundant") return
worker.removeEventListener("statechange", changed)
if (worker.state === "activated") resolve()
if (worker.state === "redundant") reject(new Error("Push worker installation failed"))
}
worker.addEventListener("statechange", changed)
changed()
})
return registration
},
}
}
export function createPushController(input: {
api: () => ServerApi["push"]
browser: PushBrowser
scope: string
id: () => string
url: string
preferences: () => {
notifications: { agent: boolean; errors: boolean }
titles: { agent: string; errors: string }
}
wanted: () => boolean
save: (enabled: boolean) => void
}) {
const [state, setState] = createStore({
enabled: false,
busy: false,
issue: input.browser.issue(),
})
const pending = { refresh: false }
async function subscribe(explicit: boolean) {
const config = await input
.api()
.get()
.catch((error: unknown) => {
// The generated client exposes HTTP failures through Error.cause.
const cause = error instanceof Error ? error.cause : error
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined
if (status === 404 || status === 405) throw new Error("serverUnsupported")
throw error
})
const key = Uint8Array.from(atob(config.publicKey.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))
const existing = await input.browser.registration(input.scope)
const registration = explicit ? await input.browser.register(input.scope) : existing
if (!registration) throw new Error("subscription")
const current = await registration.pushManager.getSubscription()
if (!current && !explicit) throw new Error("subscription")
const oldKey = current?.options.applicationServerKey
const matches =
oldKey && new Uint8Array(oldKey).length === key.length && new Uint8Array(oldKey).every((b, i) => b === key[i])
if (current && !matches) {
// Remove the old server registration before replacing a stale VAPID subscription.
await input.api().unsubscribe({ id: input.id() })
setState("enabled", false)
if (!(await current.unsubscribe())) throw new Error("subscription")
}
const subscription =
current && matches
? current
: await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: key,
})
const keys = subscription.toJSON().keys
if (!keys?.p256dh || !keys.auth) throw new Error("subscription")
await input.api().subscribe({
id: input.id(),
endpoint: subscription.endpoint,
keys: { p256dh: keys.p256dh, auth: keys.auth },
url: input.url,
...input.preferences(),
})
input.save(true)
setState("enabled", true)
}
function fail(error: unknown) {
const message = error instanceof Error ? error.message : undefined
setState("issue", message === "serverUnsupported" || message === "subscription" ? message : "failed")
}
async function run(action: () => Promise<void>) {
setState({ busy: true, issue: undefined })
await action().catch(fail)
setState("busy", false)
if (!pending.refresh) return
pending.refresh = false
await refresh()
}
function enable() {
if (state.busy) return Promise.resolve()
const issue = input.browser.issue()
if (issue) {
setState("issue", issue)
return Promise.resolve()
}
// This must run synchronously in the click handler, before registration/API awaits (iOS).
const permission =
input.browser.permission() === "default"
? input.browser.requestPermission()
: Promise.resolve(input.browser.permission())
return run(async () => {
if ((await permission) !== "granted") {
setState({ enabled: false, issue: "denied" })
return
}
await subscribe(true)
})
}
function refresh() {
if (state.busy) {
pending.refresh = true
return Promise.resolve()
}
if (!input.wanted()) {
setState("enabled", false)
return Promise.resolve()
}
const issue = input.browser.issue() ?? (input.browser.permission() === "granted" ? undefined : "denied")
if (issue) {
setState({ enabled: false, issue })
return Promise.resolve()
}
return run(async () => {
await subscribe(false).catch((error: unknown) => {
setState("enabled", false)
throw error
})
})
}
function disable() {
if (state.busy) return Promise.resolve()
return run(async () => {
await input.api().unsubscribe({ id: input.id() })
input.save(false)
setState("enabled", false)
const registration = await input.browser.registration(input.scope)
const subscription = await registration?.pushManager.getSubscription()
if (subscription && !(await subscription.unsubscribe())) throw new Error("subscription")
})
}
return { state, enable, disable, refresh }
}
+27
View File
@@ -1984,6 +1984,32 @@ export interface WorkspaceApi<E = never> {
readonly destroy: WorkspaceDestroyOperation<E>
}
export type PushGetOutput = { readonly publicKey: string }
export type PushGetOperation<E = never> = () => Effect.Effect<PushGetOutput, E>
export type PushSubscribeInput = {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}
export type PushSubscribeOutput = void
export type PushSubscribeOperation<E = never> = (input: PushSubscribeInput) => Effect.Effect<PushSubscribeOutput, E>
export type PushUnsubscribeInput = { readonly id: string }
export type PushUnsubscribeOutput = void
export type PushUnsubscribeOperation<E = never> = (
input: PushUnsubscribeInput,
) => Effect.Effect<PushUnsubscribeOutput, E>
export interface PushApi<E = never> {
readonly get: PushGetOperation<E>
readonly subscribe: PushSubscribeOperation<E>
readonly unsubscribe: PushUnsubscribeOperation<E>
}
export type VcsGetInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -2121,6 +2147,7 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly workspace: WorkspaceApi<E>
readonly push: PushApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
@@ -250,6 +250,11 @@ import type {
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
PushGetOutput,
PushSubscribeInput,
PushSubscribeOutput,
PushUnsubscribeInput,
PushUnsubscribeOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -1508,6 +1513,34 @@ const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({
destroy: EndpointWorkspaceDestroy(raw),
})
const EndpointPushGet = (raw: RawClient["server.push"]) => () =>
preserveEffect<PushGetOutput>()(raw["push.get"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointPushSubscribe = (raw: RawClient["server.push"]) => (input: PushSubscribeInput) =>
preserveEffect<PushSubscribeOutput>()(
raw["push.subscribe"]({
payload: {
id: input["id"],
endpoint: input["endpoint"],
keys: input["keys"],
url: input["url"],
notifications: input["notifications"],
titles: input["titles"],
},
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPushUnsubscribe = (raw: RawClient["server.push"]) => (input: PushUnsubscribeInput) =>
preserveEffect<PushUnsubscribeOutput>()(
raw["push.unsubscribe"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPush = (raw: RawClient["server.push"]) => ({
get: EndpointPushGet(raw),
subscribe: EndpointPushSubscribe(raw),
unsubscribe: EndpointPushUnsubscribe(raw),
})
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1615,6 +1648,7 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
workspace: adaptGroupWorkspace(raw["server.workspace"]),
push: adaptGroupPush(raw["server.push"]),
vcs: adaptGroupVcs(raw["server.vcs"]),
debug: adaptGroupDebug(raw["server.debug"]),
migration: adaptGroupMigration(raw["server.migration"]),
@@ -246,6 +246,11 @@ import type {
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
PushGetOutput,
PushSubscribeInput,
PushSubscribeOutput,
PushUnsubscribeInput,
PushUnsubscribeOutput,
VcsGetInput,
VcsGetOutput,
VcsBaseInput,
@@ -2046,6 +2051,43 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
push: {
get: (requestOptions?: RequestOptions) =>
request<PushGetOutput>(
{ method: "GET", path: `/api/push`, successStatus: 200, declaredStatuses: [400, 401, 503], empty: false },
requestOptions,
),
subscribe: (input: PushSubscribeInput, requestOptions?: RequestOptions) =>
request<PushSubscribeOutput>(
{
method: "PUT",
path: `/api/push/subscription`,
body: {
id: input["id"],
endpoint: input["endpoint"],
keys: input["keys"],
url: input["url"],
notifications: input["notifications"],
titles: input["titles"],
},
successStatus: 204,
declaredStatuses: [400, 401, 403, 503],
empty: true,
},
requestOptions,
),
unsubscribe: (input: PushUnsubscribeInput, requestOptions?: RequestOptions) =>
request<PushUnsubscribeOutput>(
{
method: "DELETE",
path: `/api/push/subscription/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
@@ -6145,6 +6145,65 @@ export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspace
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
export type PushGetOutput = { publicKey: string }
export type PushSubscribeInput = {
readonly id: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["id"]
readonly endpoint: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["endpoint"]
readonly keys: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["keys"]
readonly url: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["url"]
readonly notifications: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["notifications"]
readonly titles: {
readonly id: string
readonly endpoint: string
readonly keys: { readonly p256dh: string; readonly auth: string }
readonly url: string
readonly notifications: { readonly agent: boolean; readonly errors: boolean }
readonly titles: { readonly agent: string; readonly errors: string }
}["titles"]
}
export type PushSubscribeOutput = void
export type PushUnsubscribeInput = { readonly id: { readonly id: string }["id"] }
export type PushUnsubscribeOutput = void
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+2
View File
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
"reference",
"worktree",
"workspace",
"push",
"vcs",
"debug",
"migration",
@@ -47,6 +48,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.push)).toEqual(["get", "subscribe", "unsubscribe"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
+251
View File
@@ -12413,6 +12413,254 @@
"summary": "Destroy workspace"
}
},
"/api/push": {
"get": {
"tags": ["push"],
"operationId": "v2.push.get",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"publicKey": {
"type": "string"
}
},
"required": ["publicKey"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Get the Web Push public key"
}
},
"/api/push/subscription": {
"put": {
"tags": ["push"],
"operationId": "v2.push.subscribe",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Register or update a browser push subscription",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"endpoint": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"keys": {
"type": "object",
"properties": {
"p256dh": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{87}$"
},
"auth": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{22}$"
}
},
"required": ["p256dh", "auth"],
"additionalProperties": false
},
"url": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"notifications": {
"type": "object",
"properties": {
"agent": {
"type": "boolean"
},
"errors": {
"type": "boolean"
}
},
"required": ["agent", "errors"],
"additionalProperties": false
},
"titles": {
"type": "object",
"properties": {
"agent": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"errors": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": ["agent", "errors"],
"additionalProperties": false
}
},
"required": ["id", "endpoint", "keys", "url", "notifications", "titles"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/push/subscription/{id}": {
"delete": {
"tags": ["push"],
"operationId": "v2.push.unsubscribe",
"parameters": [
{
"name": "id",
"in": "path",
"schema": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
}
},
"summary": "Remove a browser push subscription"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -19744,6 +19992,9 @@
"name": "workspace",
"description": "Workspace lifecycle routes."
},
{
"name": "push"
},
{
"name": "vcs",
"description": "Location-scoped version control routes."
+3
View File
@@ -35,6 +35,7 @@ import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
import { WorkspaceGroup } from "./groups/workspace.js"
import { PushGroup } from "./groups/push.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -90,6 +91,7 @@ type ApiGroups<
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof WorkspaceGroup
| typeof PushGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
| LocationGroups<LocationId>
@@ -178,6 +180,7 @@ const makeApiFromGroup = <
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(WorkspaceGroup)
.add(PushGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(MigrationGroup)
+1
View File
@@ -63,6 +63,7 @@ export const groupNames = {
"server.project": "project",
"server.worktree": "worktree",
"server.workspace": "workspace",
"server.push": "push",
"server.vcs": "vcs",
"server.config": "config",
} as const
+51
View File
@@ -0,0 +1,51 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
export const PushSubscriptionID = Schema.String.check(Schema.isUUID())
export const PushSubscription = Schema.Struct({
id: PushSubscriptionID,
endpoint: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(2048)),
keys: Schema.Struct({
p256dh: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{87}$/)),
auth: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{22}$/)),
}),
url: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(1024)),
notifications: Schema.Struct({ agent: Schema.Boolean, errors: Schema.Boolean }),
titles: Schema.Struct({
agent: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)),
errors: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(100)),
}),
})
export type PushSubscription = typeof PushSubscription.Type
export const PushGroup = HttpApiGroup.make("server.push")
.add(
HttpApiEndpoint.get("push.get", "/api/push", {
success: Schema.Struct({ publicKey: Schema.String }),
error: ServiceUnavailableError,
}).annotateMerge(OpenApi.annotations({ identifier: "v2.push.get", summary: "Get the Web Push public key" })),
)
.add(
HttpApiEndpoint.put("push.subscribe", "/api/push/subscription", {
payload: PushSubscription,
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, ForbiddenError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.push.subscribe",
summary: "Register or update a browser push subscription",
}),
),
)
.add(
HttpApiEndpoint.delete("push.unsubscribe", "/api/push/subscription/:id", {
params: { id: PushSubscriptionID },
success: HttpApiSchema.NoContent,
error: ForbiddenError,
}).annotateMerge(
OpenApi.annotations({ identifier: "v2.push.unsubscribe", summary: "Remove a browser push subscription" }),
),
)
.annotateMerge(OpenApi.annotations({ title: "push" }))
+37
View File
@@ -0,0 +1,37 @@
# PWA notifications
Web Push delivers response-ready and session-error notifications without keeping an OpenCode browser tab open. The OpenCode server sends an encrypted payload to the browser's push service, which wakes a service worker on the device. No Electron wrapper or separate OpenCode notification relay is required.
## Enable
1. Open the web app over HTTPS, or localhost for development, and connect to an updated OpenCode server.
2. On iPhone or iPad, add the app to the Home Screen and open that installed app. Web Push requires iOS/iPadOS 16.4 or newer.
3. Open **Settings → Notifications**, select the server, and choose **Enable push**. Grant the browser's notification permission when prompted.
4. Keep the OpenCode server running and connected to the internet. The PWA can close; the machine running the server cannot sleep or shut down and still send new notifications.
The response-ready and error switches determine which events are sent. Each browser installation subscribes separately to each server. Enabling push requests permission directly from the button press; it never creates a push subscription just because an agent finishes.
Background notifications can also appear while the app is open. Browser push subscriptions require visible notifications; this is not a silent background polling mechanism. The app avoids also emitting its ordinary local notification for the same subscribed server. Desktop notifications are unchanged.
## Security and privacy
- Subscription registration and removal use the server's existing API authentication. This follows the server's existing single-owner trust boundary, not a new multi-user account system.
- Changing the server password, or enabling or disabling server authentication, revokes existing push registrations on the next server startup. Register notifications again using the current credentials. The server's VAPID public key remains unchanged.
- VAPID private keys remain on the server. The browser receives only the public key. The push worker does not retain server credentials or fetch authenticated session data.
- Notification payloads contain the session title and a link to the session, encrypted for the subscription. They do not contain the response text, prompts, or tool output. Device notification previews may expose the title on a lock screen.
- Outbound delivery is restricted to supported browser push-service HTTPS endpoints, not arbitrary subscriber-provided URLs. Notification clicks are restricted to OpenCode session routes on the app's own origin.
## Limits and verification
Delivery depends on the browser push service, device connectivity, OS notification settings, and Focus/Do Not Disturb. A successful server request means the push service accepted the message, not that the device displayed it. This is a notification channel, not a durable task-completion log.
- Up to 100 subscriptions are retained per server. Expired subscriptions are removed when the provider returns HTTP 404 or 410.
- Delivery uses a 128-event in-memory queue, four concurrent requests per event, a 10-second request deadline, and a one-hour push-service TTL. A full queue drops new notifications rather than slowing session execution.
- Failed deliveries are not retried, and unsent notifications do not survive a server restart. Session results remain available normally in OpenCode.
- Session titles are limited to 200 characters in notification bodies. Permission prompts and sounds still require an open app.
To check a real installation, enable notifications, start a session on that server, close all OpenCode tabs before the response completes, and confirm the notification arrives. Clicking it should open the matching server and session. Repeat with two configured servers, then disable notifications for one and confirm the other remains subscribed.
Automated service-worker tests do not establish Apple Push Notification service or Firebase delivery on a physical device. Test the closed-PWA flow on each supported target browser before treating its end-to-end delivery as verified.
The notification worker is separate from the app's existing offline-cache worker. Enabling push must not replace the app's caching or update lifecycle.
+5 -2
View File
@@ -33,11 +33,14 @@
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
"effect": "catalog:",
"web-push": "3.6.7"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
"@types/web-push": "3.6.4",
"@typescript/native-preview": "catalog:",
"http_ece": "1.2.0"
}
}
+2
View File
@@ -32,6 +32,7 @@ import { EventFeed } from "./event-feed"
import { MigrationHandler } from "./handlers/migration"
import { ConfigHandler } from "./handlers/config"
import { WorkspaceHandler } from "./handlers/workspace"
import { PushHandler } from "./handlers/push"
export const handlers = Layer.mergeAll(
HealthHandler,
@@ -64,6 +65,7 @@ export const handlers = Layer.mergeAll(
ReferenceHandler,
WorktreeHandler,
WorkspaceHandler,
PushHandler,
VcsHandler,
ConfigHandler,
)
+30
View File
@@ -0,0 +1,30 @@
import { ForbiddenError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
import { Push } from "../push"
export const PushHandler = HttpApiBuilder.group(Api, "server.push", (handlers) =>
Effect.gen(function* () {
const push = yield* Push.Service
const cors = yield* CorsConfig
const checkOrigin = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
if (!isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors))
yield* new ForbiddenError({ message: "Invalid Web Push request origin" })
})
return handlers
.handle("push.get", () => push.get)
.handle("push.subscribe", (request) =>
checkOrigin.pipe(Effect.andThen(push.subscribe(request.payload)), Effect.as(HttpApiSchema.NoContent.make())),
)
.handle("push.unsubscribe", (request) =>
checkOrigin.pipe(
Effect.andThen(push.unsubscribe(request.params.id)),
Effect.as(HttpApiSchema.NoContent.make()),
),
)
}),
)
+248
View File
@@ -0,0 +1,248 @@
export * as Push from "./push"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PushSubscription } from "@opencode-ai/protocol/groups/push"
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import type { Event } from "@opencode-ai/schema/event"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, Option, Queue, Schema, Semaphore } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { createHmac } from "node:crypto"
const Prefix = "web-push:v1:subscription:"
const Key = "web-push:v1:key"
const AuthKey = "web-push:v1:authorization"
export const SubscriptionLimit = 100
export const QueueCapacity = 128
export const DeliveryTimeout = "10 seconds"
export const TTL = 3600
const Vapid = Schema.Struct({ publicKey: Schema.String, privateKey: Schema.String })
const Authorization = Schema.Struct({ salt: Schema.String, fingerprint: Schema.String })
type Terminal = typeof SessionEvent.Execution.Succeeded.Type | typeof SessionEvent.Execution.Failed.Type
const isTerminal = (event: Event.Payload): event is Terminal =>
event.type === SessionEvent.Execution.Succeeded.type || event.type === SessionEvent.Execution.Failed.type
export interface Interface {
readonly get: Effect.Effect<{ publicKey: string }, ServiceUnavailableError>
readonly subscribe: (input: PushSubscription) => Effect.Effect<void, InvalidRequestError | ServiceUnavailableError>
readonly unsubscribe: (id: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/server/Push") {}
export function validEndpoint(input: string) {
if (!URL.canParse(input) || /[\s#]/.test(input)) return false
const url = new URL(input)
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.port) return false
return (
url.hostname === "fcm.googleapis.com" ||
[".push.services.mozilla.com", ".push.apple.com", ".notify.windows.com"].some((suffix) =>
url.hostname.endsWith(suffix),
)
)
}
export function validSessionURL(input: string) {
if (!URL.canParse(input) || /[\s?#]/.test(input)) return false
const url = new URL(input)
if (url.username || url.password || url.search || url.hash) return false
if (
url.protocol !== "https:" &&
!(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
)
return false
return /^\/server\/[A-Za-z0-9_-]+\/session\/$/.test(url.pathname)
}
const makeWith = (options: { readonly password?: string } = {}) =>
Effect.gen(function* () {
const kv = yield* KV.Service
const bus = yield* Bus.Service
const sessions = yield* SessionStore.Service
const request = yield* FetchHttpClient.Fetch
const lock = yield* Semaphore.make(1)
const queue = yield* Queue.dropping<Terminal>(QueueCapacity)
yield* Effect.addFinalizer(() => Queue.shutdown(queue))
const subscriptions = new Map<string, PushSubscription>()
const authorization = Schema.decodeUnknownOption(Authorization)(yield* kv.get(AuthKey))
const salt = Option.isSome(authorization) ? authorization.value.salt : crypto.randomUUID()
const fingerprint = createHmac("sha256", salt)
.update(options.password ? `password:${options.password}` : "disabled")
.digest("hex")
if (Option.isNone(authorization) || authorization.value.fingerprint !== fingerprint) {
// Revoke before installing the listener. Write the marker last so interrupted
// cleanup is retried; legacy subscriptions without a binding are revoked too.
while (true) {
const stale = yield* kv.scan({ prefix: Prefix, limit: SubscriptionLimit })
yield* Effect.forEach(stale.entries, (entry) => kv.remove(entry.key), { discard: true })
if (!stale.next) break
}
yield* kv.set(AuthKey, { salt, fingerprint })
}
const stored = yield* kv.scan({ prefix: Prefix, limit: SubscriptionLimit })
for (const entry of stored.entries) {
const decoded = Schema.decodeUnknownOption(PushSubscription)(entry.value)
if (Option.isNone(decoded) || !validEndpoint(decoded.value.endpoint) || !validSessionURL(decoded.value.url))
continue
subscriptions.set(decoded.value.id, decoded.value)
}
const library = yield* Effect.cached(
Effect.tryPromise({
try: () => import("web-push"),
catch: () =>
new ServiceUnavailableError({ message: "Web Push is unavailable in this runtime", service: "push" }),
}),
)
const keys = yield* Effect.cached(
Effect.gen(function* () {
const existing = yield* kv.get(Key)
if (existing !== undefined)
return yield* Schema.decodeUnknownEffect(Vapid)(existing).pipe(
Effect.mapError(
() => new ServiceUnavailableError({ message: "Web Push key is unavailable", service: "push" }),
),
)
const webpush = yield* library
const keys = webpush.generateVAPIDKeys()
yield* kv.set(Key, { ...keys })
return keys
}),
)
const remove = Effect.fnUntraced(function* (id: string, expected?: PushSubscription) {
yield* lock.withPermit(
Effect.gen(function* () {
// An expired in-flight endpoint must not delete its replacement.
if (expected && subscriptions.get(id) !== expected) return
yield* kv.remove(Prefix + id)
subscriptions.delete(id)
}),
)
})
const send = Effect.fnUntraced(function* (subscription: PushSubscription, payload: string) {
const webpush = yield* library
const vapid = yield* keys
const details = yield* Effect.try(() =>
webpush.generateRequestDetails(subscription, payload, {
TTL,
urgency: "normal",
contentEncoding: "aes128gcm",
vapidDetails: { subject: "https://opencode.ai", ...vapid },
}),
)
// web-push owns encryption/signing; fetch owns cancellable, no-redirect transport.
// Do not use the shared HttpClient's URL tracing for capability-bearing push endpoints.
const status = yield* Effect.tryPromise({
try: async (signal) => {
const response = await request(details.endpoint, {
method: "POST",
headers: details.headers,
body: new Uint8Array(details.body),
credentials: "omit",
redirect: "error",
signal,
})
await response.body?.cancel()
return response.status
},
catch: () => new Error("Web Push transport failed"),
})
if (status === 404 || status === 410) {
yield* remove(subscription.id, subscription)
return
}
if (status < 200 || status >= 300) yield* Effect.logWarning("Web Push delivery rejected", { status })
})
const deliver = Effect.fnUntraced(function* (event: Terminal) {
const session = yield* sessions.get(event.data.sessionID)
if (!session || session.parentID) return
const kind = event.type === "session.execution.succeeded" ? "agent" : "errors"
const current = Array.from(subscriptions.values()).filter((subscription) => subscription.notifications[kind])
yield* Effect.forEach(
current,
(subscription) =>
Effect.suspend(() => {
if (subscriptions.get(subscription.id) !== subscription) return Effect.void
const payload = JSON.stringify({
title: subscription.titles[kind],
body: (session.title ?? session.id).slice(0, 200),
url: subscription.url + session.id,
tag: event.id,
})
if (new TextEncoder().encode(payload).byteLength > 3500) return Effect.void
return send(subscription, payload).pipe(
Effect.timeout(DeliveryTimeout),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
() => Effect.logWarning("Web Push delivery failed"),
),
)
}),
{ concurrency: 4, discard: true },
)
})
yield* Effect.gen(function* () {
const event = yield* Queue.take(queue)
yield* deliver(event).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
() => Effect.logWarning("Web Push event delivery failed"),
),
)
}).pipe(Effect.forever, Effect.forkScoped)
const unsubscribe = yield* bus.listen((event) => {
if (!isTerminal(event)) return Effect.void
if (subscriptions.size === 0) return Effect.void
return Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted ? Effect.void : Effect.logWarning("Web Push queue full; notification dropped"),
),
)
})
yield* Effect.addFinalizer(() => unsubscribe)
return Service.of({
get: keys.pipe(Effect.map((value) => ({ publicKey: value.publicKey }))),
subscribe: Effect.fnUntraced(function* (input) {
if (!validEndpoint(input.endpoint))
yield* new InvalidRequestError({ message: "Unsupported Web Push endpoint", field: "endpoint" })
if (!validSessionURL(input.url))
yield* new InvalidRequestError({ message: "Invalid app session URL", field: "url" })
const webpush = yield* library
yield* Effect.try({
try: () => webpush.encrypt(input.keys.p256dh, input.keys.auth, "validate", "aes128gcm"),
catch: () => new InvalidRequestError({ message: "Invalid Web Push subscription keys", field: "keys" }),
})
yield* keys
yield* lock.withPermit(
Effect.gen(function* () {
if (!subscriptions.has(input.id) && subscriptions.size >= SubscriptionLimit)
yield* new InvalidRequestError({ message: "Web Push subscription limit reached" })
yield* kv.set(Prefix + input.id, input)
subscriptions.set(input.id, input)
}),
)
}),
unsubscribe: (id) => remove(id),
})
})
export const make = makeWith()
export function configured(options?: { readonly password?: string }) {
return makeGlobalNode({
service: Service,
layer: Layer.effect(Service, makeWith(options)),
deps: [Bus.node, KV.node, SessionStore.node],
})
}
export const node = configured()
+10 -1
View File
@@ -47,6 +47,7 @@ import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
import { Push } from "./push"
const applicationServiceNodes = [
Global.node,
@@ -72,6 +73,7 @@ const applicationServiceNodes = [
LocationActivity.node,
SessionRestart.node,
Workspace.node,
Push.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
@@ -99,7 +101,13 @@ export function createEmbeddedRoutes(
overrides: LayerNode.Replacements = [],
instances?: InstanceNode,
) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, instances)
return makeRoutes(
ServerAuth.Config.configLayer({ password: Option.none() }),
{ ...options, password: undefined },
() => [],
overrides,
instances,
)
}
function makeRoutes<AuthError, AuthServices>(
@@ -114,6 +122,7 @@ function makeRoutes<AuthError, AuthServices>(
Database.node.replace(Database.configured(options.database)),
PersistentPty.node.replace(PersistentPty.configured(options.pty)),
Bus.node.replace(Bus.configured({ persist: options.events?.persist })),
Push.node.replace(Push.configured({ password: options.password })),
App.node.replace(App.configured(options.app)),
ModelsDev.node.replace(ModelsDev.configured(options.models)),
Watcher.node.replace(Watcher.configured({ enabled: options.fs?.filewatcher })),
@@ -0,0 +1,166 @@
import { expect } from "bun:test"
import { createECDH, randomBytes } from "node:crypto"
import { like } from "drizzle-orm"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { KVTable } from "@opencode-ai/core/kv/sql"
import { PushSubscription } from "@opencode-ai/protocol/groups/push"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { Context, Effect, Layer, Queue, Schema } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes, createRoutes } from "../src/routes"
const boot = Effect.fnUntraced(function* (path: string, password?: string, embedded = false) {
const sent = yield* Queue.dropping<string>(16)
const fetch: typeof globalThis.fetch = Object.assign(
async (input: string | URL | Request) => {
Queue.offerUnsafe(sent, input instanceof Request ? input.url : String(input))
return new Response(null, { status: 201 })
},
{ preconnect: () => {} },
)
const context = yield* Layer.build(
(embedded ? createEmbeddedRoutes : createRoutes)({
password,
database: { path },
models: { fetch: false },
fs: { filewatcher: false },
config: { project: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch))
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
const db = Context.get(context, Database.Service).db
const bus = Context.get(context, Bus.Service)
const request = (path: string, body?: unknown, credential = embedded ? undefined : password) =>
Effect.promise(() =>
handler(
new Request(`https://opencode.local${path}`, {
method: body === undefined ? "GET" : path === "/api/session" ? "POST" : "PUT",
headers: {
"content-type": "application/json",
...(credential ? { authorization: `Basic ${btoa(`opencode:${credential}`)}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
}),
),
)
return {
request,
sent,
subscriptions: () => db.select().from(KVTable).where(like(KVTable.key, "web-push:v1:subscription:%")).all(),
values: () => db.select().from(KVTable).where(like(KVTable.key, "web-push:v1:%")).all(),
notify: Effect.gen(function* () {
const response = yield* request("/api/session", { title: "Push authentication test" })
expect(response.status).toBe(200)
const session = Schema.decodeUnknownSync(Schema.Struct({ data: Schema.Struct({ id: Session.ID }) }))(
yield* Effect.promise(() => response.json()),
)
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: session.data.id })
}),
}
})
function subscription(endpoint: string): PushSubscription {
const key = createECDH("prime256v1")
key.generateKeys()
return {
id: crypto.randomUUID(),
endpoint,
keys: { p256dh: key.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url") },
url: "https://app.opencode.ai/server/aGVsbG8/session/",
notifications: { agent: true, errors: true },
titles: { agent: "Ready", errors: "Error" },
}
}
it.live("server password rotation revokes stored push subscriptions and permits fresh authenticated registration", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-push-auth-")
const original = subscription("https://fcm.googleapis.com/old-browser")
const replacement = subscription("https://web.push.apple.com/new-browser")
const publicKey = yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "original-password")
expect((yield* server.request("/api/push/subscription", original)).status).toBe(204)
const response = yield* server.request("/api/push")
return Schema.decodeUnknownSync(Schema.Struct({ publicKey: Schema.String }))(
yield* Effect.promise(() => response.json()),
).publicKey
}).pipe(Effect.scoped)
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "original-password")
expect(yield* server.subscriptions()).toHaveLength(1)
yield* server.notify
expect(yield* Queue.take(server.sent)).toBe(original.endpoint)
}).pipe(Effect.scoped)
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "rotated-password")
expect(yield* server.subscriptions()).toHaveLength(0)
yield* server.notify
expect(yield* Queue.size(server.sent)).toBe(0)
expect((yield* server.request("/api/push/subscription", original, "original-password")).status).toBe(401)
expect((yield* server.request("/api/push/subscription", replacement)).status).toBe(204)
yield* server.notify
expect(yield* Queue.take(server.sent)).toBe(replacement.endpoint)
expect(yield* Queue.size(server.sent)).toBe(0)
const response = yield* server.request("/api/push")
expect(yield* Effect.promise(() => response.json())).toEqual({ publicKey })
const persisted = JSON.stringify(yield* server.values())
expect(persisted.includes("original-password")).toBe(false)
expect(persisted.includes("rotated-password")).toBe(false)
}).pipe(Effect.scoped)
// Returning to the old password must not resurrect already-revoked records.
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "original-password")
expect(yield* server.subscriptions()).toHaveLength(0)
yield* server.notify
expect(yield* Queue.size(server.sent)).toBe(0)
}).pipe(Effect.scoped)
}),
)
it.live("auth enabled/disabled transitions revoke subscriptions while equivalent disabled modes preserve them", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-push-modes-")
const device = subscription("https://fcm.googleapis.com/browser")
for (const phase of [
{ password: undefined, retained: false },
{ password: "", retained: true },
{ password: "enabled", retained: false },
{ password: undefined, retained: false },
])
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, phase.password)
expect((yield* server.subscriptions()).length).toBe(phase.retained ? 1 : 0)
if (!phase.retained) {
yield* server.notify
expect(yield* Queue.size(server.sent)).toBe(0)
expect((yield* server.request("/api/push/subscription", device)).status).toBe(204)
}
yield* server.notify
expect(yield* Queue.take(server.sent)).toBe(device.endpoint)
}).pipe(Effect.scoped)
}),
)
it.live("embedded routes bind the actual no-password auth mode, not an ignored options.password", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-push-embedded-")
const device = subscription("https://fcm.googleapis.com/embedded")
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "ignored-one", true)
expect((yield* server.request("/api/push/subscription", device)).status).toBe(204)
}).pipe(Effect.scoped)
yield* Effect.gen(function* () {
const server = yield* boot(`${tmp.path}/push.db`, "ignored-two", true)
expect(yield* server.subscriptions()).toHaveLength(1)
yield* server.notify
expect(yield* Queue.take(server.sent)).toBe(device.endpoint)
}).pipe(Effect.scoped)
}),
)
+117
View File
@@ -0,0 +1,117 @@
import { expect } from "bun:test"
import { createECDH, randomBytes } from "node:crypto"
import { PushSubscription } from "@opencode-ai/protocol/groups/push"
import { Effect } from "effect"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
const setup = Effect.gen(function* () {
const handler = yield* ServerFetch.make({
password: "test-secret",
database: { path: ":memory:" },
config: { project: false },
fs: { filewatcher: false },
models: { fetch: false },
})
const key = createECDH("prime256v1")
key.generateKeys()
const subscription: PushSubscription = {
id: crypto.randomUUID(),
endpoint: "https://web.push.apple.com/test",
keys: { p256dh: key.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url") },
url: "https://app.opencode.ai/server/aGVsbG8/session/",
notifications: { agent: true, errors: true },
titles: { agent: "Response ready", errors: "Session error" },
}
const request = (path: string, init: RequestInit = {}, authenticated = true) =>
Effect.promise(() =>
handler(
new Request(`http://opencode.local${path}`, {
...init,
headers: {
"content-type": "application/json",
...(authenticated ? { authorization: `Basic ${btoa("opencode:test-secret")}` } : {}),
...Object.fromEntries(new Headers(init.headers)),
},
}),
),
)
return { request, subscription }
})
it.live(
"push routes require server authentication, expose only the public key, and return empty mutation responses",
() =>
Effect.gen(function* () {
const { request, subscription } = yield* setup
for (const [path, init] of [
["/api/push", {}],
["/api/push/subscription", { method: "PUT", body: JSON.stringify(subscription) }],
[`/api/push/subscription/${subscription.id}`, { method: "DELETE" }],
] as const)
expect((yield* request(path, init, false)).status).toBe(401)
expect(
(yield* request("/api/push", { headers: { authorization: `Basic ${btoa("opencode:wrong")}` } })).status,
).toBe(401)
const response = yield* request("/api/push")
expect(response.status).toBe(200)
const value = yield* Effect.promise(() => response.json())
expect(Object.keys(value)).toEqual(["publicKey"])
expect(value.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/)
const second = yield* request("/api/push")
expect(yield* Effect.promise(() => second.json())).toEqual(value)
for (const notifications of [
{ agent: true, errors: true },
{ agent: false, errors: false },
]) {
const response = yield* request("/api/push/subscription", {
method: "PUT",
headers: { origin: "https://app.opencode.ai" },
body: JSON.stringify({ ...subscription, notifications }),
})
expect(response.status).toBe(204)
expect(yield* Effect.promise(() => response.text())).toBe("")
}
for (let index = 0; index < 2; index++)
expect((yield* request(`/api/push/subscription/${subscription.id}`, { method: "DELETE" })).status).toBe(204)
}),
)
it.live("push mutations reject untrusted browser origins even with valid server credentials", () =>
Effect.gen(function* () {
const { request, subscription } = yield* setup
for (const [path, init] of [
["/api/push/subscription", { method: "PUT", body: JSON.stringify(subscription) }],
[`/api/push/subscription/${subscription.id}`, { method: "DELETE" }],
] as const) {
const response = yield* request(path, { ...init, headers: { origin: "https://untrusted.example" } })
expect(response.status).toBe(403)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ _tag: "ForbiddenError" })
}
}),
)
it.live("push routes validate IDs, lengths, destination URLs, and subscription keys", () =>
Effect.gen(function* () {
const { request, subscription } = yield* setup
for (const invalid of [
{ ...subscription, id: "not-a-uuid" },
{ ...subscription, endpoint: "https://localhost/push" },
{ ...subscription, endpoint: `https://web.push.apple.com/${"x".repeat(2048)}` },
{ ...subscription, url: "https://app.opencode.ai/" },
{ ...subscription, keys: { p256dh: "invalid", auth: "invalid" } },
{ ...subscription, keys: { ...subscription.keys, p256dh: Buffer.alloc(65).toString("base64url") } },
]) {
const response = yield* request("/api/push/subscription", { method: "PUT", body: JSON.stringify(invalid) })
expect(response.status).toBe(400)
}
expect((yield* request("/api/push/subscription/not-a-uuid", { method: "DELETE" })).status).toBe(400)
const spec = yield* request("/openapi.json")
const document = yield* Effect.promise(() => spec.json())
expect(Object.keys(document.paths)).toContain("/api/push")
expect(Object.keys(document.paths)).toContain("/api/push/subscription")
expect(Object.keys(document.paths)).toContain("/api/push/subscription/{id}")
}),
)
+422
View File
@@ -0,0 +1,422 @@
import { expect, test } from "bun:test"
import { createECDH, createPublicKey, randomBytes, verify, type ECDH } from "node:crypto"
import { createRequire } from "node:module"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { KV } from "@opencode-ai/core/kv"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PushSubscription } from "@opencode-ai/protocol/groups/push"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Logger, Queue, Schema, Scope } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { TestClock } from "effect/testing"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { testEffect } from "../../core/test/lib/effect"
import { Push } from "../src/push"
// Use the same established RFC 8188 library as web-push to inspect real encrypted deliveries.
const ece: { decrypt: (body: Buffer, options: { version: string; privateKey: ECDH; authSecret: string }) => Buffer } =
createRequire(import.meta.url)("http_ece")
const nodes = LayerNode.group([Bus.node, Database.node, KV.node, SessionStore.node])
const layer = LayerNode.compile(nodes)
const run = testEffect(layer)
const Payload = Schema.Struct({ title: Schema.String, body: Schema.String, url: Schema.String, tag: Schema.String })
function browser(overrides: Partial<PushSubscription> = {}) {
const key = createECDH("prime256v1")
key.generateKeys()
const subscription: PushSubscription = {
id: crypto.randomUUID(),
endpoint: "https://fcm.googleapis.com/fcm/send/test-subscription",
keys: { p256dh: key.getPublicKey().toString("base64url"), auth: randomBytes(16).toString("base64url") },
url: "https://app.opencode.ai/server/aHR0cHM6Ly9zZXJ2ZXIuZXhhbXBsZQ/session/",
notifications: { agent: true, errors: true },
titles: { agent: "Response ready", errors: "Session error" },
...overrides,
}
return {
subscription,
decrypt: (body: Uint8Array) =>
Schema.decodeUnknownSync(Schema.fromJsonString(Payload))(
ece
.decrypt(Buffer.from(body), { version: "aes128gcm", privateKey: key, authSecret: subscription.keys.auth })
.toString(),
),
}
}
const seed = Effect.fnUntraced(function* (parentID?: Session.ID) {
const database = yield* Database.Service
const id = Session.ID.create()
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
yield* database.db
.insert(SessionTable)
.values({
id,
project_id: Project.ID.global,
parent_id: parentID,
directory: "/project",
slug: "push-test",
version: "test",
title: "A private session title",
})
.run()
return id
})
const transport = Effect.gen(function* () {
const sent = yield* Queue.dropping<{ url: string; options: RequestInit; body: Uint8Array }>(500)
const state = { status: 201, fail: false }
const fetch: typeof globalThis.fetch = Object.assign(
async (input: string | URL | Request, options?: RequestInit) => {
if (!options || !(options.body instanceof Uint8Array)) throw new Error("Expected encrypted push body")
Queue.offerUnsafe(sent, {
url: input instanceof Request ? input.url : String(input),
options,
body: options.body,
})
if (state.fail) throw new Error("transport failure with a sensitive URL that must not be logged")
return new Response(null, { status: state.status })
},
{ preconnect: () => {} },
)
return { sent, state, fetch }
})
test("push endpoint allowlist rejects SSRF destinations and unsafe URL forms", () => {
for (const value of [
"https://fcm.googleapis.com/fcm/send/abc",
"https://updates.push.services.mozilla.com/wpush/v2/abc",
"https://web.push.apple.com/abc",
"https://wns.notify.windows.com/abc",
])
expect(Push.validEndpoint(value)).toBe(true)
for (const value of [
"https://example.com/send",
"http://fcm.googleapis.com/send",
"https://fcm.googleapis.com.evil.test/send",
"https://evilpush.apple.com/send",
"https://push.apple.com/send",
"https://127.0.0.1/send",
"https://169.254.169.254/send",
"https://[::1]/send",
"https://localhost/send",
"https://user:secret@fcm.googleapis.com/send",
"https://fcm.googleapis.com:8443/send",
"https://fcm.googleapis.com/send#fragment",
"https://fcm.googleapis.com/send#",
" https://fcm.googleapis.com/send",
"not a URL",
])
expect(Push.validEndpoint(value)).toBe(false)
})
test("session URLs stay on secure app routes without credentials or query strings", () => {
for (const origin of [
"https://app.opencode.ai",
"http://localhost:4444",
"http://127.0.0.1:4444",
"http://[::1]:4444",
])
expect(Push.validSessionURL(`${origin}/server/aGVsbG8/session/`)).toBe(true)
for (const value of [
"http://app.example.com/server/key/session/",
"https://user:pass@app.example.com/server/key/session/",
"https://app.example.com/server/key/session/?secret=1",
"https://app.example.com/server/key/session/#",
"https://app.example.com/server/key/session/?",
"https://app.example.com/session/",
"https://app.example.com/server/key/session/ses_existing",
"https://app.example.com/server/%2f/session/",
"javascript:alert(1)",
])
expect(Push.validSessionURL(value)).toBe(false)
})
test("wire schema bounds IDs, endpoints, keys, URLs, and notification titles", () => {
const input = browser().subscription
const valid = Schema.is(PushSubscription)
expect(valid(input)).toBe(true)
expect(valid({ ...input, id: "not-a-uuid" })).toBe(false)
expect(valid({ ...input, endpoint: "x".repeat(2049) })).toBe(false)
expect(valid({ ...input, url: "x".repeat(1025) })).toBe(false)
expect(valid({ ...input, titles: { ...input.titles, agent: "x".repeat(101) } })).toBe(false)
expect(valid({ ...input, keys: { ...input.keys, auth: "x".repeat(23) } })).toBe(false)
expect(valid({ ...input, keys: { ...input.keys, p256dh: "invalid" } })).toBe(false)
})
run.effect("publishes encrypted, signed Web Push with no browser or SSE connection", () =>
Effect.gen(function* () {
const network = yield* transport
const push = yield* Push.make.pipe(Effect.provideService(FetchHttpClient.Fetch, network.fetch))
const device = browser()
yield* push.subscribe(device.subscription)
const sessionID = yield* seed()
const bus = yield* Bus.Service
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
const sent = yield* Queue.take(network.sent)
expect(device.decrypt(sent.body)).toEqual({
title: "Response ready",
body: "A private session title",
url: device.subscription.url + sessionID,
tag: event.id,
})
expect(Buffer.from(sent.body).includes(Buffer.from("A private session title"))).toBe(false)
expect(sent.options.redirect).toBe("error")
expect(sent.options.credentials).toBe("omit")
const headers = new Headers(sent.options.headers)
expect(headers.get("ttl")).toBe(String(Push.TTL))
expect(headers.get("content-encoding")).toBe("aes128gcm")
const authorization = headers.get("authorization")!
const [, token, encoded] = /^vapid t=(.+), k=(.+)$/.exec(authorization)!
const [header, payload, signature] = token.split(".")
const point = Buffer.from(encoded, "base64url")
const publicKey = createPublicKey({
format: "jwk",
key: {
kty: "EC",
crv: "P-256",
x: point.subarray(1, 33).toString("base64url"),
y: point.subarray(33).toString("base64url"),
},
})
expect(
verify(
"sha256",
Buffer.from(`${header}.${payload}`),
{ key: publicKey, dsaEncoding: "ieee-p1363" },
Buffer.from(signature, "base64url"),
),
).toBe(true)
expect(encoded).toBe((yield* push.get).publicKey)
expect(JSON.parse(Buffer.from(payload, "base64url").toString())).toMatchObject({
aud: "https://fcm.googleapis.com",
sub: "https://opencode.ai",
})
}),
)
run.effect("ignores child completions, interruption, and disabled kinds; preserves error privacy", () =>
Effect.gen(function* () {
const network = yield* transport
const push = yield* Push.make.pipe(Effect.provideService(FetchHttpClient.Fetch, network.fetch))
const device = browser({ notifications: { agent: false, errors: true } })
yield* push.subscribe(device.subscription)
const parent = yield* seed()
const child = yield* seed(parent)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: child,
error: { type: "failure", message: "private details" },
})
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: parent, reason: "user" })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: parent })
const event = yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: parent,
error: { type: "failure", message: "private details" },
})
const sent = yield* Queue.take(network.sent)
expect(device.decrypt(sent.body)).toEqual({
title: "Session error",
body: "A private session title",
url: device.subscription.url + parent,
tag: event.id,
})
expect(yield* Queue.size(network.sent)).toBe(0)
}),
)
run.effect("upserts preferences, enforces the subscription cap, and removes idempotently", () =>
Effect.gen(function* () {
const push = yield* Push.make
const input = browser().subscription
yield* Effect.forEach(
Array.from({ length: Push.SubscriptionLimit }, () => crypto.randomUUID()),
(id) => push.subscribe({ ...input, id }),
)
expect(yield* push.subscribe(input).pipe(Effect.flip)).toMatchObject({ _tag: "InvalidRequestError" })
const kv = yield* KV.Service
const stored = yield* kv.scan({ prefix: "web-push:v1:subscription:" })
const id = Schema.decodeUnknownSync(PushSubscription)(stored.entries[0].value).id
yield* push.subscribe({ ...input, id, notifications: { agent: false, errors: false } })
expect(yield* kv.get(`web-push:v1:subscription:${id}`)).toMatchObject({
notifications: { agent: false, errors: false },
})
yield* push.unsubscribe(id)
yield* push.unsubscribe(id)
yield* push.subscribe(input)
}),
)
run.effect("rejects invalid curve keys and URLs without storing a subscription", () =>
Effect.gen(function* () {
const push = yield* Push.make
const input = browser().subscription
for (const invalid of [
{ ...input, endpoint: "https://127.0.0.1/send" },
{ ...input, url: "https://example.com/unrelated" },
{ ...input, keys: { ...input.keys, p256dh: Buffer.alloc(65).toString("base64url") } },
])
expect(yield* push.subscribe(invalid).pipe(Effect.flip)).toMatchObject({ _tag: "InvalidRequestError" })
const kv = yield* KV.Service
expect((yield* kv.scan({ prefix: "web-push:v1:subscription:" })).entries).toHaveLength(0)
}),
)
run.effect("expired subscriptions are removed while sender failures stay isolated", () =>
Effect.gen(function* () {
const network = yield* transport
const push = yield* Push.make.pipe(Effect.provideService(FetchHttpClient.Fetch, network.fetch))
const stale = browser()
const active = browser({ endpoint: "https://web.push.apple.com/active" })
yield* push.subscribe(stale.subscription)
yield* push.subscribe(active.subscription)
const kv = yield* KV.Service
const bus = yield* Bus.Service
const sessionID = yield* seed()
network.state.status = 410
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
yield* Queue.take(network.sent)
yield* Queue.take(network.sent)
yield* Effect.gen(function* () {
const records = yield* kv.scan({ prefix: "web-push:v1:subscription:" })
if (records.entries.length) yield* Effect.fail("pending cleanup")
}).pipe(Effect.retry({ times: 100 }))
yield* push.subscribe(active.subscription)
network.state.fail = true
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
yield* Queue.take(network.sent)
network.state.fail = false
network.state.status = 201
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
const sent = yield* Queue.take(network.sent)
expect(active.decrypt(sent.body).tag).toBe(event.id)
expect(yield* kv.get(`web-push:v1:subscription:${active.subscription.id}`)).toBeDefined()
}),
)
run.effect("a stalled provider times out without blocking publication or subsequent delivery", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const aborted = yield* Deferred.make<void>()
const network = yield* transport
let first = true
const request: typeof globalThis.fetch = Object.assign(
(input: string | URL | Request, options?: RequestInit) => {
if (!first) return network.fetch(input, options)
first = false
Deferred.doneUnsafe(entered, Effect.void)
return new Promise<Response>((_, reject) =>
options?.signal?.addEventListener(
"abort",
() => {
Deferred.doneUnsafe(aborted, Effect.void)
reject(new Error("aborted"))
},
{ once: true },
),
)
},
{ preconnect: () => {} },
)
const push = yield* Push.make.pipe(Effect.provideService(FetchHttpClient.Fetch, request))
const device = browser()
yield* push.subscribe(device.subscription)
const bus = yield* Bus.Service
const sessionID = yield* seed()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
yield* Deferred.await(entered)
const next = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
yield* TestClock.adjust("11 seconds")
yield* Deferred.await(aborted)
expect(device.decrypt((yield* Queue.take(network.sent)).body).tag).toBe(next.id)
}),
)
run.effect("bounds queued events and aborts delivery on shutdown", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const entered = yield* Deferred.make<void>()
const aborted = yield* Deferred.make<void>()
const messages: unknown[] = []
const logger = Logger.make((entry) => messages.push(entry.message))
const request: typeof globalThis.fetch = Object.assign(
(_input: string | URL | Request, options?: RequestInit) => {
Deferred.doneUnsafe(entered, Effect.void)
return new Promise<Response>((_, reject) =>
options?.signal?.addEventListener(
"abort",
() => {
Deferred.doneUnsafe(aborted, Effect.void)
reject(new Error("secret provider response"))
},
{ once: true },
),
)
},
{ preconnect: () => {} },
)
yield* Effect.gen(function* () {
const push = yield* Push.make.pipe(
Effect.provideService(FetchHttpClient.Fetch, request),
Effect.provideService(Scope.Scope, scope),
)
yield* push.subscribe(browser().subscription)
const bus = yield* Bus.Service
const sessionID = yield* seed()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
yield* Deferred.await(entered)
yield* Effect.forEach(Array.from({ length: Push.QueueCapacity + 3 }), () =>
bus.publish(SessionEvent.Execution.Succeeded, { sessionID }),
)
expect(messages).toHaveLength(3)
expect(messages).toEqual(Array.from({ length: 3 }, () => ["Web Push queue full; notification dropped"]))
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(aborted)
// Publishing after teardown must not call the old listener or queue.
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
expect(messages).toHaveLength(3)
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })))
}),
)
test("VAPID keys and subscriptions survive server scope restart over the same database", async () => {
await using tmp = await tmpdir("opencode-push-")
const persistent = LayerNode.compile(nodes, {
replacements: [Database.node.replace(Database.configured({ path: `${tmp.path}/push.db` }))],
})
const device = browser()
const publicKey = await Effect.runPromise(
Effect.gen(function* () {
const push = yield* Push.make
yield* push.subscribe(device.subscription)
const keys = yield* Effect.all([push.get, push.get], { concurrency: 2 })
expect(keys[0]).toEqual(keys[1])
expect(Object.keys(keys[0])).toEqual(["publicKey"])
return keys[0].publicKey
}).pipe(Effect.scoped, Effect.provide(persistent)),
)
await Effect.runPromise(
Effect.gen(function* () {
const network = yield* transport
const push = yield* Push.make.pipe(Effect.provideService(FetchHttpClient.Fetch, network.fetch))
expect((yield* push.get).publicKey).toBe(publicKey)
const bus = yield* Bus.Service
const sessionID = yield* seed()
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
expect(device.decrypt((yield* Queue.take(network.sent)).body).tag).toBe(event.id)
}).pipe(Effect.scoped, Effect.provide(persistent)),
)
})
+251
View File
@@ -12413,6 +12413,254 @@
"summary": "Destroy workspace"
}
},
"/api/push": {
"get": {
"tags": ["push"],
"operationId": "v2.push.get",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"publicKey": {
"type": "string"
}
},
"required": ["publicKey"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Get the Web Push public key"
}
},
"/api/push/subscription": {
"put": {
"tags": ["push"],
"operationId": "v2.push.subscribe",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Register or update a browser push subscription",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"endpoint": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"keys": {
"type": "object",
"properties": {
"p256dh": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{87}$"
},
"auth": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{22}$"
}
},
"required": ["p256dh", "auth"],
"additionalProperties": false
},
"url": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"notifications": {
"type": "object",
"properties": {
"agent": {
"type": "boolean"
},
"errors": {
"type": "boolean"
}
},
"required": ["agent", "errors"],
"additionalProperties": false
},
"titles": {
"type": "object",
"properties": {
"agent": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"errors": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": ["agent", "errors"],
"additionalProperties": false
}
},
"required": ["id", "endpoint", "keys", "url", "notifications", "titles"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/push/subscription/{id}": {
"delete": {
"tags": ["push"],
"operationId": "v2.push.unsubscribe",
"parameters": [
{
"name": "id",
"in": "path",
"schema": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
}
},
"summary": "Remove a browser push subscription"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -19744,6 +19992,9 @@
"name": "workspace",
"description": "Workspace lifecycle routes."
},
{
"name": "push"
},
{
"name": "vcs",
"description": "Location-scoped version control routes."
+251
View File
@@ -12413,6 +12413,254 @@
"summary": "Destroy workspace"
}
},
"/api/push": {
"get": {
"tags": ["push"],
"operationId": "v2.push.get",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"publicKey": {
"type": "string"
}
},
"required": ["publicKey"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Get the Web Push public key"
}
},
"/api/push/subscription": {
"put": {
"tags": ["push"],
"operationId": "v2.push.subscribe",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"summary": "Register or update a browser push subscription",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"endpoint": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"keys": {
"type": "object",
"properties": {
"p256dh": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{87}$"
},
"auth": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{22}$"
}
},
"required": ["p256dh", "auth"],
"additionalProperties": false
},
"url": {
"type": "string",
"minLength": 1,
"maxLength": 1024
},
"notifications": {
"type": "object",
"properties": {
"agent": {
"type": "boolean"
},
"errors": {
"type": "boolean"
}
},
"required": ["agent", "errors"],
"additionalProperties": false
},
"titles": {
"type": "object",
"properties": {
"agent": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"errors": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": ["agent", "errors"],
"additionalProperties": false
}
},
"required": ["id", "endpoint", "keys", "url", "notifications", "titles"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/push/subscription/{id}": {
"delete": {
"tags": ["push"],
"operationId": "v2.push.unsubscribe",
"parameters": [
{
"name": "id",
"in": "path",
"schema": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$",
"format": "uuid"
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"403": {
"description": "ForbiddenError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenErrorEncoded"
}
}
}
}
},
"summary": "Remove a browser push subscription"
}
},
"/api/vcs": {
"get": {
"tags": ["vcs"],
@@ -19744,6 +19992,9 @@
"name": "workspace",
"description": "Workspace lifecycle routes."
},
{
"name": "push"
},
{
"name": "vcs",
"description": "Location-scoped version control routes."