mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
1
Commits
plugin-fork
...
pwa-push
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e24f71af29 |
@@ -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())))
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }))
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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}")
|
||||
}),
|
||||
)
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user