From 89638dc2edbb3e540e4b9d229918f5835e37a2c2 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 10:21:50 -0700
Subject: [PATCH 1/7] chore(quest): claim quest/m0/kix/mobile.md
Co-Authored-By: Claude Opus 5.5
From 8a1a9907c551515564fd7d0880484768c28d3a96 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 10:44:50 -0700
Subject: [PATCH 2/7] feat(kix): install as a phone app with decision push
notifications
kix.quest installs as a PWA with a web app manifest, app icons, and a
service worker. Maintainers turn on Web Push per device from /inbox or
/account; only decisions push (agent questions, publication decisions,
account limits, failed jobs, new issues, PRs ready for review), once
each, coalesced per item by notification tag and push Topic, and a tap
opens the item in the inbox. Subscriptions live in D1 per user and
session, are deleted on sign-out and when the push service reports them
gone. RFC 8291 encryption and VAPID signing use WebCrypto directly. The
nav and the installed app's icon show the unread decision count, and the
phone nav is tighter.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/.dev.vars.example | 6 +
cloudflare/README.md | 58 ++++-
cloudflare/migrations/0011_push.sql | 16 ++
cloudflare/scripts/build.ts | 7 +-
cloudflare/scripts/vapid.ts | 9 +
cloudflare/src/account.tsx | 22 ++
cloudflare/src/app.js | 82 +++++++
cloudflare/src/env.d.ts | 9 +-
cloudflare/src/github/webhook.ts | 3 +
cloudflare/src/inbox/model.ts | 44 +++-
cloudflare/src/inbox/page.tsx | 5 +-
cloudflare/src/index.tsx | 28 ++-
cloudflare/src/jobs/hub.ts | 23 +-
cloudflare/src/jobs/publish.ts | 7 +-
cloudflare/src/jobs/routes.ts | 8 +-
cloudflare/src/jobs/store.ts | 13 +-
cloudflare/src/layout.tsx | 34 ++-
cloudflare/src/manifest.webmanifest | 16 ++
cloudflare/src/push/notify.ts | 233 ++++++++++++++++++++
cloudflare/src/push/webpush.ts | 150 +++++++++++++
cloudflare/src/site.css | 51 +++++
cloudflare/src/sw.js | 66 ++++++
cloudflare/test/env.d.ts | 2 +
cloudflare/test/jobs.test.ts | 48 ++++-
cloudflare/test/project-fixtures.ts | 8 +-
cloudflare/test/publish.test.ts | 9 +-
cloudflare/test/push.test.ts | 312 +++++++++++++++++++++++++++
cloudflare/vitest.config.ts | 4 +
cloudflare/worker-configuration.d.ts | 5 +-
cloudflare/wrangler.jsonc | 9 +-
design/theme.md | 12 ++
design/theme/icon-192.png | Bin 0 -> 1360 bytes
design/theme/icon-512.png | Bin 0 -> 4147 bytes
design/theme/icon.svg | 9 +
quest/m0/kix/README.md | 1 -
quest/m0/kix/guide.md | 1 -
quest/m0/kix/mobile.md | 44 ----
quest/m1/mobile-app.md | 6 +-
38 files changed, 1259 insertions(+), 101 deletions(-)
create mode 100644 cloudflare/migrations/0011_push.sql
create mode 100644 cloudflare/scripts/vapid.ts
create mode 100644 cloudflare/src/app.js
create mode 100644 cloudflare/src/manifest.webmanifest
create mode 100644 cloudflare/src/push/notify.ts
create mode 100644 cloudflare/src/push/webpush.ts
create mode 100644 cloudflare/src/sw.js
create mode 100644 cloudflare/test/push.test.ts
create mode 100644 design/theme/icon-192.png
create mode 100644 design/theme/icon-512.png
create mode 100644 design/theme/icon.svg
delete mode 100644 quest/m0/kix/mobile.md
diff --git a/cloudflare/.dev.vars.example b/cloudflare/.dev.vars.example
index 2927eb7a..1fd165b2 100644
--- a/cloudflare/.dev.vars.example
+++ b/cloudflare/.dev.vars.example
@@ -14,3 +14,9 @@ GITHUB_APP_ID=replace-with-app-id
GITHUB_APP_PRIVATE_KEY=replace-with-quoted-PEM-private-key
GITHUB_WEBHOOK_SECRET=replace-with-random-webhook-secret
GITHUB_FORK_OWNER=replace-with-service-controlled-fork-owner
+
+# Optional decision notifications: `bun scripts/vapid.ts` prints a key pair.
+# Set VAPID_PUBLIC_KEY here, and list VAPID_PRIVATE_KEY under
+# `secrets.required` in wrangler.jsonc while trying it, as with Google.
+# VAPID_PUBLIC_KEY=replace-with-vapid-public-key
+# VAPID_PRIVATE_KEY=replace-with-vapid-private-key
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 75a65771..2028d7e8 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -2,8 +2,9 @@
A deployable foundation for Quest on Cloudflare, and the site at
`https://kix.quest`: the quest log for your repo and your agents, on the web.
-Hono serves HTML directly, so there is no client bundle; only passkeys need a
-small script (`src/passkey.js`). Every page renders
+Hono serves HTML directly, so there is no client bundle; only passkeys
+(`src/passkey.js`) and the installed app (`src/app.js` and `src/sw.js`) need
+small scripts. Every page renders
inside the shared layout in `src/layout.tsx`, which follows
[the quest log theme](../design/theme.md): the home page (`src/home.tsx`), the
docs under `/docs` (`src/docs.tsx`), the quest board under `/repos`
@@ -28,9 +29,11 @@ other docs become `/docs/` and other repository paths point at GitHub. It
also snapshots this repository's `quest/` tree and finished quests into
`build/board.json`, which supplies the public board. Enrolled repositories use authenticated
GitHub snapshots through the same board model. A shallow clone has no
-history, so its board shows no finished work. It also copies `design/theme/` and `src/site.css` into `build/public/`, which the
-Worker serves as static assets (`/theme/theme.css`, `/theme/logo.svg`,
-`/theme/og.png`, and `/site.css`). `build/` is ignored by Git.
+history, so its board shows no finished work. It also copies `design/theme/`,
+`src/site.css`, the scripts, and the web app manifest into `build/public/`,
+which the Worker serves as static assets (`/theme/theme.css`,
+`/theme/logo.svg`, `/theme/og.png`, `/site.css`, `/sw.js`, and the like).
+`build/` is ignored by Git.
Better Auth uses D1 for accounts and sessions;
adding social providers or plugins belongs in `src/auth.ts`. It handles OAuth
@@ -184,6 +187,42 @@ the item showed (a question, a GitHub change, a quest's text). A newer version
is unread again, and skipping an item lasts until it changes. A finished or
stopped job sets its item aside the same way.
+### Installed app and notifications
+
+kix.quest installs as a progressive web app (`src/manifest.webmanifest`,
+starting at `/inbox`) on Android, desktop browsers, and iOS/iPadOS 16.4+ once
+added to the Home Screen. Signed-in pages load `src/app.js`, which registers
+the service worker `src/sw.js`, sets the installed app's icon badge to the
+unread decision count where the Badging API exists, and drives the
+notification switch on `/inbox` and `/account`. The nav shows the same count.
+It reads stored observations without rechecking GitHub on every page, so it
+reveals a number and nothing else.
+
+A device turns notifications on only from a click, which asks the browser for
+permission and stores its push subscription (`push_subscriptions`) for that
+user and session. Subscriptions must name a known push service. Signing out
+deletes the session's subscriptions, and a push service reporting one gone
+(404 or 410) deletes it.
+
+Only decisions push (`src/push/notify.ts`): an agent's question, a
+publication decision, an account limit, a failed job, a new or reopened issue,
+and a pull request opened ready for review or marked ready. Drafts,
+progress, presence, and finished work never push, and neither does anything on
+a paused project. Each push goes once, from the first delivery or the attempt
+that changed, to the devices of everyone whose inbox shows the project. It
+carries the project, the item's title, and the unread count; one
+notification tag and push `Topic` per item replace an earlier one instead of
+stacking. Tapping it opens the item in the inbox. Notifications have no answer
+buttons: iOS Web Push does not support them, and every answer goes through the
+inbox's stale- and duplicate-answer checks.
+
+`src/push/webpush.ts` implements RFC 8291 message encryption and RFC 8292 VAPID
+signing with WebCrypto, so there is no push library; tests check it against
+RFC 8291's worked example. Notifications are on only when both
+`VAPID_PUBLIC_KEY` (in `wrangler.jsonc`) and the `VAPID_PRIVATE_KEY` secret are
+set. `bun scripts/vapid.ts` prints a new pair; replacing it stops every
+existing subscription until each device turns notifications on again.
+
`cloudflare/` is a Bun workspace of the repository root, so the Worker
imports the CLI's core as `quest/core` (`../src/core`), the same parser, checks,
and readiness the CLI runs.
@@ -248,7 +287,9 @@ limits and their decisions, lost branch updates and deliveries, iterated heads,
and token scopes. Output tests use local R2 for chunk ordering, retries, the
caps, expiry, and cleanup after a disconnect. Inbox tests answer prompts
through the forms, reject stale revisions and repeated answers, coalesce
-duplicate webhooks, and hide a project whose maintainer lost authority. No
+duplicate webhooks, and hide a project whose maintainer lost authority. Push
+tests encrypt RFC 8291's example, verify VAPID signatures, decrypt what a
+subscribed device receives, and check that only decisions push, once each. No
OAuth credentials or Cloudflare account are required for checks.
After changing bindings, regenerate types:
@@ -297,7 +338,10 @@ a production Google OAuth web client with redirect URI
`https://kix.quest/api/auth/callback/google`, set its public client ID as
`GOOGLE_CLIENT_ID` in `wrangler.jsonc`, and set the secret with
`bun run wrangler secret put GOOGLE_CLIENT_SECRET`; without both, Google is
-not offered. The secret is optional, so it is not listed under `secrets.required`. Deploys fail until every
+not offered. The secret is optional, so it is not listed under `secrets.required`.
+Notifications are optional the same way: run `bun scripts/vapid.ts`, set its
+public key as `VAPID_PUBLIC_KEY` in `wrangler.jsonc`, and set the private key
+with `bun run wrangler secret put VAPID_PRIVATE_KEY`. Deploys fail until every
required secret is set, so register the GitHub App first ([GITHUB.md](GITHUB.md)
steps 1 and 2), then set secrets interactively:
diff --git a/cloudflare/migrations/0011_push.sql b/cloudflare/migrations/0011_push.sql
new file mode 100644
index 00000000..eda18cae
--- /dev/null
+++ b/cloudflare/migrations/0011_push.sql
@@ -0,0 +1,16 @@
+-- Web Push subscriptions, one per browser or installed app. The endpoint
+-- names the device's push channel and moves to whoever enables
+-- notifications on it last. Signing out deletes the subscriptions that
+-- session enabled, and a push service reporting one gone deletes it.
+CREATE TABLE push_subscriptions (
+ endpoint TEXT PRIMARY KEY NOT NULL,
+ user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ -- The session that enabled it; not a foreign key, so an expired session
+ -- keeps notifying until its device signs out.
+ session TEXT NOT NULL,
+ p256dh TEXT NOT NULL,
+ auth TEXT NOT NULL,
+ createdAt INTEGER NOT NULL
+);
+CREATE INDEX push_subscriptions_user ON push_subscriptions(user);
+CREATE INDEX push_subscriptions_session ON push_subscriptions(session);
diff --git a/cloudflare/scripts/build.ts b/cloudflare/scripts/build.ts
index e2a11d30..94cc8fe8 100644
--- a/cloudflare/scripts/build.ts
+++ b/cloudflare/scripts/build.ts
@@ -1,7 +1,9 @@
// Build-time inputs for the Worker, written to build/ (ignored by Git):
// - build/docs.json: the repository's docs/*.md rendered to HTML.
// - build/board.json: this repository's quest tree and finished quests, for the board.
-// - build/public/: static assets, the theme from design/theme/, src/site.css, and src/passkey.js.
+// - build/public/: static assets: the theme from design/theme/, src/site.css, the
+// scripts src/passkey.js, src/app.js, and the service worker src/sw.js, and the
+// web app manifest.
// Wrangler runs this before dev and deploy; the check and test scripts run it first.
import { execFileSync } from "node:child_process";
import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -113,4 +115,5 @@ writeFileSync(join(build, "docs.json"), JSON.stringify(rendered));
writeFileSync(join(build, "board.json"), JSON.stringify(snapshot));
cpSync(join(design, "theme"), join(build, "public", "theme"), { recursive: true });
cpSync(join(worker, "src", "site.css"), join(build, "public", "site.css"));
-cpSync(join(worker, "src", "passkey.js"), join(build, "public", "passkey.js"));
+for (const file of ["passkey.js", "app.js", "sw.js", "manifest.webmanifest"])
+ cpSync(join(worker, "src", file), join(build, "public", file));
diff --git a/cloudflare/scripts/vapid.ts b/cloudflare/scripts/vapid.ts
new file mode 100644
index 00000000..7431948a
--- /dev/null
+++ b/cloudflare/scripts/vapid.ts
@@ -0,0 +1,9 @@
+// Print a new Web Push (VAPID) key pair: the public key for VAPID_PUBLIC_KEY
+// in wrangler.jsonc, and the private key for the VAPID_PRIVATE_KEY secret.
+// Replacing the pair stops every existing subscription; devices re-enable.
+import { base64url } from "../src/push/webpush";
+
+const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair;
+const publicKey = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer);
+const { d } = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey;
+console.log(`VAPID_PUBLIC_KEY=${base64url(publicKey)}\nVAPID_PRIVATE_KEY=${d}`);
diff --git a/cloudflare/src/account.tsx b/cloudflare/src/account.tsx
index 32817612..079a51e3 100644
--- a/cloudflare/src/account.tsx
+++ b/cloudflare/src/account.tsx
@@ -1,5 +1,6 @@
import type { Child } from "hono/jsx";
import { type Provider, providerNames } from "./auth";
+import { Notifications } from "./layout";
// Sign-in choices and the signed-in account's methods. OAuth uses plain forms;
// only the WebAuthn ceremonies need /passkey.js, which reveals its buttons
@@ -84,6 +85,9 @@ export function AccountPage(props: {
linked: Provider[];
providers: Provider[];
passkeys: Passkey[];
+ /** The VAPID public key, or null where notifications are not configured. */
+ notifications: string | null;
+ devices: number;
error?: string;
}) {
const unlinked = props.providers.filter((provider) => !props.linked.includes(provider));
@@ -144,6 +148,24 @@ export function AccountPage(props: {
Keep another passkey or a linked provider to recover access; there is no administrator recovery.
+ {props.notifications && (
+
+
+ Get a notification when something needs your decision: an agent's question, an account limit, a
+ stopped job, a new issue, or a pull request ready for review. Tapping it opens the decision.
+
+
+ {props.devices === 1
+ ? "1 device is notified."
+ : `${props.devices || "No"} devices are notified.`}{" "}
+ Signing out turns notifications off for that device.
+
+
+
+
+ )}
);
}
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
new file mode 100644
index 00000000..47b6839d
--- /dev/null
+++ b/cloudflare/src/app.js
@@ -0,0 +1,82 @@
+// The installed app, for signed-in pages: the service worker that shows
+// decision notifications, the app icon's unread badge, and the
+// notification switch. Served as /app.js; pages work without it.
+const unread = Number(document.body.dataset.unread);
+if ("setAppBadge" in navigator)
+ (unread > 0 ? navigator.setAppBadge(unread) : navigator.clearAppBadge()).catch(() => {});
+
+const supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
+const registration = supported ? navigator.serviceWorker.register("/sw.js") : null;
+
+async function post(path, body) {
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || "Request failed.");
+}
+
+function key(base64url) {
+ const text = atob(base64url.replaceAll("-", "+").replaceAll("_", "/"));
+ return Uint8Array.from(text, (c) => c.charCodeAt(0));
+}
+
+for (const control of document.querySelectorAll("[data-push]")) {
+ const status = control.querySelector("[data-push-status]");
+ const enable = control.querySelector("[data-push-enable]");
+ const disable = control.querySelector("[data-push-disable]");
+
+ async function show(message) {
+ const subscription = supported && (await (await registration).pushManager.getSubscription());
+ const on = !!subscription && Notification.permission === "granted";
+ const blocked = supported && Notification.permission === "denied";
+ // The inbox's compact control steps aside once there is nothing to switch on.
+ control.hidden = (on || blocked) && "compact" in control.dataset && !message;
+ enable.hidden = !supported || on || blocked;
+ disable.hidden = !on;
+ status.textContent =
+ message ??
+ (!supported
+ ? "This browser can't show notifications here. On an iPhone or iPad, add Quest to your Home Screen from the Share menu, then open it from there."
+ : blocked
+ ? "Notifications are blocked for this site in your browser's settings."
+ : on
+ ? "This device is notified when something needs your decision."
+ : "Get a notification on this device when something needs your decision.");
+ }
+
+ async function run(button, action) {
+ button.disabled = true;
+ try {
+ await show(await action());
+ } catch (error) {
+ await show(error.message || "Something went wrong.");
+ } finally {
+ button.disabled = false;
+ }
+ }
+
+ // The permission prompt comes only from this click.
+ enable.addEventListener("click", () =>
+ run(enable, async () => {
+ if ((await Notification.requestPermission()) !== "granted") return undefined;
+ const subscription = await (
+ await registration
+ ).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) });
+ await post("/push/subscribe", subscription.toJSON());
+ return "Notifications are on for this device.";
+ }),
+ );
+ disable.addEventListener("click", () =>
+ run(disable, async () => {
+ const subscription = await (await registration).pushManager.getSubscription();
+ if (subscription) {
+ await post("/push/unsubscribe", { endpoint: subscription.endpoint });
+ await subscription.unsubscribe();
+ }
+ return "Notifications are off for this device.";
+ }),
+ );
+ show().catch(() => {});
+}
diff --git a/cloudflare/src/env.d.ts b/cloudflare/src/env.d.ts
index cbd51769..739af924 100644
--- a/cloudflare/src/env.d.ts
+++ b/cloudflare/src/env.d.ts
@@ -1,6 +1,9 @@
-// Optional Google sign-in secret, which `wrangler types` cannot declare without
-// making every deploy require it. Wrangler dev loads only declared secrets, so
-// to try Google locally, temporarily add it to `secrets.required`.
+// Optional secrets, which `wrangler types` cannot declare without making every
+// deploy require them. Wrangler dev loads only declared secrets, so to try one
+// locally, temporarily add it to `secrets.required`.
interface Env {
+ // Google sign-in, offered only with GOOGLE_CLIENT_ID.
GOOGLE_CLIENT_SECRET?: string;
+ // The Web Push VAPID private key; notifications are offered only with VAPID_PUBLIC_KEY.
+ VAPID_PRIVATE_KEY?: string;
}
diff --git a/cloudflare/src/github/webhook.ts b/cloudflare/src/github/webhook.ts
index 717653d1..9c64436c 100644
--- a/cloudflare/src/github/webhook.ts
+++ b/cloudflare/src/github/webhook.ts
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
+import { arrived } from "../push/notify";
export function positiveId(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
@@ -168,5 +169,7 @@ githubWebhooks.post("/webhooks/github", async (c) => {
}
// Observations never approve or launch work. Trusted consumers reconcile
// current GitHub state rather than treating delayed webhook bodies as facts.
+ // A new decision item still notifies, once, from the first delivery.
+ if (observed.has(event)) c.executionCtx.waitUntil(arrived(c.env, repository, installation, event, payload));
return c.json({ status: "accepted" }, 202);
});
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index 3bb9baa8..e7404fc0 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -6,7 +6,7 @@
import { parse } from "quest/core";
import { encodePath } from "../board/model";
import { GitHubError, type Enrollment, GitHubService } from "../github/service";
-import type { Activity, ProjectState } from "../github/state";
+import { type Activity, type ProjectState, readState } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { hub } from "../jobs/routes";
import * as store from "../jobs/store";
@@ -195,6 +195,36 @@ export function arrange(items: Item[]) {
export type Runners = Awaited>;
+/** Every project's items, from its observations, its jobs, and the maintainer's read state. */
+async function collect(db: D1Database, user: string, views: ProjectView[]) {
+ const found = views.map((view) => entities(view.project, view.state));
+ const [reads, jobs] = await Promise.all([
+ db.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(),
+ Promise.all(views.map((view, i) => store.listInboxJobs(db, view.project.id, [...found[i]!.keys()]))),
+ ]);
+ return views.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results));
+}
+
+/**
+ * How many decisions are new to a maintainer, for the nav and the app icon
+ * badge. It reads only stored observations, without rechecking GitHub
+ * authority on every page, so it reveals a count and nothing else.
+ */
+export async function unread(env: Env, user: string) {
+ const projects = await new GitHubService(env).projects(user);
+ const views = await Promise.all(
+ projects.map(async (project) => ({ project, state: await readState(env.DB, project), error: null })),
+ );
+ return (await collect(env.DB, user, views)).filter((item) => item.need !== null && !item.skipped && item.unread)
+ .length;
+}
+
+/** What a notification calls an item: its issue, pull request, or quest title, as the inbox shows it. */
+export async function title(db: D1Database, project: Enrollment, target: string) {
+ const state = await readState(db, project);
+ return (entities(project, state).get(target) ?? fallback(project, target)).title;
+}
+
/**
* Load the inbox across the projects a maintainer enrolled. Every project
* rechecks current GitHub authority; one that fails is reported, not hidden.
@@ -211,13 +241,11 @@ export async function load(env: Env, user: string) {
}
}),
);
- const usable = projects.filter((view) => !view.error);
- const found = usable.map((view) => entities(view.project, view.state));
- const [reads, jobs] = await Promise.all([
- env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(),
- Promise.all(usable.map((view, i) => store.listInboxJobs(env.DB, view.project.id, [...found[i]!.keys()]))),
- ]);
- const items = usable.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results));
+ const items = await collect(
+ env.DB,
+ user,
+ projects.filter((view) => !view.error),
+ );
// Jobs run on their requester's runners, so presence comes from each requester's hub.
const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))];
const runners = new Map(
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 982b6256..13cd9c8f 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -1,7 +1,7 @@
import type { Pull } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
-import { Marker } from "../layout";
+import { Marker, Notifications } from "../layout";
import { projectURL } from "../projects";
import type { Item, ProjectView, QuestionView, Runners } from "./model";
@@ -19,6 +19,8 @@ export type InboxProps = {
working: Item[];
skipped: Item[];
runners: Map;
+ /** The VAPID public key, or null where notifications are not configured. */
+ notifications: string | null;
};
const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" };
@@ -455,6 +457,7 @@ export function InboxPage(props: InboxProps) {
? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.`
: "Nothing needs a decision right now."}
+
{props.missing && (
That decision is no longer open. It may have been answered or changed on GitHub.
diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx
index 21eb839f..ecf6fb69 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -18,6 +18,7 @@ import { purge, retain } from "./jobs/output";
import { BootstrapRequired, fromForm, jobs, resume, stop } from "./jobs/routes";
import { JobError } from "./jobs/store";
import { Layout, type Page, type User } from "./layout";
+import * as push from "./push/notify";
export const app = new Hono<{ Bindings: Env }>();
// Browsers send `Origin: null` on form posts under the default "no-referrer"
@@ -28,6 +29,7 @@ app.get("/health", (c) => c.json({ status: "ok" }));
// Short link for the one-line setup paste.
app.get("/setup", (c) => c.redirect("https://github.com/kixelated/quest/blob/main/SETUP.md"));
app.all("/api/auth/*", (c) => createAuth(c.env).handler(c.req.raw));
+app.route("/", push.subscriptions);
// The page a visitor is on, still percent-encoded, to return to after signing in.
function here(c: Context): string {
@@ -38,15 +40,16 @@ function here(c: Context): string {
// Every HTML page renders in the shared layout, with the visitor's session in the nav.
async function page(
c: Context<{ Bindings: Env }>,
- props: Omit,
+ props: Omit,
body: Child | ((user: User | null) => Child),
status: ContentfulStatusCode = 200,
) {
const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
const user = session?.user ?? null;
+ const unread = user ? await inbox.unread(c.env, user.id) : 0;
c.header("Cache-Control", "no-store");
return c.html(
-
+
{typeof body === "function" ? body(user) : body}
,
status,
@@ -244,8 +247,7 @@ app.get("/inbox", async (c) => {
const target = c.req.query("item");
const chosen = target ? items.find((i) => String(i.project.id) === project && i.target === target) : undefined;
const active = chosen ?? decisions[0] ?? null;
- if (active?.need) await inbox.markSeen(c.env.DB, user, active);
- return page(
+ const response = await page(
c,
{ title: "Decisions", description: "Your decision inbox: questions, issues, pull requests, and ready quests." },
{
working={working}
skipped={skipped}
runners={runners}
+ notifications={push.keys(c.env)?.publicKey ?? null}
/>,
);
+ // Seen once shown, so the nav's count agrees with the page that shows it.
+ if (active?.need) await inbox.markSeen(c.env.DB, user, active);
+ return response;
});
app.get("/projects/github/:owner/:repository/board", async (c) => {
const state = await liveProject(c);
@@ -451,11 +457,15 @@ app.post("/sign-in", async (c) => {
);
});
app.post("/sign-out", async (c) => {
- const response = await createAuth(c.env).api.signOut({
+ const auth = createAuth(c.env);
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ const response = await auth.api.signOut({
headers: c.req.raw.headers,
asResponse: true,
});
if (!response.ok) return response;
+ // This device stops notifying once it signs out.
+ if (session) await push.forget(c.env.DB, session.session.id);
return redirect(response, "/");
});
@@ -463,10 +473,12 @@ app.post("/sign-out", async (c) => {
app.get("/account", async (c) => {
const auth = createAuth(c.env);
const headers = c.req.raw.headers;
- if (!(await auth.api.getSession({ headers }))) return c.redirect("/sign-in?next=/account", 303);
- const [accounts, passkeys] = await Promise.all([
+ const session = await auth.api.getSession({ headers });
+ if (!session) return c.redirect("/sign-in?next=/account", 303);
+ const [accounts, passkeys, devices] = await Promise.all([
auth.api.listUserAccounts({ headers }),
auth.api.listPasskeys({ headers }),
+ push.count(c.env.DB, session.user.id),
]);
const linked = providers(c.env).filter((p) => accounts.some((account) => account.providerId === p));
return page(c, { title: "Account", description: "Your Quest sign-in methods and passkeys." }, (user) => (
@@ -475,6 +487,8 @@ app.get("/account", async (c) => {
linked={linked}
providers={providers(c.env)}
passkeys={passkeys}
+ notifications={push.keys(c.env)?.publicKey ?? null}
+ devices={devices}
error={c.req.query("error")}
/>
));
diff --git a/cloudflare/src/jobs/hub.ts b/cloudflare/src/jobs/hub.ts
index 834d8304..b29e9cd7 100644
--- a/cloudflare/src/jobs/hub.ts
+++ b/cloudflare/src/jobs/hub.ts
@@ -1,4 +1,5 @@
import { DurableObject } from "cloudflare:workers";
+import * as push from "../push/notify";
import * as output from "./output";
import { MessageError, type RunnerMessage, parse } from "./protocol";
import * as store from "./store";
@@ -147,14 +148,28 @@ export class RunnerHub extends DurableObject {
message.session,
now,
);
- case "question":
- return store.ask(db, runner, message.job, message.attempt, message, now);
+ case "question": {
+ const { question, cancel, asked } = await store.ask(
+ db,
+ runner,
+ message.job,
+ message.attempt,
+ message,
+ now,
+ );
+ if (asked && !cancel) this.ctx.waitUntil(push.job(this.env, message.job, "question"));
+ return { question, cancel };
+ }
case "output":
// Past the attempt's cap the reply says `truncated`, and the job carries on.
return output.append(this.env, runner, message, now);
- case "result":
- await store.finish(db, runner, message.job, message.attempt, message, now);
+ case "result": {
+ const state = await store.finish(db, runner, message.job, message.attempt, message, now);
+ // A limit waits on an account choice; a failure waits on a retry.
+ if (state === "waiting" || state === "failed")
+ this.ctx.waitUntil(push.job(this.env, message.job, state === "waiting" ? "account" : "failed"));
return {};
+ }
}
}
}
diff --git a/cloudflare/src/jobs/publish.ts b/cloudflare/src/jobs/publish.ts
index 83aebc38..56391d42 100644
--- a/cloudflare/src/jobs/publish.ts
+++ b/cloudflare/src/jobs/publish.ts
@@ -102,12 +102,13 @@ export function parsePublication(body: Record): Publication {
return { job, attempt, head: body.head as string | null, session, message, changes, pull };
}
-type Decision = { decision: string; cancel: boolean };
+/** A pause on a decision; `asked` is false for a retry of one already asked. */
+type Decision = { decision: string; cancel: boolean; asked: boolean };
type Published = { head: string | null; pull?: { number: number; url: string; draft: boolean } };
/** Pause the attempt on a decision the maintainer must make before this can publish. */
async function decide(db: D1Database, runner: Runner, input: Publication, prompt: unknown): Promise {
- const { question, cancel } = await store.ask(
+ const { question, cancel, asked } = await store.ask(
db,
runner,
input.job,
@@ -120,7 +121,7 @@ async function decide(db: D1Database, runner: Runner, input: Publication, prompt
},
Date.now(),
);
- return { decision: question, cancel };
+ return { decision: question, cancel, asked };
}
/**
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index 26fa9414..4eb74382 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -1,5 +1,6 @@
import { type Context, Hono } from "hono";
import { createAuth } from "../auth";
+import * as push from "../push/notify";
import { GitHubError, GitHubService, refName, type Stopped } from "../github/service";
import type { ProjectState, Pull } from "../github/state";
import { RUNNER_HEADER } from "./hub";
@@ -106,10 +107,13 @@ jobs.post("/api/runner/publish", async (c) => {
const length = Number(c.req.header("Content-Length"));
if (!Number.isSafeInteger(length) || length > LIMITS.request)
return c.json({ error: `A Content-Length of at most ${LIMITS.request} bytes is required` }, 413);
- const result = await publish(c.env, runner, parsePublication(await json(c)));
+ const input = parsePublication(await json(c));
+ const result = await publish(c.env, runner, input);
if (!("decision" in result)) return c.json(result);
await hub(c.env, runner.owner).dispatch(runner.owner);
- return c.json(result, 202);
+ if (result.asked && !result.cancel) c.executionCtx.waitUntil(push.job(c.env, input.job, "publication"));
+ const { decision, cancel } = result;
+ return c.json({ decision, cancel }, 202);
});
// A signed-in user registers their own machines and sees their presence.
diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts
index 1b852b07..b2ed7ae0 100644
--- a/cloudflare/src/jobs/store.ts
+++ b/cloudflare/src/jobs/store.ts
@@ -389,7 +389,7 @@ export async function ask(
.bind(input.summary, input.session, now, job, attempt, runner.id, id),
]);
if (question!.meta.changes === 1)
- return { question: id, cancel: !!(update!.results[0] as { cancel: number }).cancel };
+ return { question: id, cancel: !!(update!.results[0] as { cancel: number }).cancel, asked: true };
// An identical retry after the first request landed; anything else is refused.
const existing = await db
.prepare(
@@ -407,13 +407,14 @@ export async function ask(
)
.first<{ id: string; cancel: number }>();
if (!existing) throw new JobError("Attempt is no longer current");
- return { question: existing.id, cancel: !!existing.cancel };
+ return { question: existing.id, cancel: !!existing.cancel, asked: false };
}
/**
* Finish the current attempt. An account limit pauses the job with a
* decision instead of moving it to another account. A requested cancel wins
- * over whatever the runner reports, keeping its partial result.
+ * over whatever the runner reports, keeping its partial result. Returns the
+ * job's new state, or null for a retry of a report that already landed.
*/
export async function finish(
db: D1Database,
@@ -447,7 +448,7 @@ export async function finish(
statements.push(
db
.prepare(
- "UPDATE jobs SET state=CASE WHEN cancel=1 THEN 'cancelled' ELSE ? END,summary=?,result=?,lease=NULL,updatedAt=? WHERE id=? AND attempt=? AND runner=? AND state='running'",
+ "UPDATE jobs SET state=CASE WHEN cancel=1 THEN 'cancelled' ELSE ? END,summary=?,result=?,lease=NULL,updatedAt=? WHERE id=? AND attempt=? AND runner=? AND state='running' RETURNING state",
)
.bind(
input.outcome === "limited" ? "waiting" : input.outcome,
@@ -460,7 +461,8 @@ export async function finish(
),
);
const results = await db.batch(statements);
- if (results.at(-1)!.meta.changes === 1) return;
+ const finished = results.at(-1)!.results[0] as { state: JobState } | undefined;
+ if (finished) return finished.state;
// An identical retry after the first report landed is accepted; anything else is refused.
const landed = await db
.prepare(
@@ -469,6 +471,7 @@ export async function finish(
.bind(job, attempt, body, stored, attempt, runner.id)
.first();
if (!landed) throw new JobError("Attempt is no longer current");
+ return null;
}
/** How long an unrecorded branch update blocks another publication of the same job. */
diff --git a/cloudflare/src/layout.tsx b/cloudflare/src/layout.tsx
index 2f25a760..0a511f31 100644
--- a/cloudflare/src/layout.tsx
+++ b/cloudflare/src/layout.tsx
@@ -22,6 +22,8 @@ export type Page = {
// The current path, where signing in returns.
path: string;
user: User | null;
+ /** The signed-in maintainer's new decisions, for the nav and the installed app's badge. */
+ unread: number;
crumbs?: Crumb[];
};
@@ -40,20 +42,27 @@ export function Layout(props: Page & { children: Child }) {
+
+
-
+
+ {props.user && }
);
@@ -129,6 +139,26 @@ export function Paste(props: { id: string; text: string }) {
);
}
+/**
+ * Turning decision notifications on or off for this device. /app.js reveals
+ * the control that fits the browser, and asks for permission only when the
+ * enable button is clicked. `compact` hides it once this device is on.
+ */
+export function Notifications(props: { publicKey: string | null; compact?: boolean }) {
+ if (!props.publicKey) return null;
+ return (
+
+
+
+
+
+ );
+}
+
// Progressive enhancement: reveal copy buttons only where the clipboard works.
const copyScript = `for (const button of document.querySelectorAll("[data-copy]")) {
if (!navigator.clipboard) continue;
diff --git a/cloudflare/src/manifest.webmanifest b/cloudflare/src/manifest.webmanifest
new file mode 100644
index 00000000..45ef9eac
--- /dev/null
+++ b/cloudflare/src/manifest.webmanifest
@@ -0,0 +1,16 @@
+{
+ "name": "Quest",
+ "short_name": "Quest",
+ "description": "Decisions on your Quest projects: agent questions, issues, and pull requests.",
+ "start_url": "/inbox",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#0a0c11",
+ "theme_color": "#0a0c11",
+ "icons": [
+ { "src": "/theme/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/theme/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/theme/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
+ { "src": "/theme/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/cloudflare/src/push/notify.ts b/cloudflare/src/push/notify.ts
new file mode 100644
index 00000000..b1cb88db
--- /dev/null
+++ b/cloudflare/src/push/notify.ts
@@ -0,0 +1,233 @@
+// Push notifications for decisions. Only something that waits on a
+// maintainer pushes: an agent's question, an account limit, a stopped job,
+// a new issue, or a pull request ready for review. Each notification names
+// its project and item and opens that item in the inbox, where every answer
+// is made; it carries no answer of its own. One tag per item coalesces
+// repeated updates on the device, and one Topic per item at the push service.
+
+import { Hono } from "hono";
+import { createAuth } from "../auth";
+import type { Enrollment } from "../github/service";
+import { title, unread } from "../inbox/model";
+import { inboxURL } from "../jobs/routes";
+import { type KeyPair, type Subscription, base64url, send } from "./webpush";
+
+/** Why an item pushes, which leads its notification. */
+export type Reason = "question" | "publication" | "account" | "failed" | "issue" | "pull";
+const reasons: Record = {
+ question: "Agent question",
+ publication: "Publishing decision",
+ account: "Account limit",
+ failed: "Job stopped",
+ issue: "New issue",
+ pull: "Ready for review",
+};
+
+/** A decision item to notify about, and its title when the caller already has it. */
+export type Decision = { project: Enrollment; target: string; reason: Reason; title?: string };
+
+/** The application server's VAPID keys, or null where notifications are not configured. */
+export function keys(env: Env): KeyPair | null {
+ return env.VAPID_PUBLIC_KEY && env.VAPID_PRIVATE_KEY
+ ? { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY }
+ : null;
+}
+
+/** A device to notify: its subscription and whose it is. */
+export type Device = Subscription & { user: string };
+
+/**
+ * The devices of everyone whose inbox shows this project: today the
+ * maintainer who enrolled it. A push reveals only the project and the item's
+ * title; the inbox rechecks authority before showing anything more.
+ */
+export async function devices(env: Env, project: number): Promise {
+ if (!keys(env)) return [];
+ const { results } = await env.DB.prepare(
+ "SELECT s.endpoint,s.p256dh,s.auth,s.user FROM push_subscriptions s JOIN github_projects p ON p.enrolledBy=s.user WHERE p.id=?",
+ )
+ .bind(project)
+ .all();
+ return results;
+}
+
+// Undelivered decisions stay in the inbox, so a day-old push adds nothing.
+const TTL = 24 * 60 * 60;
+
+/** Push one decision to these devices, and forget any the push service reports gone. */
+export async function deliver(env: Env, decision: Decision, targets: Device[]) {
+ const pair = keys(env);
+ if (!pair || !targets.length) return;
+ const { project, target } = decision;
+ const name = decision.title ?? (await title(env.DB, project, target));
+ const tag = `${project.id}/${target}`;
+ const topic = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag)))).slice(
+ 0,
+ 32,
+ );
+ const users = [...new Set(targets.map((d) => d.user))];
+ const badges = new Map(await Promise.all(users.map(async (user) => [user, await unread(env, user)] as const)));
+ const results = await Promise.allSettled(
+ targets.map((device) =>
+ send(
+ device,
+ {
+ title: `${project.owner}/${project.name}`,
+ body: `${reasons[decision.reason]}: ${name.slice(0, 120)}`,
+ tag,
+ url: inboxURL(project.id, target),
+ badge: badges.get(device.user) ?? 0,
+ },
+ { keys: pair, subject: new URL(env.AUTH_URL).origin, topic, ttl: TTL },
+ ),
+ ),
+ );
+ const gone = targets.filter((_, i) => {
+ const result = results[i]!;
+ if (result.status === "rejected" || result.value === "failed")
+ console.error({ event: "push_failed", host: new URL(targets[i]!.endpoint).host });
+ return result.status === "fulfilled" && result.value === "gone";
+ });
+ if (gone.length)
+ await env.DB.batch(
+ gone.map((device) =>
+ env.DB.prepare("DELETE FROM push_subscriptions WHERE endpoint=? AND user=?").bind(
+ device.endpoint,
+ device.user,
+ ),
+ ),
+ );
+}
+
+/** Notify everyone with a device about a decision on a project. */
+export async function notify(env: Env, decision: Omit & { project: number }) {
+ const targets = await devices(env, decision.project);
+ if (!targets.length) return;
+ const project = await env.DB.prepare("SELECT id,owner,name,state FROM github_projects WHERE id=?")
+ .bind(decision.project)
+ .first();
+ if (project) await deliver(env, { ...decision, project }, targets);
+}
+
+// The push services browsers use. A subscription elsewhere is refused, so a
+// client cannot make the Worker post to an arbitrary address.
+const services = [
+ /^fcm\.googleapis\.com$/,
+ /^updates\.push\.services\.mozilla\.com$/,
+ /^([a-z0-9-]+\.)*push\.apple\.com$/,
+ /^([a-z0-9-]+\.)*notify\.windows\.com$/,
+];
+function endpoint(value: unknown): string | null {
+ if (typeof value !== "string" || value.length > 1000) return null;
+ try {
+ const url = new URL(value);
+ return url.protocol === "https:" && services.some((host) => host.test(url.hostname)) ? url.href : null;
+ } catch {
+ return null;
+ }
+}
+function key(value: unknown, bytes: number) {
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value)) return null;
+ return Math.floor((value.length * 3) / 4) === bytes ? value : null;
+}
+
+/**
+ * The signed-in device's subscription. Enabling stores it for this user and
+ * session; disabling removes it. The browser asks for permission only from
+ * a click on the enable button.
+ */
+export const subscriptions = new Hono<{ Bindings: Env }>();
+subscriptions.use("/push/*", async (c, next) => {
+ if (c.req.header("Origin") !== new URL(c.env.AUTH_URL).origin) return c.json({ error: "Invalid origin" }, 403);
+ await next();
+});
+subscriptions.post("/push/subscribe", async (c) => {
+ const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ error: "Sign in required" }, 401);
+ if (!keys(c.env)) return c.json({ error: "Notifications are not configured" }, 503);
+ const body = await c.req.json().catch(() => null);
+ const found = endpoint(body?.endpoint);
+ const p256dh = key(body?.keys?.p256dh, 65);
+ const auth = key(body?.keys?.auth, 16);
+ if (!found || !p256dh || !auth) return c.json({ error: "Invalid push subscription" }, 400);
+ const statements = [
+ c.env.DB.prepare(
+ `INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt) VALUES(?,?,?,?,?,?)
+ ON CONFLICT(endpoint) DO UPDATE SET user=excluded.user,session=excluded.session,p256dh=excluded.p256dh,auth=excluded.auth,createdAt=excluded.createdAt`,
+ ).bind(found, session.user.id, session.session.id, p256dh, auth, Date.now()),
+ ];
+ // A browser that rotated its subscription names the one it replaces.
+ const replaced = endpoint(body?.replaces);
+ if (replaced && replaced !== found)
+ statements.push(
+ c.env.DB.prepare("DELETE FROM push_subscriptions WHERE endpoint=? AND user=?").bind(
+ replaced,
+ session.user.id,
+ ),
+ );
+ await c.env.DB.batch(statements);
+ return c.body(null, 204);
+});
+subscriptions.post("/push/unsubscribe", async (c) => {
+ const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ error: "Sign in required" }, 401);
+ const body = await c.req.json().catch(() => null);
+ if (typeof body?.endpoint !== "string") return c.json({ error: "Endpoint required" }, 400);
+ await c.env.DB.prepare("DELETE FROM push_subscriptions WHERE endpoint=? AND user=?")
+ .bind(body.endpoint, session.user.id)
+ .run();
+ return c.body(null, 204);
+});
+
+/** Stop notifying the devices a session enabled, when it signs out. */
+export function forget(db: D1Database, session: string) {
+ return db.prepare("DELETE FROM push_subscriptions WHERE session=?").bind(session).run();
+}
+
+/** How many devices notify a maintainer. */
+export async function count(db: D1Database, user: string) {
+ return (
+ (
+ await db
+ .prepare("SELECT COUNT(*) AS n FROM push_subscriptions WHERE user=?")
+ .bind(user)
+ .first<{ n: number }>()
+ )?.n ?? 0
+ );
+}
+
+/** Notify about a job that now waits on a maintainer. */
+export async function job(env: Env, id: string, reason: Reason) {
+ const found = await env.DB.prepare("SELECT repository,target FROM jobs WHERE id=?")
+ .bind(id)
+ .first<{ repository: number; target: string }>();
+ if (found) await notify(env, { project: found.repository, target: found.target, reason });
+}
+
+/**
+ * Notify about a new issue, or a pull request ready for review, that a
+ * webhook reported on an enrolled project. A paused project offers no work
+ * on them, so it does not notify. Other updates only refresh the inbox.
+ */
+export async function arrived(env: Env, repository: number, installation: number, event: string, payload: any) {
+ const pull = payload.pull_request;
+ const issue = payload.issue;
+ let decision: Pick | null = null;
+ if (event === "issues" && (payload.action === "opened" || payload.action === "reopened") && !issue?.pull_request)
+ decision = { target: `issues/${issue?.number}`, reason: "issue", title: issue?.title };
+ else if (
+ event === "pull_request" &&
+ (payload.action === "ready_for_review" ||
+ ((payload.action === "opened" || payload.action === "reopened") && pull?.draft === false))
+ )
+ decision = { target: `pulls/${pull?.number}`, reason: "pull", title: pull?.title };
+ if (!decision || !/^(issues|pulls)\/[1-9]\d*$/.test(decision.target)) return;
+ if (typeof decision.title !== "string") delete decision.title;
+ const project = await env.DB.prepare(
+ "SELECT id,owner,name,state FROM github_projects WHERE id=? AND installation=? AND state='enabled'",
+ )
+ .bind(repository, installation)
+ .first();
+ if (!project) return;
+ await deliver(env, { ...decision, project }, await devices(env, project.id));
+}
diff --git a/cloudflare/src/push/webpush.ts b/cloudflare/src/push/webpush.ts
new file mode 100644
index 00000000..e8fdc4b6
--- /dev/null
+++ b/cloudflare/src/push/webpush.ts
@@ -0,0 +1,150 @@
+// Web Push with WebCrypto only: RFC 8291 message encryption (aes128gcm,
+// RFC 8188) and RFC 8292 VAPID signing, which the Workers runtime supports
+// directly, so there is no push library.
+
+const encoder = new TextEncoder();
+
+export function base64url(bytes: Uint8Array): string {
+ return btoa(String.fromCharCode(...bytes))
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replace(/=+$/, "");
+}
+
+export function unbase64url(text: string): Uint8Array {
+ return Uint8Array.from(atob(text.replaceAll("-", "+").replaceAll("_", "/")), (c) => c.charCodeAt(0));
+}
+
+function concat(...parts: Uint8Array[]) {
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
+ let offset = 0;
+ for (const part of parts) {
+ out.set(part, offset);
+ offset += part.length;
+ }
+ return out;
+}
+
+/** A P-256 key pair in the usual Web Push form: the 65-byte public point and the 32-byte private scalar, base64url. */
+export type KeyPair = { publicKey: string; privateKey: string };
+
+// WebCrypto imports a raw private scalar only as a JWK, which also needs the public point.
+function jwk(pair: KeyPair): JsonWebKey {
+ const point = unbase64url(pair.publicKey);
+ if (point.length !== 65 || point[0] !== 4) throw new Error("Expected an uncompressed P-256 public key");
+ return {
+ kty: "EC",
+ crv: "P-256",
+ x: base64url(point.slice(1, 33)),
+ y: base64url(point.slice(33)),
+ d: pair.privateKey,
+ ext: true,
+ };
+}
+
+async function hkdf(salt: Uint8Array, ikm: Uint8Array, info: Uint8Array, bytes: number) {
+ const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
+ return new Uint8Array(
+ await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, key, bytes * 8),
+ );
+}
+
+/** A browser's push subscription, as `PushSubscription.toJSON()` reports it. */
+export type Subscription = { endpoint: string; p256dh: string; auth: string };
+
+/**
+ * Encrypt one push message for a subscription as a single aes128gcm record.
+ * Tests pass the RFC's fixed sender key and salt; otherwise both are fresh.
+ */
+export async function encrypt(
+ subscription: Subscription,
+ plaintext: Uint8Array,
+ fixed?: { sender: KeyPair; salt: Uint8Array },
+) {
+ const receiver = unbase64url(subscription.p256dh);
+ const secret = unbase64url(subscription.auth);
+ if (receiver.length !== 65 || secret.length !== 16) throw new Error("Invalid push subscription keys");
+ const ecdh = { name: "ECDH", namedCurve: "P-256" } as const;
+ const sender = fixed
+ ? {
+ privateKey: await crypto.subtle.importKey("jwk", jwk(fixed.sender), ecdh, false, ["deriveBits"]),
+ publicKey: unbase64url(fixed.sender.publicKey),
+ }
+ : await (async () => {
+ const pair = (await crypto.subtle.generateKey(ecdh, true, ["deriveBits"])) as CryptoKeyPair;
+ return {
+ privateKey: pair.privateKey,
+ publicKey: new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer),
+ };
+ })();
+ const salt = fixed?.salt ?? crypto.getRandomValues(new Uint8Array(16));
+ const peer = await crypto.subtle.importKey("raw", receiver, ecdh, false, []);
+ const shared = new Uint8Array(
+ await crypto.subtle.deriveBits(
+ { name: "ECDH", public: peer } as SubtleCryptoDeriveKeyAlgorithm,
+ sender.privateKey,
+ 256,
+ ),
+ );
+ const ikm = await hkdf(secret, shared, concat(encoder.encode("WebPush: info\0"), receiver, sender.publicKey), 32);
+ const cek = await hkdf(salt, ikm, encoder.encode("Content-Encoding: aes128gcm\0"), 16);
+ const nonce = await hkdf(salt, ikm, encoder.encode("Content-Encoding: nonce\0"), 12);
+ const key = await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["encrypt"]);
+ // One record: the plaintext and its last-record delimiter, with no padding.
+ const sealed = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv: nonce },
+ key,
+ concat(plaintext, new Uint8Array([2])),
+ );
+ // The header: salt, record size, and the sender's public key as the key ID.
+ const header = new Uint8Array(21);
+ header.set(salt);
+ new DataView(header.buffer).setUint32(16, 4096);
+ header[20] = sender.publicKey.length;
+ return concat(header, sender.publicKey, new Uint8Array(sealed));
+}
+
+/** The VAPID `Authorization` header for a push service, signed by the application server key. */
+export async function vapid(endpoint: string, keys: KeyPair, subject: string, now: number) {
+ const json = (value: unknown) => base64url(encoder.encode(JSON.stringify(value)));
+ // Push services accept at most 24 hours; stay under it.
+ const claims = { aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 3600, sub: subject };
+ const unsigned = `${json({ typ: "JWT", alg: "ES256" })}.${json(claims)}`;
+ const key = await crypto.subtle.importKey("jwk", jwk(keys), { name: "ECDSA", namedCurve: "P-256" }, false, [
+ "sign",
+ ]);
+ // WebCrypto's ECDSA signature is already JWS's raw r||s form.
+ const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, encoder.encode(unsigned));
+ return `vapid t=${unsigned}.${base64url(new Uint8Array(signature))}, k=${keys.publicKey}`;
+}
+
+/** How a push service's reply leaves a subscription: delivered, gone for good, or failed this time. */
+export type Delivery = "sent" | "gone" | "failed";
+
+/**
+ * Send one encrypted message. `topic` makes the push service replace an
+ * undelivered message with the same topic instead of queueing another.
+ */
+export async function send(
+ subscription: Subscription,
+ message: unknown,
+ options: { keys: KeyPair; subject: string; topic: string; ttl: number },
+): Promise {
+ const body = await encrypt(subscription, encoder.encode(JSON.stringify(message)));
+ const response = await fetch(subscription.endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: await vapid(subscription.endpoint, options.keys, options.subject, Date.now()),
+ "Content-Encoding": "aes128gcm",
+ "Content-Type": "application/octet-stream",
+ TTL: String(options.ttl),
+ Topic: options.topic,
+ // Decisions are what the notifications are for, so they should arrive promptly.
+ Urgency: "high",
+ },
+ body,
+ });
+ await response.body?.cancel();
+ if (response.ok) return "sent";
+ return response.status === 404 || response.status === 410 ? "gone" : "failed";
+}
diff --git a/cloudflare/src/site.css b/cloudflare/src/site.css
index c3bb5f20..e9a28f70 100644
--- a/cloudflare/src/site.css
+++ b/cloudflare/src/site.css
@@ -1140,6 +1140,25 @@ a.entry-title:hover {
position: static;
}
+ /* A tighter nav leaves the screen to the decision. */
+ .nav {
+ gap: 0.25rem 1rem;
+ padding-block: 0.5rem;
+ }
+
+ .nav a {
+ font-size: 0.95rem;
+ }
+
+ .nav a.brand {
+ font-size: 1.25rem;
+ }
+
+ .account {
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ }
+
.region {
padding: 0.75rem 0.75rem 1rem;
}
@@ -1343,3 +1362,35 @@ a.entry-title:hover {
.entry-title[aria-current] {
color: var(--ql-accent);
}
+
+/* The nav's count of new decisions. */
+.nav .count {
+ display: inline-block;
+ min-width: 1.5em;
+ margin-left: 0.4em;
+ padding: 0 0.35em;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ letter-spacing: 0;
+ text-align: center;
+ color: var(--ql-bg);
+ background: var(--ql-accent);
+ border-radius: 999px;
+}
+
+/* The notification switch for this device, revealed by /app.js. */
+.notify {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ align-items: center;
+ margin-block: 1rem;
+}
+.notify[hidden],
+.notify [hidden] {
+ display: none;
+}
+.notify p {
+ flex: 1 1 16rem;
+ margin: 0;
+}
diff --git a/cloudflare/src/sw.js b/cloudflare/src/sw.js
new file mode 100644
index 00000000..36afc7ce
--- /dev/null
+++ b/cloudflare/src/sw.js
@@ -0,0 +1,66 @@
+// The service worker: shows decision notifications and opens the decision
+// when one is tapped. Notifications carry no answer actions; every answer is
+// made in the inbox. It caches nothing, so pages always load fresh.
+self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
+
+self.addEventListener("push", (event) => {
+ let message = {};
+ try {
+ message = event.data?.json() ?? {};
+ } catch {}
+ const badge = Number(message.badge);
+ event.waitUntil(
+ Promise.all([
+ // One tag per decision item replaces its earlier notification.
+ self.registration.showNotification(message.title || "Quest", {
+ body: message.body || "Something needs your decision.",
+ tag: message.tag,
+ icon: "/theme/icon-192.png",
+ data: { url: message.url },
+ }),
+ "setAppBadge" in navigator
+ ? (badge > 0 ? navigator.setAppBadge(badge) : navigator.clearAppBadge()).catch(() => {})
+ : null,
+ ]),
+ );
+});
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+ // Only ever open a page on this site.
+ const url = new URL(event.notification.data?.url || "/inbox", self.location.origin);
+ if (url.origin !== self.location.origin) return;
+ event.waitUntil(
+ (async () => {
+ const windows = await self.clients.matchAll({ type: "window" });
+ const open = windows.find((client) => new URL(client.url).origin === url.origin);
+ if (open) {
+ await open.focus();
+ // Navigating needs a page this worker controls; otherwise open another.
+ return open.navigate(url.href).catch(() => self.clients.openWindow(url.href));
+ }
+ return self.clients.openWindow(url.href);
+ })(),
+ );
+});
+
+// A browser that replaces its subscription keeps notifying under the new one.
+self.addEventListener("pushsubscriptionchange", (event) => {
+ const old = event.oldSubscription;
+ if (!old?.options.applicationServerKey) return;
+ event.waitUntil(
+ (async () => {
+ const subscription =
+ event.newSubscription ??
+ (await self.registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: old.options.applicationServerKey,
+ }));
+ await fetch("/push/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...subscription.toJSON(), replaces: old.endpoint }),
+ });
+ })(),
+ );
+});
diff --git a/cloudflare/test/env.d.ts b/cloudflare/test/env.d.ts
index 84095cfe..25e3a9e2 100644
--- a/cloudflare/test/env.d.ts
+++ b/cloudflare/test/env.d.ts
@@ -1,5 +1,7 @@
declare namespace Cloudflare {
interface Env {
TEST_MIGRATIONS: import("cloudflare:test").D1Migration[];
+ // A test-only key, set in vitest.config.ts.
+ VAPID_PRIVATE_KEY: string;
}
}
diff --git a/cloudflare/test/jobs.test.ts b/cloudflare/test/jobs.test.ts
index 35072003..ced8e25b 100644
--- a/cloudflare/test/jobs.test.ts
+++ b/cloudflare/test/jobs.test.ts
@@ -341,7 +341,7 @@ describe("job coordination", () => {
await run({ [home.id]: 1 }, 0);
const input = { summary: "Need a decision", session: "session-1", prompt: { question: "A or B?" } };
const { question } = await store.ask(db, home, id, 1, input, 1);
- expect(await store.ask(db, home, id, 1, input, 2)).toEqual({ question, cancel: false });
+ expect(await store.ask(db, home, id, 1, input, 2)).toEqual({ question, cancel: false, asked: false });
await expect(store.ask(db, home, id, 1, { ...input, prompt: { question: "C?" } }, 2)).rejects.toThrow(
"no longer current",
);
@@ -468,6 +468,52 @@ describe("runner connections", () => {
expect((await request(machine.token, false)).status).toBe(426);
});
+ it("notifies the project's maintainer when a job asks a question or fails, once each", async () => {
+ const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
+ "deriveBits",
+ ])) as CryptoKeyPair;
+ const p256dh = Buffer.from((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer).toString(
+ "base64url",
+ );
+ await db
+ .prepare(
+ "INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt) VALUES('https://fcm.googleapis.com/fcm/send/owner','owner','s',?,'AAAAAAAAAAAAAAAAAAAAAA',0)",
+ )
+ .bind(p256dh)
+ .run();
+ const sent: string[] = [];
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
+ sent.push(String(input));
+ return new Response(null, { status: 201 });
+ });
+ const machine = await runner("owner", "machine");
+ const live = await connect(machine.token);
+ live.send(status(1, "main", [], 2));
+ await live.next();
+ const asking = await job(10, "owner", {}, Date.now());
+ const failing = await job(10, "owner", { target: "quest/second.md", branch: "quest/second" }, Date.now());
+ await hub().dispatch("owner");
+ await live.next();
+ await live.next();
+ const attempts = [asking, failing].map((id) => ({ job: id, attempt: 1 }));
+ live.send(status(9, "main", attempts, 0));
+ expect(await live.next()).toMatchObject({ type: "reply", id: 9 });
+ const prompt = { questions: [{ question: "Which?", options: [{ label: "A" }, { label: "B" }] }] };
+ const question = { id: 2, type: "question", job: asking, attempt: 1, summary: "Asked", session: "s", prompt };
+ live.send(question);
+ expect(await live.next()).toMatchObject({ type: "reply", id: 2, cancel: false });
+ // A retried question is the same decision, so it does not notify again.
+ live.send(question);
+ expect(await live.next()).toMatchObject({ type: "reply", id: 2, cancel: false });
+ live.send({ id: 3, type: "result", job: failing, attempt: 1, outcome: "failed", summary: "Broke" });
+ expect(await live.next()).toEqual({ type: "reply", id: 3 });
+ await vi.waitFor(() => expect(sent).toHaveLength(2));
+ expect(sent).toEqual([
+ "https://fcm.googleapis.com/fcm/send/owner",
+ "https://fcm.googleapis.com/fcm/send/owner",
+ ]);
+ });
+
it("pushes assignments and cancellation and fences progress over the live connection", async () => {
const machine = await runner("owner", "machine");
const live = await connect(machine.token);
diff --git a/cloudflare/test/project-fixtures.ts b/cloudflare/test/project-fixtures.ts
index 9d98e402..9847dadd 100644
--- a/cloudflare/test/project-fixtures.ts
+++ b/cloudflare/test/project-fixtures.ts
@@ -1,5 +1,6 @@
// A signed-in maintainer of an enrolled project with complete observations,
// and a mocked GitHub that answers the requests authority checks make.
+import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { vi } from "vitest";
import { app } from "../src/index";
@@ -140,7 +141,9 @@ export async function delivery(
);
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
const signature = "sha256=" + Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
- return app.request(
+ // Background work, such as notifications, finishes before the test goes on.
+ const ctx = createExecutionContext();
+ const response = await app.request(
"http://localhost:8787/webhooks/github",
{
method: "POST",
@@ -148,5 +151,8 @@ export async function delivery(
body,
},
env,
+ ctx,
);
+ await waitOnExecutionContext(ctx);
+ return response;
}
diff --git a/cloudflare/test/publish.test.ts b/cloudflare/test/publish.test.ts
index e42459c6..ed0e4494 100644
--- a/cloudflare/test/publish.test.ts
+++ b/cloudflare/test/publish.test.ts
@@ -1,3 +1,4 @@
+import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { app } from "../src/index";
@@ -203,9 +204,10 @@ async function running(extra: Partial = {}) {
await store.dispatch(db, user, new Map([[machine.id, 1]]), 0);
return id;
}
-function publish(body: Record, token = machine.token) {
+async function publish(body: Record, token = machine.token) {
const text = JSON.stringify(body);
- return app.request(
+ const ctx = createExecutionContext();
+ const response = await app.request(
`${origin}/api/runner/publish`,
{
method: "POST",
@@ -217,7 +219,10 @@ function publish(body: Record, token = machine.token) {
body: text,
},
testEnv,
+ ctx,
);
+ await waitOnExecutionContext(ctx);
+ return response;
}
const file = (path: string, content: string, executable?: boolean) => ({
path,
diff --git a/cloudflare/test/push.test.ts b/cloudflare/test/push.test.ts
new file mode 100644
index 00000000..0fbc702f
--- /dev/null
+++ b/cloudflare/test/push.test.ts
@@ -0,0 +1,312 @@
+import { env } from "cloudflare:workers";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { app } from "../src/index";
+import * as store from "../src/jobs/store";
+import * as push from "../src/push/notify";
+import { base64url, encrypt, unbase64url, vapid } from "../src/push/webpush";
+import { privateKey } from "./github-key";
+import { origin } from "./login-fixtures";
+import { commit, delivery, maintainerOf } from "./project-fixtures";
+
+const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
+const db = env.DB;
+const keys = { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY };
+
+describe("Web Push encryption and signing", () => {
+ it("matches RFC 8291's worked example", async () => {
+ const body = await encrypt(
+ {
+ endpoint: "https://push.example.net/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV",
+ p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ auth: "BTBZMqHH6r4Tts7J_aSIgg",
+ },
+ new TextEncoder().encode("When I grow up, I want to be a watermelon"),
+ {
+ sender: {
+ publicKey:
+ "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8",
+ privateKey: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw",
+ },
+ salt: unbase64url("DGv6ra1nlYgDCS1FRnbzlw"),
+ },
+ );
+ expect(base64url(body)).toBe(
+ "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
+ );
+ });
+
+ it("signs a VAPID token the push service can verify", async () => {
+ const header = await vapid("https://fcm.googleapis.com/fcm/send/abc", keys, "https://kix.quest", 1_000_000);
+ const [, token, key] = header.match(/^vapid t=([^,]+), k=(.+)$/)!;
+ expect(key).toBe(keys.publicKey);
+ const [head, claims, signature] = token!.split(".");
+ expect(JSON.parse(new TextDecoder().decode(unbase64url(claims!)))).toEqual({
+ aud: "https://fcm.googleapis.com",
+ exp: 1000 + 12 * 3600,
+ sub: "https://kix.quest",
+ });
+ const verifier = await crypto.subtle.importKey(
+ "raw",
+ unbase64url(keys.publicKey),
+ { name: "ECDSA", namedCurve: "P-256" },
+ false,
+ ["verify"],
+ );
+ const valid = await crypto.subtle.verify(
+ { name: "ECDSA", hash: "SHA-256" },
+ verifier,
+ unbase64url(signature!),
+ new TextEncoder().encode(`${head}.${claims}`),
+ );
+ expect(valid).toBe(true);
+ });
+});
+
+/** A browser's side of a push subscription, which can read what the Worker sends it. */
+async function browser(endpoint = `https://fcm.googleapis.com/fcm/send/${crypto.randomUUID()}`) {
+ const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
+ "deriveBits",
+ ])) as CryptoKeyPair;
+ const p256dh = base64url(new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer));
+ const auth = crypto.getRandomValues(new Uint8Array(16));
+ const hkdf = async (salt: Uint8Array, ikm: Uint8Array, info: string | Uint8Array, bytes: number) => {
+ const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
+ const data = typeof info === "string" ? new TextEncoder().encode(info) : info;
+ return new Uint8Array(
+ await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info: data }, key, bytes * 8),
+ );
+ };
+ return {
+ endpoint,
+ json: { endpoint, keys: { p256dh, auth: base64url(auth) } },
+ /** Decrypt an aes128gcm body as the browser would. */
+ async read(body: Uint8Array) {
+ const salt = body.slice(0, 16);
+ const sender = body.slice(21, 21 + body[20]!);
+ const peer = await crypto.subtle.importKey("raw", sender, { name: "ECDH", namedCurve: "P-256" }, false, []);
+ const shared = new Uint8Array(
+ await crypto.subtle.deriveBits(
+ { name: "ECDH", public: peer } as SubtleCryptoDeriveKeyAlgorithm,
+ pair.privateKey,
+ 256,
+ ),
+ );
+ const info = new Uint8Array([
+ ...new TextEncoder().encode("WebPush: info\0"),
+ ...unbase64url(p256dh),
+ ...sender,
+ ]);
+ const ikm = await hkdf(auth, shared, info, 32);
+ const cek = await crypto.subtle.importKey(
+ "raw",
+ await hkdf(salt, ikm, "Content-Encoding: aes128gcm\0", 16),
+ "AES-GCM",
+ false,
+ ["decrypt"],
+ );
+ const iv = await hkdf(salt, ikm, "Content-Encoding: nonce\0", 12);
+ const plain = new Uint8Array(
+ await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cek, body.slice(21 + body[20]!)),
+ );
+ expect(plain.at(-1)).toBe(2);
+ return JSON.parse(new TextDecoder().decode(plain.slice(0, -1)));
+ },
+ };
+}
+
+type Sent = { endpoint: string; headers: Headers; body: Uint8Array };
+
+describe("decision notifications", () => {
+ let user = "";
+ let cookie = "";
+ let sent: Sent[] = [];
+ let status = 201;
+
+ beforeEach(async () => {
+ await db.batch([
+ db.prepare("DELETE FROM push_subscriptions"),
+ db.prepare("DELETE FROM jobs"),
+ db.prepare("DELETE FROM runners"),
+ db.prepare("DELETE FROM github_projects"),
+ ]);
+ const maintainer = await maintainerOf();
+ ({ user, cookie } = maintainer);
+ sent = [];
+ status = 201;
+ // Push services answer here; everything else goes to the mocked GitHub.
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
+ const url = String(input instanceof Request ? input.url : input);
+ if (!url.startsWith("https://fcm.googleapis.com/")) return maintainer.mock(input);
+ sent.push({
+ endpoint: url,
+ headers: new Headers(init?.headers),
+ body: new Uint8Array(await new Response(init?.body).arrayBuffer()),
+ });
+ return new Response(null, { status });
+ });
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ function post(path: string, body: unknown, headers: Record = { Cookie: cookie, Origin: origin }) {
+ return app.request(
+ `${origin}${path}`,
+ { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) },
+ testEnv,
+ );
+ }
+ async function subscribe() {
+ const device = await browser();
+ expect((await post("/push/subscribe", device.json)).status).toBe(204);
+ return device;
+ }
+ const subscriptions = async () =>
+ (await db.prepare("SELECT endpoint,user FROM push_subscriptions").all<{ endpoint: string; user: string }>())
+ .results;
+
+ it("stores a signed-in device's subscription only from this site and a known push service", async () => {
+ const device = await browser();
+ expect((await post("/push/subscribe", device.json, { Origin: origin })).status).toBe(401);
+ expect(
+ (await post("/push/subscribe", device.json, { Cookie: cookie, Origin: "https://evil.test" })).status,
+ ).toBe(403);
+ const elsewhere = { ...device.json, endpoint: "https://evil.test/push" };
+ expect((await post("/push/subscribe", elsewhere)).status).toBe(400);
+ const short = { ...device.json, keys: { ...device.json.keys, auth: "AAAA" } };
+ expect((await post("/push/subscribe", short)).status).toBe(400);
+ expect((await post("/push/subscribe", device.json)).status).toBe(204);
+ expect(await subscriptions()).toEqual([{ endpoint: device.endpoint, user }]);
+ // A rotated subscription replaces the one it names.
+ const rotated = await browser();
+ expect((await post("/push/subscribe", { ...rotated.json, replaces: device.endpoint })).status).toBe(204);
+ expect(await subscriptions()).toEqual([{ endpoint: rotated.endpoint, user }]);
+ expect((await post("/push/unsubscribe", { endpoint: rotated.endpoint })).status).toBe(204);
+ expect(await subscriptions()).toEqual([]);
+ });
+
+ it("stops notifying a device once it signs out", async () => {
+ await subscribe();
+ const account = await (await app.request(`${origin}/account`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(account).toContain("1 device is notified.");
+ expect(account).toContain(`data-push="${env.VAPID_PUBLIC_KEY}"`);
+ const out = await app.request(
+ `${origin}/sign-out`,
+ { method: "POST", headers: { Cookie: cookie, Origin: origin } },
+ testEnv,
+ );
+ expect(out.status).toBe(303);
+ expect(await subscriptions()).toEqual([]);
+ });
+
+ it("pushes a new issue once, opening its decision, and never other updates", async () => {
+ const device = await subscribe();
+ const opened = {
+ action: "opened",
+ installation: { id: 5 },
+ repository: { id: 10 },
+ issue: { id: 13, number: 3, title: "Crash on start" },
+ };
+ const id = crypto.randomUUID();
+ expect((await delivery("issues", opened, id)).status).toBe(202);
+ expect((await delivery("issues", opened, id)).status).toBe(200);
+ expect((await delivery("issues", { ...opened, action: "edited" })).status).toBe(202);
+ expect((await delivery("issue_comment", { ...opened, action: "created" })).status).toBe(202);
+ expect(sent).toHaveLength(1);
+ const [message] = sent;
+ expect(message!.endpoint).toBe(device.endpoint);
+ expect(message!.headers.get("Content-Encoding")).toBe("aes128gcm");
+ expect(message!.headers.get("Authorization")).toMatch(/^vapid t=.+, k=/);
+ expect(message!.headers.get("Topic")).toMatch(/^[A-Za-z0-9_-]{32}$/);
+ expect(await device.read(message!.body)).toEqual({
+ title: "upstream/project",
+ body: "New issue: Crash on start",
+ tag: "10/issues/3",
+ url: "/inbox?project=10&item=issues%2F3",
+ // The open issue and pull request, and the new issue. Ready quests wait for a
+ // refresh after a webhook marks the observations stale.
+ badge: 3,
+ });
+ });
+
+ it("pushes a pull request when it is ready for review, not while it is a draft", async () => {
+ const device = await subscribe();
+ const event = (action: string, draft: boolean) => ({
+ action,
+ installation: { id: 5 },
+ repository: { id: 10 },
+ pull_request: { id: 22, number: 4, title: "Add it", draft },
+ });
+ await delivery("pull_request", event("opened", true));
+ expect(sent).toHaveLength(0);
+ await delivery("pull_request", event("ready_for_review", false));
+ await delivery("pull_request", event("opened", false));
+ expect(sent).toHaveLength(2);
+ const message = await device.read(sent[0]!.body);
+ expect(message).toMatchObject({ body: "Ready for review: Add it", tag: "10/pulls/4" });
+ // The same item shares one topic, so the push service replaces rather than queues.
+ expect(sent[0]!.headers.get("Topic")).toBe(sent[1]!.headers.get("Topic"));
+ });
+
+ it("does not push for a paused project", async () => {
+ await subscribe();
+ await db.prepare("UPDATE github_projects SET state='paused' WHERE id=10").run();
+ await delivery("issues", {
+ action: "opened",
+ installation: { id: 5 },
+ repository: { id: 10 },
+ issue: { id: 13, number: 3 },
+ });
+ expect(sent).toHaveLength(0);
+ });
+
+ it("pushes an agent's question with the item's title and forgets a device that is gone", async () => {
+ const device = await subscribe();
+ const machine = await store.createRunner(db, user, "laptop", "linux", false, 0);
+ const runner = { ...machine, owner: user, name: "laptop", platform: "linux" as const, vm: 0 };
+ await store.report(
+ db,
+ runner,
+ [{ id: "main", provider: "anthropic", kind: "subscription", label: "Main", limited: false }],
+ [],
+ 0,
+ );
+ const { id } = await store.createJob(
+ db,
+ {
+ repository: 10,
+ actor: user,
+ workflow: "start",
+ target: "quest/first.md",
+ revision: commit,
+ branch: "quest/first",
+ trusted: true,
+ },
+ 1,
+ );
+ await store.dispatch(db, user, new Map([[runner.id, 1]]), 2);
+ const asked = await store.ask(db, runner, id, 1, { summary: "Which?", session: "s", prompt: {} }, 3);
+ expect(asked.asked).toBe(true);
+ await push.job(env, id, "question");
+ expect(await device.read(sent[0]!.body)).toMatchObject({
+ body: "Agent question: [S] First",
+ url: "/inbox?project=10&item=quest%2Ffirst.md",
+ });
+ status = 410;
+ await push.job(env, id, "question");
+ expect(await subscriptions()).toEqual([]);
+ });
+
+ it("shows new decisions in the nav and offers notifications in the inbox", async () => {
+ await subscribe();
+ const html = await (await app.request(`${origin}/account`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(html).toContain('aria-label="Decisions, 3 new"');
+ expect(html).toContain('3');
+ expect(html).toContain('data-unread="3"');
+ expect(html).toContain('');
+ const inbox = await (await app.request(`${origin}/inbox`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(inbox).toContain("data-compact");
+ expect(inbox).toContain('3');
+ // Opening the inbox marks its active decision seen.
+ const after = await (await app.request(`${origin}/docs`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(after).toContain('2');
+ });
+});
diff --git a/cloudflare/vitest.config.ts b/cloudflare/vitest.config.ts
index 5fc30d81..223b173c 100644
--- a/cloudflare/vitest.config.ts
+++ b/cloudflare/vitest.config.ts
@@ -25,6 +25,10 @@ export default defineConfig({
GITHUB_WEBHOOK_SECRET: "test-webhook-secret",
GITHUB_APP_PRIVATE_KEY: "test-key-injected-by-service-tests",
GITHUB_FORK_OWNER: "quest-bot-forks",
+ // A test-only key pair from scripts/vapid.ts.
+ VAPID_PUBLIC_KEY:
+ "BFh5yJqSEBLDPtwYUindkvfE7r9Hm6c8xTT3EZnAo6EfvJLAWHX-HKbq2h83IbBtOvzzDbYp2DwDyqqO-_Uoq_k",
+ VAPID_PRIVATE_KEY: "JEYbUlDOII0-TzHqoZrz-7lCBGijto6dcqOFWnLs09o",
TEST_MIGRATIONS: await readD1Migrations("./migrations"),
},
},
diff --git a/cloudflare/worker-configuration.d.ts b/cloudflare/worker-configuration.d.ts
index c89de3ba..dabf1299 100644
--- a/cloudflare/worker-configuration.d.ts
+++ b/cloudflare/worker-configuration.d.ts
@@ -1,5 +1,5 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: 405d73cc2b46c37c28e88d59fca322f9)
+// Generated by Wrangler by running `wrangler types` (hash: 6bbe45f69166e3360ae28584139366f8)
// Runtime types generated with workerd@1.20261012.1 2026-10-01 nodejs_compat
interface __BaseEnv_Env {
OUTPUT: R2Bucket;
@@ -8,6 +8,7 @@ interface __BaseEnv_Env {
GITHUB_APP_ID: "";
GITHUB_FORK_OWNER: "";
GOOGLE_CLIENT_ID: "";
+ VAPID_PUBLIC_KEY: "";
AUTH_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
@@ -27,7 +28,7 @@ type StringifyValues> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
- interface ProcessEnv extends StringifyValues> {}
+ interface ProcessEnv extends StringifyValues> {}
}
declare module "*.txt" {
const value: string;
diff --git a/cloudflare/wrangler.jsonc b/cloudflare/wrangler.jsonc
index 7629ea7f..257609f4 100644
--- a/cloudflare/wrangler.jsonc
+++ b/cloudflare/wrangler.jsonc
@@ -13,7 +13,14 @@
"assets": { "directory": "build/public" },
"routes": [{ "pattern": "kix.quest", "custom_domain": true }],
"workers_dev": false,
- "vars": { "AUTH_URL": "https://kix.quest", "GITHUB_APP_ID": "", "GITHUB_FORK_OWNER": "", "GOOGLE_CLIENT_ID": "" },
+ "vars": {
+ "AUTH_URL": "https://kix.quest",
+ "GITHUB_APP_ID": "",
+ "GITHUB_FORK_OWNER": "",
+ "GOOGLE_CLIENT_ID": "",
+ // The Web Push application server key; see README.md. Notifications stay off while it is empty.
+ "VAPID_PUBLIC_KEY": "",
+ },
"secrets": {
"required": [
"AUTH_SECRET",
diff --git a/design/theme.md b/design/theme.md
index 23e289e2..8a52f0aa 100644
--- a/design/theme.md
+++ b/design/theme.md
@@ -164,6 +164,18 @@ square. It carries its own fill, so it reads on light and dark pages, and it
stays legible at 16 px for favicons. Use it as is: do not recolour it, add
text inside it, or pair it with game art.
+[`theme/icon.svg`](theme/icon.svg) is the installed app's icon: the same `!`
+on a full-bleed amber square, since home screens round or mask icons
+themselves, with the `!` inside the maskable safe zone. `icon-192.png` and
+`icon-512.png` are its exports:
+
+```sh
+for size in 192 512; do
+ google-chrome --headless=new --hide-scrollbars --window-size=$size,$size \
+ --screenshot=design/theme/icon-$size.png "file://$PWD/design/theme/icon.svg"
+done
+```
+
## Social image
[`theme/og.svg`](theme/og.svg) is the source and
diff --git a/design/theme/icon-192.png b/design/theme/icon-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..5f5e8be4313fa0e381556ba701b8a9d528d0bf70
GIT binary patch
literal 1360
zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rftA(M#WAE}&YQdT{l3)_M?aQ6
zKR4CqS<&6-o{6&qR%nQ*2)VebOD*lwP!YMu#@iac;KIcfDk5C)*9;nw$+UH~!18k$EcD3($HUHYfQbc8QNJ(-F3;&GKJ4qQ7A{R($fRGm=tPibSZwr~)Wrn$P~VV?e`=MlBWJ1#x$M{5Y4a
z&|c#&DiX@1`BwUrm5E89>V!*7R}!r|qZ&lEGOs*cD^l8|sqgUgvEnKgA>j)>^}F{DOy>D+`ao`8oF
zJhVafFK+J-jse<#>(p$AskXjRoJ$U9uUW^%rPDa&XLqY>u5SY8k~0jVxmu0f6PU^s
zrrz~xRG$&V9Ac!!xf@^bq(>Ryh&|Z@(
zDiY14Y37i1>&}sB4qki#hu5l>F$TTiTe7S?_zmZ>Hw>oI(i>GL%y3AqzH;XzFVK47
zgTgCfi-m#OesX1pvZ_u8YMUVS)%>LDghR|%Qp!E!8m8=FUU~8Bl$s_@I|tr2_aaUe
zwFx;s3u4|l?0ds`Wzl|5y+)C`)}NsP6`V^xFr3UQJmjq)s8HO@8Q&nXkNM=jg@^n%
z%(Sn&W2x|@Fv{`+-zHvhc3y1(yM*WGYdz${-zxCcv>^oZPT4e3NFAA+nOY65PeAlX^%J$+@
zZ~1rm`k>(Na`m%p!zzWCOTK=a#U)}}_C#p<3-|MOs~s*TyXzS`m2J
ZW+=MWRIvQPK|Y`egQu&X%Q~loCICI>9dG~u
literal 0
HcmV?d00001
diff --git a/design/theme/icon-512.png b/design/theme/icon-512.png
new file mode 100644
index 0000000000000000000000000000000000000000..270aa954c04140d9c16d403c0bc0d2ad77b18a34
GIT binary patch
literal 4147
zcmeHKX;_oj5}xlPKmtNo1!WT`BqEj|SQdds0TEQd>w-mVP%7dE6sd?T`9K6j+=^QE
zL~IciK?}+%L`A@|1*N5c37Z63P?TM^+!Jg2-20>d?|q*Bnm1?8WafR(oJr0ZPj?pu
zxutRdpy0Z}>0baif;f;RCVZ*m9RTW}tJB(TCo+aRJ8uiUGHc(ljwctT`Q32sY?add
zhL?KDruQj7I>av^!|~m|j8oE7y4)V}$sfqsJLO~IwMNuV>Th>iyMnGh?*4;G3^!
zN>lydi!|e-9ckaM2~dxzP8lAa&%0k))ip3NL(OtNE^IFRWvYC3Zr+6kqj{UbNd*W`
z9F*Ju)=_{-$AD}EaE%1ZE(65=>=G2X~1S}
zn~DSboVAV5kzuplz>;vd+S8#~vP14^FW(0L!mm-4z$^s6ngCm!*~L6|Wweog4CwZuEU=*FahNmgcr1r_JlJ
z`SbI@PfQ=W5|mWbr$Fpz$j^)Q{ay#-s=pXo(W80L1^l0SwMo2&P}Dnm(n#CzH5u-t
z>|s%AH>}Aa{GXcen!dof%h?>uui_%(wU=$CfI?}NP+xY#Rw4M;k?Iv~m}x4obk&ne
zN5zXdBqA;%S|IV_Ovn7Yp>^2v%fP?5WKt)Xu-q?~5SND!#~Ok6Sgq?WCw
zE-r^rf%PW5$(=Y?ik)hCAS+T!d#Mf?8cXYiGV$wWQGrdC^`BW=+A2YsMRTQs;yFbU
zA&N*9NW3`nSbkygqiZeEz|RPqJR0O5;v<(J%ENcB+z8`6&EM*7y3bOtb=Jd~)PSOb
z#J+55AdLIu^xv}nbVdnIGJcNQuB{PC2K#BZ@Q6NgUuX+hpES_KB-;D#+iWuWoFOr@i|s-(qbss_5A4t5+U4F
z&idB(+UFS8iuKJ0FYwdr-)syv9jC##U&s8n+Vb`V|<1IP=|EYo$0PkYS8PDa$@q!g)~nc29LQBNiCMijn_z<`9}Z6P{in
ziAP@`FM0B6R{DI*77}6p@+7O9uK838d{e?pd0QBXz=(S`C9f@goeaLy9pTZ}ebg|Y
zMH*ci)^h8Wp@#83O0u+-nY35En`(+NE>|yO5F|7|1|5(~c(lq3GsQfk
zfw8CCslo_Ml%PhwLSYJ+(n8QQ3(~D2E2)#APujhD7tX~oAG<2>AtMtA68S#{F{G>YjGP2#!huT;gv&~+)qP0`1|&iyThzM}^Na=07#8q3$XLOk
z$6rAycy?@tM5I=I*rvQn2Z>}$B23l=)YyY(ycOIcr=By%opf-fy8erIS0UFCavz()
z;3j0WAjJ5upiS5o=f@_55x6OX+!u36h}s`$zC;$>-pMfa&vGuJZ(5E^(4}G$Q-Y!}
zrUx=Y;G+9=@o94%;*45A?Z;tBP1_8i^~Y}4us_kRU@B}wW7m7J9SyNNM3SK8sALJ6
z!wGcNy(=y5}q9vo50)t8<9l{Wm}8v;pV`E{%;M#Xg|WeqEr~X+ghfus2d%nj%4a
zkc<*EEe&W4^`DR;(BuhTY-5H}#2|uAqdO#LmkgdQk8?yw)ek730ZEl?1t#9VHlQvz
zq#{ABh`$7l$^#mfCZjTd=BWD0BZp9m)#wUl8{I+u#5KjZxlbFK5bF=O3-5;O_$TqrV(ODd_ZNsiLbg@FMZeCrfxu4yPr;@e
zzo8VI<;!lR_emPdZ!M%mw>P%#!hIH#h$Sh4$TC^*d!z*xjPf%}4Veuwz>q3TzlrXj
zDH$xfkzC85fk1`*M}$rD#0MIQ6fMeTJ(6+|dqUWR0QS{}-?d)SsOZlMr%HB9V(=y^Wp!tqntf=V;Gn=B&3*|dZQ19D*GxJg>RCl(wSeGLjncOwiH7`0)Ffz842#zqn
zmZ7H`KKj6FA!LboHn42u_JxJ|^r)Frz8>zLN#I%Ucp+o93kA2BHEog~eZt|q7q7XX
z!+tePxWJ4G-b>S;xoB`abwLQ3HM71k(@YlcbhR>e##C2V0wiDMs`64ebU`iA3tTd&bVt&?7i7w$oT(x0?_x4
zWG#1TjsIa4qP%Jn63L4R6hhFa1GuU47T0Oo%P7|p!l|g59K)YkCqf=wSuvUYRfP%fzgYKh(2O&AbxlAT
z7oF_IiOBMiheLIR0p8tNtz%iG11~~#+N+x7G*6S^zTK?o;goRV<-7;0wW`5!=rAX2
z>R6y%H=BJ(8Gd@-Il!9Aq*irv_Dqi-R9<+kHx)VdM#E10#o31)`znuTR*45m6Gx6@
zR1J*L-psv~qB@MWT6$RNm56i-9Oix(TNgce@b))jKR2MiZh0{voP8JXFy7AMMD6oU
zyD$-XxS)KVFG?=h$VLK+dXBN|CvKW$&Mn;QvI`l#8Sjx9-fJ~*!!5tF{P0+!IA`L1
zl|!|1N4c}^RR?tvbUVI-Ml>h_zl`L~F9kJrM;+Q_n=rHj
NuFmdG`RjaQ{tZ5q`dk12
literal 0
HcmV?d00001
diff --git a/design/theme/icon.svg b/design/theme/icon.svg
new file mode 100644
index 00000000..948114c7
--- /dev/null
+++ b/design/theme/icon.svg
@@ -0,0 +1,9 @@
+
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index 529aa45f..ba4f4d8a 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -109,7 +109,6 @@ operator-paid-hosting decisions:
- [Retry from the branch](/quest/m0/kix/retry.md) - a retried job continues from the head its failed attempt published
- [Decision paper trail](/quest/m0/kix/paper-trail.md) - planning PRs carry the recorded questions and answers, rendered by the service
- [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries
-- [Mobile web app](/quest/m0/kix/mobile.md) - an installable PWA with Web Push for decisions
- [Web planning](/quest/m0/kix/planning.md) - issue-to-quest interviews produce draft planning PRs
- [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs
- [Onboarding guide](/quest/m0/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR
diff --git a/quest/m0/kix/guide.md b/quest/m0/kix/guide.md
index d5d06516..06b2bd14 100644
--- a/quest/m0/kix/guide.md
+++ b/quest/m0/kix/guide.md
@@ -31,5 +31,4 @@ how the service works.
## Required
- [Web execution](/quest/m0/kix/execution.md) - the complete user flow being documented
-- [Mobile web app](/quest/m0/kix/mobile.md) - install and notification steps
- [Operator setup](/quest/m0/kix/operator-setup.md) - verified deployment and App setup
diff --git a/quest/m0/kix/mobile.md b/quest/m0/kix/mobile.md
deleted file mode 100644
index eb551216..00000000
--- a/quest/m0/kix/mobile.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# [M] Install kix.quest as a phone app with push notifications
-
-## Goal
-
-A maintainer installs kix.quest as a progressive web app on their phone or
-desktop, enables notifications, and receives a Web Push notification whenever
-something needs a decision. Tapping it opens the app on that decision. This
-works on Android, desktop browsers, and iOS/iPadOS 16.4+ once the app is added
-to the home screen.
-
-## Plan
-
-Decided 2026-10-15 in the decisions-not-implementations interview:
-
-- An installable PWA with Web Push is the m0 mobile path. A native app
- follows in [the native mobile app quest](/quest/m1/mobile-app.md).
-- Only decisions push: an agent question, a new issue or PR decision item, a
- stopped or account-limited job, or a PR ready for review. Progress
- summaries, runner presence changes, and successful completions without a
- pending action never push. The in-app
- inbox still records everything.
-- Coalesce per decision item (for example with the notification `tag`), so
- repeated updates and duplicate webhooks replace a notification instead of
- stacking new ones.
-- Notifications open the prompt; they have no answer buttons. iOS Web Push
- does not support notification actions, and a single answer path keeps
- stale- and duplicate-answer protection in one place. Quick replies wait for
- the native app.
-- Store push subscriptions per user and device in D1. Delete them on
- sign-out and when the push service reports them gone (404/410). Ask for
- permission only after a user gesture, never on page load.
-- Keep the VAPID private key in Worker secrets. Verify that Web Push payload
- encryption and VAPID signing work in the Workers runtime with WebCrypto
- before choosing a library. Keep payloads minimal (project and decision
- title), since the full context loads in the app.
-- Show an unread decision count in the nav and, where the Badging API is
- supported, on the installed app icon. Decided 2026-10-18 as a follow-up of
- the decision inbox (#144), folded here rather than its own quest.
-- Make the installed app phone-first: the inbox, prompts, and activity cards
- fit a narrow viewport and work with touch and keyboard.
-- Sources:
- [Web Push on iOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/),
- [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API),
- and [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291).
diff --git a/quest/m1/mobile-app.md b/quest/m1/mobile-app.md
index fa697d9a..6b405347 100644
--- a/quest/m1/mobile-app.md
+++ b/quest/m1/mobile-app.md
@@ -12,6 +12,8 @@ full prompt in the app.
- Decided 2026-10-15: the native app follows the m0 PWA. It adds what Web
Push cannot do on every platform: notification quick replies and native
delivery.
+- Extend the PWA's push events and coalescing (`cloudflare/src/push/`)
+ rather than defining a second set.
- Reuse the service's decision API, authorization, and stale/duplicate-answer
checks. A quick reply is a normal answer bound to the decision's revision,
not a second path with weaker checks.
@@ -21,7 +23,3 @@ full prompt in the app.
example React Native/Expo). Research the current options when this starts
and choose with the user. Split any app-store account setup that waits on a
human into its own quest.
-
-## Required
-
-- [Mobile web app](/quest/m0/kix/mobile.md) - the push events and phone-first UI this extends
From 6f176415dc4cdf0a913191479703510436a63508 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 11:49:18 -0700
Subject: [PATCH 3/7] fix(push): keep notification state honest after merging
main
Rename the push migration to 0012_push.sql, after main's 0011_project_scan.sql.
Signing out now drops the browser's subscription too, and the switch stores
an existing subscription again when it loads, so it never shows a device as
notified that the server forgot. Jobs on a paused project no longer push.
Main keeps quests available across webhooks that cannot change them, so the
badge in the new-issue test now counts the ready quest.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 6 ++-
.../{0011_push.sql => 0012_push.sql} | 0
cloudflare/src/app.js | 38 ++++++++++++++++++-
cloudflare/src/push/notify.ts | 9 ++++-
cloudflare/test/push.test.ts | 11 ++++--
5 files changed, 55 insertions(+), 9 deletions(-)
rename cloudflare/migrations/{0011_push.sql => 0012_push.sql} (100%)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 9b3fae7f..df32a171 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -203,8 +203,10 @@ reveals a number and nothing else.
A device turns notifications on only from a click, which asks the browser for
permission and stores its push subscription (`push_subscriptions`) for that
user and session. Subscriptions must name a known push service. Signing out
-deletes the session's subscriptions, and a push service reporting one gone
-(404 or 410) deletes it.
+deletes the session's subscriptions and the browser's own, so the next person
+to sign in there opts in for themselves. A push service reporting one gone
+(404 or 410) deletes it. The switch stores the browser's subscription again
+when it loads, so it never shows a device as notified that the server forgot.
Only decisions push (`src/push/notify.ts`): an agent's question, a
publication decision, an account limit, a failed job, a new or reopened issue,
diff --git a/cloudflare/migrations/0011_push.sql b/cloudflare/migrations/0012_push.sql
similarity index 100%
rename from cloudflare/migrations/0011_push.sql
rename to cloudflare/migrations/0012_push.sql
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 47b6839d..002e8766 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -22,7 +22,36 @@ function key(base64url) {
return Uint8Array.from(text, (c) => c.charCodeAt(0));
}
-for (const control of document.querySelectorAll("[data-push]")) {
+// Signing out stops this device's notifications: the server forgets the
+// session's subscriptions, and the browser drops its own, so whoever signs in
+// next turns them on for themselves.
+for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
+ if (!supported) break;
+ form.addEventListener(
+ "submit",
+ (event) => {
+ event.preventDefault();
+ registration
+ .then((found) => found.pushManager.getSubscription())
+ .then((subscription) => subscription?.unsubscribe())
+ .catch(() => {})
+ .finally(() => form.submit());
+ },
+ { once: true },
+ );
+}
+
+// The server can forget a subscription the browser still has, such as one a
+// signed-out session enabled. Store it again for this session, so a switch
+// that shows notifications on means this device is notified.
+const controls = document.querySelectorAll("[data-push]");
+const synced = (async () => {
+ if (!supported || !controls.length || Notification.permission !== "granted") return;
+ const subscription = await (await registration).pushManager.getSubscription();
+ if (subscription) await post("/push/subscribe", subscription.toJSON());
+})();
+
+for (const control of controls) {
const status = control.querySelector("[data-push-status]");
const enable = control.querySelector("[data-push-enable]");
const disable = control.querySelector("[data-push-disable]");
@@ -78,5 +107,10 @@ for (const control of document.querySelectorAll("[data-push]")) {
return "Notifications are off for this device.";
}),
);
- show().catch(() => {});
+ synced
+ .then(
+ () => show(),
+ (error) => show(error.message || "Something went wrong."),
+ )
+ .catch(() => {});
}
diff --git a/cloudflare/src/push/notify.ts b/cloudflare/src/push/notify.ts
index b1cb88db..57ff4aab 100644
--- a/cloudflare/src/push/notify.ts
+++ b/cloudflare/src/push/notify.ts
@@ -99,11 +99,16 @@ export async function deliver(env: Env, decision: Decision, targets: Device[]) {
);
}
-/** Notify everyone with a device about a decision on a project. */
+/**
+ * Notify everyone with a device about a decision on a project. A paused
+ * project offers no work, so a job that stops after it pauses does not notify.
+ */
export async function notify(env: Env, decision: Omit & { project: number }) {
const targets = await devices(env, decision.project);
if (!targets.length) return;
- const project = await env.DB.prepare("SELECT id,owner,name,state FROM github_projects WHERE id=?")
+ const project = await env.DB.prepare(
+ "SELECT id,owner,name,state FROM github_projects WHERE id=? AND state='enabled'",
+ )
.bind(decision.project)
.first();
if (project) await deliver(env, { ...decision, project }, targets);
diff --git a/cloudflare/test/push.test.ts b/cloudflare/test/push.test.ts
index 0fbc702f..b076897c 100644
--- a/cloudflare/test/push.test.ts
+++ b/cloudflare/test/push.test.ts
@@ -221,9 +221,9 @@ describe("decision notifications", () => {
body: "New issue: Crash on start",
tag: "10/issues/3",
url: "/inbox?project=10&item=issues%2F3",
- // The open issue and pull request, and the new issue. Ready quests wait for a
- // refresh after a webhook marks the observations stale.
- badge: 3,
+ // The open issue and pull request, the ready quest, and the new issue. A new
+ // issue cannot change which quests are ready, so they stay available.
+ badge: 4,
});
});
@@ -290,6 +290,11 @@ describe("decision notifications", () => {
body: "Agent question: [S] First",
url: "/inbox?project=10&item=quest%2Ffirst.md",
});
+ // A project paused while its job runs stops notifying.
+ await db.prepare("UPDATE github_projects SET state='paused' WHERE id=10").run();
+ await push.job(env, id, "question");
+ expect(sent).toHaveLength(1);
+ await db.prepare("UPDATE github_projects SET state='enabled' WHERE id=10").run();
status = 410;
await push.job(env, id, "question");
expect(await subscriptions()).toEqual([]);
From dc9e51ca2d635364120d9b5e4f05be0559570109 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 12:04:52 -0700
Subject: [PATCH 4/7] fix(push): never hand a device to another user, and push
only to current maintainers
The switch no longer stores a browser's leftover subscription for whoever
signs in. It asks /push/check whether the server notifies that endpoint for
the signed-in user, which moves a match to the current session, and shows
notifications off otherwise, so turning them on again takes a click. The
sign-out handler that dropped the browser's subscription is no longer needed.
Each push rechecks that its recipient still maintains the project, as the
inbox does before listing its items. Tests sign with the test App key by
default so background work in the runner hub can make that check.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 9 ++++---
cloudflare/src/app.js | 40 ++++++++++--------------------
cloudflare/src/github/service.ts | 10 ++++++++
cloudflare/src/push/notify.ts | 42 +++++++++++++++++++++++++-------
cloudflare/test/jobs.test.ts | 8 ++++--
cloudflare/test/push.test.ts | 38 +++++++++++++++++++++++++++--
cloudflare/vitest.config.ts | 4 ++-
7 files changed, 106 insertions(+), 45 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index df32a171..58da3939 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -203,10 +203,11 @@ reveals a number and nothing else.
A device turns notifications on only from a click, which asks the browser for
permission and stores its push subscription (`push_subscriptions`) for that
user and session. Subscriptions must name a known push service. Signing out
-deletes the session's subscriptions and the browser's own, so the next person
-to sign in there opts in for themselves. A push service reporting one gone
-(404 or 410) deletes it. The switch stores the browser's subscription again
-when it loads, so it never shows a device as notified that the server forgot.
+deletes the session's subscriptions, and a push service reporting one gone
+(404 or 410) deletes it. The switch shows a device as notified only while the
+server stores its subscription for the signed-in user, so whoever signs in
+next on that browser turns notifications on with their own click. Each push
+rechecks that its recipient still maintains the project, as the inbox does.
Only decisions push (`src/push/notify.ts`): an agent's question, a
publication decision, an account limit, a failed job, a new or reopened issue,
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 002e8766..c2df4f53 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -14,7 +14,9 @@ async function post(path, body) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
- if (!response.ok) throw new Error((await response.json().catch(() => null))?.error || "Request failed.");
+ const result = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(result?.error || "Request failed.");
+ return result;
}
function key(base64url) {
@@ -22,33 +24,15 @@ function key(base64url) {
return Uint8Array.from(text, (c) => c.charCodeAt(0));
}
-// Signing out stops this device's notifications: the server forgets the
-// session's subscriptions, and the browser drops its own, so whoever signs in
-// next turns them on for themselves.
-for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
- if (!supported) break;
- form.addEventListener(
- "submit",
- (event) => {
- event.preventDefault();
- registration
- .then((found) => found.pushManager.getSubscription())
- .then((subscription) => subscription?.unsubscribe())
- .catch(() => {})
- .finally(() => form.submit());
- },
- { once: true },
- );
-}
-
-// The server can forget a subscription the browser still has, such as one a
-// signed-out session enabled. Store it again for this session, so a switch
-// that shows notifications on means this device is notified.
+// Whether the server notifies this browser's subscription for whoever is
+// signed in. It forgets one when a session signs out, and never hands one to
+// another user, so the browser's own subscription is not enough to show "on".
const controls = document.querySelectorAll("[data-push]");
-const synced = (async () => {
+let stored = false;
+const checked = (async () => {
if (!supported || !controls.length || Notification.permission !== "granted") return;
const subscription = await (await registration).pushManager.getSubscription();
- if (subscription) await post("/push/subscribe", subscription.toJSON());
+ if (subscription) stored = (await post("/push/check", { endpoint: subscription.endpoint })).stored === true;
})();
for (const control of controls) {
@@ -58,7 +42,7 @@ for (const control of controls) {
async function show(message) {
const subscription = supported && (await (await registration).pushManager.getSubscription());
- const on = !!subscription && Notification.permission === "granted";
+ const on = stored && !!subscription && Notification.permission === "granted";
const blocked = supported && Notification.permission === "denied";
// The inbox's compact control steps aside once there is nothing to switch on.
control.hidden = (on || blocked) && "compact" in control.dataset && !message;
@@ -94,6 +78,7 @@ for (const control of controls) {
await registration
).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) });
await post("/push/subscribe", subscription.toJSON());
+ stored = true;
return "Notifications are on for this device.";
}),
);
@@ -102,12 +87,13 @@ for (const control of controls) {
const subscription = await (await registration).pushManager.getSubscription();
if (subscription) {
await post("/push/unsubscribe", { endpoint: subscription.endpoint });
+ stored = false;
await subscription.unsubscribe();
}
return "Notifications are off for this device.";
}),
);
- synced
+ checked
.then(
() => show(),
(error) => show(error.message || "Something went wrong."),
diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts
index f01e5142..6942621d 100644
--- a/cloudflare/src/github/service.ts
+++ b/cloudflare/src/github/service.ts
@@ -360,6 +360,16 @@ export class GitHubService {
).results;
}
+ /** Whether a user currently maintains a project, as its inbox items require. */
+ async maintains(repo: GitHubRepository, userId: string) {
+ try {
+ return await this.#authorized(repo, userId, {}, async () => true);
+ } catch (error) {
+ if (error instanceof GitHubError) return false;
+ throw error;
+ }
+ }
+
/** Let any current maintainer reach stop controls without a valid preview. */
async enrolledProject(owner: string, repository: string, userId: string): Promise {
return this.#withInstallation({ owner, name: repository }, {}, async (token, installation) => {
diff --git a/cloudflare/src/push/notify.ts b/cloudflare/src/push/notify.ts
index 57ff4aab..78810b9d 100644
--- a/cloudflare/src/push/notify.ts
+++ b/cloudflare/src/push/notify.ts
@@ -7,7 +7,7 @@
import { Hono } from "hono";
import { createAuth } from "../auth";
-import type { Enrollment } from "../github/service";
+import { type Enrollment, GitHubService } from "../github/service";
import { title, unread } from "../inbox/model";
import { inboxURL } from "../jobs/routes";
import { type KeyPair, type Subscription, base64url, send } from "./webpush";
@@ -37,9 +37,8 @@ export function keys(env: Env): KeyPair | null {
export type Device = Subscription & { user: string };
/**
- * The devices of everyone whose inbox shows this project: today the
- * maintainer who enrolled it. A push reveals only the project and the item's
- * title; the inbox rechecks authority before showing anything more.
+ * The devices of everyone whose inbox lists this project: today the
+ * maintainer who enrolled it. Delivery rechecks that they still maintain it.
*/
export async function devices(env: Env, project: number): Promise {
if (!keys(env)) return [];
@@ -54,19 +53,30 @@ export async function devices(env: Env, project: number): Promise {
// Undelivered decisions stay in the inbox, so a day-old push adds nothing.
const TTL = 24 * 60 * 60;
-/** Push one decision to these devices, and forget any the push service reports gone. */
-export async function deliver(env: Env, decision: Decision, targets: Device[]) {
+/**
+ * Push one decision to the devices of those who still maintain its project,
+ * as the inbox requires before showing its items, and forget any device the
+ * push service reports gone.
+ */
+export async function deliver(env: Env, decision: Decision, found: Device[]) {
const pair = keys(env);
- if (!pair || !targets.length) return;
+ if (!pair || !found.length) return;
const { project, target } = decision;
+ const github = new GitHubService(env);
+ const users = [...new Set(found.map((d) => d.user))];
+ const current = await Promise.all(users.map((user) => github.maintains(project, user)));
+ const maintainers = users.filter((_, i) => current[i]);
+ const targets = found.filter((device) => maintainers.includes(device.user));
+ if (!targets.length) return;
const name = decision.title ?? (await title(env.DB, project, target));
const tag = `${project.id}/${target}`;
const topic = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(tag)))).slice(
0,
32,
);
- const users = [...new Set(targets.map((d) => d.user))];
- const badges = new Map(await Promise.all(users.map(async (user) => [user, await unread(env, user)] as const)));
+ const badges = new Map(
+ await Promise.all(maintainers.map(async (user) => [user, await unread(env, user)] as const)),
+ );
const results = await Promise.allSettled(
targets.map((device) =>
send(
@@ -173,6 +183,20 @@ subscriptions.post("/push/subscribe", async (c) => {
await c.env.DB.batch(statements);
return c.body(null, 204);
});
+// Whether the server still notifies the browser's subscription for this user.
+// It moves a match to this session, so signing out here forgets it, but never
+// takes over another user's device or one that a sign-out forgot: turning
+// notifications on again takes a click.
+subscriptions.post("/push/check", async (c) => {
+ const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ error: "Sign in required" }, 401);
+ const body = await c.req.json().catch(() => null);
+ if (typeof body?.endpoint !== "string") return c.json({ error: "Endpoint required" }, 400);
+ const result = await c.env.DB.prepare("UPDATE push_subscriptions SET session=? WHERE endpoint=? AND user=?")
+ .bind(session.session.id, body.endpoint, session.user.id)
+ .run();
+ return c.json({ stored: result.meta.changes > 0 });
+});
subscriptions.post("/push/unsubscribe", async (c) => {
const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers });
if (!session) return c.json({ error: "Sign in required" }, 401);
diff --git a/cloudflare/test/jobs.test.ts b/cloudflare/test/jobs.test.ts
index de73a5af..3e300315 100644
--- a/cloudflare/test/jobs.test.ts
+++ b/cloudflare/test/jobs.test.ts
@@ -469,6 +469,8 @@ describe("runner connections", () => {
});
it("notifies the project's maintainer when a job asks a question or fails, once each", async () => {
+ const maintainer = await maintainerOf();
+ await db.prepare("UPDATE github_projects SET enrolledBy=? WHERE id=10").bind(maintainer.user).run();
const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveBits",
])) as CryptoKeyPair;
@@ -477,12 +479,14 @@ describe("runner connections", () => {
);
await db
.prepare(
- "INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt) VALUES('https://fcm.googleapis.com/fcm/send/owner','owner','s',?,'AAAAAAAAAAAAAAAAAAAAAA',0)",
+ "INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt) VALUES('https://fcm.googleapis.com/fcm/send/owner',?,'s',?,'AAAAAAAAAAAAAAAAAAAAAA',0)",
)
- .bind(p256dh)
+ .bind(maintainer.user, p256dh)
.run();
const sent: string[] = [];
+ // Push services answer here; GitHub answers the recipient's authority check.
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
+ if (!String(input).startsWith("https://fcm.googleapis.com/")) return maintainer.mock(input);
sent.push(String(input));
return new Response(null, { status: 201 });
});
diff --git a/cloudflare/test/push.test.ts b/cloudflare/test/push.test.ts
index b076897c..6420a9a2 100644
--- a/cloudflare/test/push.test.ts
+++ b/cloudflare/test/push.test.ts
@@ -5,7 +5,7 @@ import * as store from "../src/jobs/store";
import * as push from "../src/push/notify";
import { base64url, encrypt, unbase64url, vapid } from "../src/push/webpush";
import { privateKey } from "./github-key";
-import { origin } from "./login-fixtures";
+import { identity, oauth, origin } from "./login-fixtures";
import { commit, delivery, maintainerOf } from "./project-fixtures";
const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
@@ -121,6 +121,7 @@ describe("decision notifications", () => {
let cookie = "";
let sent: Sent[] = [];
let status = 201;
+ let github: Awaited>["github"];
beforeEach(async () => {
await db.batch([
@@ -130,7 +131,7 @@ describe("decision notifications", () => {
db.prepare("DELETE FROM github_projects"),
]);
const maintainer = await maintainerOf();
- ({ user, cookie } = maintainer);
+ ({ user, cookie, github } = maintainer);
sent = [];
status = 201;
// Push services answer here; everything else goes to the mocked GitHub.
@@ -197,6 +198,39 @@ describe("decision notifications", () => {
expect(await subscriptions()).toEqual([]);
});
+ it("shows a device as notified only while its subscription is stored for the signed-in user", async () => {
+ const device = await subscribe();
+ const check = async (headers?: Record) =>
+ (await post("/push/check", { endpoint: device.endpoint }, headers)).json();
+ // A session that expired leaves its device notifying; a check moves it to the
+ // current session, so signing out here forgets it.
+ await db.prepare("UPDATE push_subscriptions SET session='expired'").run();
+ expect(await check()).toEqual({ stored: true });
+ // Another person signing in on this browser does not take the device over.
+ const other = (await oauth(identity("github"))).session!;
+ expect(await check({ Cookie: other, Origin: origin })).toEqual({ stored: false });
+ expect(await subscriptions()).toEqual([{ endpoint: device.endpoint, user }]);
+ await app.request(
+ `${origin}/sign-out`,
+ { method: "POST", headers: { Cookie: cookie, Origin: origin } },
+ testEnv,
+ );
+ expect(await subscriptions()).toEqual([]);
+ });
+
+ it("does not push to someone who no longer maintains the project", async () => {
+ await subscribe();
+ github.role = "write";
+ await delivery("issues", {
+ action: "opened",
+ installation: { id: 5 },
+ repository: { id: 10 },
+ issue: { id: 13, number: 3, title: "Crash on start" },
+ });
+ expect(sent).toHaveLength(0);
+ expect(await subscriptions()).toHaveLength(1);
+ });
+
it("pushes a new issue once, opening its decision, and never other updates", async () => {
const device = await subscribe();
const opened = {
diff --git a/cloudflare/vitest.config.ts b/cloudflare/vitest.config.ts
index 223b173c..5f69578e 100644
--- a/cloudflare/vitest.config.ts
+++ b/cloudflare/vitest.config.ts
@@ -1,6 +1,7 @@
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
import { existsSync } from "node:fs";
import { defineConfig } from "vitest/config";
+import { privateKey } from "./test/github-key";
// Miniflare keeps D1 as SQLite under the OS temp dir and fsyncs every write, so
// other processes' disk I/O stalls each statement and the route tests time out.
@@ -23,7 +24,8 @@ export default defineConfig({
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
GITHUB_APP_ID: "123",
GITHUB_WEBHOOK_SECRET: "test-webhook-secret",
- GITHUB_APP_PRIVATE_KEY: "test-key-injected-by-service-tests",
+ // Background work, such as a push's authority check, signs with the test App key.
+ GITHUB_APP_PRIVATE_KEY: privateKey,
GITHUB_FORK_OWNER: "quest-bot-forks",
// A test-only key pair from scripts/vapid.ts.
VAPID_PUBLIC_KEY:
From 8132d5353bcee2542f50411c10ed6b1597e8c73e Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 13:06:38 -0700
Subject: [PATCH 5/7] fix(push): keep opt-in through subscription and key
rotation
A rotated subscription replaces the old one only when the signed-in user
enabled it, so a rotation never opts in whoever is signed in now. The switch
drops a browser subscription made for an earlier VAPID key pair, which could
not receive pushes and would block enrolling with the new key.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 4 +++-
cloudflare/src/app.js | 17 ++++++++++++++-
cloudflare/src/push/notify.ts | 40 +++++++++++++++++++++++------------
cloudflare/test/push.test.ts | 8 +++++++
4 files changed, 53 insertions(+), 16 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index a8d891aa..a087e208 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -211,7 +211,9 @@ user and session. Subscriptions must name a known push service. Signing out
deletes the session's subscriptions, and a push service reporting one gone
(404 or 410) deletes it. The switch shows a device as notified only while the
server stores its subscription for the signed-in user, so whoever signs in
-next on that browser turns notifications on with their own click. Each push
+next on that browser turns notifications on with their own click. A rotated
+subscription replaces the old one only for the user who enabled it, and the
+switch drops a browser subscription made for an earlier VAPID key pair. Each push
rechecks that its recipient still maintains the project, as the inbox does.
Only decisions push (`src/push/notify.ts`): an agent's question, a
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index c2df4f53..86d36558 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -24,6 +24,20 @@ function key(base64url) {
return Uint8Array.from(text, (c) => c.charCodeAt(0));
}
+// The browser's subscription, dropping one made for an earlier key pair: it
+// cannot receive pushes signed with the current key, and it blocks subscribing
+// with the new one.
+async function current(publicKey) {
+ const subscription = await (await registration).pushManager.getSubscription();
+ const made = subscription?.options.applicationServerKey;
+ const want = key(publicKey);
+ if (!made || (made.byteLength === want.length && new Uint8Array(made).every((b, i) => b === want[i])))
+ return subscription;
+ await post("/push/unsubscribe", { endpoint: subscription.endpoint }).catch(() => {});
+ await subscription.unsubscribe();
+ return null;
+}
+
// Whether the server notifies this browser's subscription for whoever is
// signed in. It forgets one when a session signs out, and never hands one to
// another user, so the browser's own subscription is not enough to show "on".
@@ -31,7 +45,7 @@ const controls = document.querySelectorAll("[data-push]");
let stored = false;
const checked = (async () => {
if (!supported || !controls.length || Notification.permission !== "granted") return;
- const subscription = await (await registration).pushManager.getSubscription();
+ const subscription = await current(controls[0].dataset.push);
if (subscription) stored = (await post("/push/check", { endpoint: subscription.endpoint })).stored === true;
})();
@@ -74,6 +88,7 @@ for (const control of controls) {
enable.addEventListener("click", () =>
run(enable, async () => {
if ((await Notification.requestPermission()) !== "granted") return undefined;
+ await current(control.dataset.push);
const subscription = await (
await registration
).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key(control.dataset.push) });
diff --git a/cloudflare/src/push/notify.ts b/cloudflare/src/push/notify.ts
index 78810b9d..479c6a6d 100644
--- a/cloudflare/src/push/notify.ts
+++ b/cloudflare/src/push/notify.ts
@@ -165,22 +165,34 @@ subscriptions.post("/push/subscribe", async (c) => {
const p256dh = key(body?.keys?.p256dh, 65);
const auth = key(body?.keys?.auth, 16);
if (!found || !p256dh || !auth) return c.json({ error: "Invalid push subscription" }, 400);
- const statements = [
+ // A browser that rotated its subscription names the one it replaces. The new
+ // one takes its place only if the signed-in user turned that one on, so a
+ // rotation never opts in whoever is signed in now.
+ const replaces = body?.replaces === undefined ? null : endpoint(body.replaces);
+ if (body?.replaces !== undefined && !replaces) return c.json({ error: "Invalid push subscription" }, 400);
+ const [stored] = await c.env.DB.batch([
c.env.DB.prepare(
- `INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt) VALUES(?,?,?,?,?,?)
+ `INSERT INTO push_subscriptions(endpoint,user,session,p256dh,auth,createdAt)
+ SELECT ?,?,?,?,?,? WHERE ? IS NULL OR EXISTS(SELECT 1 FROM push_subscriptions WHERE endpoint=? AND user=?)
ON CONFLICT(endpoint) DO UPDATE SET user=excluded.user,session=excluded.session,p256dh=excluded.p256dh,auth=excluded.auth,createdAt=excluded.createdAt`,
- ).bind(found, session.user.id, session.session.id, p256dh, auth, Date.now()),
- ];
- // A browser that rotated its subscription names the one it replaces.
- const replaced = endpoint(body?.replaces);
- if (replaced && replaced !== found)
- statements.push(
- c.env.DB.prepare("DELETE FROM push_subscriptions WHERE endpoint=? AND user=?").bind(
- replaced,
- session.user.id,
- ),
- );
- await c.env.DB.batch(statements);
+ ).bind(
+ found,
+ session.user.id,
+ session.session.id,
+ p256dh,
+ auth,
+ Date.now(),
+ replaces,
+ replaces,
+ session.user.id,
+ ),
+ c.env.DB.prepare("DELETE FROM push_subscriptions WHERE endpoint=? AND endpoint<>? AND user=?").bind(
+ replaces,
+ found,
+ session.user.id,
+ ),
+ ]);
+ if (!stored!.meta.changes) return c.json({ error: "Notifications are not on for this device" }, 403);
return c.body(null, 204);
});
// Whether the server still notifies the browser's subscription for this user.
diff --git a/cloudflare/test/push.test.ts b/cloudflare/test/push.test.ts
index 6420a9a2..d8f7d226 100644
--- a/cloudflare/test/push.test.ts
+++ b/cloudflare/test/push.test.ts
@@ -180,6 +180,14 @@ describe("decision notifications", () => {
const rotated = await browser();
expect((await post("/push/subscribe", { ...rotated.json, replaces: device.endpoint })).status).toBe(204);
expect(await subscriptions()).toEqual([{ endpoint: rotated.endpoint, user }]);
+ // A rotation while someone else is signed in does not opt them in.
+ const other = (await oauth(identity("github"))).session!;
+ const again = await browser();
+ const theirs = { ...again.json, replaces: rotated.endpoint };
+ expect((await post("/push/subscribe", theirs, { Cookie: other, Origin: origin })).status).toBe(403);
+ const unknown = { ...again.json, replaces: `https://fcm.googleapis.com/fcm/send/${crypto.randomUUID()}` };
+ expect((await post("/push/subscribe", unknown)).status).toBe(403);
+ expect(await subscriptions()).toEqual([{ endpoint: rotated.endpoint, user }]);
expect((await post("/push/unsubscribe", { endpoint: rotated.endpoint })).status).toBe(204);
expect(await subscriptions()).toEqual([]);
});
From 7a68873f56dd4e091ca8091cf8433067a8070a24 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 13:15:53 -0700
Subject: [PATCH 6/7] fix(push): drop the browser's subscription when signing
out
The sign-out form unsubscribes the browser's push subscription before it
submits, so a push the push service still holds for that device is discarded
rather than shown to whoever uses it next. A sign-out without JavaScript
leaves such pushes to arrive within their 24-hour TTL.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 6 ++++--
cloudflare/src/app.js | 20 ++++++++++++++++++++
cloudflare/test/app.test.ts | 34 ++++++++++++++++++++++++++++++++++
cloudflare/vitest.config.ts | 2 +-
4 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 cloudflare/test/app.test.ts
diff --git a/cloudflare/README.md b/cloudflare/README.md
index a087e208..50ac452a 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -208,8 +208,10 @@ reveals a number and nothing else.
A device turns notifications on only from a click, which asks the browser for
permission and stores its push subscription (`push_subscriptions`) for that
user and session. Subscriptions must name a known push service. Signing out
-deletes the session's subscriptions, and a push service reporting one gone
-(404 or 410) deletes it. The switch shows a device as notified only while the
+deletes the session's subscriptions, and the sign-out form first drops the
+browser's own, which discards pushes still queued for it (a sign-out without
+JavaScript leaves those to arrive within the 24-hour TTL). A push service
+reporting one gone (404 or 410) deletes it. The switch shows a device as notified only while the
server stores its subscription for the signed-in user, so whoever signs in
next on that browser turns notifications on with their own click. A rotated
subscription replaces the old one only for the user who enabled it, and the
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 86d36558..85824986 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -38,6 +38,26 @@ async function current(publicKey) {
return null;
}
+// Signing out stops this device's notifications at once. The server forgets the
+// session's subscriptions, and dropping the browser's own also discards any push
+// the push service still holds for it. A sign-out without JavaScript leaves those
+// to arrive within their TTL.
+for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
+ if (!supported) break;
+ form.addEventListener(
+ "submit",
+ (event) => {
+ event.preventDefault();
+ registration
+ .then((found) => found.pushManager.getSubscription())
+ .then((subscription) => subscription?.unsubscribe())
+ .catch(() => {})
+ .finally(() => form.submit());
+ },
+ { once: true },
+ );
+}
+
// Whether the server notifies this browser's subscription for whoever is
// signed in. It forgets one when a session signs out, and never hands one to
// another user, so the browser's own subscription is not enough to show "on".
diff --git a/cloudflare/test/app.test.ts b/cloudflare/test/app.test.ts
new file mode 100644
index 00000000..ab85a660
--- /dev/null
+++ b/cloudflare/test/app.test.ts
@@ -0,0 +1,34 @@
+// The signed-in pages' script, run against a stand-in browser.
+import { afterEach, expect, it, vi } from "vitest";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("drops the browser's push subscription before signing out", async () => {
+ const steps: string[] = [];
+ const subscription = {
+ endpoint: "https://fcm.googleapis.com/fcm/send/device",
+ unsubscribe: async () => steps.push("unsubscribe"),
+ };
+ let submit: ((event: { preventDefault(): void }) => void) | undefined;
+ const form = {
+ addEventListener: (type: string, listener: typeof submit) => {
+ if (type === "submit") submit = listener;
+ },
+ submit: () => steps.push("submit"),
+ };
+ vi.stubGlobal("document", {
+ body: { dataset: {} },
+ querySelectorAll: (selector: string) => (selector === 'form[action="/sign-out"]' ? [form] : []),
+ });
+ vi.stubGlobal("window", { PushManager: class {}, Notification: class {} });
+ vi.stubGlobal("Notification", { permission: "granted" });
+ vi.stubGlobal("navigator", {
+ serviceWorker: { register: async () => ({ pushManager: { getSubscription: async () => subscription } }) },
+ });
+ // @ts-expect-error: a browser script with no type declarations.
+ await import("../src/app.js");
+ let prevented = false;
+ submit!({ preventDefault: () => (prevented = true) });
+ expect(prevented).toBe(true);
+ await vi.waitFor(() => expect(steps).toEqual(["unsubscribe", "submit"]));
+});
diff --git a/cloudflare/vitest.config.ts b/cloudflare/vitest.config.ts
index 5f69578e..5cc3f169 100644
--- a/cloudflare/vitest.config.ts
+++ b/cloudflare/vitest.config.ts
@@ -1,7 +1,7 @@
import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin";
import { existsSync } from "node:fs";
import { defineConfig } from "vitest/config";
-import { privateKey } from "./test/github-key";
+import { privateKey } from "./test/github-key.ts";
// Miniflare keeps D1 as SQLite under the OS temp dir and fsyncs every write, so
// other processes' disk I/O stalls each statement and the route tests time out.
From dadfe66d6c19d78e2bdc3fdb141f381067cf864f Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 13:22:43 -0700
Subject: [PATCH 7/7] fix(push): count the shown decision as seen, and clear
the badge on sign-out
The inbox marks its active decision seen before the nav and the app badge
count what is new, so opening a notification lowers the badge at once. The
sign-out form also clears the app badge, which signed-out pages never update.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/src/app.js | 24 ++++++++++++++----------
cloudflare/src/index.tsx | 10 ++++++----
cloudflare/test/app.test.ts | 8 ++++++--
cloudflare/test/push.test.ts | 6 ++++--
4 files changed, 30 insertions(+), 18 deletions(-)
diff --git a/cloudflare/src/app.js b/cloudflare/src/app.js
index 85824986..2aa4ab57 100644
--- a/cloudflare/src/app.js
+++ b/cloudflare/src/app.js
@@ -38,21 +38,25 @@ async function current(publicKey) {
return null;
}
-// Signing out stops this device's notifications at once. The server forgets the
-// session's subscriptions, and dropping the browser's own also discards any push
-// the push service still holds for it. A sign-out without JavaScript leaves those
-// to arrive within their TTL.
+// Signing out stops this device's notifications at once and clears the app
+// badge, which signed-out pages never update. The server forgets the session's
+// subscriptions, and dropping the browser's own also discards any push the push
+// service still holds for it. A sign-out without JavaScript leaves those to
+// arrive within their TTL.
for (const form of document.querySelectorAll('form[action="/sign-out"]')) {
- if (!supported) break;
form.addEventListener(
"submit",
(event) => {
event.preventDefault();
- registration
- .then((found) => found.pushManager.getSubscription())
- .then((subscription) => subscription?.unsubscribe())
- .catch(() => {})
- .finally(() => form.submit());
+ const steps = [
+ "clearAppBadge" in navigator ? navigator.clearAppBadge() : null,
+ supported
+ ? registration
+ .then((found) => found.pushManager.getSubscription())
+ .then((subscription) => subscription?.unsubscribe())
+ : null,
+ ];
+ Promise.allSettled(steps).then(() => form.submit());
},
{ once: true },
);
diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx
index 7185d522..c24adb9d 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -267,7 +267,12 @@ app.get("/inbox", async (c) => {
const target = c.req.query("item");
const chosen = target ? items.find((i) => String(i.project.id) === project && i.target === target) : undefined;
const active = chosen ?? decisions[0] ?? null;
- const response = await page(
+ // Seen once shown, before the nav and the app badge count what is new.
+ if (active?.need) {
+ await inbox.markSeen(c.env.DB, user, active);
+ active.unread = false;
+ }
+ return page(
c,
{ title: "Decisions", description: "Your decision inbox: questions, issues, pull requests, and ready quests." },
{
notifications={push.keys(c.env)?.publicKey ?? null}
/>,
);
- // Seen once shown, so the nav's count agrees with the page that shows it.
- if (active?.need) await inbox.markSeen(c.env.DB, user, active);
- return response;
});
app.get("/projects/github/:owner/:repository/board", async (c) => {
const state = await liveProject(c);
diff --git a/cloudflare/test/app.test.ts b/cloudflare/test/app.test.ts
index ab85a660..8c381727 100644
--- a/cloudflare/test/app.test.ts
+++ b/cloudflare/test/app.test.ts
@@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from "vitest";
afterEach(() => vi.unstubAllGlobals());
-it("drops the browser's push subscription before signing out", async () => {
+it("drops the browser's push subscription and clears the app badge before signing out", async () => {
const steps: string[] = [];
const subscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/device",
@@ -24,11 +24,15 @@ it("drops the browser's push subscription before signing out", async () => {
vi.stubGlobal("Notification", { permission: "granted" });
vi.stubGlobal("navigator", {
serviceWorker: { register: async () => ({ pushManager: { getSubscription: async () => subscription } }) },
+ clearAppBadge: async () => steps.push("clear"),
});
// @ts-expect-error: a browser script with no type declarations.
await import("../src/app.js");
+ steps.length = 0;
let prevented = false;
submit!({ preventDefault: () => (prevented = true) });
expect(prevented).toBe(true);
- await vi.waitFor(() => expect(steps).toEqual(["unsubscribe", "submit"]));
+ await vi.waitFor(() => expect(steps).toHaveLength(3));
+ expect(steps.slice(0, 2).sort()).toEqual(["clear", "unsubscribe"]);
+ expect(steps[2]).toBe("submit");
});
diff --git a/cloudflare/test/push.test.ts b/cloudflare/test/push.test.ts
index d8f7d226..3c2a67df 100644
--- a/cloudflare/test/push.test.ts
+++ b/cloudflare/test/push.test.ts
@@ -351,8 +351,10 @@ describe("decision notifications", () => {
expect(html).toContain('');
const inbox = await (await app.request(`${origin}/inbox`, { headers: { Cookie: cookie } }, testEnv)).text();
expect(inbox).toContain("data-compact");
- expect(inbox).toContain('3');
- // Opening the inbox marks its active decision seen.
+ // The inbox marks its active decision seen before the nav and badge count it.
+ expect(inbox).toContain('2');
+ expect(inbox).toContain('data-unread="2"');
+ expect(inbox).toContain("3 waiting on you, 2 new.");
const after = await (await app.request(`${origin}/docs`, { headers: { Cookie: cookie } }, testEnv)).text();
expect(after).toContain('2');
});