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 6370227b..f60ad2c2 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
@@ -202,6 +205,49 @@ 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 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
+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
+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 the project's members, whose inbox shows it. 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.
@@ -261,7 +307,8 @@ jobs that are queued, running, failed, or waiting on an agent's questions, a
tool permission, or an account limit.
`dev/github.ts` fakes GitHub for every outbound request, so nothing leaves
-localhost and no real credential is used. Sign in with GitHub completes as the
+localhost and no real credential is used. Notifications are off, since a push
+would reach a real push service. Sign in with GitHub completes as the
seeded maintainer. From a shell, sign in with a cookie jar:
```sh
@@ -298,7 +345,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:
@@ -347,7 +396,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/dev/serve.ts b/cloudflare/dev/serve.ts
index c499cf1c..77f8b3a5 100644
--- a/cloudflare/dev/serve.ts
+++ b/cloudflare/dev/serve.ts
@@ -80,6 +80,9 @@ const mf = new Miniflare(
GITHUB_FORK_OWNER: "quest-dev-forks",
GOOGLE_CLIENT_ID: "",
GOOGLE_CLIENT_SECRET: "",
+ // Notifications stay off: a push would leave localhost for a real push service.
+ VAPID_PUBLIC_KEY: "",
+ VAPID_PRIVATE_KEY: "",
},
// Every outbound request goes to the fake; nothing reaches the network.
outboundService: async (request) => {
diff --git a/cloudflare/migrations/0014_push.sql b/cloudflare/migrations/0014_push.sql
new file mode 100644
index 00000000..eda18cae
--- /dev/null
+++ b/cloudflare/migrations/0014_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..2aa4ab57
--- /dev/null
+++ b/cloudflare/src/app.js
@@ -0,0 +1,141 @@
+// 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),
+ });
+ const result = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(result?.error || "Request failed.");
+ return result;
+}
+
+function key(base64url) {
+ const text = atob(base64url.replaceAll("-", "+").replaceAll("_", "/"));
+ 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;
+}
+
+// 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"]')) {
+ form.addEventListener(
+ "submit",
+ (event) => {
+ event.preventDefault();
+ 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 },
+ );
+}
+
+// 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]");
+let stored = false;
+const checked = (async () => {
+ if (!supported || !controls.length || Notification.permission !== "granted") return;
+ const subscription = await current(controls[0].dataset.push);
+ if (subscription) stored = (await post("/push/check", { endpoint: subscription.endpoint })).stored === true;
+})();
+
+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]");
+
+ async function show(message) {
+ const subscription = supported && (await (await registration).pushManager.getSubscription());
+ 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;
+ 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;
+ await current(control.dataset.push);
+ const subscription = await (
+ 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.";
+ }),
+ );
+ disable.addEventListener("click", () =>
+ run(disable, async () => {
+ 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.";
+ }),
+ );
+ checked
+ .then(
+ () => show(),
+ (error) => show(error.message || "Something went wrong."),
+ )
+ .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/service.ts b/cloudflare/src/github/service.ts
index 2871cfa0..bfca9d67 100644
--- a/cloudflare/src/github/service.ts
+++ b/cloudflare/src/github/service.ts
@@ -425,6 +425,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/github/webhook.ts b/cloudflare/src/github/webhook.ts
index adb4f181..5b0c2faa 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";
import { GitHubService } from "./service";
export function positiveId(value: unknown): value is number {
@@ -205,7 +206,18 @@ githubWebhooks.post("/webhooks/github", async (c) => {
}
// Observations never approve or launch work. The background read reconciles
// current GitHub state rather than treating delayed webhook bodies as facts.
- if (reads !== null && committed[reads]!.meta.changes > 0)
- c.executionCtx.waitUntil(new GitHubService(c.env).observe(repository));
+ // A new decision item still notifies, once, from the first delivery, after
+ // the read so a title the webhook lacks can come from GitHub.
+ const read = reads !== null && committed[reads]!.meta.changes > 0;
+ if (read || observed.has(event))
+ c.executionCtx.waitUntil(
+ (async () => {
+ try {
+ if (read) await new GitHubService(c.env).observe(repository);
+ } finally {
+ if (observed.has(event)) await 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 efad07e6..79c8f26c 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, NotMaintainer } 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";
@@ -198,6 +198,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 the user is a member of. Every project
* rechecks current GitHub authority. A denied check ends the membership and
@@ -217,13 +247,11 @@ export async function load(env: Env, user: string) {
}),
);
const projects = views.filter((view) => view !== null);
- 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 6e78500c..ee844133 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -1,7 +1,7 @@
import { type Pull, startable } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { type Done, 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";
@@ -21,6 +21,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 recorded: Record = {
@@ -498,6 +500,7 @@ export function InboxPage(props: InboxProps) {
{recorded[props.done as Done]}
)}
+
{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 2cbbcfb5..c24adb9d 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -18,6 +18,7 @@ import { purge, retain } from "./jobs/output";
import { BootstrapRequired, fromForm, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes";
import { JobError, active } 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,
@@ -264,7 +267,11 @@ 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);
+ // 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." },
@@ -278,6 +285,7 @@ app.get("/inbox", async (c) => {
working={working}
skipped={skipped}
runners={runners}
+ notifications={push.keys(c.env)?.publicKey ?? null}
/>,
);
});
@@ -487,11 +495,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, "/");
});
@@ -499,10 +511,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) => (
@@ -511,6 +525,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 b94a995d..fecb0308 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, type Pull, startable } from "../github/state";
import { RUNNER_HEADER } from "./hub";
@@ -109,10 +110,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 adaaa313..e5d7e24a 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..ed175cbb
--- /dev/null
+++ b/cloudflare/src/push/notify.ts
@@ -0,0 +1,275 @@
+// 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, GitHubService } 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 lists this project: its members.
+ * Delivery rechecks that they still maintain it, and a denial ends the
+ * membership as it does in the inbox.
+ */
+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_project_members m ON m.user=s.user WHERE m.repository=?",
+ )
+ .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 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 || !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 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(
+ 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. 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=? AND state='enabled'",
+ )
+ .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);
+ // 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)
+ 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(),
+ 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.
+// 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);
+ 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/app.test.ts b/cloudflare/test/app.test.ts
new file mode 100644
index 00000000..8c381727
--- /dev/null
+++ b/cloudflare/test/app.test.ts
@@ -0,0 +1,38 @@
+// 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 and clears the app badge 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 } }) },
+ 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).toHaveLength(3));
+ expect(steps.slice(0, 2).sort()).toEqual(["clear", "unsubscribe"]);
+ expect(steps[2]).toBe("submit");
+});
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 e1c406cf..80c72ecd 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,56 @@ 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 () => {
+ // The project's maintainer is a member of it, with a device.
+ const maintainer = await maintainerOf();
+ 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',?,'s',?,'AAAAAAAAAAAAAAAAAAAAAA',0)",
+ )
+ .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 });
+ });
+ 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 aea41440..dd6295a4 100644
--- a/cloudflare/test/project-fixtures.ts
+++ b/cloudflare/test/project-fixtures.ts
@@ -185,6 +185,7 @@ 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("");
+ // Background work, such as reads and notifications, finishes before the test goes on.
const ctx = createExecutionContext();
const response = await app.request(
"http://localhost:8787/webhooks/github",
diff --git a/cloudflare/test/publish.test.ts b/cloudflare/test/publish.test.ts
index de228204..044f0218 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";
@@ -207,9 +208,10 @@ async function running(extra: Partial = {}, now = 0) {
async function fail(id: string) {
await store.finish(db, machine, id, 1, { outcome: "failed", summary: "Failed", result: undefined }, 0);
}
-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",
@@ -221,7 +223,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..bef392ba
--- /dev/null
+++ b/cloudflare/test/push.test.ts
@@ -0,0 +1,392 @@
+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 { identity, oauth, 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;
+ let github: Awaited>["github"];
+ // Push services answer here; everything else goes to the mocked GitHub.
+ let respond: (input: RequestInfo | URL, init?: RequestInit) => Promise;
+
+ 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, github } = maintainer);
+ sent = [];
+ status = 201;
+ respond = 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 });
+ };
+ vi.spyOn(globalThis, "fetch").mockImplementation(respond);
+ });
+ 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 }]);
+ // 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([]);
+ });
+
+ 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("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 to every member of the project, and drops one who no longer maintains it", async () => {
+ const mine = await subscribe();
+ const who = identity("github");
+ const session = (await oauth(who)).session!;
+ vi.spyOn(globalThis, "fetch").mockImplementation(respond);
+ const member = (await db
+ .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?")
+ .bind(who.id)
+ .first<{ userId: string }>())!.userId;
+ await db.prepare("INSERT INTO github_project_members(repository,user) VALUES(10,?)").bind(member).run();
+ const theirs = await browser();
+ expect((await post("/push/subscribe", theirs.json, { Cookie: session, Origin: origin })).status).toBe(204);
+ const opened = (number: number) => ({
+ action: "opened",
+ installation: { id: 5 },
+ repository: { id: 10 },
+ issue: { id: 10 + number, number, title: `Issue ${number}` },
+ });
+ github.maintainers.push(who.id);
+ await delivery("issues", opened(3));
+ expect(sent.map((s) => s.endpoint).sort()).toEqual([mine.endpoint, theirs.endpoint].sort());
+ // A co-maintainer who lost the role is not notified, and leaves the project's inbox.
+ github.maintainers.length = 0;
+ await delivery("issues", opened(4));
+ expect(sent.slice(2).map((s) => s.endpoint)).toEqual([mine.endpoint]);
+ const members = await db.prepare("SELECT user FROM github_project_members WHERE repository=10").all();
+ expect(members.results).toEqual([{ user }]);
+ });
+
+ 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, the ready quest, and the new issue. A new
+ // issue cannot change which quests are ready, so they stay available.
+ badge: 4,
+ });
+ });
+
+ 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",
+ });
+ // 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([]);
+ });
+
+ 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");
+ // 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');
+ });
+});
diff --git a/cloudflare/test/seed.test.ts b/cloudflare/test/seed.test.ts
index 59a85b42..8d4356cc 100644
--- a/cloudflare/test/seed.test.ts
+++ b/cloudflare/test/seed.test.ts
@@ -22,7 +22,7 @@ it("seeds a representative inbox through the Worker's own routes", async () => {
]);
const data = world();
vi.spyOn(globalThis, "fetch").mockImplementation(fakeGitHub(data));
- // Webhooks read in the background, so each request finishes its `waitUntil` work.
+ // Webhooks read and notify in the background, so each request finishes its `waitUntil` work.
const request = async (path: string, init?: RequestInit) => {
const ctx = createExecutionContext();
const response = await app.request(`${origin}${path}`, init, testEnv, ctx);
diff --git a/cloudflare/vitest.config.ts b/cloudflare/vitest.config.ts
index 5fc30d81..5cc3f169 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.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.
@@ -23,8 +24,13 @@ 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:
+ "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 e2f0b831..893197f5 100644
--- a/design/theme.md
+++ b/design/theme.md
@@ -165,6 +165,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 00000000..5f5e8be4
Binary files /dev/null and b/design/theme/icon-192.png differ
diff --git a/design/theme/icon-512.png b/design/theme/icon-512.png
new file mode 100644
index 00000000..270aa954
Binary files /dev/null and b/design/theme/icon-512.png differ
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 f141577a..b2061a20 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -104,7 +104,6 @@ operator-paid-hosting decisions:
- [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR
- [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