Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cloudflare/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 60 additions & 8 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -28,9 +29,11 @@ other docs become `/docs/<name>` 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions cloudflare/dev/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
16 changes: 16 additions & 0 deletions cloudflare/migrations/0014_push.sql
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 5 additions & 2 deletions cloudflare/scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
9 changes: 9 additions & 0 deletions cloudflare/scripts/vapid.ts
Original file line number Diff line number Diff line change
@@ -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}`);
22 changes: 22 additions & 0 deletions cloudflare/src/account.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -144,6 +148,24 @@ export function AccountPage(props: {
Keep another passkey or a linked provider to recover access; there is no administrator recovery.
</p>
</Panel>
{props.notifications && (
<Panel title="Notifications">
<p>
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.
</p>
<p class="muted">
{props.devices === 1
? "1 device is notified."
: `${props.devices || "No"} devices are notified.`}{" "}
Signing out turns notifications off for that device.
</p>
<Notifications publicKey={props.notifications} />
<noscript>
<p class="muted">Notifications need JavaScript.</p>
</noscript>
</Panel>
)}
</section>
);
}
141 changes: 141 additions & 0 deletions cloudflare/src/app.js
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +82 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-register subscriptions after users sign back in

When a user signs out, /sign-out removes the D1 subscription but does not unsubscribe the browser's PushSubscription. After the user signs back in, this check treats that stale browser subscription as enabled, hides the compact control, and never posts it back to /push/subscribe; the UI therefore claims the device is notified while the server has no destination and sends it nothing. Reconcile the existing subscription with the server before reporting notifications as on.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6f17641a. Signing out now also unsubscribes the browser's own subscription (the sign-out form waits for it), so the next person to sign in on that browser opts in for themselves. When the switch loads with notification permission granted, it stores any existing browser subscription again for the current session, so it never shows a device as notified that the server forgot (sign-out without JS, or a session that expired and signed back in).

(Written by Claude Opus 5.5)

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(() => {});
}
9 changes: 6 additions & 3 deletions cloudflare/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Enrollment | null> {
return this.#withInstallation({ owner, name: repository }, {}, async (token, installation) => {
Expand Down
Loading
Loading