diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 04768ac6..b17c1214 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,12 +9,6 @@ updates: groups: bun: patterns: ["*"] - ignore: - # Bump by hand once @cloudflare/vitest-plugin's peer range includes the new major. - - dependency-name: vitest - update-types: ["version-update:semver-major"] - # The flake's opencode-client check pins the client to nixpkgs' opencode; bump both together. - - dependency-name: "@opencode/client" - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/isolated.yml b/.github/workflows/isolated.yml deleted file mode 100644 index 88623fa7..00000000 --- a/.github/workflows/isolated.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Isolated - -# Runs each Worker test case in its own process, catching a test that passes -# only on module-level state another test left behind. Too slow for every pull -# request, so it runs daily on main. -on: - schedule: - - cron: "23 6 * * *" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: isolated-${{ github.ref }} - cancel-in-progress: true - -jobs: - worker: - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f - with: - determinate: false - # The build snapshots finished quests from the full history, as the Check job's does. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Install dependencies - run: nix develop --command just install - - name: Run each Worker test case alone - run: nix develop --command just worker-test-isolated diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml deleted file mode 100644 index 0515d75e..00000000 --- a/.github/workflows/sandbox.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Sandbox - -# The runner sandbox's boundary tests (`just runner-sandbox`), with its pool's -# limit checks, run nightly on a GitHub-hosted runner, and on pull requests that -# change the agents they check: the flake's pins, the runner's sources, the -# limit messages they match, or the tests themselves. They stay -# off the merge queue's required checks, so landing never waits on them; -# /quest-merge names a failing optional check before it queues. The job runs PR -# code, so it stays read-only and uses no secrets. GitHub reports a failed scheduled run only to the account -# that last changed the cron schedule (with Actions notifications on), not to -# the repository's watchers; that is the maintainer, whose account lands the -# workflow, so they see a boundary failure. -on: - schedule: - - cron: "23 7 * * *" - workflow_dispatch: - pull_request: - paths: - - flake.lock - - flake.nix - - nix/** - - package.json - - bun.lock - - src/runner/** - - src/cli/limit.ts - - src/cli/review.ts - - test/live/runner-sandbox*.ts - - .github/workflows/sandbox.yml - -permissions: - contents: read - -concurrency: - group: sandbox-${{ github.ref }} - cancel-in-progress: true - -jobs: - runner-sandbox: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f - with: - determinate: false - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Install dependencies - run: nix develop --command just install - # The test runs bubblewrap from the Nix store, which Ubuntu's AppArmor would - # refuse the unprivileged user namespace it needs. - - name: Allow unprivileged user namespaces - run: | - if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - name: Check the runner sandbox's boundary - run: nix develop --command just runner-sandbox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a84e0b8..dcbc3311 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,8 +22,8 @@ dependencies. `packageManager` in `package.json` names the same Bun for CI runners without Nix, and `nix flake check` fails when the two disagree. Without Nix, install the tools listed in `flake.nix` and run the same just recipes. -`just test` runs vitest, the shell hook suites, the built CLI's lifecycle check, -and the Worker tests. `just check` includes TypeScript, formatting, workflow, +`just test` runs vitest, the shell hook suites, and the built CLI's lifecycle +check. `just check` includes TypeScript, formatting, workflow, shell, and Nix checks in addition to quest validation. `nix build` builds the package; `nix run -- --help` runs it. `nix flake check` diff --git a/README.md b/README.md index bc852ee8..5fa62416 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,7 @@ README has no `Required` left) removes the links to that milestone too; `quest check` flags any it misses. The finished plan stays in Git history, beside the code that completed it. -Work is taken through a live runner lock or an open change on the forge. -kix.quest records each active job in its database and reassigns a job whose -runner goes offline. On GitHub, a draft PR +Work is taken through an open change on the forge. On GitHub, a draft PR opened from the quest's branch, including from a fork, claims the work; `quest start` opens one. These states stay out of the plan file. @@ -161,7 +159,7 @@ same way. A fork whose parent keeps no quests is its own upstream. A failed lookup, such as an expired token or a network error, fails the command rather than picking a different upstream. `--remote` picks a remote other than `origin`. Off GitHub, only branches are checked. -Neither form inspects kix.quest runner locks or outside conditions. +Neither form inspects outside conditions. ## Find what waits on you @@ -522,13 +520,13 @@ quest merge 128 --land # auto-merge bound to that head; takes no label A taken PR carries the `quest:active` label while an agent works on it. Sessions often share a GitHub account, so taking it prints a hold token: the ID of the timeline event that added the label. Every command refuses while a -`quest:active` or `quest:kix` label it holds no token for is on the PR, -including another session's `quest:active`; a draft its starter still holds, one without a +`quest:active` label it holds no token for is on the PR; a draft its starter still holds, one without a `## Waiting` (or older `## Decisions needed`) section; and a merged or closed PR, since a push after the merge brings the branch back. The refusal names the holder's label, age, and token, so you can decide whether to release a stale hold. When two -sessions add labels at once, the first label added keeps the PR and the other -backs off. +sessions add the label at once, the first added keeps the PR; GitHub records no +event for adding a label already on the PR, so two takes in the same moment +are not told apart. `quest merge` takes no label: the head-pinned auto-merge or merge-queue entry it creates is the queue signal, so a queued PR shows no hold. It refuses while @@ -1202,8 +1200,7 @@ ownership instead: a scope of the gate's own would take the command out of the enclosing one's reach. Internal compiler or test worker parallelism remains the command's responsibility. -This repository's `just check`, `just test`, `just worker-check`, and -`just worker-test` run through the gate. +This repository's `just check` and `just test` run through the gate. Arguments, working directory, environment, terminal streams, and the exit status pass through, and waiting happens before the command starts. Each @@ -1285,9 +1282,8 @@ neither gets M's reviewers. `--size` overrides both. The first reviewer whose model is not the latest writer's reviews: the record's last `Implementer:` line names it, since each session moves its line last whenever it pushes, or `--exclude-model` names each model to avoid instead. Earlier writers may review. -Without a record, or when its last line names an `unknown` model, or when the -body's and kix's sessions comment's last lines name different models, whose -order cannot be told, the harness running the command, usually the session that +Without a record, or when its last line names an `unknown` model, the harness +running the command, usually the session that just pushed, is taken to be the latest writer, so Codex reviews from Claude Code and Claude Code from Codex. `--harness` names the reviewer instead, at its entry's model and effort, and `--model` and `--effort` override those, as a repository whose @@ -1444,8 +1440,7 @@ session must also be this repository's, as its Codex log, its checkout's remotes, or, once the checkout is deleted, a Codex log for that exact directory names (never a parent's, which a nested clone may not share); otherwise it stays unattributed. A session is reviewing when the PR's `## Review` record names it or Codex marks it a review, implementing when it began before the PR opened, -and iterating otherwise. Each PR shows its `## Models` size and implementers, -with those on kix's sessions comment; its latest `quest review` round's +and iterating otherwise. Each PR shows its `## Models` size and implementers; its latest `quest review` round's reviewer, round, score, open findings, and the last round's findings fixed or refuted; the land fixer's fixes; and the later PRs that list it under `## Caused by`. @@ -1512,175 +1507,6 @@ escaped, `NoDefaultCurrentDirectoryInExePath` stops Windows' implicit search of the working directory, and `stop` ends the agent's whole process tree at once, since Windows has no `SIGTERM`. -## Run kix jobs on your machines - -`quest runner` turns a Linux or macOS machine with Nix into a runner for kix at -[quest.moq.dev](https://quest.moq.dev), driving Claude Code or opencode with -that machine's own logins: - -```sh -quest runner login --name laptop # prints a link to approve while signed in to kix -quest runner account add # a Claude subscription, into the runner's pool -quest runner account add codex # a ChatGPT plan, into the pool Codex reviews with -ANTHROPIC_API_KEY=sk-ant-... quest runner account add --console --label work -quest runner account add opencode # opencode's sign-in: OpenCode Go, ChatGPT, or an API key -quest runner account add opencode --model openai/gpt-5.5 -quest runner account list -quest runner --slots 2 # runs jobs in the foreground until interrupted -``` - -`login` makes a token that never leaves the machine; the link carries only its -hash, and kix connects the runner once you approve it. Claude and Codex -subscriptions pool behind the runner's own -[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI): `account add` -signs each one in through the proxy's OAuth flow (`-claude-login` or -`-codex-login`; over SSH, Claude's prints its link and takes the callback URL -you paste back, and Codex's takes a device code), and `--console` adds an -Anthropic Console API key from `ANTHROPIC_API_KEY` to the Claude pool. Signing a -pooled account in again refreshes its credential. `quest runner` starts the -proxy on a free loopback port with a client key of its own and stops it on -exit, and every Claude Code job and review and every Codex review goes through -it, so work spreads round-robin across the pool, keeping each session on one -account for its prompt cache, and moves to another account when one is -refused. The agent sees only the proxy's client key, through Claude Code's -`apiKeyHelper` rather than its environment, never a subscription's token. kix -sees the Claude pool as one account, labeled with its members; Codex's pool -only reviews, since no adapter runs Codex jobs. opencode accounts sign in with -`opencode auth login`, each in its own set of XDG directories, and use only -the providers they signed in to, with `--model` or opencode's default. kix -stores only an opaque account id, its kind, and a label. kix's `/runners` page -shows each runner's presence and sets the order jobs use your accounts in. - -Runner state from before the pool, with Claude Code accounts in their own -homes, is refused with a message: delete `accounts.json` and `accounts/` from -the runner's home and add each account again. - -Every job runs in its own checkout with the project's devShell, which the runner -builds with `nix print-dev-env` from the job's revision, honoring the flake's -`nixConfig`; a project without a flake gets a bootstrap job instead. An OS -sandbox (bubblewrap on Linux, Seatbelt on macOS) confines every command the -agent runs, the devShell's shellHook included: Claude Code's own sandbox on -the Claude pool, and on opencode accounts the same policy through -[sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime), -which the runner sets as opencode's shell. Commands -write only to the checkout and a private temporary directory that is -also their `HOME`, cannot read account homes, the runner's state (the proxy's -credentials and client key included), or shared temporary directories, and -have no Nix daemon. A trusted revision's commands reach only the npm, -crates.io, and PyPI package registries, so its checks can install -dependencies; an untrusted one's reach no network host, the proxy on loopback -included. npm's registry -also accepts publishes, so a prompt-injected agent holding someone's npm token -could publish the checkout there. The agent's file tools stay in the checkout -(opencode's may also use the private temporary directory, and its read tool -the output it saved), WebFetch and opencode's web search are off, and nothing -the checkout configures is loaded: not its `.claude` settings, hooks, and MCP -servers, nor its `opencode.json` and `.opencode` plugins, tools, and MCP -servers, and opencode's language servers and formatters are off. An opencode -job refuses to run if opencode loaded any config besides the runner's and the -account's, such as a machine's managed `/etc/opencode`, which would outrank -the runner's. The CLI itself, Claude Code or -`opencode serve`, runs outside the sandbox and outside the devShell, and its -commands start from a minimal environment, so its login or the proxy's client -key, the opencode server's password, and the kix tools' token stay out of their -reach. This is an OS -sandbox, not a VM, and it narrows rather than removes the risk of running -agent commands on your machine. On Linux, `just runner-sandbox` in this -repository runs a scripted model through the pinned Claude Code, and through -the pinned opencode and sandbox-runtime, in a checkout that tries to read -credentials, write outside its sandbox, plant its own agent config and hooks, -reach the network, and connect to the Nix daemon's or another Unix socket, and -fails if any attempt gets through. CI runs it nightly and on pull requests that change the pins or the runner, -outside the merge queue. - -On Linux the sandbox needs `bwrap` and `socat` (the flake's `runner` package -pins both) and unprivileged user namespaces, including the nested one the -seccomp helpers of Claude Code and sandbox-runtime make inside the sandbox. -Before taking jobs, `quest runner` runs a trivial command in nested sandbox -namespaces and refuses to start if bubblewrap cannot make them. On Ubuntu 23.10 and later, AppArmor allows them -only to programs whose profile grants `userns`, so for the runner package's -bubblewrap the refusal prints this profile, filled in with its Nix store path, -and the commands that install it as `/etc/apparmor.d/quest-bwrap`: - -``` -abi , -include - -profile quest-bwrap /nix/store/HASH-bubblewrap-VERSION/bin/bwrap flags=(unconfined) { - userns, -} -``` - -It is the form Ubuntu gives other programs that sandbox themselves. Ubuntu's -own `bwrap-userns-restrict` profile for `/usr/bin/bwrap` takes capabilities from -the commands bubblewrap runs, which breaks the seccomp helpers, so a -system bwrap there is refused, and a second profile for it would disable both; -use the runner package instead. The job's commands still get no user namespace -of their own: the seccomp helper refuses them one. The profile names one exact store path, since anyone -who can build with Nix can add a store path matching a glob, so a runner update -that changes bubblewrap refuses to start until you install the profile it prints. - -For opencode accounts, `quest runner` also checks that sandbox-runtime keeps -commands from making Unix sockets, which it does only with its seccomp helper, -and refuses to start without it. nixpkgs' sandbox-runtime leaves the helper -out, so the runner package builds it from sandbox-runtime's source. sandbox-runtime -keeps its own `/tmp/claude` open to commands whenever it exists; they cannot -write it, but can read it. - -Merge jobs retain existing PR submodule pointers when merging the base. Their -`wait` tool wakes on GitHub webhook hints or reconnects and rereads the PR; -a hint never approves work. If someone moves the head, the maintainer can -continue from that exact head through the existing approval flow. The same -runner checkpoints and merges its unpublished source locally; conflicts pause -for a decision. Stale local changes are preserved in the runner's bare clone -under `refs/kix/checkpoints//`, and are not reapplied after another runner -publishes. Existing failover still restarts from the latest published branch -checkpoint. Initialized submodules leave their checkout intact until their -local commits and changes can be preserved explicitly; this path never deletes -them to force continuation. - -The agent reports through kix tools: `checkpoint` summaries publish -work in progress to the job's branch, `ask` pauses the job on a decision for you, -and `deliver` publishes the result and its pull request, listing any suggested -follow-ups, which the inbox offers you to plan for 30 days after the job -completes. Expired offers cannot start a plan, and the job's history remains. -A job on an issue or -pull request reads it through `context`, since the runner holds no GitHub -token; a new plan reads your idea and pasted context there. kix publishes as its GitHub App, so the runner never pushes. Once -every account in a pool is cooling down, Claude Code shows the proxy's "All -credentials for model ... are cooling down" without its reset, so the runner -asks the proxy, which names it in `Retry-After` without asking any provider, -and marks the pool limited until then (an hour when neither says); adding an -account to the pool lifts that. A job that -hits its account's or pool's limit pauses for you to pick another account; kix -never switches on its own. A review whose pool is limited leaves the head -unreviewed, saying when the pool resets, and landing it needs a maintainer's -approval on GitHub. -On SIGINT or SIGTERM, running work is published and its jobs move to another -runner. `quest runner` takes only trusted revisions until it can build -untrusted heads' devShells in a VM: it registers without declaring VM builds, -so kix routes it no other job, and it fails one if sent. A pull request's head is trusted only when every -commit beyond the default branch has a valid signature from a current -maintainer, so sign your commits (for example with -[SSH signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification)) -for its iterate and merge jobs to run. Any other head, such as one kix -pushed to, waits in the inbox for a maintainer to review its commits and -approve that exact head; the approval lapses when the head moves, and counts -only while its approver is still a maintainer. A job resumes with another maintainer's answer -only once you approve it in the inbox. opencode runs as `opencode serve` on -localhost behind a password made for each turn. - -The flake's `runner` package pins Claude Code 2.1.305 from its official release -manifest; opencode, Codex, CLIProxyAPI, sandbox-runtime (and on Linux, bubblewrap and socat) follow -this repository's lock (`nix run github:moq-dev/quest#runner -- runner`); -otherwise `claude`, `opencode`, `codex`, `cliproxyapi`, and `srt` come from `QUEST_RUNNER_CLAUDE`, -`QUEST_RUNNER_OPENCODE`, `QUEST_RUNNER_CODEX`, `QUEST_RUNNER_CLIPROXY`, and `QUEST_RUNNER_SRT`, or `PATH`. -Codex reviews the heads that jobs on Anthropic's models publish with the -Codex pool, and Claude Code the rest with the Claude pool, so add a Codex -account for Claude jobs to be reviewed. State lives in `$QUEST_RUNNER_HOME`, by default -`$XDG_DATA_HOME/quest/runner`; one `quest runner` runs in a home at a time. To keep a runner up, run `quest runner` as a user -service, such as a systemd user unit with `Restart=on-failure`. - ## Prunable local checkouts The experimental `quest worktree` commands share one disk budget across this @@ -1938,14 +1764,6 @@ losing your plans. The binary carries the skills, your tool manager (mise or nix pins its version, and your repository's instructions stay yours. Quest supports macOS, Linux, and [native Windows](docs/windows.md). -The hosted service, kix, runs at [quest.moq.dev](https://quest.moq.dev) for -moq-dev until it opens to everyone at kix.quest: maintainers -plan issues, start ready quests, and act on GitHub PRs through a decision UI. -Hosted work will use explicitly selected maintainer or donor API funding. -Automatic project history passes read updates since their prior issue/PR -boundaries, including timestamp ties. Explicit Refresh starts or resumes a full -history pass; successive refreshes repair cached issues deleted without a -webhook. Open draft bodies are read only to classify `## Waiting`. [The first milestone](quest/m0/) holds the quests that remain. ## Contributing diff --git a/SETUP.md b/SETUP.md index a3a1bf45..c9fffa63 100644 --- a/SETUP.md +++ b/SETUP.md @@ -116,7 +116,7 @@ Settings โ†’ Actions โ†’ General โ†’ Workflow permissions to read; move reposito secrets, and organization secrets shared with the repository, into environments whose deployment branches are limited to protected branches; and keep write `permissions:` out of jobs that pull requests and pushes to other -branches run. kix checks the same settings when a repository enrolls. +branches run. Change settings and workflows only if the user agrees. Run `quest check`, then suggest next steps: restart the agent session so it diff --git a/assets/AGENTS.md b/assets/AGENTS.md index 3c8e7c07..24239068 100644 --- a/assets/AGENTS.md +++ b/assets/AGENTS.md @@ -115,10 +115,8 @@ Stop and report a PR that makes the same change; one that only touches the same `quest unblocked` ranks the ready quests, and `quest unblocked ` prints what blocks one. With `--unclaimed`, it skips quests someone holds and lists them on stderr. -Work is taken when it has a live runner lock or an open change on the forge. -kix.quest records each active job in its database and reassigns a job whose -runner goes offline. Check those locks and open changes before -starting. These states stay out of the quest file. +Work is taken when it has an open change on the forge. Check open changes +before starting. These states stay out of the quest file. On GitHub, a quest is claimed by an open PR whose head branch is the quest's branch, from the upstream repository or a fork, or by that branch upstream diff --git a/assets/CONTEXT.md b/assets/CONTEXT.md index 18bc7922..5157fcb8 100644 --- a/assets/CONTEXT.md +++ b/assets/CONTEXT.md @@ -96,7 +96,7 @@ Run the repository's checks locally while the PR is a draft; CI may run only on Before a PR is marked ready for review, and before each push to one that is, a model other than the one that changed it last reviews the committed branch locally, as `/quest-review` describes. -The PR body's `## Models` section records its size as `Size: M` (its quest's, or for a skill's own PR, as a quest is sized) and one `Implementer: Codex, gpt-6.1-sol, high effort` line per implementing session, moved last whenever it pushes; kix records one that pushes to an open PR in its own comment. +The PR body's `## Models` section records its size as `Size: M` (its quest's, or for a skill's own PR, as a quest is sized) and one `Implementer: Codex, gpt-6.1-sol, high effort` line per implementing session, moved last whenever it pushes. Read your model and effort rather than guess, writing `unknown` for any you cannot find: Claude Code knows its model and exports `CLAUDE_EFFORT`, and Codex records `model` and `effort` in the last `turn_context` of `~/.codex/sessions/*/*/*/rollout-*-$CODEX_THREAD_ID.jsonl`. GitHub gives secrets to workflows on branches pushed to the repository, as quest branches usually are, so keep pull request CI, which runs unreviewed code, read-only: a read-only `GITHUB_TOKEN`, and write credentials only in environments limited to protected branches. diff --git a/assets/skills/review.md b/assets/skills/review.md index a15ab7d2..69e3e22a 100644 --- a/assets/skills/review.md +++ b/assets/skills/review.md @@ -22,7 +22,7 @@ If a `## Review` record that `/quest-merge` counts names the committed head, or If you judge the change too trivial to review, skip the review instead and record the skip and its reason in `## Review`. Otherwise, review the committed branch with `quest review --base /`, which picks a reviewer other than the model that changed it last, and its effort, by the PR's size; `quest review --help` explains the round. -It reads the latest implementing model, and on a branch that is not a quest's the size, from the PR body's `## Models` record and kix's sessions comment, so write that record before the first round; a quest branch's size is read from its quest. +It reads the latest implementing model, and on a branch that is not a quest's the size, from the PR body's `## Models` record, so write that record before the first round; a quest branch's size is read from its quest. Pass `--exclude-model` or `--size` only for a PR whose body cannot hold the record. Where the repository's root instructions name other reviewers, pass them with `--harness`, `--model`, and `--effort`. Run it in the foreground; a round takes minutes. diff --git a/assets/skills/start.md b/assets/skills/start.md index df62cdfb..82cf25d2 100644 --- a/assets/skills/start.md +++ b/assets/skills/start.md @@ -9,7 +9,7 @@ The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. Confirm you can write in your worktree; if you cannot, stop and report without claiming. -Update your checkout and check the forge for a live runner lock and for an open PR already doing the quest's work, then claim the quest with `quest start `. +Update your checkout and check the forge for an open PR already doing the quest's work, then claim the quest with `quest start `. If it refuses, the quest is blocked or someone else holds it: report what it printed and stop. If it warns that the quest's local branch already exists, the claim succeeded: resume that earlier attempt as the guide describes. If you cannot pursue a claim before pushing work, release it with `quest start --release ` and remove your isolated worktree; once work is pushed, hand it back as the guide describes. diff --git a/bun.lock b/bun.lock index 6f1b3d85..c61e9d37 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "quest", "dependencies": { - "@opencode/client": "2.2.1", "commander": "15.0.0", "mdast-util-from-markdown": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.1", @@ -21,228 +20,17 @@ "vitest": "5.0.5", }, }, - "cloudflare": { - "name": "quest-cloudflare", - "dependencies": { - "@better-auth/passkey": "1.7.14", - "better-auth": "1.7.14", - "hono": "4.13.14", - "marked": "18.1.2", - "quest": "file:..", - }, - "devDependencies": { - "@cloudflare/vitest-plugin": "1.4.11", - "@types/node": "26.6.6", - "miniflare": "5.20261012.0-beta", - "prettier": "3.9.10", - "typescript": "7.0.2", - "vitest": "5.0.5", - "wrangler": "4.156.0", - }, - }, }, "trustedDependencies": [], "packages": { - "@better-auth/core": ["@better-auth/core@1.7.14", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.4", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA=="], - - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig=="], - - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg=="], - - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg=="], - - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ=="], - - "@better-auth/passkey": ["@better-auth/passkey@1.7.14", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.14", "better-call": "1.4.4", "nanostores": "^1.0.1" } }, "sha512-ZUODCRp9vnbk2GThOXRja+bpTLweJar5bJppSoobck2DhM7XCRPaQjzrCIJllnjy6VUBcCkjGm2oKmy2yvepeg=="], - - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ=="], - - "@better-auth/telemetry": ["@better-auth/telemetry@1.7.14", "", { "peerDependencies": { "@better-auth/core": "^1.7.14", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg=="], - - "@better-auth/utils": ["@better-auth/utils@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="], - - "@better-fetch/fetch": ["@better-fetch/fetch@1.3.3", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="], - - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], - - "@cloudflare/vitest-plugin": ["@cloudflare/vitest-plugin@1.4.11", "", { "dependencies": { "cjs-module-lexer": "1.2.3", "esbuild": "0.28.2", "miniflare": "5.20261023.3-beta", "wrangler": "4.156.0", "zod": "4.6.6" }, "peerDependencies": { "vitest": "^4.1.0 || ^5.0.0" } }, "sha512-ZBte0LzR9eWQL17wb5gJpNrIWma0UlcfnU0aBgF8fVuKN8/uZpqEKRvuLE/mBZTTAvR5pCl18DG872NGlUwyvg=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20261012.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20261012.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20261012.1", "", { "os": "linux", "cpu": "x64" }, "sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20261012.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261012.1", "", { "os": "win32", "cpu": "x64" }, "sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q=="], - - "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - - "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], - - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], - - "@noble/ciphers": ["@noble/ciphers@2.4.0", "", {}, "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw=="], - - "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], - - "@opencode/client": ["@opencode/client@2.2.1", "", { "dependencies": { "@opencode/protocol": "2.2.1", "@opencode/schema": "2.2.1" }, "peerDependencies": { "effect": "4.0.1", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-7QWtocDRR9eUsCJpyaj6k+aAEVDWO2CIzn9lwDNBJhiyt2hMf+yOyV/BKeK/9R7qKcXEOZ0tlGDvQC8HEtyjQg=="], - - "@opencode/protocol": ["@opencode/protocol@2.2.1", "", { "dependencies": { "@opencode/schema": "2.2.1", "effect": "4.0.1" } }, "sha512-lqkkRhrckEKmiSJi249rvfX/fXX8JeiMS7adtXonO1Ol3IqnEqY4uSZgvQiQ11SBR5RyDQaRW9KGoOjaTY4PAQ=="], - - "@opencode/schema": ["@opencode/schema@2.2.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.1" } }, "sha512-TicxPQC1UKqq954X1yUKmBlXfJYl5CRr52FKwLZbYOfTxJd406Hoqt2arM4YI3c+abs17b9sumoq5uqFLhiGFA=="], - "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], - "@peculiar/asn1-android": ["@peculiar/asn1-android@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw=="], - - "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-attr": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g=="], - - "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg=="], - - "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q=="], - - "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.11.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-pkcs8": "^2.11.0", "@peculiar/asn1-rsa": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ=="], - - "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w=="], - - "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.11.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.11.0", "@peculiar/asn1-pfx": "^2.11.0", "@peculiar/asn1-pkcs8": "^2.11.0", "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "@peculiar/asn1-x509-attr": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA=="], - - "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA=="], - - "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.11.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A=="], - - "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww=="], - - "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.11.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.11.0", "@peculiar/asn1-x509": "^2.11.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA=="], - - "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], - - "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], - - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], - - "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], - - "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], @@ -275,16 +63,6 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], - - "@simplewebauthn/server": ["@simplewebauthn/server@13.3.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA=="], - - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - - "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -345,46 +123,26 @@ "@vitest/spy": ["@vitest/spy@5.0.5", "", {}, "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw=="], - "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "better-auth": ["better-auth@1.7.14", "", { "dependencies": { "@better-auth/core": "1.7.14", "@better-auth/drizzle-adapter": "1.7.14", "@better-auth/kysely-adapter": "1.7.14", "@better-auth/memory-adapter": "1.7.14", "@better-auth/mongo-adapter": "1.7.14", "@better-auth/prisma-adapter": "1.7.14", "@better-auth/telemetry": "1.7.14", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.4", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw=="], - - "better-call": ["better-call@1.4.4", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw=="], - - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], - "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], - - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": "bin/esbuild" }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -393,14 +151,6 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], - - "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "kysely": ["kysely@0.29.6", "", {}, "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg=="], - "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -431,8 +181,6 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@18.1.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], @@ -493,20 +241,12 @@ "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], - "miniflare": ["miniflare@5.20261012.0-beta", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.29.1", "workerd": "1.20261012.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.19", "", { "bin": "bin/nanoid.cjs" }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], - "nanostores": ["nanostores@1.5.4", "", {}, "sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ=="], - "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -515,50 +255,22 @@ "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], - "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], - - "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], - - "quest": ["quest@root:", {}], - - "quest-cloudflare": ["quest-cloudflare@workspace:cloudflare"], - - "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], - "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": "bin/cli.mjs" }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], - "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], - - "semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "set-cookie-parser": ["set-cookie-parser@3.1.3", "", {}, "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew=="], - - "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], - "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], - "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], @@ -573,64 +285,6 @@ "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": "cli.js" }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], - "workerd": ["workerd@1.20261012.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261012.1", "@cloudflare/workerd-darwin-arm64": "1.20261012.1", "@cloudflare/workerd-linux-64": "1.20261012.1", "@cloudflare/workerd-linux-arm64": "1.20261012.1", "@cloudflare/workerd-windows-64": "1.20261012.1" }, "bin": "bin/workerd" }, "sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA=="], - - "wrangler": ["wrangler@4.156.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.2", "miniflare": "5.20261023.3-beta", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20261023.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20261023.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-kVLAShuLiNgE3jcrmXcymDf/iPKDPrSxACe5ueugW/33HeNB9E9CkKYchudCeBwgmsnl4k+1zt4NO7vqb/fRuQ=="], - - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], - - "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], - - "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - - "@better-auth/core/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - - "@better-auth/passkey/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - - "@cloudflare/vitest-plugin/miniflare": ["miniflare@5.20261023.3-beta", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.30.1", "workerd": "1.20261023.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-thdXzlktu5GE1o/BlmMAPeJ0Cu6Tu5LpMbRlyfRrpVvMRrhd6p52TMqcITCZZLVzt1UO92VGBkLDhLOpryUAyQ=="], - - "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - - "better-auth/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], - - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], - - "better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], - - "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], - - "wrangler/miniflare": ["miniflare@5.20261023.3-beta", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.30.1", "workerd": "1.20261023.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-thdXzlktu5GE1o/BlmMAPeJ0Cu6Tu5LpMbRlyfRrpVvMRrhd6p52TMqcITCZZLVzt1UO92VGBkLDhLOpryUAyQ=="], - - "wrangler/workerd": ["workerd@1.20261023.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261023.1", "@cloudflare/workerd-darwin-arm64": "1.20261023.1", "@cloudflare/workerd-linux-64": "1.20261023.1", "@cloudflare/workerd-linux-arm64": "1.20261023.1", "@cloudflare/workerd-windows-64": "1.20261023.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg=="], - - "@cloudflare/vitest-plugin/miniflare/undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], - - "@cloudflare/vitest-plugin/miniflare/workerd": ["workerd@1.20261023.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261023.1", "@cloudflare/workerd-darwin-arm64": "1.20261023.1", "@cloudflare/workerd-linux-64": "1.20261023.1", "@cloudflare/workerd-linux-arm64": "1.20261023.1", "@cloudflare/workerd-windows-64": "1.20261023.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg=="], - - "wrangler/miniflare/undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], - - "wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20261023.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg=="], - - "wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20261023.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ=="], - - "wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20261023.1", "", { "os": "linux", "cpu": "x64" }, "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw=="], - - "wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20261023.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ=="], - - "wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261023.1", "", { "os": "win32", "cpu": "x64" }, "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw=="], - - "@cloudflare/vitest-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20261023.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg=="], - - "@cloudflare/vitest-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20261023.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ=="], - - "@cloudflare/vitest-plugin/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20261023.1", "", { "os": "linux", "cpu": "x64" }, "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw=="], - - "@cloudflare/vitest-plugin/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20261023.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ=="], - - "@cloudflare/vitest-plugin/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261023.1", "", { "os": "win32", "cpu": "x64" }, "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw=="], } } diff --git a/cloudflare/.dev.vars.example b/cloudflare/.dev.vars.example deleted file mode 100644 index fc29c2b3..00000000 --- a/cloudflare/.dev.vars.example +++ /dev/null @@ -1,21 +0,0 @@ -# Generate a random secret with `openssl rand -hex 32`; replace these placeholders. -AUTH_SECRET=replace-with-a-random-secret-at-least-32-characters -GITHUB_CLIENT_ID=replace-with-github-app-client-id -GITHUB_CLIENT_SECRET=replace-with-github-app-client-secret -# Optional Google sign-in, offered only when both are set. Wrangler dev loads -# the secret only while it is listed in wrangler.jsonc's `secrets.required`. -# GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id -# GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret -# Local development serves the app from Wrangler instead of https://quest.moq.dev. -AUTH_URL=http://localhost:8787 - -# Server-only GitHub App integration; see GITHUB.md. Separate from OAuth login. -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 - -# 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/.gitignore b/cloudflare/.gitignore deleted file mode 100644 index b977d2e3..00000000 --- a/cloudflare/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules/ -.wrangler/ -.dev.vars -.dev.vars.* -!.dev.vars.example -.env -.env.* -build/ diff --git a/cloudflare/.prettierignore b/cloudflare/.prettierignore deleted file mode 100644 index ccc10520..00000000 --- a/cloudflare/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -worker-configuration.d.ts -.wrangler/ -build/ diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md deleted file mode 100644 index e2285831..00000000 --- a/cloudflare/GITHUB.md +++ /dev/null @@ -1,791 +0,0 @@ -# GitHub App integration - -The operator registers one public Quest App from the reviewed -[manifest](github-app-manifest.json). Project users install that existing App; -they do not create their own bots. The same App's OAuth client signs users in, -while its installation identity authors service comments and pull requests. App secrets -and installation tokens stay inside the Worker; agents receive neither. - -## Permissions and events - -| Repository permission | Why the installation grants it | Per-operation token | -| --------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------- | -| Metadata: read | Resolve repository identity and maintainer authority | Every operation | -| Contents: write | Read quest files, write job branches and iterated PR heads on the upstream | Read for quest reads; write only for that publication or merge | -| Issues: write | Read native issues, publish approved comments, label merge jobs' PRs | Read or write for that operation | -| Pull requests: write | Observe, open and update job PRs, explicitly merge an exact head | Read or write for that operation | -| Checks: write | Observe checks; record a runner's review as the `quest review` check run | Read for check reads; write only to record a runner's review | -| Commit statuses: read | Observe legacy CI status contexts | Check reads only | -| Administration: read | Read whether the default workflow token can write | The setup page's [CI check](#read-only-pull-request-ci) only | -| Secrets: read | List the names of Actions secrets that reach every workflow | The setup page's CI check only | -| Actions: read | List environments and their deployment branch policies | The setup page's CI check only | -| Environments: read | Count each environment's secrets | The setup page's CI check only | - -The manifest subscribes to push, issues, issue comments, pull requests, reviews, -review threads, inline review comments, checks and statuses. Existing App registrations -must enable `pull_request_review_thread` and `pull_request_review_comment` in their -webhook subscriptions so resolving discussions and replying inline wake merge jobs. GitHub also sends installation lifecycle and repository -selection events automatically. The App also needs the **Email addresses: read** -user permission, which Better Auth needs to sign in users whose email is private. -GitHub rejects account permissions in a manifest, so the operator adds it in the -App's settings after registering. -It grants no repository authority: the user token is used only for the sign-in -profile and email reads and to list the [project picker](#project-enrollment)'s -repositories, and never for repository writes or authority checks. No organization, -administration-write, or workflow permissions are requested, and GitHub never -returns a secret's value, only its name. These installation grants -are necessarily broader than individual operations: minted tokens always name -one repository ID and the particular permissions required. Tokens are never -cached and are revoked after use; failed cleanup is logged without secrets and -does not retry a successful write. GitHub also expires tokens after an hour. - -## Trust by stage - -Each stage of a job trusts as little as it can. Untrusted text, such as an -issue, a pull request, a comment, a prompt answer, or anything an agent writes, -is data at every stage: it never grants authority, approves work, or changes -what kix may write. - -| Stage | What is trusted | Why | -| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Intake | GitHub's live answers to kix's own reads. A webhook only asks for a read; its signature proves GitHub sent it, not that its contents are current | Deliveries arrive late, out of order, or not at all, and bodies are anyone's text ([webhooks](#webhooks-and-revocation), [project state](#project-state-and-activity)) | -| Job creation | The signed-in maintainer's request, after a live check of their maintain or admin role, bound to the revision or head the maintainer saw | A browser's claims grant nothing, and a head that moved since observations read it is refused ([README](README.md)) | -| Routing | The requester's own runners, and their owner's word for each runner. Untrusted heads go only to Linux runners whose owner declared VM builds; kix does not verify the flag | A runner is its owner's machine and risk, and jobs never reach another user's runner | -| devShell | A trusted revision: the default branch, or a pull request head whose every commit beyond the default branch carries a valid signature from a current maintainer, or whose exact SHA a current maintainer approved in the inbox (`head_approvals`). A head kix pushed to is trusted only by such an approval | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it. An approval lapses when the head moves or its approver loses the role ([README](README.md)) | -| Agent run | Nothing the agent does. Its commands run in an OS sandbox with no GitHub credential, and it acts on an answer only once the runner's owner gave or approved it | Issue and pull request text can inject prompts ([README](../README.md)) | -| Publication | Nothing in the submitted files. kix writes them as Git data to the job's branch or the same-repository head it iterates, refuses workflow files and the default branch, and fences out stale attempts | A prompt-injected agent or a compromised runner can send anything ([Publication](#publication)) | -| CI | The enrolled repository's settings keep pull-request CI read-only. A runner's request to mark its pull request ready is its owner's statement, and under read-only CI it starts only harmless checks | GitHub gives same-repository branches' workflows the repository's secrets and default token, unlike fork pull requests ([Read-only pull-request CI](#read-only-pull-request-ci)) | -| Review | A review that counts on the exact head: a writer's approval, the App's `quest review` check, or the author's own `## Review` record on a commit no App made. An agent's recommendation never counts | Picking merge approves the head the maintainer saw, not whatever is pushed after it ([Landing only reviewed heads](#landing-only-reviewed-heads)) | -| Land | The maintainer's explicit pick of that head, under GitHub's branch rules and checks | kix never writes the default branch itself; GitHub's refusals stand ([Merge jobs](#merge-jobs)) | - -## Trusted boundary - -`src/github/service.ts` provides specific server-only operations for quest -reads, issue/PR reads, check/status reads, comments, job publication (see -[Publication](#publication)), and an explicitly authorized merge or close -with an expected head SHA. -Callers pass a user ID obtained from an authenticated local session. The service -resolves its linked OAuth account, looks up the current GitHub login by numeric -ID, and verifies current repository admin or maintain authority. A browser's -login name, claimed installation, repository role, or webhook sender grants no -authority. Ordinary write and custom roles are rejected. Installation identity, -permission grants and repository identity are fetched live for every operation. - -These methods are integration primitives, not public HTTP routes or an agent -tool interface. Their trusted caller owns the user's approval of the exact -output/action, product policy, branch protections and review/check gating. -Webhook receipt never supplies that approval. No automatic comments, PRs, -merges or runner launches occur; a job publishes only what its maintainer -authorized, on behalf of that maintainer's current authority. Check reads accept a page number (default 1) and return up to 100 checks -and status contexts with GitHub's totals; callers must request additional pages -before treating the result as complete or approving a merge. - -The App writes job branches on the enrolled repository itself. Installing the -App there is enough; the operator provisions no service fork or fork namespace. -Contributor-fork pull requests remain readable, but the App cannot update them. - -## Publication - -A running job publishes its work at `POST /api/runner/publish`, authenticated -with its runner's bearer token (`src/jobs/publish.ts`). The JSON request names -the `job` and `attempt`, the commit the changes build on (`head`, or `null` -before the job's branch exists), a commit `message`, and `changes`: up to 500 -files, 16 MiB in all, each `{ path, content, executable?, symlink? }` with -base64 content, or `null` content to delete. A symlink's content is its target. -Submodule changes cannot be published. An optional `pull: { title, body, draft }` -opens or updates the job's pull request, and `session` names the agent session -to resume if the publication needs a decision. Checkpoints publish work in -progress the same way, so a reassigned job continues from its branch, as does -a retry of a failed job: it starts from the head and pull request the failed -job recorded, and a branch moved since still becomes a decision. The reply -is `{ head, pull? }`, or `202 { decision, cancel }` when the job paused. - -Submitted files are data. The publisher never checks them out or runs their -scripts or hooks: it writes blobs, a tree, and a commit with the Git Data API -and moves one branch. Before writing anything it checks, in order: - -- The request comes from the job's current `(attempt, runner)` while it runs. - A cancelling attempt may still publish its partial work. A reassignment - increments the attempt, which fences out the previous runner. -- Every path is a plain work-tree path: no `.git`, `.` or `..` segment, no - absolute path, backslash, or control character. Each workflow publishes - only its own changes: `plan` only quest documents under `quest/` (Markdown - other than `AGENTS.md` and `CLAUDE.md`), `bootstrap` only `flake.nix` and - `flake.lock`, `start`, `iterate`, and `merge` their task's changes. No workflow changes - `.github/workflows/`, because the App does not request the workflows - permission; a merge commit may keep a pull request's own workflow changes, - but not change the workflows of the head it updates. A refused change pauses the job with a `publication` decision - listing each path and reason, instead of being dropped; the maintainer's - answer resumes the agent's session. -- The job's requester still holds maintainer authority, and the repository's - identity, visibility, installation, and enrollment are checked live, as for - every other operation. -- The branch is where the job left it: absent before the first publication, - then the last commit kix published. A `head` that differs gets a - `409 { error, head }` naming the commit to continue from, so a runner whose - previous reply was lost continues from what landed instead of failing. A - branch changed outside the job, or one that earlier work left on the upstream, - becomes a decision; publication never overwrites it. On a job's own upstream - branch, the decision also offers to start over: at the maintainer's request - (`restart: true` in place of an `answer`, after a live authority check), kix - replaces the job with a new one at the job's revision, with a new agent - session and no recorded pull request, and then force-resets the branch there. - Replacing the job first means a concurrent answer or cancel refuses the - restart before any commit is discarded. If the reset fails, the new job waits - on the same Start over decision. An iterated pull request's head is never - reset. - -`plan`, `start`, and `bootstrap` jobs write their branch on the enrolled -upstream itself. `start` uses the quest's branch, `plan` uses `plan/` or -`plan/issue-`, and `bootstrap` uses `kix/devshell`, outside the quest claim -namespace. An `iterate` or `merge` job writes its pull request's actual head -only when it belongs to that same repository. A contributor-fork head pauses -with a decision reporting the blocked update; kix never opens a replacement -pull request. No job writes the repository's default branch, and no runner ever -receives a credential. - -The App authors the commits as well as the pull requests: the service sets no -author or committer, so GitHub attributes and signs them as the App. -Contributor and model attribution comes from the agent's commit message and -pull request body, written to the repository's own instructions. - -Interruptions are reconciled before anything is replayed. The service creates -the commit, records it as pending under the attempt fence, and then moves the -branch with a fast-forward-only update. If the outcome is lost, the next -publication finds the pending commit on the branch and adopts it; until then, -for up to two minutes, other publications of the job wait. GitHub cannot check -kix's fence, so the fence is rechecked immediately before that update: a -fenced-out attempt's write lands only if it passed the check before the -reassignment, and the fast-forward check means it can never overwrite newer -work. Delivery looks for the branch's open pull request before creating one, -and reconciles a refused creation the same way, so a retry never opens a -duplicate. Since the branch is verified to be where the job left it, its only -open pull request is the job's, even one a failed job opened without recording -it. A pull request opened since the job and then closed by the maintainer is -not replaced. - -Planning pull requests are always drafts, as are those of jobs asked to stop. -Other jobs set `draft`, which defaults to `true` while decisions or unfinished -work remain; a later delivery with `draft: false` marks the pull request ready. -kix never converts a ready pull request back. Delivery and landing require an enabled -project; a paused project's running jobs can still preserve their work on their -branches. Job pull requests use same-repository branches, which upstream maintainers can -edit through their normal repository access. - -A planning pull request's body ends with a decision section the service -writes from the job's answered `decision` questions, after those of each -failed job it continues as a retry, whether or not that job opened the pull -request (`src/jobs/trail.ts`): each round's -questions, every option with โœ… on each choice, and the -maintainer's own words. Permission requests and the service's `account` and -`publication` questions stay out, since they can name runners and accounts. -Prompts and answers are untrusted, so each appears only as a code span with its -line breaks and bidirectional controls removed: GitHub renders no Markdown or -HTML there and links no mention, issue, cross-repository or commit reference, -URL, or closing keyword. Every delivery renders the section again between -`` markers, after dropping any the agent wrote. Rounds -past GitHub's 65,536-character body limit are counted rather than cut. - -GitHub keeps pull request status, checks, and reviews. `mergePullRequest` -merges only an exact head a maintainer explicitly chose, once a review counts -on it (see [Landing only reviewed heads](#landing-only-reviewed-heads)), with -the first method the repository allows: squash, then a merge commit, then -rebase. GitHub's branch rules and refusals stand, and an agent's -recommendation is never approval. The decision inbox offers it on a pull request item only when -GitHub reports the PR's merge state as `CLEAN` or `HAS_HOOKS` and no job or -session holds the PR. The form binds the head on screen. A moved head asks for -another look, GitHub's refusal is shown in its own words, and neither is -retried. - -### Merge jobs - -On any other open, non-draft pull request that nothing holds, the inbox's -merge button starts a `merge` job instead. Picking it covers the job's own -fixes for conflicts, failing checks, and review findings; a head moved by -anyone else, a design change, and the merge skill's design checkpoint pause it -on a decision. - -- The job holds the PR with its own `quest:kix` label, under the contract - `quest iterate --help` states for local sessions: the bot adds it when the - job is created, and refuses the pick while another hold label is on the PR. - The job keeps the timeline event that added the label as its `hold`. Like - local sessions, kix ignores the `quest:merge` label older quest versions - left on queued PRs. -- The job's agent resolves conflicts by merging the base branch with Git. A - publication may name that `merge` commit, which kix accepts only when the - PR's upstream base branch already holds it; kix then publishes a merge - commit whose files are the changes applied to the merged commit's. -- Landing is a publication with `land`: kix verifies the published head and - the job's hold, then enables auto-merge bound to that head, entering the - merge queue when the base has one, or merges it at once when GitHub would - refuse auto-merge because nothing is left to wait for. It uses the method the - job names, else the first the repository allows. A base that requires the - `quest land` check queues the PR for that gate instead, as `quest merge` - does: squash auto-merge whose commit body pins the head. Either way, kix - first requires a review that counts on that head; without one, the job - pauses on a decision that says why. A refusal also pauses the job on a - decision, in GitHub's words. -- The label stays while the job runs or waits, and while the PR is queued or - auto-merging. When the job ends without a landing in progress, or a - `pull_request` webhook arrives and GitHub shows the landing ended unmerged, - the App removes its own label. The PR is then an idle decision item again. - -Labels need `issues: write` on the upstream; landing needs `contents: write`, -`pull_requests: write`, and `checks: read` there, as a direct merge does. - -### Deleting pull requests - -A free pull request, one no hold label holds and not a draft its starter has -not handed back, also offers delete: - -- A quest's own pull request, from an upstream `quest/` branch naming a quest - in the tree, starts a `delete` job, which runs `/quest-delete` through the - merge job's paths: the same `quest:kix` hold, publication, review, and - landing. Its agent repurposes the PR with new commits that leave only the - quest's deletion and the mentions `quest rm` reports, and delivers the PR's - new title and body, which kix writes while the job holds the PR at the - published head. kix lands it only while the PR changes nothing but quest - files, marking a handed-back draft ready first. A fork's quest PR offers - neither delete nor close, since the bot cannot update its branch. -- Any other pull request closes as the App (`POST -/projects/github///pulls//close` with the `head` on screen), - refused while one of its jobs is active, its head moved, or it is held. - Closing leaves a branch in place, so a PR whose upstream `quest/` branch - still claims a quest is refused. - -### Landing only reviewed heads - -Picking merge is a maintainer's decision about the head they saw, not a review -of whatever a runner, or anyone, pushes after it. So both a direct merge and a -merge job's landing first require a review that counts on the exact head being -landed (`src/github/review.ts`): - -- an approval on GitHub of that commit by a person with write access other than - the pull request's author; reviews by Apps never count; -- a completed, successful `quest review` check run on that commit, written by - this App from a runner's review (see [Runner reviews](#runner-reviews)). - Only the App can write one, and GitHub binds it to the commit; -- or the author's own `## Review` record, as the guide's default review policy - counts it in every enrolled repository: the `## Review` section of the PR - body names the commit's full SHA, the author has write access, and the body's - latest edit is the author's. The record is self-reported, so it never counts - on a commit GitHub attributes to an App's bot, as it does every commit a - runner publishes; a runner's head needs one of the other two. - -A reviewer's suggested patch applied on GitHub is its applier's own commit, so -the head it makes needs a new review. - -A review also carries to a head whose only changes since the reviewed commit -are merges of commits the default branch already holds that resolved nothing: -every file holds the reviewed side's version or the default branch's, whichever -changed it. kix verifies this from the commits' trees, up to five merges back. -A file both sides changed, even one Git merged line by line without a -conflict, needs a new review, as does any other commit, a truncated tree, or a -read that fails. -An author's record carries only across merges no App committed. - -kix defers to GitHub only where GitHub enforces the same rule: a ruleset on the -base that requires an approval and either dismisses stale approvals or requires -approval of the most recent push, which GitHub reports this App cannot bypass. -Classic branch protection's review settings need the administration -permission, which landing tokens do not carry, so kix keeps its own check -there. - -When no review counts, a direct merge is refused and a merge job pauses on a -decision. Both say which commit needs a review and how to give one. - -The inbox says so before the click. Each project read takes every open PR's -body, its author's association with the repository, the writers' latest -reviews, and the head commit's parents and this App's `quest review` check in -one batched GraphQL request, the same request that classifies drafts, and -keeps only the land gate's reason (`observedGap`), never the body. A PR whose -merge is offered shows that reason beside **Merge this PR**. It counts what the -gate counts on the head itself. The author's record counts unless GitHub's -association (anything but owner, member, or collaborator) rules out write -access. A merge head's reason says that an earlier review may still carry -across it, since only the gate's tree reads can tell. The read does not look -at rulesets, so where GitHub itself requires the review, the item is flagged -and GitHub refuses the same head. The click's own check stays the authority: -it confirms write access, that no App committed the head, and any carried -review, and it reads the latest reviews, which a read can lag. The App's own -`quest review` check run, unlike CI's, asks for a read of the PRs it names, -so a runner's review clears the flag without a refresh. - -GitHub keeps auto-merge enabled across a push by anyone with write access, the -App's included, and checks the `expectedHeadOid` kix pins only when auto-merge -is enabled. Its merge queue likewise keeps a queued pull request across such a -push. So once a merge job arms auto-merge on a reviewed head while its checks -run, whatever a writer pushes after it lands with it, unreviewed. Only the -`quest land` gate's pin, or a ruleset GitHub enforces that requires a new -approval of every push, refuses such a head. The maintainer accepted this gap -on 2026-11-02, matching `quest merge --land`, rather than make a merge job wait -for its checks and merge only at once. - -kix closes it for its own pushes: a job pushing to a pull request that is -auto-merging would land its new head with no review of it. Before a job moves -or resets a branch that heads an open pull request, its own or an iterated -one, kix turns that pull request's auto-merge off. If GitHub refuses or does -not show whether it is on, the job pushes nothing and pauses on a decision. The -new head lands only through a new landing. - -### Read-only pull-request CI - -kix pull requests come from upstream branches, and GitHub gives workflows on -same-repository branches the repository's secrets and default token, unlike -fork pull requests. Pushing a job branch also runs its `push` workflows. So -enrolled repositories must keep that CI read-only: a read-only default -`GITHUB_TOKEN`, no secrets at repository or organization level, and secrets -only in environments limited to protected branches. Agent-written code then -runs in CI before review with nothing it can write through. Jobs whose -workflow asks for write `permissions:` itself, and `pull_request_target` and -`workflow_run` jobs that check out a PR head, fall under the same rule. kix -never runs pull request code in the publisher and grants runners no GitHub -credentials. This rule, rather than a service fork or a review gate on every -push, is kix's answer to agent-written code reaching CI. - -The project's setup page, where a maintainer enables contributions and later -pauses them, or opens the [setup pull request](#setup-pull-request) whose merge -enables them, checks the repository's settings with the installation's token -(`src/github/ci.ts`) and lists each problem it finds: - -- the default workflow token can write; -- Actions secrets at repository level, or organization secrets shared with the - repository, by name; -- an environment with secrets whose deployment branches include one GitHub - does not report protected. - -An environment limited to protected branches passes while some branch is -protected, since GitHub lets every branch deploy when none is. One limited to -selected branches passes when each branch pattern names a protected branch, -or the merge queue's `gh-readonly-queue//*` for one; a wildcard could -match a branch kix writes, so it is reported. Tag patterns pass, since kix -never writes tags. Required reviewers on an environment do not count: a -reviewer approves a deployment, not the code a job branch carries. - -The check only warns: enabling still works, and jobs still run. A failed read -becomes a warning of its own. It makes at most 30 reads, so the page stays -within a Worker's subrequest budget, and says when it stopped before checking -every environment. It -does not read workflow files, so the page reminds the maintainer of the rest -of the rule. An installation that has not accepted the check's permissions -gets a warning naming them instead of the checks they cover. - -Installation grants are broader than any one publication: `contents: write` -and `pull_requests: write` cover every installed repository. Each token names -one repository ID and only the permissions that step needs. Branch writes get -`contents: write` on the upstream alone, plus `pull_requests: write` to turn -off the auto-merge of a pull request the branch heads, and pull request -delivery gets `pull_requests: write` there. That token could write any -unprotected upstream branch; the service restricts it to the job's branch or -the pull request's head and refuses the default branch. Repositories should -still protect their default branch. - -Mocked tests cover this path. These GitHub behaviors still need a live check -against real installations ([operator setup](../quest/m0/kix/operator-setup.md)): -plan/start/bootstrap publication on the enrolled upstream without a fork, -GitHub's signature on App-authored API commits, and pushing or resetting a job -branch under the repository's branch rules. - -### Runner reviews - -A runner reviews the heads its jobs publish, so they land through the gate -above without a person's approval. When a job delivers a head it published, -other than to a pull request that stays a draft, the runner process runs -`quest review` on that head with the other vendor's model: Codex for a job on -an Anthropic account, Claude Code otherwise. It runs outside the agent's -sandbox, in a checkout of the published commit the agent cannot reach, and -reports the round over `POST /api/runner/review`. The agent never touches the -report, so it can neither skip nor fake the review. A merge job publishes its -fixes, has them reviewed, and lands only a head whose review found nothing. - -kix records the round as the App's `quest review` check run on the commit, with -the reviewer, model, effort, and round as its summary, the round's score out of -10 in its title, and the score's rationale and each finding as plain text, -cutting any too long to fit rather than refusing the report: `success` when the -round found nothing, `failure` otherwise, whatever the score. The agent fixes or -refutes each finding and delivers again; the next round resumes the reviewer's -session with its responses. kix records a report only from the job's current -attempt, only on the head kix itself last published for the job and GitHub -attributes to a bot, not one a merge job continued onto, and only from another -vendor's model than the job's account runs, and writes it with `checks: write` -on behalf of the job's requester, after rechecking their maintainer authority. - -Claude Code reviews with one of the runner's Claude Code accounts that is not -limited. Runners hold no Codex accounts, so Codex reviews with the login of -the user running `quest runner`. A runner that cannot review, for want of a -reviewer or because the review failed, says so to the agent and leaves the -head to a person's approval, as before. - -## Webhooks and revocation - -`POST /webhooks/github` verifies `X-Hub-Signature-256` over the exact raw bytes -before parsing. GitHub's 25 MB payload ceiling bounds the request. Delivery IDs, -body digest, event and minimal installation/repository metadata persist in D1; -untrusted issue/PR bodies are not stored. Dedup and lifecycle changes commit in -one transaction. A duplicate cannot reapply a state transition; reuse with a -different body/event is rejected for 30 days; each insert prunes older records -in the same transaction. There is no asynchronous enqueue/ack gap or remote side -effect here. - -Deleted or suspended installations immediately gate new local operations. -Permission and repository-selection changes invalidate the recorded revision; -operations never rely on a cached revision for authority. Created/unsuspended -installations can reopen the local gate, but every operation still queries -GitHub live, so delayed events cannot restore access GitHub revoked. A failed -GitHub lookup, removed repository, reduced permission, suspended installation, -or changed repository ID fails closed. Already in-flight remote writes may -finish; no new work is authorized from event observations. Consumers should -reconcile current provider state rather than replay issue/PR payloads as facts. - -## Operator setup - -1. Review the manifest's name, homepage, webhook URL, callback URL, permissions - and events. The callback is `https://quest.moq.dev/api/auth/callback/github`; - **Request user authorization (OAuth) during installation** is enabled. - Register the public App once under the service owner's account using - [GitHub's manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) - or enter the same settings in GitHub. For the manifest flow, submit the JSON - as the `manifest` form field to GitHub's new-App endpoint with a random - CSRF state and your operator-controlled redirect URL. Verify returned state - and exchange the temporary code at `POST /app-manifests/{code}/conversions` - within one hour. This repository intentionally has no public registration - callback that would disclose the generated credentials. -2. Store the returned/generated App ID in the `GITHUB_APP_ID` Wrangler var. - Put the PEM private key and high-entropy webhook secret into Workers secrets - named `GITHUB_APP_PRIVATE_KEY` and `GITHUB_WEBHOOK_SECRET` using - `bun run wrangler secret put NAME` from `cloudflare/`. Keep multiline PEM - intact. Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` to this same App's - OAuth client ID and client secret (the manifest conversion returns - `client_id` and `client_secret`). No separate OAuth app is used. The App ID - and OAuth client ID are different values. Never commit conversion responses, - PEM keys, installation tokens or `.dev.vars`. -3. Install the public App on selected upstream repositories. Existing - installations must accept permission increases before operations needing - those grants become available. An App registered before runner reviews - holds `checks: read`: raise it to **Checks: write** in the App's settings, - and have each installation accept the change, or runners' reviews are not - recorded and their heads need a person's approval to land. GitHub's installation UI, not browser form - claims, chooses repository access. Enroll each repository on `/projects`; - jobs publish their branches there, with no fork setup. -4. Existing App registrations must add the **Administration**, **Secrets**, - **Actions**, and **Environments** repository permissions, each read-only, - which the setup page's [CI check](#read-only-pull-request-ci) reads with. - Each installation then accepts them on GitHub; until it does, the check - names them instead of running. -5. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal - Worker deployment from [README](README.md). GitHub App setup, credentials, - production deployment and live installation tests - are operator work; this change performs none of those external actions. -6. Send a GitHub ping, check successful delivery, then test installation - suspension/removal and repository deselection in a development installation. - Check that service operations reject revoked access and reduced permissions. - Use GitHub's delivery/redelivery UI to retry failed deliveries. Rotate the - private key and webhook secret through Workers secrets and the App settings. - -Sign-in starts at the Worker's origin and keeps Better Auth's persisted state -and cookie checks. An authorization callback started by installing the App -directly on GitHub has no sign-in state from the Worker: its code is discarded -and the visitor returns to the sign-in form, then completes a fresh state-bound flow -that ends on `/projects`. A visitor already signed in goes straight there. -Installation and sign-in do not enroll a repository; the maintainer still opts -in from the project picker after a live authority check. - -For local tests, fixtures sign bodies and mock all GitHub API responses. No -production credentials, GitHub account or installation are required. - -References: [installation tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation), -[App user authorization](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app), -[Better Auth GitHub Apps](https://better-auth.com/docs/authentication/github#setting-up-your-github-app), -[JWTs](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app), -[webhook validation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), -[repository permissions](https://docs.github.com/en/rest/collaborators/collaborators#get-repository-permissions-for-a-user), -and [PR creation](https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request). - -The earlier draft #43 informed JWT/signature handling and current repository -identity checks. Its Rust/WASM, mirroring, Artifacts credentials, custom changes, -workflow orchestration and repository coordinator design were not reused. - -## Project enrollment - -`/projects` is the short maintainer setup flow, separate from the quest board -and decision inbox. It links the registered App's installation URL and lists -the repositories to pick from: those the App's installations reach and the -user administers or maintains, each with its contribution state, an Enable -button, or why it cannot be enabled yet (archived, private, no Quest files, -or no OSI-approved license). A repository without Quest files enables through -its [setup pull request](#setup-pull-request). - -The picker is navigation, built from the user's own GitHub App token so its -cost does not grow with an installation's size: one read of the user's -installations, one per hundred repositories in each, one GraphQL read per -hundred public repositories for their default branch's `quest` directory, and -one SPDX read per distinct license. GitHub lists each repository with the -user's role, so repositories they only write to never appear. Installations of -other Apps, suspended ones, and ones the webhook gate has blocked are skipped. -Better Auth refreshes an expired user token; when none is available, such as -for an account without a linked GitHub identity, the page asks the maintainer -to sign in with GitHub again or link it. Its Enable button posts the same form -as the project's setup page, so enabling rechecks everything below with an -installation token, including the whole quest tree with `quest check`. - -Selecting `owner/repository` by name remains, to reach an enrolled project's -pause and disconnect controls once the picker no longer lists it. Both paths -verify the linked GitHub identity's current maintain/admin authority through -the existing service. Installing the App never creates a `github_projects` -record or enables work. -Repository selection resolves its installation using the App JWT, then reads -metadata with a token restricted to the named repository and metadata-read -permission. Subsequent authorized operations scope tokens by repository ID. -Resolution uses the same installation gates and token revocation as other -reads; it never consumes GitHub's shared anonymous repository-lookup quota. - -### Owner allowlist - -Until the public release, only repositories whose owner's GitHub ID is in -`ENROLL_OWNERS` (`wrangler.jsonc`, each ID with its login in a comment) may -enroll; the deployed list is the moq-dev organization and the kixelated user. -Users and organizations are checked alike, by ID alone. Logins are not -identities: GitHub frees a deleted or renamed account's login for anyone to -claim. `/projects` says -Quest isn't open yet, and lists another owner's repositories as not open, -without reading their Quest files or licenses. The service's live repository -check treats such an owner like an archived repository: preview, opt-in, -observations, queuing a job, a job's issue or pull request reads, publication, -and merges all refuse it, while pause and disconnect still work, so a project -transferred to another owner can still be stopped. A job its maintainer queued -before such a transfer may still start on their own runners, with nothing from -GitHub through kix; pausing the project holds it. The -webhook route acknowledges another owner's deliveries with `ignored` before -recording anything, by the repository's owner for repository events and the -installation's account for installation events. The seeded local server admits -its fake owner instead. Removing the list opens enrollment to everyone. - -The preview reads the default branch at an exact commit, validates its Quest -tree with the shared core, and lists ready quests. Blob selection uses the -core's `isQuest` predicate, excluding root and nested `AGENTS.md`/`CLAUDE.md` -instructions just as the CLI does. It fetches all quest blobs -in one GraphQL read using the scoped installation token, rather than one -external request per file. Blob IDs come from the pinned Git tree; missing, -binary, truncated, partial, or hash-mismatched content fails closed. This keeps -enrollment's external request count independent of the number of quest files. -It rejects private, -unavailable, incomplete, invalid, and symlinked quest snapshots. A repository -without Quest files gets the App's [setup pull request](#setup-pull-request) -instead; nothing else in enrollment writes to the repository. - -The D1 enrollment records a project-wide enabled/paused state, distinct from -installation permissions. Every opt-in/resume, pause, and disconnect rechecks -current authority and provider gates. Pause/disconnect do not require valid -Quest files, public visibility, or an active/unarchived repository, so maintainers can stop contributions after -those conditions change. Disconnect deletes the enrollment, not the GitHub -App installation or existing contributions. Remembered selections are only -navigation and never confer authority. Any current maintainer can select an -already enrolled repository by name and reach pause/disconnect even if it has -become private, archived, or Quest-invalid. The enrolled-project lookup proves -current identity and authority independently of the failing preview; it does -not depend on being the original enroller. It matches enrollment by the durable -GitHub repository ID, and refreshes the stored owner/name after authority is -verified, so selecting a renamed repository recovers the same stop controls. If a transfer -or reinstall changes the App installation, those stop controls remain available -while the new installation requires explicit opt-in; selection never silently -reassigns an existing contribution authorization to the new installation. Future job coordination must recheck -this state and current provider eligibility before launching new work. - -Contributions require a public repository whose GitHub-detected SPDX license -identifier matches a license marked `isOsiApproved: true` in SPDX's current -published license metadata. A recognized SPDX identifier alone is insufficient. -Missing, `NOASSERTION`, custom, and non-OSI licenses cannot opt in. Add or correct -the repository's license on GitHub and return to setup; no manual attestation -or review bypass is provided. This policy was selected by the maintainer on -2026-10-15 to make public open-source eligibility explicit and independently -verifiable. - -Each preview, opt-in, and resume reads -`https://spdx.org/licenses/.json` without storing a positive -authorization cache. The returned ID must match exactly, and the approval flag -must be the boolean `true`. Failed/malformed lookups fail closed with guidance; -pause and disconnect remain available. Opt-in/resume recheck live repository -identity, visibility, detected license, OSI approval, and maintainer authority -after reading the quest snapshot. Neither license names nor browser form fields -can override those checks. Deprecated SPDX identifiers GitHub still detects -remain supported when SPDX marks that exact ID OSI-approved. - -This uses the maintained [SPDX JSON interface](https://github.com/spdx/license-list-data/blob/main/accessingLicenses.md) -instead of maintaining an application allowlist. Approval freshness follows -SPDX's latest published license list and the upstream serving infrastructure. -GitHub's detector only recognizes a limited set of license files, so custom or -unrecognized arrangements stay ineligible even when they may be open-source. -See the official [GitHub license API](https://docs.github.com/en/rest/licenses/licenses), -[SPDX license list](https://spdx.org/licenses/), -[Git trees API](https://docs.github.com/en/rest/git/trees#get-a-tree), -[GraphQL blobs](https://docs.github.com/en/graphql/reference/git#blob), and -[App metadata API](https://docs.github.com/en/rest/apps/apps#get-the-authenticated-app). - -### Setup pull request - -On a repository whose default branch has no Quest files, **Enable** opens a -short form holding one optional question: what the project is working toward. -Submitting it opens the App's setup pull request, with no runner involved -(`src/github/setup.ts`). The core's `planInit`, the same file plan -`quest init` applies to a checkout, plans it from the default branch's tree: -the skill stubs this deployment's `quest` ships (rendered into -`build/stubs.json` at build time), `quest/README.md` with the answer as its -Goal, the `Quests: ` line in the root agent instructions, and the -`.gitattributes` line. Heading -syntax in the answer is escaped, and the planned `quest/README.md` must pass -`quest check`, so a goal that would fail it, such as one linking a missing -quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill -directory that is not a Quest stub, is named on the -page and nothing is written; kix never deletes or renames a repository's -files. The plan does not follow symlinks, since a Git tree stores their targets -as data: one on a planned path is refused the same way, pointing at running -`quest init` in a clone. - -The App publishes the plan as for job branches: blobs, a tree, and a commit -through the Git Data API on `kix/setup`, outside the quest claim namespace, -then a ready pull request against the default branch. Its body lists each -change and what it leaves out, because those steps need judgement about the -repository: pinning `quest` in its tool manager and adapting existing -instructions. kix's jobs need neither, since runners bring their own `quest`. -Without a `flake.nix`, the body says the `bootstrap` job adds one after the -merge. - -Until the pull request merges the project is enrolled, setting up -(`github_project_setups`), which the project page and picker show with the pull -request's link. Every open, retry, and enable rechecks the maintainer's -authority, the repository's visibility, and its license live. A `pull_request` -webhook that closes or reopens it only asks the service to look: it rereads the -pull request and, once merged, enables contributions through the same live -checks as **Enable**, including `quest check` on the merged tree, with the -enroller's authority, so no second visit is needed. The project page reconciles -the same way when a webhook was missed, and keeps the reason when enabling -fails. A pull request closed without merging leaves the project setting up, -offering to open it again from the current default branch or to disconnect. -Opening again replaces `kix/setup` only while it is still at a commit the App -recorded writing there; a branch with anyone else's commits is refused, naming -it. Disconnecting leaves the pull request and its branch on GitHub. - -The setup preview does not reserve or launch ready quests. -The jobs/funding quests own live eligibility checks and contribution execution. - -## Project state and activity - -An enrolled project's setup page links to `/projects/github///state`. -Webhooks reread observations in the background (see below), and a maintainer -can refresh them explicitly with its same-origin form. Each read rereads the default branch at an exact commit using the preview's shared -snapshot reader and validates the Quest tree with the pure core. The authenticated -board and nested quest pages use the existing `Project` seam. Source links retain -the exact commit; live project state never replaces GitHub as authority. - -Each refresh advances two passes with independent opaque cursors. The history -pass reads one page of native issues and **all** PRs (including human -contributions and closed/merged PRs); continue reconciliation until it finishes. -A completed history pass detects deleted entities, and refreshing after one starts -again, so missed webhooks and later changes to old entities are recovered. The -occupancy pass reads open PRs and `quest/` branches, the only activity that -decides candidates, and starts again on every refresh after it completes. Its -pages are small, so candidates are normally current after a single refresh no -matter how long the history is. Candidates remain pending while the occupancy -pass is incomplete or a webhook has invalidated it. -This deliberately avoids GitHub's bounded, delayed activity feed; GitHub does -[not automatically redeliver failed webhooks](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries). -There is no scheduled background poll in this stage. - -Each webhook that names an issue or PR (a comment on a PR names the PR by its -number), or that invalidates candidates (below), asks for a background read of its enabled project once its delivery commits. -The read runs after the response (`waitUntil`) with the App's read-only, -repository-scoped installation token, and is the same read as a refresh: one -page of each pass, plus every entity a webhook named since it was last read, -fetched by number in the same GraphQL query. A new issue or PR therefore shows -its title and state after one read however far the history pass is from it, -and a closed one stops showing as open. A lease in `github_project_state` lets -one read run at a time per project: it answers every request made before it -started, requests made during it get one more read, and duplicate deliveries -never reach it, so a burst costs one or two reads rather than one each. A -per-entity webhook count lets a read clear only the updates it saw. One -invocation reads at most three rounds, to stay within a Worker's subrequest -budget; a request left after them waits for the next webhook or a refresh. -A read overtaken by another read or by a webhook's revision bump reads again. -Paused and disconnected projects are not read. A failed or rate-limited read is -recorded and shown next to the refresh fallback, and is not retried: only a -request made during it gets another round, and otherwise the next webhook or a -refresh reads again. Checks, statuses and other pushes name no entity and -change no candidate, so they cost no read, except this App's own `quest review` -check run, which rereads the PRs its payload names by number. - -The inbox also treats a PR whose row still says open as closed once a -complete, current occupancy pass no longer lists it, so a missed close webhook -corrects itself on the next read. - -A single GraphQL activity query reads up to 50 issues, 50 PRs, 100 open PRs and -100 branch claims, keeping external request count independent of entity/file counts. Bulk -D1 writes use `json_each`, avoiding a query per entity or SQLite's parameter -limit. A revision comparison commits each page and cursor atomically; concurrent -refreshes or an intervening webhook cannot publish over a newer observation. -Only a PR opening, closing, reopening or receiving new commits, a `quest/` branch -being created or deleted, or a push to the default branch invalidates candidates -and restarts the occupancy pass; new work waits for the next read, so a job -binds the PR head and tree the maintainer saw. Checks, statuses, comments, -reviews and other pushes do not invalidate candidates, so a busy repository -neither withholds candidates nor races a refresh. Installation tokens remain read-only, -repository-scoped, and revoked after use. - -Durable activity items use the GitHub repository ID, entity kind and full database -ID. GitHub's GraphQL `fullDatabaseId` strings and webhook numeric IDs map to the -same stored text key. Duplicate delivery IDs, redelivery with new IDs, later -pushes/comments/reviews, and reconciliation all update the same item. Webhooks -retain identity pointers only, never issue/PR bodies. Authoritative API reads add -escaped titles, state, comment/review counts, current PR head, that head's check -rollup, aggregate review decision, and up to 20 latest reviewer records with an -explicit pagination flag. Raw bodies and individual check logs are not cached: -a PR's body is kept only as whether it has a `## Waiting` section (or the -older `## Decisions needed`) and as the land gate's reason its head needs a -review, read with the open PRs' bodies in one batched request per read. -Unavailable/deleted entities retain their notification identity and are marked -missing rather than disappearing or presenting old state as current. - -GitHub reports `mergeStateStatus` `UNKNOWN` while it computes mergeability and -may send no event once it has. A read that sees it on a recorded PR at the same -head keeps the last known merge state, so nothing looks new; at a new head it -stores `UNKNOWN` but leaves the item's change time, so it becomes news once a -definite state arrives. A newly seen PR is news either way. - -Closed entities only appear in the activity log, so a read that finds an entity -closed for longer than `RETENTION` (`cloudflare/src/github/state.ts`) deletes its -row instead of updating it, unless a webhook named it again since. History -rereads every entity, so a pass prunes every expired row. The board, inbox, jobs -and `/data` read only open entities and unread webhook pointers, through a -partial index, and the activity log pages 50 rows at a time, so neither read -grows with a repository's history. - -A PR associates with a quest only when its head branch exactly matches the quest's -path without `.md` (or an epic's `/README.md`). An unrelated human PR remains in -activity without a guessed association. Open associated PRs and observed quest -branch claims exclude that quest from candidate data; branch claims display as -Claimed on the board. So does a draft PR its starter still holds, one without a -`## Waiting` (or older `## Decisions needed`) section, which the inbox lists as in progress rather than -as a decision; a ready PR or a handed-back draft is Ready to turn in. -As in the CLI, a `quest/` branch whose tip is the head of a merged PR from it -claims nothing, since its work landed; one whose PR closed unmerged, or with -commits pushed after the merge, still does. A push to an existing branch does -not invalidate candidates, so such a push shows on the next occupancy pass. -Runner/run associations belong to job coordination. - -Every cached read and refresh proves current maintainer authority, live repository -identity/public visibility, installation access, and enrollment for that -installation. Refresh rechecks those boundaries after provider reads. A -background read has no user, so it checks the same boundaries except maintainer -authority; it publishes observations only, and grants or caches no authority. -Every page and action still rechecks the maintainer acting on it. Paused -projects remain readable but have no candidates. Disconnect cascades cached -snapshots and activity; explicitly enrolling a replacement installation clears -prior observations. No positive authority cache is created. Candidate data is an -observation tied to its commit, **never execution permission**: job coordination -must validate current eligibility, fresh revision, locks, and PRs before a start. -Review/check data is similarly descriptive and cannot approve a merge. - -The `/data` endpoint exposes the same authenticated snapshot and open activity with -`Cache-Control: no-store` for future decision and job consumers. The activity -page is a secondary read view; the durable decision inbox and its unread/answer -state belong to the prompts workflow. Neither incoming activity nor refreshing -starts a model, creates a quest/job, spends usage, or writes to GitHub. diff --git a/cloudflare/README.md b/cloudflare/README.md deleted file mode 100644 index 2a015263..00000000 --- a/cloudflare/README.md +++ /dev/null @@ -1,593 +0,0 @@ -# Quest Worker - -A deployable foundation for Quest on Cloudflare, and the kix site at -`https://quest.moq.dev`: the quest log for your repo and your agents, on the web. -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` -(`src/board/`), sign-in in the nav, and the not-found page. App pages extend -the layout (breadcrumbs, the status marker, the paste box) rather than adding -a second shell. - -The quest board at `/repos/` opens on each milestone with its progress, then -its epics and quests with their statuses, filtered by status with -`?show=available` and the like. Each quest has a page at its path without -`.md`, such as `/repos/quest/quest/m0/kix/prompts`: its Markdown rendered -with raw HTML escaped, its status, and what to do next. Readiness comes from -`quest/core`, so the board agrees with `quest unblocked`. Progress counts finished -quests, which merges delete, from Git history (`scripts/history.ts`), weighted -by size. A deletion counts only when its commit changed something outside -`quest/`, since deleting only plans abandons a quest. - -`scripts/build.ts` runs before every check, test, dev server, and deploy. It -renders the repository's `docs/*.md` into `build/docs.json`, so the Markdown -stays the single source and its relative links keep working on GitHub: links to -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. It renders the skill -stubs `quest init` installs into `build/stubs.json`, for the setup pull request -a repository without Quest files gets when enrolling -([GITHUB.md](GITHUB.md#setup-pull-request)). 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/`, -`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 -state, cookies, and callbacks. Form routes enforce the configured origin before -calling Better Auth's server API. - -### Sign-in methods - -People sign in at `/sign-in` with GitHub, Google (when configured), or a -passkey, and manage their methods at `/account`. Every method reaches the same -local account, but repository authority comes only from the account's linked -GitHub identity and the App installation (`src/github/service.ts`); Google and -passkeys never grant it on their own. - -- **Linking is explicit.** A signed-in person links another provider from - `/account`, proving ownership through its OAuth flow; the emails may differ. - A matching email never links accounts: signing in with a new provider whose - email belongs to an existing account fails with instructions to link it. -- **One identity per provider.** A provider identity belongs to one account, and - an account links at most one of each provider, so the GitHub identity behind - repository authority is unambiguous. Account merging is not supported. -- **No unlinking yet.** Better Auth's unlink endpoint is disabled, so an account - cannot strand itself without GitHub authority or a way back in. -- **Passkeys** enroll from `/account` on a session less than a day old, and - require user verification (a PIN, biometric, or equivalent) to enroll and to - sign in. Removing one needs the signed-in owner. Recovery is through another - passkey or a linked provider; there is no administrator bypass. - `src/passkey.js` drives the browser's WebAuthn JSON helpers directly; where a - browser lacks them, the passkey buttons stay hidden. - -The passkey relying party is the hostname of `AUTH_URL`, and its exact origin -is the only one accepted. Changing the hostname orphans existing passkeys; -people enroll new ones after signing in with a provider. - -GitHub is authoritative for enrolled project state. D1 holds accounts, -sessions, GitHub App state, and jobs. The R2 bucket bound as `OUTPUT` holds raw -agent output. The one Durable Object, `RunnerHub`, holds runners' live -connections and keeps no records of its own. - -### Jobs - -A job is one maintainer-authorized workflow (`plan`, `start`, `iterate`, -`merge`, or `bootstrap`) on one target, pinned to a revision (`src/jobs/`). Maintainers -create, answer, and cancel jobs at `/projects/github///jobs`, and -every request rechecks current GitHub authority. `start` takes only quests that -observations show ready and unclaimed, except that retrying a failed job does -not count that job's own open pull request as a claim: the one it recorded, or -else the only open one observed on its branch at a commit it published. A -retry records the failed job it `continues`. A new `plan` takes the -maintainer's `idea`, up to 4,000 characters, and optional pasted `context` -(an email, call notes, findings), up to 64,000, on its own `plans/` -target; a retry keeps both. A completed job's result may list suggested -`followups`, and planning them targets `plans/`: kix writes the -idea and takes the follow-ups and summary as context from the job's own -result, never from the request, and offers them once. A job on an issue or pull request -rereads it from GitHub, refusing one that closed or whose head moved since -observations read it. `iterate` and `merge` jobs trust a head only when -every commit beyond the repository's default branch, independently of the -PR's chosen base, has a valid signature from a current maintain or admin -collaborator, or a maintainer approved that exact head. The inbox shows a -queued job's other head as a decision linking GitHub's compare view of its -commits beyond the default branch; `POST -/projects/github/:owner/:repository/pulls/:number/approve` with the `head` -on screen records the approver, the SHA, and the time in `head_approvals`, -one per pull request. An approval lapses when the head moves and counts only -while its approver keeps a maintain or admin role. The check runs at -creation, again whenever the job queues or its head moves, without waiting for -a free runner, and again at assignment. Other heads, including unsigned ones and -those kix pushed to, route only to Linux runners whose owner declared VM -builds when registering them, a statement kix does not verify -([trust by stage](GITHUB.md#trust-by-stage)). `quest runner` cannot build in a -VM yet: `quest runner login` registers it without declaring VM builds, so such -jobs wait for a runner that declares them. kix trusts that declaration, so a -runner declared through `POST /api/runners` with `vm: true` that cannot build -in a VM is still sent the job; `quest runner` fails it rather than run it. A -`merge` job runs the merge -skill's loop on an open, non-draft pull request and lands it, and a `delete` -job runs the delete skill on a quest's own pull request the same way; each holds the PR -with its own `quest:kix` label, which local sessions respect as they do each -other's `quest:active` (`src/jobs/landing.ts`), as described in -[GITHUB.md](GITHUB.md#merge-jobs). A five-minute scheduled sweep retries ended jobsโ€™ hold releases even when a lifecycle callback or GitHub read was missed; a live job, a replacement hold, or an active landing keeps its label. Without a `flake.nix`, the route -offers a `bootstrap` job instead. A paused project gets no new jobs and none are -claimed; resuming it dispatches the jobs that waited. Disconnecting a project -deletes its jobs and their output and tells their runners to stop. Duplicate -requests return the active job for the same target. - -Runners are a user's own machines. `quest runner login` makes a bearer token -on the machine and prints a link to `/runners?name=โ€ฆ&platform=โ€ฆ&key=โ€ฆ`, where -`key` is the token's SHA-256; the signed-in user approves it there -(`POST /runners/connect`), and the runner polls `GET /api/runner` with its token -until it is accepted. A script can instead register at `POST /api/runners` and -get a token back, declaring `vm: true` if it builds untrusted heads in a VM. -Either way kix stores only the hash. With that token a -runner opens a WebSocket at `/api/runner/connect`. `/runners`, linked from the -decision inbox, lists the user's runners with their presence and accounts, and -moves an account earlier or later in the preference order -(`POST /runners/accounts`). The connection goes to its -owner's `RunnerHub` (`src/jobs/hub.ts`), the one place that owner's jobs are -routed, and the open connection is the runner's presence. - -Over the socket the runner sends `status` messages: its opaque accounts, the -attempts it is running, and its free job slots. It also sends `checkpoint`, -`question`, `output`, and `result` messages. Each gets a `reply` with the same -`id` (`src/jobs/protocol.ts`). The hub pushes `assign` and `cancel` messages -whenever jobs or connections change. A status that lists an assigned attempt -acknowledges it; until then the assignment holds one of the runner's slots and -the hub resends it, and cancelling a -job whose assignment was never acknowledged stops it at once. An `assign` -carries the job and its `instructions`, which the runner gives the agent -alongside the workflow's skill, or `null`. A planning job's instructions say -kix writes its pull request's decision record, so the agent does not. - -Routing gives a job to the connected runner with a free slot and the owner's -most preferred usable account. A pin, the job's trust, or a paused session's -home runner can override that. Each assignment increments the job's attempt. -Runner messages name their attempt and land only while it is current. - -When a runner disconnects, its running jobs get a two-minute grace deadline. -The hub's alarm moves them once it passes, so a returning runner is fenced out. -A status that omits an acknowledged job gives it up at once. - -An account limit pauses the job with an account decision; kix never switches -accounts on its own. A runner reports its Claude subscriptions as one pooled -account, which its own CLIProxyAPI rotates between, so the pool's limit means -every subscription in it is out. An answer requeues the job and resumes its session on the -runner that holds it. Until the job sandbox ships, that holds only for the -requester's own answers: the agent acts on an answer with the requester's -machine and credentials, so another maintainer's answer waits, with an -`approval` notification, until the requester approves it -(`POST โ€ฆ/jobs//approve`) or answers the question themselves. The -assignment carries each answer's `approvedAt`, and the runner refuses to -resume with one that has none. - -A `question` carries one round of independent questions -(`src/jobs/prompt.ts`): `{ kind, questions: [{ header?, question, options: -[{ label, description?, recommended? }], multiple? }] }`, where `kind` is -`decision` (the default) or `permission` for a tool permission request. The -service refuses a prompt it cannot render. An agent question's answer is -`{ answers: [{ choices, text? }] }`, one per question, naming offered labels, -the maintainer's own words, or both; an account question's answer is -`{ runner, account }`. A job request may name the `revision` the maintainer -saw, and is refused once observations moved past it. - -The current attempt publishes its work over HTTPS at `POST /api/runner/publish`, -not over its connection, so slow GitHub writes never hold up the hub. It sends -file changes as data; the Worker commits them as the GitHub App to the job's -one branch, which a reassigned attempt or a retried failed job continues from, -and opens or updates the job's pull request. Each job records its branch's -`head` and its `pull`. -Changes a workflow may not publish pause the job with a `publication` -decision. The current attempt of a job on an issue or pull request reads it at -`GET /api/runner/context?job=โ€ฆ&attempt=โ€ฆ`, as the App with the requester's -current authority: an issue's text and comments, or a pull request's text, -comments, reviews, unresolved review threads, and checks at its head, each by -its latest run as `quest status` judges them. A new -plan's attempt reads its `idea` and `context` there instead. [GITHUB.md](GITHUB.md#publication) describes the request, its -validation, and how interrupted publications are reconciled. After a delivery, -the runner reviews the head the job published with the other vendor's model -and reports the round at `POST /api/runner/review`, which kix records as the -App's `quest review` check run on that commit -([GITHUB.md](GITHUB.md#runner-reviews)). - -### Raw output - -Runners upload raw agent output as `output` messages of at most 1 MiB of UTF-8 -(`src/jobs/output.ts`). Each attempt numbers its chunks with a contiguous `seq` -from 0 and sends them in order: kix accepts only the attempt's next `seq`, -once the chunk before it is stored, or an identical retry of a chunk it still -holds, which succeeds without writing again. A different chunk with a used `seq` is refused, and so is a retry of an -expired one. Each chunk is one R2 object at `output///`, -readable as soon as it lands, and D1 keeps a small index row per chunk plus each -attempt's totals. - -An attempt keeps at most 64 MiB and 4096 chunks. The chunk that would pass -either cap, and every later one, gets a reply with `truncated: true` instead of -an error; the job keeps running and its summaries are unaffected. Expiring -chunks never give that capacity back. - -Maintainers read output at `GET /projects/github///jobs//output`, -in pages of four chunks ordered by `(attempt, seq)`; `?after=.` -continues from a page's `next`, and `truncated` lists the attempts that reached -the cap. Every request rechecks current maintainer authority. - -Output is kept for 30 days from each chunk's upload. A bucket lifecycle rule -deletes `output/` objects, and a daily cron (`triggers.crons` in -`wrangler.jsonc`) deletes their index rows; reads skip a row whose object is -already gone. Disconnecting a project queues its jobs' output for deletion in -the same D1 batch that deletes the jobs, then deletes each `output//` -prefix at once. The cron retries any deletion that failed. An upload racing the -disconnect finds its index row gone, queues its job's prefix for the cron again, -and deletes its own object. - -### Decision inbox - -After signing in, a maintainer lands on `/inbox` (`src/inbox/`): one active -decision thread across their projects, then what is up next, what is in -progress, and their projects. Each item is one job target (`issues/1`, -`pulls/2`, a quest path), so an issue, its planning job, and that job's -questions stay one item, and webhook updates and reconnects never add another -prompt. Agents waiting on an answer come first, then failed jobs, new pull -requests, new issues, ready quests, and a finished job's suggested follow-ups, -offered as "plan these?" on the `plans/` target their interview takes, so -the offer, the interview, and its questions stay one item. Each webhook rereads its project in the -background, so a new issue or PR arrives with its title, ready to act on. An -item offers only actions that would work: none on a paused project, and only a -refresh while availability is pending or a background read failed, with the -failure's reason. Every action is a plain form post to the job routes, which -recheck authority and move on to the next active decision (or the empty -state) with a short notice such as "Answer recorded"; the item acted on stays -reachable from its list with its earlier answers. A refused action says why -and links back to its item. Prompts render as escaped text, list the -recommended option first, always take an answer in the maintainer's own words, -and never preselect the recommendation. Each project's "Plan something new" -box takes an idea and optional pasted context, which the agent reads as -untrusted, and folds the runner and account behind an automatic default. A -plan's thread keeps what it started from and links its draft proposal once -delivered; a running job names its runner and account. A `publication` decision lists the refused -paths or why the branch is blocked, and takes the maintainer's reply as text. -A job's own upstream branch that changed outside its job also offers Start over, -which resets the branch to the job's revision and queues a new job there. -Another maintainer's answer shows the job's requester the answer with an -approve button above the usual form, and shows everyone else the job waiting. -The inbox reads each target's latest job however old it is: every one that -awaits a decision, and any other whose issue, pull request, or quest is still -observed, so finished work stays set aside. Retrying a failed planning -interview keeps its `plans/` target. - -A pull request someone holds is in progress with no actions, and names its -holder: the author of a draft without a `## Waiting` (or older `## Decisions -needed`) section, or the `quest:*` label a session holds it with. Such a draft -quiet for a week becomes a "quiet draft" decision offering to take it over -with an iterate job, or to skip it until it changes; a labeled hold is never -offered, since its session or job releases it. Every other open pull request -offers merge, iterate, delete, and skip, and a draft handed back with `## Waiting` -also offers "Mark ready for review" (`POST -/projects/github///pulls//ready` with the `head` on screen), -which the App does once no job of that pull request is active. Delete runs a -`delete` job on a quest's own pull request and closes any other -([GITHUB.md](GITHUB.md#deleting-pull-requests)). - -Each project's "Start ready quests on your runners" box opts the viewer in to -starting its ready quests without a click, optionally only in some milestones -(`POST /projects/github///autostart`, stored in `autostart`; -`src/jobs/autostart.ts`). Whenever a dispatch pass leaves one of the -opted-in maintainer's runners with a free slot and a usable account, their -hub starts the next unclaimed ready quest in `quest unblocked` order, -round-robin across their opted-in projects, as an ordinary start job they -requested, after the same current GitHub checks as their own start. A quest -that already had a job, or that they skipped in the inbox at its current -text, waits for them, so a skipped, stopped, or failed start never reruns on -its own. The scheduled sweep wakes every opted-in hub, so quests that became -ready since the last runner event start too. Losing the maintainer role ends -the opt-in. A start job's agent first checks the quest is still worth -starting, and asks one decision when in doubt: start, skip, deprioritize to a -later milestone, or delete. - -Beside the decisions (below them on a phone), an Agents card shows each job -on the maintainer's projects (`src/inbox/activity.ts`): every target's latest -job while it is queued, running, or waiting, and an ended one while the pull -request it delivered is open and no later job works on that pull request. A -card names the job's target and state (queued, working, runner offline, -waiting on you, account limited, PR open, or stopped), its runner and account, -the agent's latest checkpoint or result summary as escaped text, and how long -ago it wrote it. A working job silent for 15 minutes says "No update since" -instead of guessing. Each card stands in for its job's in-progress row. Raw -output stays behind the card's disclosure: `/app.js` fetches the output route -above only once a maintainer opens it, and shows it as text. An enrolled -project's quest log also charts its tree as the quest map, ringing the quests -those same jobs work on. - -A user's projects are those in `github_project_members`. Enrolling a project -adds its enroller, and any other user joins by passing the maintainer check on -one of its pages, such as its setup page; no extra GitHub calls list -maintainers. Membership only decides what the inbox lists: every action still -rechecks authority. A check GitHub denies drops the user's membership along with -their read and skip state for the project, while one that cannot complete, such -as a rate limit, fails the request and leaves membership as it is. -Disconnecting a project removes its memberships. - -Each maintainer's read state lives in `inbox_items`, keyed by a version of what -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. - -### Landing queue - -`/projects/github///queue` (`src/queue.tsx`) shows each base's -landing queue, under the same maintainer check as the project's other pages, -read from GitHub when the page loads (`src/github/queue.ts`); kix stores none -of it. A base with GitHub's merge queue lists its entries by position, building -entries linking their merge group's run, then the PRs whose armed auto-merge -waits on checks to join. Any other base lists its armed PRs in the order they -were armed, the watcher's own queue order, each with the `quest queue` check -the watcher writes, whose in-progress run links its build. Recent drops are -the watcher's marked removal comments from the last day, in both modes, each -paired with the watcher's next marked comment on that pick: re-enqueued, -handed back, not applied, refused, or held. A drop with no later word, such -as one a hold, a push, or a person's removal ends, shows what its own comment -said happens next. Only comments from an app or a writer count. - -### Installed app and notifications - -kix 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. - -## Development - -From the repository root: - -```sh -nix develop -just install -cp cloudflare/.dev.vars.example cloudflare/.dev.vars -``` - -Fill in the local file with a random `AUTH_SECRET` (at least 32 characters) and -the development GitHub App's client ID and client secret. Google sign-in is optional and off by default: -to try it, register a Google OAuth web client with redirect URI -`http://localhost:8787/api/auth/callback/google`, set both Google values in -the local file, and temporarily list `GOOGLE_CLIENT_SECRET` under -`secrets.required` in `wrangler.jsonc`, because Wrangler dev loads only declared -secrets. Configure the GitHub app's homepage as -`http://localhost:8787` and callback as -`http://localhost:8787/api/auth/callback/github`. The file is ignored by Git. -Its `AUTH_URL` overrides the production origin in `wrangler.jsonc`. Use a -separate GitHub App for production. The App needs the Email addresses read -user permission, added in its settings since a manifest cannot request it, for accounts with private email addresses; see [GITHUB.md](GITHUB.md). - -```sh -just worker-dev -``` - -This applies local D1 migrations and starts Wrangler. Open -`http://localhost:8787`, sign in, and sign out. `/health` reports process health; -it does not probe storage. `/setup` redirects to `SETUP.md` on GitHub, so the -one-line setup paste can use `https://quest.moq.dev/setup`. D1 persists locally -under `cloudflare/.wrangler/`. There are no remote-only bindings: local -development and tests need no Cloudflare login. Wrangler reruns the build when -`src/`, `scripts/`, or `docs/` change. - -### Seeded local server - -Check UI changes in a browser with representative data, and no GitHub App or -Cloudflare login: - -```sh -just worker-seeded # or: just worker-seeded --port 8788 -``` - -This bundles the Worker as `wrangler deploy --dry-run` does and serves it with -Miniflare at `http://localhost:8787`, on in-memory D1, R2, and Durable -Objects, so every run starts from the same seed. It works where `wrangler dev` -hangs, such as sandboxed agent environments. After applying the checked-in -migrations, `dev/seed.ts` signs in a maintainer and enrolls and observes their -projects through the Worker's own routes: ready, claimed, and blocked quests, -issues (one known only from a webhook, one with a long title), pull requests -with checks and reviews (one clean to merge), and a paused second project. It then adds runners and -jobs that are queued, running, failed, or waiting on an agent's questions, a -tool permission, or an account limit, and raw output behind their cards: one -long enough to page, a short one, and one that reached the size cap. - -`dev/github.ts` fakes GitHub for every outbound request, so nothing leaves -localhost and no real credential is used. It models what publication needs: -commits and trees, fast-forward branch updates on the upstream, and pull -request creation, so a runner's job publishes end to end. Like -GitHub, it scopes each installation token to the repositories and permissions -it was minted for. Notifications are off, since a push -would reach a real push service. Sign in with GitHub completes as the -seeded maintainer. Browsers share localhost's cookies across ports, so each -seeded server names its cookies after its port (`seeded-8788.โ€ฆ`), and servers -on different ports keep their own sessions. From a shell, sign in with a cookie jar: - -```sh -curl -sL -c jar -b jar -H 'Origin: http://localhost:8787' -d provider=github http://localhost:8787/sign-in -``` - -Check phone and desktop widths with the browser's device emulation. The Worker -tests run the same seed (`test/seed.test.ts`), so it follows schema and route -changes, and later end-to-end tests can start from it. The publication tests -(`test/publish.test.ts`) use the same fake with a smaller world of their own. -`just worker-test` also starts this server with `--check` on a free port, which -checks what only it wires up: the built static assets and its cookie names. - -## Checks - -```sh -just check -just test -``` - -These include the Worker and run in the existing Linux/macOS Nix CI jobs. -`just worker-check` checks generated binding types, TypeScript, formatting, and -`wrangler deploy --dry-run`. `just worker-test` runs workerd integration tests -with real local D1. Tests cover the home page and rendered docs in the shared -layout, the board and quest pages, statuses, progress, safe Markdown, and -finished quests from history, the mocked GitHub OAuth callback, persisted -sessions, sign-out, and rejected origins/invalid state. Login tests mock GitHub -and Google OAuth and run real P-256 WebAuthn registration and assertions: the -linking rules, collisions, passkey user verification, origin, signature, -replay, and ownership. Job tests race dispatch passes, reassign a disconnected -runner's job and fence its late writes, and cover routing, questions, account -limits, and cancellation. They drive real WebSocket connections through the hub, -including a replaced connection and the alarm that reassigns work, and check the -maintainer-only routes. Publication tests run against a stateful fake of -GitHub's Git and pull request APIs: fenced and stale writes, workflow path -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. 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. - -The Worker tests share one runtime and reset only storage between tests, so a -test could still lean on module-level state another left behind. A daily CI job -(`.github/workflows/isolated.yml`) runs each test case in its own process -instead, which is too slow for every pull request. Run it locally with -`just worker-test-isolated`, optionally with `--jobs N` and file filters such as -`inbox`. - -After changing bindings, regenerate types: - -```sh -bun run --cwd cloudflare types -``` - -Wrangler is a dependency pinned in the root `bun.lock` and shared with the -test plugin; the Nix shell does not provide it. Use `bun run wrangler` from -`cloudflare/` for the documented deployment commands so generated types and -deployment use that same version, on Bun (`cloudflare/bunfig.toml`). - -## Deployment - -Deployment is manual. This scaffold does not provision resources or deploy -from CI. In the Nix shell, enter `cloudflare/` and authenticate: - -```sh -cd cloudflare -bun run wrangler login -bun run wrangler d1 create quest-auth -bun run wrangler r2 bucket create quest-output -bun run wrangler r2 bucket lifecycle add quest-output output-expiry output/ --expire-days 30 -``` - -The lifecycle rule is bucket configuration, not part of `wrangler.jsonc`, so -check it on an existing bucket with -`bun run wrangler r2 bucket lifecycle list quest-output`. Keep it at the -30 days the Worker's cron uses (`RETENTION` in `src/jobs/output.ts`). - -The checked-in config deploys to `https://quest.moq.dev` as a Workers custom -domain, which requires the `moq.dev` zone in the same Cloudflare account; -`workers.dev` is disabled so sign-in has one origin. `AUTH_URL` is the only -authentication origin and supplies generated absolute links. - -Until the public release, only repositories whose owner's GitHub ID is in -`ENROLL_OWNERS` (`wrangler.jsonc`) may enroll. Find an owner's ID with -`gh api users/ --jq .id` and note its login beside it; see -[the owner allowlist](GITHUB.md#owner-allowlist). - -For an existing deployment, retain the Worker name, D1 database ID, and -`AUTH_SECRET` so account data is preserved. The `create` commands above are -for a fresh deployment only. For another environment, change `routes`, -`AUTH_URL`, Worker name, account, database, bucket, and OAuth credentials -together. - -Register the production GitHub App from [the manifest](github-app-manifest.json), -which includes callback `https://quest.moq.dev/api/auth/callback/github` and requests -user authorization during installation. Use that App's client ID and client -secret as `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`; no separate GitHub OAuth -app is needed. For Google sign-in, also register -a production Google OAuth web client with redirect URI -`https://quest.moq.dev/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`. -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: - -```sh -bun run wrangler secret put AUTH_SECRET -bun run wrangler secret put GITHUB_CLIENT_ID -bun run wrangler secret put GITHUB_CLIENT_SECRET -bun run wrangler secret put GITHUB_APP_PRIVATE_KEY -bun run wrangler secret put GITHUB_WEBHOOK_SECRET -bun run wrangler d1 migrations apply DB --remote -bun run types -bun run check -bun run deploy -``` - -D1 schema migrations are applied separately before deployment. The `exports` -block in `wrangler.jsonc` creates the SQLite-backed `RunnerHub` class bound as -`RUNNERS`; it stores only its owner's ID, so it needs no data migration. - -After deploying, verify `/health`, `/setup`, and a theme asset, then complete -sign-in/sign-out in the browser. - -The auth schema migration is checked in for review. When upgrading Better Auth -or adding a plugin, generate/review its schema changes and add a D1 migration; -do not migrate the production database on HTTP requests. - -References: [Durable Object migrations](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/), -[Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/), -[Better Auth database](https://better-auth.com/docs/concepts/database), and -[Hono integration](https://better-auth.com/docs/integrations/hono). - -The server-only GitHub App integration, reviewed manifest, permission rationale, -webhook lifecycle and operator registration are documented in [GITHUB.md](GITHUB.md). diff --git a/cloudflare/bunfig.toml b/cloudflare/bunfig.toml deleted file mode 100644 index 132f7c0e..00000000 --- a/cloudflare/bunfig.toml +++ /dev/null @@ -1,7 +0,0 @@ -# The bundled skills import as text, as in the root bunfig.toml, so the build can render their stubs. -[loader] -".md" = "text" - -# Scripts run on Bun even when a tool declares a Node shebang, as in the root bunfig.toml. -[run] -bun = true diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts deleted file mode 100644 index fcd82f87..00000000 --- a/cloudflare/dev/seed.ts +++ /dev/null @@ -1,654 +0,0 @@ -// Representative kix state for checking UI changes locally, and the base for -// stubbed end-to-end tests. Sign-in, enrollment, observations, and webhooks go -// through the Worker's own routes against the fake GitHub, so they follow the -// current code; only runners and jobs, which need a live runner, are written -// to D1 directly, and their raw output through the store a runner's uploads use. -import { append } from "../src/jobs/output"; -import type { Runner } from "../src/jobs/store"; -import { - type Comment, - type Pull, - type Repo, - type World, - commitOf, - databaseId, - headOf, - webhookSignature, -} from "./github"; - -const quest = (title: string, goal: string, required: string[] = []) => - `# ${title}\n\n## Goal\n\n${goal}\n` + (required.length ? `\n## Required\n\n${required.join("\n")}\n` : ""); -/** A discussion of `count` short comments. */ -const remarks = (count: number, author: string): Comment[] => - Array.from({ length: count }, (_, i) => ({ author, body: `Seen it too (${i + 1}).`, at: "2026-10-19T12:00:00Z" })); - -/** A fresh world: `seed` adds to it, and the fake GitHub answers from it. */ -export function world(): World { - const rocket: Repo = { - id: 9101, - owner: "example", - name: "rocket", - maintainers: ["dev-maintainer"], - writers: ["reviewer"], - files: [ - { path: "flake.nix", content: "{ outputs = _: { }; }\n" }, - { - path: "quest/README.md", - content: quest("Quests", "Plan the rocket app."), - }, - { - path: "quest/m0/README.md", - content: quest("Beta", "Ship the first public beta."), - }, - { path: "quest/m0/import.md", content: quest("[S] Import a project from a ZIP archive", "Restore it.") }, - { path: "quest/m0/landing.md", content: quest("[S] Build the landing page", "Explain the app.") }, - { path: "quest/m0/sign-in.md", content: quest("[M] Add passkey sign-in", "Sign in without a password.") }, - { path: "quest/m0/docs.md", content: quest("[XS] Write the getting-started guide", "A first-run guide.") }, - { - path: "quest/m0/telemetry.md", - content: quest("[S] Report anonymous crash telemetry", "Learn about crashes.", [ - "- [Passkey sign-in](/quest/m0/sign-in.md) - telemetry is opt-in per account", - ]), - }, - { path: "quest/m0/export.md", content: quest("[S] Export a project as a ZIP archive", "Take your data.") }, - { - path: "quest/m1/README.md", - content: quest("Later", "Work after the beta."), - }, - { path: "quest/m1/fonts.md", content: quest("[S] Choose the editor font", "Read comfortably.") }, - { path: "quest/m1/search.md", content: quest("[S] Search across projects", "Find anything.") }, - { - path: "quest/m1/offline.md", - content: quest( - "[L] Keep working offline and sync every change once the phone reconnects to the network", - "Edits made offline sync without conflicts.", - ), - }, - { path: "quest/m1/themes.md", content: quest("[M] Support custom color themes", "Pick your colors.") }, - ], - issues: [ - { number: 2, title: "Old crash on startup", author: "reporter", closed: true }, - { - number: 3, - title: "Crash when the config file is empty", - author: "reporter", - body: "The app crashes on startup when `config.toml` exists but is empty.", - comments: [ - { author: "maintainer", body: "Reproduced on main.", at: "2026-10-19T12:00:00Z" }, - { author: null, body: "Same with a file holding only whitespace.", at: "2026-10-19T13:00:00Z" }, - ], - }, - { - number: 7, - title: "Settings page loses unsaved changes when the session expires while the browser tab sits in the background on a phone", - author: "phone-user", - comments: remarks(5, "phone-user"), - }, - { number: 8, title: "Add a dark mode toggle", author: "night-owl" }, - ], - pulls: [ - { - number: 12, - title: "Build the landing page hero", - author: "contributor", - branch: "quest/m0/landing", - checks: "SUCCESS", - review: "APPROVED", - reviews: [{ state: "APPROVED", author: "reviewer" }], - comments: remarks(3, "reviewer"), - merge: "CLEAN", - }, - { - number: 14, - title: "Fix the flaky sign-in redirect", - author: "contributor", - branch: "fix/sign-in-redirect", - checks: "FAILURE", - review: "CHANGES_REQUESTED", - reviews: [{ state: "CHANGES_REQUESTED", author: "reviewer" }], - }, - // A draft its starter left quiet for weeks: offered for takeover. - { - number: 15, - title: "Try a shared build cache", - author: "dev-maintainer", - branch: "build-cache", - checks: "PENDING", - review: null, - draft: true, - }, - { - number: 16, - title: "Bump dependencies", - author: "bot", - branch: "deps", - checks: null, - review: "REVIEW_REQUIRED", - }, - // A claim its starter still holds and touched just now: work in progress, not a decision. - { - number: 17, - title: "Draft the theme picker", - author: "contributor", - branch: "quest/m1/themes", - checks: "PENDING", - review: null, - draft: true, - body: "Started on the picker.", - updatedAt: new Date().toISOString(), - }, - // A draft handed back with decisions: it waits on the maintainer. - { - number: 18, - title: "Search quest titles", - author: "contributor", - branch: "quest/m1/search", - checks: "SUCCESS", - review: null, - draft: true, - body: "Titles are indexed.\n\n## Waiting\n\n- Maintainer: index quest bodies too?\n", - }, - // Clean when first read; `seed` rereads it while GitHub reports UNKNOWN. - { - number: 19, - title: "Speed up cold start", - author: "contributor", - branch: "perf/cold-start", - checks: "SUCCESS", - review: "APPROVED", - merge: "CLEAN", - }, - ], - // A claim pushed without a pull request. - branches: { - "quest/m0/docs": "d".repeat(40), - // Changed outside its waiting job, which can start over. - "quest/m1/fonts": "f".repeat(40), - }, - commits: {}, - trees: {}, - }; - // kix opened this one from its upstream branch for a job that then failed before recording it. - const imported: Pull = { - number: 20, - title: "Import a project from a ZIP archive", - author: "quest-dev[bot]", - branch: "quest/m0/import", - checks: "PENDING", - review: null, - draft: true, - }; - rocket.pulls.push(imported); - rocket.branches[imported.branch] = headOf(rocket, imported); - const archive: Repo = { - id: 9102, - owner: "example", - name: "archive", - maintainers: ["dev-maintainer"], - files: [ - { - path: "quest/README.md", - content: quest("Quests", "Retire the old site."), - }, - { - path: "quest/m0/README.md", - content: quest("Retire", "Retire the old site."), - }, - { path: "quest/m0/cleanup.md", content: quest("[S] Remove the old pages", "Nothing links to them.") }, - ], - issues: [{ number: 1, title: "Archive the old site", author: "reporter" }], - pulls: [], - branches: {}, - commits: {}, - trees: {}, - }; - return { - app: { id: 123, slug: "quest-dev" }, - installation: 9201, - owner: 9301, - users: [{ id: 9001, login: "dev-maintainer", name: "Dev Maintainer", email: "maintainer@example.test" }], - repos: [rocket, archive], - }; -} - -/** Send a request to the Worker by path, as a browser on its origin would. */ -export type Fetch = (path: string, init?: RequestInit) => Response | Promise; - -async function expect(response: Response | Promise, status: number, step: string) { - const result = await response; - if (result.status !== status) - throw new Error(`Seed step "${step}" returned ${result.status}: ${(await result.text()).slice(0, 300)}`); - return result; -} - -const cookies = (response: Response) => - response.headers - .getSetCookie() - .map((cookie) => cookie.split(";")[0]) - .join("; "); - -const form = (fields: Record) => ({ - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(fields).toString(), -}); - -/** - * Sign in as the world's first user, enroll its repositories (the second one - * paused), observe them, and add jobs in every state the inbox shows. - * Returns the signed-in user and session cookie. - */ -export async function seed(options: { - fetch: Fetch; - db: D1Database; - /** The Worker's `OUTPUT` bucket, for the jobs' raw output. */ - output: R2Bucket; - world: World; - origin: string; - secret: string; -}) { - const { db, world, origin } = options; - const post = (path: string, fields: Record, cookie: string) => { - const body = form(fields); - return options.fetch(path, { - method: "POST", - redirect: "manual", - headers: { ...body.headers, Origin: origin, Cookie: cookie }, - body: body.body, - }); - }; - - const start = await expect(post("/sign-in", { provider: "github" }, ""), 303, "sign in"); - const state = new URL(start.headers.get("Location")!).searchParams.get("state"); - const callback = await expect( - options.fetch(`/api/auth/callback/github?code=fake&state=${state}`, { - headers: { Cookie: cookies(start) }, - redirect: "manual", - }), - 302, - "OAuth callback", - ); - // The seeded server names its cookies after its port, so match the session by suffix. - const cookie = cookies(callback) - .split("; ") - .filter((c) => c.split("=")[0]!.endsWith("better-auth.session_token")) - .join("; "); - if (!cookie) throw new Error("Seed sign-in returned no session"); - const me = world.users[0]!; - const user = (await db - .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?") - .bind(String(me.id)) - .first<{ userId: string }>())!.userId; - - const [rocket, archive] = world.repos as [Repo, Repo]; - for (const repo of world.repos) { - const path = `/projects/github/${repo.owner}/${repo.name}`; - await expect(post(path, { id: String(repo.id), state: "enabled" }, cookie), 303, `enroll ${repo.name}`); - await expect(post(`${path}/refresh`, {}, cookie), 303, `refresh ${repo.name}`); - } - await expect( - post(`/projects/github/${archive.owner}/${archive.name}`, { id: String(archive.id), state: "paused" }, cookie), - 303, - "pause archive", - ); - - // GitHub recomputes a recorded PR's mergeability, such as after a push to - // its base. The read below keeps its last known state rather than marking it new. - rocket.pulls.find((pull) => pull.number === 19)!.merge = "UNKNOWN"; - // An issue opened since the refresh: its webhook rereads the project in the background. - const pointer = { number: 9, title: "Sync stops after the laptop sleeps", author: "reporter" }; - rocket.issues.push(pointer); - const body = JSON.stringify({ - action: "opened", - installation: { id: world.installation }, - repository: { id: rocket.id, owner: { id: world.owner } }, - issue: { id: Number(databaseId(rocket, pointer.number)), number: pointer.number }, - }); - await expect( - options.fetch("/webhooks/github", { - method: "POST", - headers: { - "X-GitHub-Event": "issues", - "X-GitHub-Delivery": crypto.randomUUID(), - "X-Hub-Signature-256": await webhookSignature(options.secret, body), - }, - body, - }), - 202, - "issue webhook", - ); - - await jobs({ DB: db, OUTPUT: options.output }, user, rocket); - return { user, cookie }; -} - -// Runners and jobs a live runner would have reported, in each state the inbox shows. -async function jobs(storage: { DB: D1Database; OUTPUT: R2Bucket }, user: string, repo: Repo) { - const db = storage.DB; - const now = Date.now(); - const commit = commitOf(repo); - const head = (n: number) => - headOf( - repo, - repo.pulls.find((p) => p.number === n)!, - ); - const runners = [ - ["workstation", "workstation", "linux", 1], - ["laptop", "laptop", "darwin", 0], - ] as const; - const accounts = [ - ["workstation", "claude-max", "claude", "subscription", "Claude Max", 0, 0], - ["laptop", "claude-pro", "claude", "subscription", "Claude Pro", 1, 1], - ["workstation", "api", "anthropic", "api-key", "Anthropic API key", 2, 0], - ] as const; - type Job = { - id: string; - workflow: "plan" | "start" | "iterate"; - target: string; - revision: string; - branch: string; - state: "queued" | "running" | "waiting" | "failed" | "completed"; - /** The commit the job last published to its branch. */ - head?: string; - runner?: string; - account?: string; - summary?: string; - /** What a completed job reported, such as its suggested follow-ups. */ - result?: unknown; - /** A new plan's request: the maintainer's idea and what they pasted with it. */ - idea?: string; - context?: string; - question?: { kind: "agent" | "account" | "publication"; prompt: unknown }; - }; - const list: Job[] = [ - { - id: "seed-plan-config", - workflow: "plan", - target: "issues/3", - revision: commit, - branch: "plan/issue-3", - state: "waiting", - runner: "workstation", - account: "claude-max", - summary: "Read the issue and the config loader. Three choices need you before planning.", - question: { - kind: "agent", - prompt: { - kind: "decision", - questions: [ - { - header: "Behavior", - question: "What should an empty config file do?", - options: [ - { - label: "Use defaults", - description: "Treat it like a missing file.", - recommended: true, - }, - { label: "Fail with an error", description: "Name the file and the expected format." }, - ], - }, - { - header: "Scope", - question: "Which other config edge cases should the quest cover?", - multiple: true, - options: [ - { label: "Whitespace only", recommended: true }, - { label: "Comments only" }, - { label: "Byte order mark" }, - ], - }, - { - question: "Should the fix ship as a patch release?", - options: [{ label: "Yes", recommended: true }, { label: "No, wait for the beta" }], - }, - ], - }, - }, - }, - { - id: "seed-start-sign-in", - workflow: "start", - target: "quest/m0/sign-in.md", - revision: commit, - branch: "quest/m0/sign-in", - state: "waiting", - runner: "laptop", - account: "claude-pro", - summary: "Stopped at the account's usage limit after adding the passkey routes.", - question: { kind: "account", prompt: { runner: "laptop", account: "claude-pro" } }, - }, - { - id: "seed-iterate-cache", - workflow: "iterate", - target: "pulls/15", - revision: head(15), - branch: "build-cache", - state: "waiting", - runner: "workstation", - account: "claude-max", - summary: "Wants to populate the build cache.", - question: { - kind: "agent", - prompt: { - kind: "permission", - questions: [ - { - question: "Allow `nix build .#cache` to write to the shared cache?", - options: [{ label: "Allow once" }, { label: "Deny" }], - }, - ], - }, - }, - }, - { - id: "seed-iterate-redirect", - workflow: "iterate", - target: "pulls/14", - revision: head(14), - branch: "fix/sign-in-redirect", - state: "failed", - runner: "workstation", - account: "claude-max", - summary: "just test failed: two sign-in tests time out against the new redirect.", - }, - { - id: "seed-start-import", - workflow: "start", - target: "quest/m0/import.md", - revision: commit, - branch: "quest/m0/import", - state: "failed", - // It opened pull request 20 at this commit, then failed before recording it. - head: head(20), - runner: "workstation", - account: "claude-max", - summary: "just check failed after the pull request opened: the importer skips empty folders.", - }, - { - id: "seed-start-fonts", - workflow: "start", - target: "quest/m1/fonts.md", - revision: commit, - branch: "quest/m1/fonts", - state: "waiting", - head: "e".repeat(40), - runner: "workstation", - account: "claude-max", - summary: "Added the font picker; publishing it found the branch moved.", - question: { - kind: "publication", - prompt: { - type: "publication", - blocked: "The branch quest/m1/fonts changed outside this job.", - restart: true, - }, - }, - }, - { - id: "seed-plan-settings", - workflow: "plan", - target: "issues/7", - revision: commit, - branch: "plan/issue-7", - state: "running", - runner: "workstation", - account: "claude-max", - summary: "Reproducing the lost settings in a background tab.", - }, - { - id: "00000000-5eed-4000-8000-000000000001", - workflow: "plan", - target: "plans/00000000-5eed-4000-8000-000000000001", - revision: commit, - branch: "plan/00000000", - state: "waiting", - runner: "workstation", - account: "claude-max", - idea: "Let people share a read-only link to a project", - context: "From the call with the design team:\n- links should expire\n- no sign-in to view", - summary: "Read the call notes and the project pages. One choice decides the quests.", - question: { - kind: "agent", - prompt: { - kind: "decision", - questions: [ - { - header: "Expiry", - question: "How long should a shared link last?", - options: [ - { label: "Until revoked" }, - { label: "30 days", description: "Matches the call notes.", recommended: true }, - ], - }, - ], - }, - }, - }, - { - id: "00000000-5eed-4000-8000-000000000002", - workflow: "start", - target: "quest/m0/cli.md", - revision: commit, - branch: "quest/m0/cli", - state: "completed", - runner: "workstation", - account: "claude-max", - summary: "Added the command-line entry point and its tests.", - result: { - head: null, - followups: ["Shell completions for bash, zsh, and fish", "A --json flag on every listing command"], - }, - }, - { - id: "seed-start-offline", - workflow: "start", - target: "quest/m1/offline.md", - revision: commit, - branch: "quest/m1/offline", - state: "queued", - }, - ]; - await db.batch([ - ...runners.map(([id, name, platform, vm]) => - db - .prepare( - "INSERT INTO runners(id,owner,name,tokenHash,platform,vm,createdAt,seenAt) VALUES(?,?,?,?,?,?,?,?)", - ) - .bind(id, user, name, `seed-${id}`, platform, vm, now, now), - ), - ...accounts.map(([runner, id, provider, kind, label, rank, limited]) => - db - .prepare( - "INSERT INTO runner_accounts(runner,id,provider,kind,label,rank,limited) VALUES(?,?,?,?,?,?,?)", - ) - .bind(runner, id, provider, kind, label, rank, limited), - ), - ...list.flatMap((job, i) => { - const attempt = job.runner ? 1 : 0; - // Older jobs first, so each one's question keeps a stable order. - const at = now - (list.length - i) * 60_000; - return [ - db - .prepare( - "INSERT INTO jobs(id,repository,workflow,target,revision,branch,head,actor,trusted,state,attempt,runner,account,delivered,summary,result,idea,context,createdAt,updatedAt) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", - ) - .bind( - job.id, - repo.id, - job.workflow, - job.target, - job.revision, - job.branch, - job.head ?? null, - user, - job.workflow === "iterate" ? 0 : 1, - job.state, - attempt, - job.runner ?? null, - job.account ?? null, - attempt, - job.summary ?? null, - job.result === undefined ? null : JSON.stringify(job.result), - job.idea ?? null, - job.context ?? null, - at, - now, - ), - ...(job.state === "completed" - ? [ - db - .prepare("INSERT INTO job_events(job,attempt,kind,body,at) VALUES(?,?,'result',?,?)") - .bind( - job.id, - attempt, - JSON.stringify({ outcome: "completed", summary: job.summary ?? "" }), - at, - ), - ] - : []), - ...(job.question - ? [ - db - .prepare( - "INSERT INTO job_questions(id,job,attempt,kind,prompt,askedAt) VALUES(?,?,?,?,?,?)", - ) - .bind( - `${job.id}-q`, - job.id, - attempt, - job.question.kind, - JSON.stringify(job.question.prompt), - at, - ), - ] - : []), - ]; - }), - ]); - - // Raw output behind the agent cards' disclosure: a transcript long enough to - // page, a short one, and one that reached the size cap. - const output: Record = { - "seed-plan-settings": [ - "$ quest guide\nRead the guide.\n", - "$ git log --oneline -3 -- src/settings\n4b1c2d0 Save settings on blur\n9e8f7a6 Add the settings page\n", - "Opening issue #7 and its five comments.\nThe reports all mention a background tab on a phone.\n", - "$ just test settings\nโœ“ saves on blur (12 ms)\nโœ“ restores a draft (8 ms)\n", - "Simulating an expired session while the tab is hidden...\nThe save request returns 401 and the draft is dropped.\n", - "Reproduced. Writing up the cause before planning the fix.\n", - ], - "seed-plan-config": [ - "$ rg -n 'config.toml' src\nsrc/config.rs:14: let text = fs::read_to_string(path)?;\n", - "An empty file parses as an error, not as defaults. Asking how it should behave.\n", - ], - "seed-iterate-cache": ["$ nix build .#cache\nbuilding '/nix/store/โ€ฆ-cache.drv'...\n"], - }; - for (const [job, chunks] of Object.entries(output)) { - const id = list.find((j) => j.id === job)!.runner!; - const [, name, platform, vm] = runners.find(([runner]) => runner === id)!; - const runner: Runner = { id, owner: user, name, platform, vm }; - for (const [seq, text] of chunks.entries()) await append(storage, runner, { job, attempt: 1, seq, text }, now); - } - // The cap's note, without uploading 64 MiB. - await db.prepare("UPDATE job_output_attempts SET truncated=1 WHERE job='seed-iterate-cache'").run(); -} diff --git a/cloudflare/dev/serve.ts b/cloudflare/dev/serve.ts deleted file mode 100644 index 78157c9b..00000000 --- a/cloudflare/dev/serve.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Serve the real Worker on localhost with seeded data, a fake GitHub, and a -// local sign-in, for checking UI changes in a browser. It runs the bundle that -// `wrangler deploy --dry-run` writes (see the `seeded` package script) on -// Miniflare directly, since `wrangler dev`'s proxy hangs in some sandboxes. -// Storage is in memory, so every run starts from the same seed. With --check, -// it checks what only this server wires up, on a free port, and exits. -import { readFileSync, readdirSync } from "node:fs"; -import { type AddressInfo, createServer } from "node:net"; -import { join } from "node:path"; -import { parseArgs } from "node:util"; -import { - Miniflare, - Response as MiniflareResponse, - type V4WorkerOptionsShape, - convertV4MiniflareOptions, -} from "miniflare"; -import { unstable_getMiniflareWorkerOptions, unstable_splitSqlQuery } from "wrangler"; -import { privateKey } from "../test/github-key"; -import { fakeGitHub } from "./github"; -import { seed, world } from "./seed"; - -const root = join(import.meta.dirname, ".."); -const { values } = parseArgs({ - options: { port: { type: "string", default: "8787" }, check: { type: "boolean", default: false } }, -}); - -/** A port nothing listens on, so checks run beside other seeded servers. */ -async function freePort() { - const server = createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as AddressInfo; - await new Promise((resolve) => server.close(resolve)); - return port; -} - -const port = values.check ? await freePort() : Number(values.port); -const prefix = `seeded-${port}.`; -const origin = `http://localhost:${port}`; -const secret = "dev-webhook-secret"; -const data = world(); -const github = fakeGitHub(data); - -// Bindings, assets, and Durable Objects come from the deploy config. Wrangler -// types these with its own copy of Miniflare's types; the version is the same. -const { workerOptions } = unstable_getMiniflareWorkerOptions(join(root, "wrangler.jsonc")) as unknown as { - workerOptions: V4WorkerOptionsShape & { modulesRules?: unknown }; -}; -// The bundle is a single ES module, and Miniflare 5 cannot convert module rules. -const { modulesRules: _, ...app } = workerOptions; - -// Browsers share localhost's cookies across ports, so seeded servers on two -// ports would sign each other out. The entry Worker gives this server's cookies -// their own names: it prefixes each cookie the app sets with COOKIES, and -// passes the app only the cookies so prefixed, without it. -// -// GitHub's authorize page would leave localhost, so the entry Worker also sends -// that redirect straight to the callback, which the fake GitHub completes as -// the seeded maintainer. Everything else, WebSockets included, passes through. -const entry = `export default { - async fetch(request, env) { - const sent = (request.headers.get("Cookie") ?? "") - .split(/;\\s*/) - .filter((cookie) => cookie.startsWith(env.COOKIES)) - .map((cookie) => cookie.slice(env.COOKIES.length)); - const forwarded = new Headers(request.headers); - if (sent.length) forwarded.set("Cookie", sent.join("; ")); - else forwarded.delete("Cookie"); - const response = await env.APP.fetch(new Request(request, { headers: forwarded })); - - const cookies = response.headers.getSetCookie(); - const location = response.headers.get("Location"); - const authorize = location?.startsWith("https://github.com/login/oauth/authorize"); - if (!cookies.length && !authorize) return response; - const headers = new Headers(response.headers); - headers.delete("Set-Cookie"); - for (const cookie of cookies) headers.append("Set-Cookie", env.COOKIES + cookie); - if (authorize) { - const callback = new URL("/api/auth/callback/github", request.url); - callback.searchParams.set("code", "fake"); - callback.searchParams.set("state", new URL(location).searchParams.get("state") ?? ""); - headers.set("Location", callback.href); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - webSocket: response.webSocket, - }); - }, -};`; - -const mf = new Miniflare( - convertV4MiniflareOptions({ - host: "127.0.0.1", - port, - workers: [ - { - name: "entry", - modules: true, - script: entry, - compatibilityDate: app.compatibilityDate, - bindings: { COOKIES: prefix }, - serviceBindings: { APP: "app" }, - }, - { - ...app, - name: "app", - modules: true, - scriptPath: join(root, ".wrangler/seeded/index.js"), - // Local values only: never a credential from .dev.vars, and no Google sign-in. - bindings: { - ...app.bindings, - AUTH_URL: origin, - AUTH_SECRET: "dev-only-secret-that-is-at-least-32-characters", - GITHUB_CLIENT_ID: "dev-client-id", - GITHUB_CLIENT_SECRET: "dev-client-secret", - GITHUB_APP_ID: String(data.app.id), - GITHUB_APP_PRIVATE_KEY: privateKey, - GITHUB_WEBHOOK_SECRET: secret, - // The seed's owner, rather than the deployed allowlist. - ENROLL_OWNERS: [data.owner], - 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) => { - const body = ["GET", "HEAD"].includes(request.method) ? undefined : await request.text(); - const response = await github(request.url, { - method: request.method, - headers: [...request.headers], - body, - }); - return new MiniflareResponse(await response.arrayBuffer(), { - status: response.status, - headers: [...response.headers], - }); - }, - }, - ], - }), -); -const stop = async () => { - await mf.dispose(); - process.exit(0); -}; -process.on("SIGINT", stop); -process.on("SIGTERM", stop); -await mf.ready; - -// Apply the checked-in migrations in order, as `wrangler d1 migrations apply` does. -const db = (await mf.getD1Database("DB", "app")) as unknown as D1Database; -const migrations = join(root, "migrations"); -for (const file of readdirSync(migrations) - .filter((f) => f.endsWith(".sql")) - .sort()) { - const statements = unstable_splitSqlQuery(readFileSync(join(migrations, file), "utf8")); - await db.batch(statements.map((sql) => db.prepare(sql))); -} - -const { cookie } = await seed({ - fetch: (path, init) => mf.dispatchFetch(`${origin}${path}`, init as never) as unknown as Promise, - db, - output: (await mf.getR2Bucket("OUTPUT", "app")) as unknown as R2Bucket, - world: data, - origin, - secret, -}); - -if (values.check) { - // Over the listening socket, as a browser would reach it. - const problems: string[] = []; - const css = await fetch(`${origin}/site.css`); - if (!css.ok || !css.headers.get("Content-Type")?.startsWith("text/css")) - problems.push(`/site.css returned ${css.status} ${css.headers.get("Content-Type")}, not the built stylesheet`); - if (!cookie.startsWith(prefix)) problems.push(`the session cookie is not named after the port: ${cookie}`); - const inbox = async (sent: string) => - (await fetch(`${origin}/inbox`, { headers: { Cookie: sent }, redirect: "manual" })).status; - const own = await inbox(cookie); - if (own !== 200) problems.push(`/inbox returned ${own} with the session cookie`); - // Another server's cookie, as a browser sends every localhost port's, is not this server's session. - const foreign = await inbox(cookie.slice(prefix.length)); - if (foreign === 200) problems.push("/inbox accepted the session under another server's cookie name"); - await mf.dispose(); - for (const problem of problems) console.error(`Seeded server check failed: ${problem}`); - process.exit(problems.length ? 1 : 0); -} - -const [login] = data.users.map((user) => user.login); -console.log(` -Seeded kix at ${origin}/inbox, signed in as ${login}. Ctrl-C stops it. -Sign in with GitHub completes locally. From a shell, send the session cookie: - curl -H 'Cookie: ${cookie}' ${origin}/inbox -or sign in with a cookie jar: - curl -sL -c jar -b jar -H 'Origin: ${origin}' -d provider=github ${origin}/sign-in -`); diff --git a/cloudflare/github-app-manifest.json b/cloudflare/github-app-manifest.json deleted file mode 100644 index 012c28ec..00000000 --- a/cloudflare/github-app-manifest.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "Quest", - "url": "https://quest.moq.dev", - "description": "Versioned quest plans and explicitly approved GitHub operations.", - "public": true, - "hook_attributes": { - "url": "https://quest.moq.dev/webhooks/github", - "active": true - }, - "default_permissions": { - "metadata": "read", - "contents": "write", - "issues": "write", - "pull_requests": "write", - "checks": "write", - "statuses": "read", - "administration": "read", - "secrets": "read", - "actions": "read", - "environments": "read" - }, - "default_events": [ - "push", - "issues", - "issue_comment", - "pull_request", - "pull_request_review", - "pull_request_review_thread", - "pull_request_review_comment", - "check_run", - "check_suite", - "status" - ], - "callback_urls": ["https://quest.moq.dev/api/auth/callback/github"], - "request_oauth_on_install": true -} diff --git a/cloudflare/migrations/0001_auth.sql b/cloudflare/migrations/0001_auth.sql deleted file mode 100644 index aa63c2f5..00000000 --- a/cloudflare/migrations/0001_auth.sql +++ /dev/null @@ -1,46 +0,0 @@ --- Better Auth core schema, SQLite/D1. -CREATE TABLE user ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - emailVerified INTEGER NOT NULL DEFAULT 0, - image TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL -); -CREATE TABLE session ( - id TEXT PRIMARY KEY NOT NULL, - expiresAt INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - ipAddress TEXT, - userAgent TEXT, - userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE -); -CREATE INDEX session_userId ON session(userId); -CREATE TABLE account ( - id TEXT PRIMARY KEY NOT NULL, - accountId TEXT NOT NULL, - providerId TEXT NOT NULL, - userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - accessToken TEXT, - refreshToken TEXT, - idToken TEXT, - accessTokenExpiresAt INTEGER, - refreshTokenExpiresAt INTEGER, - scope TEXT, - password TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL -); -CREATE INDEX account_userId ON account(userId); -CREATE TABLE verification ( - id TEXT PRIMARY KEY NOT NULL, - identifier TEXT NOT NULL, - value TEXT NOT NULL, - expiresAt INTEGER NOT NULL, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL -); -CREATE INDEX verification_identifier ON verification(identifier); diff --git a/cloudflare/migrations/0002_github.sql b/cloudflare/migrations/0002_github.sql deleted file mode 100644 index 2f643a5c..00000000 --- a/cloudflare/migrations/0002_github.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Provider state is an authorization gate, never quest content or approval. -CREATE TABLE github_installations ( - id INTEGER PRIMARY KEY NOT NULL, - blocked INTEGER NOT NULL CHECK (blocked IN (0, 1)), - revision INTEGER NOT NULL DEFAULT 1, - action TEXT NOT NULL -); --- Retain delivery IDs to reject replay; do not store untrusted issue/PR bodies. -CREATE TABLE github_deliveries ( - id TEXT PRIMARY KEY NOT NULL, - digest TEXT NOT NULL, - event TEXT NOT NULL, - installation INTEGER, - repository INTEGER, - action TEXT, - receivedAt INTEGER NOT NULL, - processed INTEGER NOT NULL DEFAULT 0 CHECK (processed IN (0, 1)) -); diff --git a/cloudflare/migrations/0003_github_deliveries_received.sql b/cloudflare/migrations/0003_github_deliveries_received.sql deleted file mode 100644 index 2008ec4a..00000000 --- a/cloudflare/migrations/0003_github_deliveries_received.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Webhook inserts prune deliveries past the replay window by age. -CREATE INDEX github_deliveries_receivedAt ON github_deliveries(receivedAt); diff --git a/cloudflare/migrations/0004_logins.sql b/cloudflare/migrations/0004_logins.sql deleted file mode 100644 index d6d41d63..00000000 --- a/cloudflare/migrations/0004_logins.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Better Auth passkey plugin schema. Public keys only; private keys stay on the authenticator. -CREATE TABLE passkey ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT, - publicKey TEXT NOT NULL, - userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - credentialID TEXT NOT NULL UNIQUE, - counter INTEGER NOT NULL, - deviceType TEXT NOT NULL, - backedUp INTEGER NOT NULL, - transports TEXT, - createdAt INTEGER, - aaguid TEXT -); -CREATE INDEX passkey_userId ON passkey(userId); --- A provider identity belongs to one user, and a user links at most one --- account per provider, so the GitHub identity behind repository authority is unambiguous. -CREATE UNIQUE INDEX account_provider_identity ON account(providerId, accountId); -CREATE UNIQUE INDEX account_user_provider ON account(userId, providerId); diff --git a/cloudflare/migrations/0005_projects.sql b/cloudflare/migrations/0005_projects.sql deleted file mode 100644 index 1dc20159..00000000 --- a/cloudflare/migrations/0005_projects.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Installing the App never enrolls a project. Only an authenticated current --- maintainer's explicit opt-in creates this record. Provider gates stay separate. -CREATE TABLE github_projects ( - id INTEGER PRIMARY KEY NOT NULL, - owner TEXT NOT NULL, - name TEXT NOT NULL, - installation INTEGER NOT NULL, - state TEXT NOT NULL CHECK (state IN ('enabled', 'paused')), - enrolledBy TEXT NOT NULL REFERENCES user(id), - updatedAt INTEGER NOT NULL -); -CREATE INDEX github_projects_maintainer ON github_projects(enrolledBy); diff --git a/cloudflare/migrations/0006_project_state.sql b/cloudflare/migrations/0006_project_state.sql deleted file mode 100644 index 252807f0..00000000 --- a/cloudflare/migrations/0006_project_state.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Cached observations never grant authority. Disconnect removes their retention. -CREATE TABLE github_project_state ( - repository INTEGER PRIMARY KEY REFERENCES github_projects(id) ON DELETE CASCADE, - revision INTEGER NOT NULL DEFAULT 0, - epoch TEXT NOT NULL, - invalidated INTEGER NOT NULL DEFAULT 0 CHECK(invalidated IN (0,1)), - snapshot TEXT, - scan TEXT, - observedAt INTEGER -); --- One durable notification per provider entity, independent of delivery IDs. --- Webhooks keep only identity pointers; authenticated reconciliation fills data. -CREATE TABLE github_project_activity ( - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK(kind IN ('issue','pull')), - id TEXT NOT NULL, - number INTEGER NOT NULL, - firstSeen INTEGER NOT NULL, - changedAt INTEGER NOT NULL, - data TEXT, - seenIn TEXT, - missing INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY(repository,kind,id) -); diff --git a/cloudflare/migrations/0007_jobs.sql b/cloudflare/migrations/0007_jobs.sql deleted file mode 100644 index 92000750..00000000 --- a/cloudflare/migrations/0007_jobs.sql +++ /dev/null @@ -1,92 +0,0 @@ --- A user's own machines. The token authenticates the machine to the job API --- only; runners never receive GitHub credentials. -CREATE TABLE runners ( - id TEXT PRIMARY KEY NOT NULL, - owner TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - name TEXT NOT NULL, - tokenHash TEXT NOT NULL UNIQUE, - platform TEXT NOT NULL CHECK(platform IN ('linux','darwin')), - -- Can build an untrusted PR's devShell in a VM. - vm INTEGER NOT NULL DEFAULT 0 CHECK(vm IN (0,1)), - createdAt INTEGER NOT NULL, - -- Last status over its live connection. Presence itself is the connection. - seenAt INTEGER -); -CREATE INDEX runners_owner ON runners(owner); --- Opaque references to agent logins held on a runner. Never tokens. -CREATE TABLE runner_accounts ( - runner TEXT NOT NULL REFERENCES runners(id) ON DELETE CASCADE, - id TEXT NOT NULL, - provider TEXT NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('subscription','api-key')), - label TEXT NOT NULL, - -- The owner's preference order across all of their runners, lowest first. - rank INTEGER NOT NULL, - limited INTEGER NOT NULL DEFAULT 0 CHECK(limited IN (0,1)), - PRIMARY KEY(runner,id) -); --- One authorized unit of work. `attempt` fences runner writes: each claim --- increments it, so a delayed or returning runner cannot write over its replacement. -CREATE TABLE jobs ( - id TEXT PRIMARY KEY NOT NULL, - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - workflow TEXT NOT NULL CHECK(workflow IN ('plan','start','iterate','bootstrap')), - target TEXT NOT NULL, - revision TEXT NOT NULL, - branch TEXT NOT NULL, - actor TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - trusted INTEGER NOT NULL CHECK(trusted IN (0,1)), - pinRunner TEXT, - pinAccount TEXT, - state TEXT NOT NULL CHECK(state IN ('queued','running','waiting','completed','failed','cancelled')), - attempt INTEGER NOT NULL DEFAULT 0, - runner TEXT, - account TEXT, - -- The agent session to resume on the same runner and account. - session TEXT, - -- While its runner is disconnected, when the running attempt moves. - lease INTEGER, - -- Whether the runner has listed the current attempt in a status; until - -- then the hub resends the assignment. - delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1)), - cancel INTEGER NOT NULL DEFAULT 0 CHECK(cancel IN (0,1)), - summary TEXT, - result TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL -); --- Duplicate requests for the same work find the active job instead. -CREATE UNIQUE INDEX jobs_active ON jobs(repository,target) WHERE state IN ('queued','running','waiting'); -CREATE INDEX jobs_claimable ON jobs(actor,state,createdAt); --- The job's progress record: claims, checkpoint summaries, cancellation, results. -CREATE TABLE job_events ( - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('claimed','summary','cancel','result')), - body TEXT NOT NULL, - at INTEGER NOT NULL -); -CREATE INDEX job_events_job ON job_events(job); --- At most one open question per attempt; an answer requeues the job. -CREATE TABLE job_questions ( - id TEXT PRIMARY KEY NOT NULL, - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('agent','account')), - prompt TEXT NOT NULL, - answer TEXT, - answeredBy TEXT REFERENCES user(id) ON DELETE SET NULL, - askedAt INTEGER NOT NULL, - answeredAt INTEGER, - UNIQUE(job,attempt) -); --- Raw agent output, readable only by current project maintainers. The --- runner numbers each attempt's chunks, so a retried chunk lands once. -CREATE TABLE job_output ( - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - seq INTEGER NOT NULL, - text TEXT NOT NULL, - at INTEGER NOT NULL, - PRIMARY KEY(job,attempt,seq) -); diff --git a/cloudflare/migrations/0008_output.sql b/cloudflare/migrations/0008_output.sql deleted file mode 100644 index be3ee5ea..00000000 --- a/cloudflare/migrations/0008_output.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Raw agent output moves to R2, one object per chunk at --- output///. Nothing is deployed yet, so the D1 text is --- dropped rather than copied. -DROP TABLE job_output; --- Each attempt's output totals. It lives as long as the job, so expiring --- chunks never give capacity back and an expired `seq` is never reused. -CREATE TABLE job_output_attempts ( - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - runner TEXT NOT NULL, - -- The only `seq` the attempt accepts next; also its count of chunks. - next INTEGER NOT NULL DEFAULT 0, - bytes INTEGER NOT NULL DEFAULT 0, - -- Set once a chunk is refused for the attempt's cap; later chunks are too. - truncated INTEGER NOT NULL DEFAULT 0 CHECK(truncated IN (0,1)), - PRIMARY KEY(job,attempt) -); --- The index of stored chunks, readable only by current project maintainers. --- A row reserves its `seq` for one text, by hash, before the object is --- written; `complete` marks the object written. The daily cron deletes rows --- older than the bucket's 30-day lifecycle. -CREATE TABLE job_output ( - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - seq INTEGER NOT NULL, - hash TEXT NOT NULL, - complete INTEGER NOT NULL DEFAULT 0 CHECK(complete IN (0,1)), - at INTEGER NOT NULL, - PRIMARY KEY(job,attempt,seq) -); -CREATE INDEX job_output_at ON job_output(at); --- Jobs whose output objects a disconnect still has to delete. Written in the --- same batch that deletes the project, and retried by the daily cron. -CREATE TABLE output_cleanup ( - job TEXT PRIMARY KEY NOT NULL, - at INTEGER NOT NULL -); diff --git a/cloudflare/migrations/0009_publication.sql b/cloudflare/migrations/0009_publication.sql deleted file mode 100644 index 9a8a809f..00000000 --- a/cloudflare/migrations/0009_publication.sql +++ /dev/null @@ -1,28 +0,0 @@ --- The commit the job's branch holds, as far as kix has published or observed --- it. NULL until the first publication creates the branch; an iteration starts --- at the pull request's head. -ALTER TABLE jobs ADD COLUMN head TEXT; --- A commit whose branch update is in flight. If the Worker stops before --- recording the outcome, the next publication finds it on the branch and --- adopts it instead of treating it as someone else's write. -ALTER TABLE jobs ADD COLUMN pending TEXT; -ALTER TABLE jobs ADD COLUMN pendingAt INTEGER; --- The pull request this job delivered, which later deliveries update. -ALTER TABLE jobs ADD COLUMN pull INTEGER; --- Publication decisions are service-authored, unlike agent questions. SQLite --- cannot alter a CHECK constraint, so rebuild the table. -CREATE TABLE job_questions_new ( - id TEXT PRIMARY KEY NOT NULL, - job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - attempt INTEGER NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('agent','account','publication')), - prompt TEXT NOT NULL, - answer TEXT, - answeredBy TEXT REFERENCES user(id) ON DELETE SET NULL, - askedAt INTEGER NOT NULL, - answeredAt INTEGER, - UNIQUE(job,attempt) -); -INSERT INTO job_questions_new SELECT id,job,attempt,kind,prompt,answer,answeredBy,askedAt,answeredAt FROM job_questions; -DROP TABLE job_questions; -ALTER TABLE job_questions_new RENAME TO job_questions; diff --git a/cloudflare/migrations/0010_inbox.sql b/cloudflare/migrations/0010_inbox.sql deleted file mode 100644 index 943bcd1e..00000000 --- a/cloudflare/migrations/0010_inbox.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Each maintainer's own view of a project's decision items, keyed by the job --- target an item coalesces on (`issues/1`, `pulls/2`, a quest path, ...). --- A version names what the item showed: a question, a GitHub change, a quest's --- text. A newer version is unread again; a skip lasts until the item changes. --- Read state is navigation only, never an answer or an approval. -CREATE TABLE inbox_items ( - user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - item TEXT NOT NULL, - seen TEXT, - skipped TEXT, - PRIMARY KEY(user, repository, item) -); - --- The inbox reads each target's latest job. -CREATE INDEX jobs_target ON jobs(repository, target, createdAt); diff --git a/cloudflare/migrations/0011_project_scan.sql b/cloudflare/migrations/0011_project_scan.sql deleted file mode 100644 index 5d7fee26..00000000 --- a/cloudflare/migrations/0011_project_scan.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Scans split into history and occupancy passes. Observations are a cache, so --- drop scans in the old shape; the next refresh starts both passes. -UPDATE github_project_state SET scan=NULL; diff --git a/cloudflare/migrations/0012_project_members.sql b/cloudflare/migrations/0012_project_members.sql deleted file mode 100644 index 0faf6440..00000000 --- a/cloudflare/migrations/0012_project_members.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Whose decision inbox lists a project: each user who passed a current --- maintainer check on it, the enrolling user included. Membership is --- navigation, never authority; every action still rechecks GitHub. A check --- GitHub denies removes it, and disconnecting the project cascades. --- `github_projects.enrolledBy` stays a record of who enrolled it. -CREATE TABLE github_project_members ( - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - PRIMARY KEY(repository, user) -); -CREATE INDEX github_project_members_user ON github_project_members(user); -INSERT INTO github_project_members(repository, user) SELECT id, enrolledBy FROM github_projects; diff --git a/cloudflare/migrations/0013_reconcile.sql b/cloudflare/migrations/0013_reconcile.sql deleted file mode 100644 index 4fc8bd05..00000000 --- a/cloudflare/migrations/0013_reconcile.sql +++ /dev/null @@ -1,11 +0,0 @@ --- A webhook asks for a background read of its project. `requested` stays set --- until a read starts, and `lease` (an expiry in ms) lets only one read run at --- a time, so a burst of deliveries coalesces into one read. `failure` keeps the --- last background read's error until a read or refresh succeeds. -ALTER TABLE github_project_state ADD COLUMN requested INTEGER NOT NULL DEFAULT 0 CHECK(requested IN (0,1)); -ALTER TABLE github_project_state ADD COLUMN lease INTEGER; -ALTER TABLE github_project_state ADD COLUMN failure TEXT; --- Counts the webhooks that named an entity since it was last read by number, so --- a read clears only what it saw. -ALTER TABLE github_project_activity ADD COLUMN stale INTEGER NOT NULL DEFAULT 0; -UPDATE github_project_activity SET stale=1 WHERE data IS NULL AND missing=0; diff --git a/cloudflare/migrations/0014_push.sql b/cloudflare/migrations/0014_push.sql deleted file mode 100644 index eda18cae..00000000 --- a/cloudflare/migrations/0014_push.sql +++ /dev/null @@ -1,16 +0,0 @@ --- 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/migrations/0015_activity_reads.sql b/cloudflare/migrations/0015_activity_reads.sql deleted file mode 100644 index 79eb7851..00000000 --- a/cloudflare/migrations/0015_activity_reads.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Activity reads that every board, inbox, and background read makes stay --- proportional to open work, not to a repository's history. `readState` reads --- only open entities and unread webhook pointers, in this index's order (its --- WHERE term matches the index's exactly), and a background read takes the --- entities webhooks named. The paged activity log, read only on its own page, --- sorts the repository's rows, which retention bounds. -CREATE INDEX github_project_activity_live ON github_project_activity(repository,firstSeen,kind,id) - WHERE (data IS NULL OR json_extract(data,'$.state')='OPEN'); -CREATE INDEX github_project_activity_stale ON github_project_activity(repository,changedAt,id) WHERE stale>0; diff --git a/cloudflare/migrations/0016_recovery.sql b/cloudflare/migrations/0016_recovery.sql deleted file mode 100644 index f30ccaec..00000000 --- a/cloudflare/migrations/0016_recovery.sql +++ /dev/null @@ -1,3 +0,0 @@ --- The failed job a retry continues. A retry's decision record follows this --- chain, so answers survive a failure that opened no pull request. -ALTER TABLE jobs ADD COLUMN continues TEXT REFERENCES jobs(id) ON DELETE SET NULL; diff --git a/cloudflare/migrations/0017_answer_approval.sql b/cloudflare/migrations/0017_answer_approval.sql deleted file mode 100644 index 76e39796..00000000 --- a/cloudflare/migrations/0017_answer_approval.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Until the job sandbox ships, a job resumes on its requester's runner with --- another maintainer's answer only once the requester approves it. The --- requester's own answers are approved as they are given. -ALTER TABLE job_questions ADD COLUMN approvedAt INTEGER; --- Answers given before the gate already resumed their jobs. -UPDATE job_questions SET approvedAt=answeredAt WHERE answer IS NOT NULL; diff --git a/cloudflare/migrations/0018_plan_request.sql b/cloudflare/migrations/0018_plan_request.sql deleted file mode 100644 index e0704b11..00000000 --- a/cloudflare/migrations/0018_plan_request.sql +++ /dev/null @@ -1,6 +0,0 @@ --- What a new planning interview starts from: the maintainer's idea in their --- own words, and an optional pasted block (an email, call notes, findings, or --- a finished job's suggested follow-ups), which agents read as untrusted --- context like issue text. A retry keeps both. -ALTER TABLE jobs ADD COLUMN idea TEXT; -ALTER TABLE jobs ADD COLUMN context TEXT; diff --git a/cloudflare/migrations/0019_merge_jobs.sql b/cloudflare/migrations/0019_merge_jobs.sql deleted file mode 100644 index ed845cb6..00000000 --- a/cloudflare/migrations/0019_merge_jobs.sql +++ /dev/null @@ -1,62 +0,0 @@ --- A merge job lands a pull request the maintainer picked. SQLite cannot alter --- the workflow CHECK constraint, so the jobs table is rebuilt. Dropping it --- cascades to its dependents, so their rows are set aside and restored. -PRAGMA defer_foreign_keys = true; -CREATE TABLE jobs_rebuilt ( - id TEXT PRIMARY KEY NOT NULL, - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - workflow TEXT NOT NULL CHECK(workflow IN ('plan','start','iterate','merge','bootstrap')), - target TEXT NOT NULL, - revision TEXT NOT NULL, - branch TEXT NOT NULL, - actor TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - trusted INTEGER NOT NULL CHECK(trusted IN (0,1)), - pinRunner TEXT, - pinAccount TEXT, - state TEXT NOT NULL CHECK(state IN ('queued','running','waiting','completed','failed','cancelled')), - attempt INTEGER NOT NULL DEFAULT 0, - runner TEXT, - account TEXT, - session TEXT, - lease INTEGER, - delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1)), - cancel INTEGER NOT NULL DEFAULT 0 CHECK(cancel IN (0,1)), - summary TEXT, - result TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - head TEXT, - pending TEXT, - pendingAt INTEGER, - pull INTEGER, - continues TEXT REFERENCES jobs_rebuilt(id) ON DELETE SET NULL, - idea TEXT, - context TEXT, - -- A merge job's hold on its pull request: the ID of the timeline event that - -- added the `quest:merge` label for it, as `quest merge` names holds. Null - -- once the label is released or was never added. - hold TEXT -); -INSERT INTO jobs_rebuilt(id,repository,workflow,target,revision,branch,actor,trusted,pinRunner,pinAccount,state,attempt,runner,account,session,lease,delivered,cancel,summary,result,createdAt,updatedAt,head,pending,pendingAt,pull,continues,idea,context) - SELECT id,repository,workflow,target,revision,branch,actor,trusted,pinRunner,pinAccount,state,attempt,runner,account,session,lease,delivered,cancel,summary,result,createdAt,updatedAt,head,pending,pendingAt,pull,continues,idea,context FROM jobs; -CREATE TABLE kept_job_events AS SELECT * FROM job_events ORDER BY rowid; -CREATE TABLE kept_job_questions AS SELECT * FROM job_questions ORDER BY rowid; -CREATE TABLE kept_job_output_attempts AS SELECT * FROM job_output_attempts; -CREATE TABLE kept_job_output AS SELECT * FROM job_output; -DROP TABLE jobs; -ALTER TABLE jobs_rebuilt RENAME TO jobs; -DELETE FROM job_events; -DELETE FROM job_questions; -DELETE FROM job_output_attempts; -DELETE FROM job_output; -INSERT INTO job_events SELECT * FROM kept_job_events ORDER BY rowid; -INSERT INTO job_questions SELECT * FROM kept_job_questions ORDER BY rowid; -INSERT INTO job_output_attempts SELECT * FROM kept_job_output_attempts; -INSERT INTO job_output SELECT * FROM kept_job_output; -DROP TABLE kept_job_events; -DROP TABLE kept_job_questions; -DROP TABLE kept_job_output_attempts; -DROP TABLE kept_job_output; -CREATE UNIQUE INDEX jobs_active ON jobs(repository,target) WHERE state IN ('queued','running','waiting'); -CREATE INDEX jobs_claimable ON jobs(actor,state,createdAt); -CREATE INDEX jobs_target ON jobs(repository, target, createdAt); diff --git a/cloudflare/migrations/0020_head_approval.sql b/cloudflare/migrations/0020_head_approval.sql deleted file mode 100644 index 7c75ed92..00000000 --- a/cloudflare/migrations/0020_head_approval.sql +++ /dev/null @@ -1,13 +0,0 @@ --- A maintainer's approval of one exact pull request head, such as one kix --- pushed, so its iterate and merge jobs run on any runner although not every --- commit carries a maintainer signature. One row per pull request: approving --- a newer head replaces it, and a head that moves no longer matches it. The --- approver's current maintain or admin role is rechecked each time it counts. -CREATE TABLE head_approvals ( - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - pull INTEGER NOT NULL, - head TEXT NOT NULL, - approver TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - approvedAt INTEGER NOT NULL, - PRIMARY KEY(repository, pull) -); diff --git a/cloudflare/migrations/0021_project_setups.sql b/cloudflare/migrations/0021_project_setups.sql deleted file mode 100644 index a53058d8..00000000 --- a/cloudflare/migrations/0021_project_setups.sql +++ /dev/null @@ -1,23 +0,0 @@ --- A repository without Quest files that a maintainer chose to enroll: the --- App's setup pull request, which writes what `quest init` would. The project --- is enrolled, setting up, until that pull request merges, when the project --- moves to github_projects as enabled and this row goes. A closed pull request --- leaves the row, offering a retry or a disconnect. It never grants authority: --- opening, retrying, and enabling each recheck GitHub live. -CREATE TABLE github_project_setups ( - id INTEGER PRIMARY KEY NOT NULL, - owner TEXT NOT NULL, - name TEXT NOT NULL, - installation INTEGER NOT NULL, - -- The branch's commit kix last published, and one it is moving the branch to, - -- so a retry recognizes kix's own branch after an interrupted update. - head TEXT, - pending TEXT, - pull INTEGER, - state TEXT NOT NULL CHECK(state IN ('open','closed')), - -- Why the merged setup could not enable contributions, until a retry succeeds. - failure TEXT, - enrolledBy TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - updatedAt INTEGER NOT NULL -); -CREATE INDEX github_project_setups_user ON github_project_setups(enrolledBy); diff --git a/cloudflare/migrations/0022_trust_recheck.sql b/cloudflare/migrations/0022_trust_recheck.sql deleted file mode 100644 index 59b0a22e..00000000 --- a/cloudflare/migrations/0022_trust_recheck.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Whether a PR job's recorded trust is stale: set when the job enters the --- queue or its head moves, such as after an answer to a job kix pushed for, or --- a lease expiry. Dispatch rereads a queued job's stale trust even without a --- free runner slot, so the inbox shows the head approval at once. New jobs --- start stale, and existing ones are rechecked once. -ALTER TABLE jobs ADD COLUMN recheck INTEGER NOT NULL DEFAULT 1 CHECK(recheck IN (0,1)); diff --git a/cloudflare/migrations/0023_autostart.sql b/cloudflare/migrations/0023_autostart.sql deleted file mode 100644 index 220baf70..00000000 --- a/cloudflare/migrations/0023_autostart.sql +++ /dev/null @@ -1,14 +0,0 @@ --- A maintainer's opt-in to start a project's ready quests on their own --- runners whenever one of their job slots frees, within the listed milestone --- directories (a JSON array), or every milestone when NULL. It lasts only as --- long as the membership: a lost maintainer role, a disconnect, or a deleted --- account ends it, so regaining access never resumes starts unasked. -CREATE TABLE autostart ( - repository INTEGER NOT NULL, - user TEXT NOT NULL, - milestones TEXT, - createdAt INTEGER NOT NULL, - PRIMARY KEY(repository, user), - FOREIGN KEY(repository, user) REFERENCES github_project_members(repository, user) ON DELETE CASCADE -); -CREATE INDEX autostart_user ON autostart(user); diff --git a/cloudflare/migrations/0024_delete_jobs.sql b/cloudflare/migrations/0024_delete_jobs.sql deleted file mode 100644 index b64cb189..00000000 --- a/cloudflare/migrations/0024_delete_jobs.sql +++ /dev/null @@ -1,64 +0,0 @@ --- A delete job abandons the quest a pull request claims, as /quest-delete --- does. SQLite cannot alter the workflow CHECK constraint, so the jobs table is --- rebuilt. Dropping it cascades to its dependents, so their rows are set aside --- and restored. -PRAGMA defer_foreign_keys = true; -CREATE TABLE jobs_rebuilt ( - id TEXT PRIMARY KEY NOT NULL, - repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE, - workflow TEXT NOT NULL CHECK(workflow IN ('plan','start','iterate','merge','delete','bootstrap')), - target TEXT NOT NULL, - revision TEXT NOT NULL, - branch TEXT NOT NULL, - actor TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - trusted INTEGER NOT NULL CHECK(trusted IN (0,1)), - pinRunner TEXT, - pinAccount TEXT, - state TEXT NOT NULL CHECK(state IN ('queued','running','waiting','completed','failed','cancelled')), - attempt INTEGER NOT NULL DEFAULT 0, - runner TEXT, - account TEXT, - session TEXT, - lease INTEGER, - delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1)), - cancel INTEGER NOT NULL DEFAULT 0 CHECK(cancel IN (0,1)), - summary TEXT, - result TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL, - head TEXT, - pending TEXT, - pendingAt INTEGER, - pull INTEGER, - continues TEXT REFERENCES jobs_rebuilt(id) ON DELETE SET NULL, - idea TEXT, - context TEXT, - -- A merge or delete job's hold on its pull request: the ID of the timeline - -- event that added the `quest:kix` label for it. Null once the label is - -- released or was never added. - hold TEXT, - recheck INTEGER NOT NULL DEFAULT 1 CHECK(recheck IN (0,1)) -); -INSERT INTO jobs_rebuilt(id,repository,workflow,target,revision,branch,actor,trusted,pinRunner,pinAccount,state,attempt,runner,account,session,lease,delivered,cancel,summary,result,createdAt,updatedAt,head,pending,pendingAt,pull,continues,idea,context,hold,recheck) - SELECT id,repository,workflow,target,revision,branch,actor,trusted,pinRunner,pinAccount,state,attempt,runner,account,session,lease,delivered,cancel,summary,result,createdAt,updatedAt,head,pending,pendingAt,pull,continues,idea,context,hold,recheck FROM jobs; -CREATE TABLE kept_job_events AS SELECT * FROM job_events ORDER BY rowid; -CREATE TABLE kept_job_questions AS SELECT * FROM job_questions ORDER BY rowid; -CREATE TABLE kept_job_output_attempts AS SELECT * FROM job_output_attempts; -CREATE TABLE kept_job_output AS SELECT * FROM job_output; -DROP TABLE jobs; -ALTER TABLE jobs_rebuilt RENAME TO jobs; -DELETE FROM job_events; -DELETE FROM job_questions; -DELETE FROM job_output_attempts; -DELETE FROM job_output; -INSERT INTO job_events SELECT * FROM kept_job_events ORDER BY rowid; -INSERT INTO job_questions SELECT * FROM kept_job_questions ORDER BY rowid; -INSERT INTO job_output_attempts SELECT * FROM kept_job_output_attempts; -INSERT INTO job_output SELECT * FROM kept_job_output; -DROP TABLE kept_job_events; -DROP TABLE kept_job_questions; -DROP TABLE kept_job_output_attempts; -DROP TABLE kept_job_output; -CREATE UNIQUE INDEX jobs_active ON jobs(repository,target) WHERE state IN ('queued','running','waiting'); -CREATE INDEX jobs_claimable ON jobs(actor,state,createdAt); -CREATE INDEX jobs_target ON jobs(repository, target, createdAt); diff --git a/cloudflare/package.json b/cloudflare/package.json deleted file mode 100644 index 8df260d2..00000000 --- a/cloudflare/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "quest-cloudflare", - "private": true, - "type": "module", - "scripts": { - "dev": "wrangler dev", - "seeded": "wrangler deploy --dry-run --outdir .wrangler/seeded && bun dev/serve.ts", - "build": "bun scripts/build.ts", - "check": "bun run build && wrangler types --check && tsc --noEmit && prettier --check . && wrangler deploy --dry-run", - "test": "bun run build && vitest run && bun run seeded --check", - "fix": "prettier --write .", - "types": "wrangler types", - "migrate": "wrangler d1 migrations apply DB --local", - "deploy": "wrangler deploy" - }, - "dependencies": { - "@better-auth/passkey": "1.7.14", - "better-auth": "1.7.14", - "hono": "4.13.14", - "marked": "18.1.2", - "quest": "file:.." - }, - "devDependencies": { - "@cloudflare/vitest-plugin": "1.4.11", - "@types/node": "26.6.6", - "miniflare": "5.20261012.0-beta", - "prettier": "3.9.10", - "typescript": "7.0.2", - "vitest": "5.0.5", - "wrangler": "4.156.0" - } -} diff --git a/cloudflare/scripts/build.ts b/cloudflare/scripts/build.ts deleted file mode 100644 index 4d2ede5f..00000000 --- a/cloudflare/scripts/build.ts +++ /dev/null @@ -1,122 +0,0 @@ -// 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/stubs.json: the skill stubs `quest init` installs, for the setup pull request. -// - 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"; -import { join, posix } from "node:path"; -import { Lexer, Marked, type Token, type Tokens } from "marked"; -import type { Snapshot } from "../src/board/project"; -import { SKILLS } from "../../src/cli/skills.ts"; -import { LOG, finished, isQuest } from "./history.ts"; - -const repo = "https://github.com/moq-dev/quest"; -const worker = join(import.meta.dirname, ".."); -const root = join(worker, ".."); -const docs = join(root, "docs"); -const design = join(root, "design"); -const build = join(worker, "build"); - -// Docs link relative to their file so they work on GitHub. Other docs become -// site pages; anything else in the repository links to GitHub. -function rewrite(href: string, image: boolean): string { - if (/^([a-z][a-z0-9+.-]*:|#|\/\/)/i.test(href)) return href; - const [path, hash = ""] = href.split(/(?=#)/); - const target = path.startsWith("/") ? path.slice(1) : posix.normalize(posix.join("docs", path)); - const doc = /^docs\/([^/]+)\.md$/.exec(target); - if (doc) return `/docs/${doc[1]}${hash}`; - if (image) return `https://raw.githubusercontent.com/moq-dev/quest/main/${target}`; - return `${repo}/blob/main/${target}${hash}`; -} - -// GitHub's heading anchors, so links like getting-started.md#set-it-up keep working. -function slugger() { - const seen = new Map(); - return (text: string) => { - const base = text - .toLowerCase() - .trim() - .replace(/[^\p{L}\p{N}\s_-]/gu, "") - .replace(/\s/g, "-"); - const count = seen.get(base) ?? 0; - seen.set(base, count + 1); - return count ? `${base}-${count}` : base; - }; -} - -// Plain text of inline Markdown, for titles and summaries. -function plain(markdown: string): string { - return markdown - .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/[`*_]/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -function render(slug: string, markdown: string) { - const tokens = Lexer.lex(markdown, { gfm: true }); - const heading = tokens.find((token): token is Tokens.Heading => token.type === "heading" && token.depth === 1); - if (!heading) throw new Error(`docs/${slug}.md has no title`); - const opening = tokens.find((token): token is Tokens.Paragraph => token.type === "paragraph"); - - const anchor = slugger(); - const marked = new Marked({ - gfm: true, - renderer: { - heading({ tokens, depth, text }) { - return `${this.parser.parseInline(tokens)}\n`; - }, - }, - }); - marked.walkTokens(tokens, (token: Token) => { - if (token.type === "link" || token.type === "image") token.href = rewrite(token.href, token.type === "image"); - }); - return { - slug, - title: plain(heading.text), - summary: opening ? plain(opening.text) : "", - html: marked.parser(tokens), - }; -} - -const rendered = readdirSync(docs) - .filter((name) => name.endsWith(".md")) - .sort() - .map((name) => render(name.slice(0, -3), readFileSync(join(docs, name), "utf8"))); - -// The board's snapshot of this repository: its quest tree as checked out, and -// the quests that history shows finished. A shallow clone has no history, so -// it shows no finished work. - -function git(...args: string[]): string { - return execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 256 << 20 }); -} - -function questTree(dir: string, out: Snapshot["documents"] = []): Snapshot["documents"] { - for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) { - const path = `${dir}/${entry.name}`; - if (entry.isDirectory()) questTree(path, out); - else if (isQuest(path)) out.push({ path, content: readFileSync(join(root, path), "utf8") }); - } - return out; -} - -const tree = questTree("quest"); -const [commit, date] = git("log", "-1", "--format=%H %cs").trim().split(" "); -const paths = new Set(tree.map(({ path }) => path)); -const completed = finished(git(...LOG), paths, (rev, path) => git("show", `${rev}:${path}`)); -const snapshot: Snapshot = { commit, date, documents: tree, completed }; - -rmSync(build, { recursive: true, force: true }); -mkdirSync(join(build, "public"), { recursive: true }); -writeFileSync(join(build, "docs.json"), JSON.stringify(rendered)); -writeFileSync(join(build, "board.json"), JSON.stringify(snapshot)); -writeFileSync(join(build, "stubs.json"), JSON.stringify(SKILLS.map(({ installed, stubs }) => ({ installed, stubs })))); -cpSync(join(design, "theme"), join(build, "public", "theme"), { recursive: true }); -cpSync(join(worker, "src", "site.css"), join(build, "public", "site.css")); -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/history.ts b/cloudflare/scripts/history.ts deleted file mode 100644 index 935a4e60..00000000 --- a/cloudflare/scripts/history.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Finished quests, read from Git history. Merged quests are deleted, so -// history is the only record of them. Pure, so the Worker tests can run it. - -import type { Completed } from "../src/board/project"; - -/** - * The `git log` this reads: the main line newest first, each merge as one - * change the way a squash merge is, with renames, unquoted paths, and one NUL - * before each commit. - */ -export const LOG = [ - "-c", - "core.quotePath=false", - "log", - "--first-parent", - "--diff-merges=first-parent", - "-M", - "--name-status", - "--format=%x00%H %cs", -]; - -/** Quest documents, as the CLI collects them: Markdown other than agent instructions. */ -export function isQuest(path: string): boolean { - const name = path.slice(path.lastIndexOf("/") + 1); - return path.startsWith("quest/") && name.endsWith(".md") && name !== "AGENTS.md" && name !== "CLAUDE.md"; -} - -function dirname(path: string): string { - const index = path.lastIndexOf("/"); - return index < 0 ? "" : path.slice(0, index); -} - -/** - * The quests that `log` (from `LOG`) shows finished. A deletion counts when - * its commit also changed something outside `quest/`: deleting only plans - * abandons a quest rather than finishing it. Paths follow later directory - * renames, such as a milestone's, to where they live now; `paths` is every quest - * that exists now, and a deletion that resolves to one of them was a move Git - * did not pair up. `read` returns a file's text at a commit. - */ -export function finished(log: string, paths: Set, read: (commit: string, path: string) => string): Completed[] { - const dirs = new Set( - [...paths].flatMap((path) => path.split("/").map((_, i, parts) => parts.slice(0, i).join("/"))), - ); - const moved = new Map(); - // A directory can move back to a name it left, so each one moves at most once. - const current = (path: string, seen = new Set()): string => { - for (let dir = dirname(path); dir !== ""; dir = dirname(dir)) { - const to = moved.get(dir); - if (to === undefined || seen.has(dir)) continue; - seen.add(dir); - return current(to + path.slice(dir.length), seen); - } - return path; - }; - const found: Completed[] = []; - // Newest first, so every rename is known before the older deletions it moves. - for (const block of log.split("\0").slice(1)) { - const [header, ...lines] = block.trim().split("\n"); - const [commit, date] = header.split(" "); - const changes = lines.filter(Boolean).map((line) => line.split("\t")); - const work = changes.some(([, ...paths]) => paths.some((path) => !path.startsWith("quest/"))); - // Only a directory that no longer exists has moved; a quest that moved - // between two live epics says nothing about the rest of either. Renames - // come first, so a quest finished in the same commit follows them too. - for (const [status, from, to] of changes) { - if (!status.startsWith("R")) continue; - const [old, now] = [dirname(from), dirname(to)]; - if (old !== now && !dirs.has(old) && !moved.has(old)) moved.set(old, now); - } - for (const [status, from] of changes) { - if (status !== "D" || !work || !isQuest(from)) continue; - const path = current(from); - if (paths.has(path)) continue; - const title = /^# (.+)$/m.exec(read(`${commit}^`, from))?.[1] ?? from; - found.push({ path, title, date, commit }); - } - } - return found; -} diff --git a/cloudflare/scripts/isolated.ts b/cloudflare/scripts/isolated.ts deleted file mode 100644 index d99da1e2..00000000 --- a/cloudflare/scripts/isolated.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Run each Worker test case in its own vitest process. The suite shares one -// runtime and resets only storage between tests (vitest.config.ts), so a test -// can still lean on module-level state another left behind. Each case alone is -// too slow for every PR (seconds of startup apiece), so a scheduled CI job runs -// this instead. Cases declared on one line, such as a loop's, run together. -// -// bun scripts/isolated.ts [--jobs N] [filter...] -// -// Filters keep the cases whose file path contains one of them. Run it after -// `bun run build`, as `vitest run` is. -import { spawn } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { availableParallelism, tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { parseArgs } from "node:util"; - -const root = join(import.meta.dirname, ".."); -const { values, positionals: filters } = parseArgs({ - options: { jobs: { type: "string", default: String(Math.max(1, Math.floor(availableParallelism() / 2))) } }, - allowPositionals: true, -}); -const jobs = Number(values.jobs); -if (!Number.isInteger(jobs) || jobs < 1) throw new Error(`--jobs must be a positive integer, not ${values.jobs}`); - -/** Run vitest with `args` in the Worker's directory, returning its exit code and combined output. */ -function vitest(args: string[]) { - return new Promise<{ code: number; output: string }>((resolve, reject) => { - const child = spawn("bun", ["x", "vitest", ...args], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); - let output = ""; - child.stdout.on("data", (chunk) => (output += chunk)); - child.stderr.on("data", (chunk) => (output += chunk)); - child.on("error", reject); - child.on("close", (code, signal) => resolve({ code: code ?? (signal ? 1 : 0), output })); - }); -} - -// Every case's location, from vitest's own collection. -const scratch = mkdtempSync(join(tmpdir(), "quest-isolated-")); -let cases: string[]; -try { - const file = join(scratch, "cases.json"); - const listed = await vitest(["list", `--json=${file}`]); - if (listed.code !== 0) { - process.stderr.write(listed.output); - throw new Error("vitest list failed"); - } - const tests = JSON.parse(readFileSync(file, "utf8")) as { file: string; location?: { line: number } }[]; - cases = [ - ...new Set( - tests.map((test) => { - if (!test.location) throw new Error(`vitest listed ${test.file} without a location`); - return `${relative(root, test.file)}:${test.location.line}`; - }), - ), - ].filter((location) => !filters.length || filters.some((filter) => location.includes(filter))); -} finally { - rmSync(scratch, { recursive: true, force: true }); -} -if (!cases.length) throw new Error("No test cases matched"); - -console.log(`Running ${cases.length} test cases, each in its own process, ${jobs} at a time.`); -const failed: string[] = []; -let done = 0; -const queue = [...cases]; -await Promise.all( - Array.from({ length: Math.min(jobs, queue.length) }, async () => { - for (let location = queue.shift(); location; location = queue.shift()) { - // A location matching no test fails too, so a stale listing cannot pass. - const { code, output } = await vitest(["run", location]); - done++; - if (code === 0) continue; - failed.push(location); - console.log(`\nโœ— ${location} (${done}/${cases.length})\n${output}`); - } - }), -); - -if (failed.length) { - console.log(`\n${failed.length} of ${cases.length} test cases failed on their own:`); - for (const location of failed.sort()) console.log(` ${location}`); - process.exit(1); -} -console.log(`All ${cases.length} test cases passed on their own.`); diff --git a/cloudflare/scripts/vapid.ts b/cloudflare/scripts/vapid.ts deleted file mode 100644 index 7431948a..00000000 --- a/cloudflare/scripts/vapid.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 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 deleted file mode 100644 index 079a51e3..00000000 --- a/cloudflare/src/account.tsx +++ /dev/null @@ -1,171 +0,0 @@ -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 -// where the browser supports passkeys. - -export type Passkey = { id: string; name?: string | null; createdAt: Date | string | null }; - -// Better Auth reports OAuth failures as `?error=`. Explain the ones a -// person can act on; never echo the raw value. -const errors: Record = { - account_not_linked: - "That email already belongs to an account signed in another way. Sign in with that method, then link this one from your account page.", - account_already_linked_to_different_user: "That sign-in already belongs to a different account.", - unable_to_link_account: "That sign-in could not be linked. Each account links one of each provider.", -}; - -function Problem(props: { error?: string }) { - if (!props.error) return null; - return ( - - ); -} - -function Panel(props: { title: string; children: Child }) { - return ( -
-

{props.title}

- {props.children} -
- ); -} - -// The passkey script's result line, and the fallback without JavaScript. -function PasskeyStatus() { - return ( - <> -

- - - - ); -} - -export function SignInPage(props: { providers: Provider[]; next: string; error?: string }) { - return ( -
-

Sign in

- - - {props.providers.map((provider, i) => ( -
- - -

- -

-
- ))} -

- -

- -

- Acting on a repository needs a linked GitHub account; other methods sign in to the same account once - linked from your account page. -

-
-
- ); -} - -export function AccountPage(props: { - name: string; - 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)); - return ( -
-

Account

-

Signed in as {props.name}.

- - -
    - {props.linked.map((provider) => ( -
  • {providerNames[provider]}
  • - ))} -
- {props.linked.includes("github") ? ( -

Repository access comes from your linked GitHub account.

- ) : ( -

Link GitHub to act on repositories you maintain.

- )} - {unlinked.map((provider) => ( -
- -

- -

-
- ))} -
- - {props.passkeys.length ? ( -
    - {props.passkeys.map((key) => ( -
  • - {key.name || "Passkey"} - {key.createdAt && ( - - {" "} - added {new Date(key.createdAt).toISOString().slice(0, 10)} - - )} -
    - - -
    -
  • - ))} -
- ) : ( -

No passkeys yet.

- )} -

- -

- -

- 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 deleted file mode 100644 index ddf4efe2..00000000 --- a/cloudflare/src/app.js +++ /dev/null @@ -1,283 +0,0 @@ -// 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 }, - ); -} - -// A job's raw output loads only when a maintainer opens its disclosure, a page -// at a time, and the server rechecks their authority on every page. It is -// shown as text, never markup. -function bindRawOutput(root = document) { - for (const details of root.querySelectorAll("details[data-output]:not([data-output-bound])")) { - details.dataset.outputBound = ""; - const output = details.querySelector("pre"); - const status = details.querySelector("[data-output-status]"); - const more = details.querySelector("[data-output-more]"); - let next = null; - let started = false; - async function load() { - more.disabled = true; - try { - const url = new URL(details.dataset.output, location.href); - if (next) url.searchParams.set("after", `${next.attempt}.${next.seq}`); - const response = await fetch(url, { headers: { Accept: "application/json" } }); - const page = await response.json().catch(() => null); - if (!response.ok) throw new Error(page?.error || "The raw output could not be read."); - output.append(page.output.map((chunk) => chunk.text).join("")); - output.hidden = !output.textContent; - next = page.next; - more.hidden = !next; - status.textContent = !output.textContent - ? "No raw output is stored for this job." - : page.truncated.length - ? "Some output was dropped at the size limit." - : ""; - } catch (error) { - status.textContent = error.message || "Something went wrong."; - // Opening the disclosure again retries a first page that failed. - if (!output.textContent) started = false; - } finally { - more.disabled = false; - } - } - details.addEventListener("toggle", () => { - if (!details.open || started) return; - started = true; - load(); - }); - more.addEventListener("click", load); - } -} -bindRawOutput(); - -// 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(() => {}); -} - -// Live cards receive only invalidation hints. Every content read repeats the -// server's current-maintainer checks and renders escaped summaries. -for (const root of document.querySelectorAll("[data-agent-cards]")) { - const status = root.parentElement.querySelector("[data-live-status]"); - let socket = null; - let stopped = false; - let refreshing = false; - let dirty = false; - let timer; - let retry = 1000; - const show = (message = "") => { - status.textContent = message; - status.hidden = !message; - }; - async function refresh() { - dirty = true; - if (refreshing || stopped) return; - refreshing = true; - try { - do { - dirty = false; - const response = await fetch(root.dataset.cards, { - headers: { Accept: "text/html" }, - cache: "no-store", - }); - if (response.status === 401 || response.status === 403) { - stopped = true; - socket?.close(); - root.replaceChildren(); - show("Sign in again to read current agent updates."); - return; - } - if (!response.ok) throw new Error("Agent updates could not be refreshed."); - const fragment = document.createElement("template"); - fragment.innerHTML = await response.text(); - const previous = new Map( - [...root.querySelectorAll("[data-job]")].map((card) => [card.dataset.job, card]), - ); - for (const card of fragment.content.querySelectorAll("[data-job]")) { - const oldOutput = previous.get(card.dataset.job)?.querySelector("details[data-output]"); - const newOutput = card.querySelector("details[data-output]"); - // Keep an open disclosure and its already authorized, loaded pages intact. - if (oldOutput && newOutput && oldOutput.dataset.output === newOutput.dataset.output) - newOutput.replaceWith(oldOutput); - } - root.replaceChildren(fragment.content); - bindRawOutput(root); - show(); - } while (dirty && !stopped); - } catch (error) { - show(error.message || "Agent updates could not be refreshed."); - } finally { - refreshing = false; - } - } - function connect() { - if (stopped || document.hidden || (socket && socket.readyState < WebSocket.CLOSING)) return; - clearTimeout(timer); - const url = new URL(root.dataset.live, location.href); - url.protocol = location.protocol === "https:" ? "wss:" : "ws:"; - socket = new WebSocket(url); - socket.addEventListener("open", () => { - retry = 1000; - refresh(); - }); - socket.addEventListener("message", (event) => { - if (event.data === "changed") refresh(); - }); - socket.addEventListener("close", () => { - if (stopped) return; - show("Live updates paused. Reconnectingโ€ฆ"); - timer = setTimeout(connect, retry); - retry = Math.min(retry * 2, 30000); - }); - } - window.addEventListener("online", connect); - document.addEventListener("visibilitychange", () => { - if (!document.hidden) { - connect(); - refresh(); - } - }); - window.addEventListener("pagehide", () => { - stopped = true; - clearTimeout(timer); - socket?.close(); - }); - window.addEventListener("pageshow", (event) => { - if (event.persisted) { - stopped = false; - connect(); - } - }); - connect(); -} diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts deleted file mode 100644 index 088be1f0..00000000 --- a/cloudflare/src/auth.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { passkey } from "@better-auth/passkey"; -import { betterAuth } from "better-auth"; -import { APIError } from "better-auth/api"; - -export type Provider = "github" | "google"; - -// Sign-in providers in display order. GitHub is always configured; Google only -// when both of its credentials are set. -export function providers(env: Env): Provider[] { - return env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? ["github", "google"] : ["github"]; -} - -export const providerNames: Record = { github: "GitHub", google: "Google" }; - -// Better Auth's passkey plugin verifies signatures but not user verification; -// require a PIN, biometric, or equivalent check on every ceremony. -function requireUserVerification(verified: boolean | undefined) { - if (!verified) throw new APIError("FORBIDDEN", { message: "Passkey user verification required" }); -} - -// Construct per request: D1 bindings cannot be shared across request contexts. -// New providers/plugins belong here; routes use the same auth API. -// -// Every sign-in method reaches the same local user, but repository authority -// comes only from that user's linked GitHub account (see src/github/service.ts). -export function createAuth(env: Env) { - const url = new URL(env.AUTH_URL); - return betterAuth({ - database: env.DB, - baseURL: env.AUTH_URL, - secret: env.AUTH_SECRET, - trustedOrigins: [url.origin], - // Providers join an account only through an explicit link by its signed-in - // owner, which proves both identities; a matching email never links them. - account: { accountLinking: { disableImplicitLinking: true, allowDifferentEmails: true } }, - // Unlinking is deferred: it could strand an account without its GitHub authority. - disabledPaths: ["/unlink-account"], - databaseHooks: { - account: { - create: { - // One account per provider, so the linked GitHub identity is unambiguous. - // Returning false makes Better Auth report the link as failed. - async before(account) { - const existing = await env.DB.prepare("SELECT 1 FROM account WHERE userId=? AND providerId=?") - .bind(account.userId, account.providerId) - .first(); - return existing ? false : undefined; - }, - }, - }, - }, - plugins: [ - passkey({ - rpID: url.hostname, - rpName: "Quest", - origin: url.origin, - // Discoverable keys sign in without a username. Enrollment requires a - // fresh session (the plugin default). - authenticatorSelection: { residentKey: "required", userVerification: "required" }, - registration: { - afterVerification: async ({ verification }) => - requireUserVerification(verification.registrationInfo?.userVerified), - }, - authentication: { - afterVerification: async ({ verification }) => - requireUserVerification(verification.authenticationInfo.userVerified), - }, - }), - ], - socialProviders: { - // GitHub App permissions are registered in its manifest, not OAuth scopes. - github: { - disableDefaultScope: true, - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - }, - ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET - ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } - : {}), - }, - }); -} diff --git a/cloudflare/src/board/markdown.ts b/cloudflare/src/board/markdown.ts deleted file mode 100644 index 95947083..00000000 --- a/cloudflare/src/board/markdown.ts +++ /dev/null @@ -1,67 +0,0 @@ -// A quest's Markdown, rendered for its page. Quests can arrive from -// contributors' forks, so raw HTML shows as text, images as their alt text, -// and only web and mail links survive. Links into the tree open board pages. - -import { Marked, type Token } from "marked"; -import { resolve } from "quest/core"; -import { blobHref, questHref } from "./model"; -import type { Project } from "./project"; - -export type Section = { heading: string; html: string }; - -function escape(text: string): string { - return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); -} - -/** - * Where a link in `from` should point on the site, or `null` to drop it and - * keep its text. Relative links resolve the way GitHub resolves them. - */ -export function rewrite(project: Project, from: string, href: string, known: (path: string) => boolean): string | null { - if (/^(https?|mailto):/i.test(href)) return href; - if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return null; - if (href.startsWith("#")) return href; - const fragment = href.indexOf("#"); - const hash = fragment < 0 ? "" : href.slice(fragment); - const target = resolve(from, href); - if (target === null || target === "") return null; - if (known(target)) return questHref(project, target) + hash; - return blobHref(project, target, hash); -} - -/** The document's `## ` sections in order, each rendered to HTML. The `# ` title is left to the page. */ -export function render(project: Project, path: string, content: string, known: (path: string) => boolean): Section[] { - const marked = new Marked({ - gfm: true, - renderer: { - html: ({ text }) => escape(text), - image: ({ text }) => escape(text), - // After an inline ``, `
`, or `
-				{props.user && }
-			
-		
-	);
-}
-
-// Signing in returns to `path`.
-export function SignIn(props: { path: string; primary?: boolean; children: Child }) {
-	return (
-		
- - -
- ); -} - -function Crumbs(props: { crumbs: Crumb[] }) { - return ( - - ); -} - -// A line to paste, such as into an agent, with a copy button. -export function Paste(props: { id: string; text: string }) { - return ( -
- {props.text} - -
- ); -} - -/** - * 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; - button.hidden = false; - button.addEventListener("click", async () => { - await navigator.clipboard.writeText(document.getElementById(button.dataset.copy).textContent); - button.textContent = "Copied"; - setTimeout(() => (button.textContent = "Copy"), 2000); - }); -}`; - -// Status glyphs, drawn like the mark. Colour comes from the marker class. -export function Bang() { - return ( - - ); -} - -export function Query() { - return ( - - ); -} - -// A token chip: agent tokens, donated or spent. Display only. -export function Token() { - return ( - - ); -} - -// A quest's status, per the glossary in design/theme.md. -export type Status = "available" | "pending" | "blocked" | "accepted" | "turn-in"; - -// A status glyph: a yellow `!` for Available, a grey one for Requires, and a -// yellow `?` for Ready to turn in. Accepted and Pending have none. Always pair it with its label. -export function Marker(props: { status: Status }) { - if (props.status === "accepted" || props.status === "pending") return ; - return {props.status === "turn-in" ? : }; -} - -export type Size = "XS" | "S" | "M" | "L" | "XL"; - -// A size label in its size colour. -export function Difficulty(props: { size: Size }) { - return {props.size}; -} diff --git a/cloudflare/src/licenses.ts b/cloudflare/src/licenses.ts deleted file mode 100644 index 5f1cbd6d..00000000 --- a/cloudflare/src/licenses.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** Current published SPDX metadata includes OSI approval, not just a known ID. - * Fetch it for each decision so a prior positive result never grants access. - * https://github.com/spdx/license-list-data/blob/main/accessingLicenses.md - */ -export async function osiApproved(identifier: string | undefined, request: typeof fetch = fetch): Promise { - if (!identifier || identifier === "NOASSERTION" || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*\+?$/.test(identifier)) return false; - const response = await request(`https://spdx.org/licenses/${encodeURIComponent(identifier)}.json`, { - // Workers refuse `redirect: "error"`; a redirect is not ok, so it fails below. - redirect: "manual", - cache: "no-store", - headers: { Accept: "application/json" }, - }); - if (response.status === 404) return false; - if (!response.ok) throw new Error("License approval data unavailable"); - const data: unknown = await response.json(); - if ( - !data || - typeof data !== "object" || - !("licenseId" in data) || - !("isOsiApproved" in data) || - typeof data.licenseId !== "string" || - typeof data.isOsiApproved !== "boolean" - ) - throw new Error("License approval data invalid"); - return data.licenseId === identifier && data.isOsiApproved === true; -} diff --git a/cloudflare/src/manifest.webmanifest b/cloudflare/src/manifest.webmanifest deleted file mode 100644 index 45ef9eac..00000000 --- a/cloudflare/src/manifest.webmanifest +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/map.tsx b/cloudflare/src/map.tsx deleted file mode 100644 index afb8288d..00000000 --- a/cloudflare/src/map.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { type Doc, requires } from "quest/core"; -import { - type Milestone, - type Board, - type Progress, - type Quest, - type Status, - boardHref, - milestoneAnchor, - flatten, -} from "./board/model"; -import { Difficulty } from "./layout"; - -// The home page's quest map: the project's own tree, read left to right like -// a world map. Milestones are regions, epics are paths, and quests are waypoints -// coloured by size. Statuses use the format's own terms, with no display -// aliases (decided 2026-10-07 on moq-dev/quest#78). - -/** - * One path through a region: an epic, or the quests a milestone lists directly. - * An epic's title and each waypoint open their board pages. - */ -export type Path = { title: string; href: string | null; progress: Progress | null; waypoints: Quest[] }; - -export type Region = { milestone: Milestone; href: string; paths: Path[] }; - -/** - * How far along its path a quest sits: 0 with nothing in the tree before it, - * otherwise one past the furthest quest it requires. A required epic counts - * as everything it still holds. - */ -function steps(docs: Map): (path: string) => number { - const all = [...docs.values()]; - const memo = new Map(); - const step = (path: string): number => { - const known = memo.get(path); - if (known !== undefined) return known; - // A cycle is a `quest check` failure; it must not hang the page. - memo.set(path, 0); - const required = requires(all, docs.get(path)!).filter((target) => docs.has(target)); - const value = required.length === 0 ? 0 : 1 + Math.max(...required.map(step)); - memo.set(path, value); - return value; - }; - return step; -} - -/** The board's milestones as map regions, each path ordered by step, then path order. */ -export function chart(board: Board): Region[] { - const step = steps(board.docs); - const ordered = (quests: Quest[]): Quest[] => - quests - .map((quest, rank) => ({ quest, rank, step: step(quest.path) })) - .sort((a, b) => a.step - b.step || a.rank - b.rank) - .map(({ quest }) => quest); - - return board.milestones.map((milestone) => { - // Paths keep the milestone's path order. The quests directly in it share one - // main path, placed where the first of them sorts. - const loose = milestone.items.flatMap((item) => (item.kind === "quest" ? [item.quest] : [])); - const main: Path = { title: "Main path", href: null, progress: null, waypoints: ordered(loose) }; - const paths = milestone.items.flatMap((item): Path[] => { - if (item.kind === "quest") return item.quest === loose[0] ? [main] : []; - const { epic } = item; - return [ - { - title: epic.title, - href: epic.href, - progress: epic.progress, - waypoints: ordered(flatten(epic.items)), - }, - ]; - }); - return { milestone, href: `${boardHref(board.project)}#${milestoneAnchor(milestone)}`, paths }; - }); -} - -/** A waypoint's status in the format's own terms. */ -function text(status: Status): string { - switch (status.kind) { - case "accepted": - return "claimed"; - case "available": - return "ready"; - case "pending": - return "pending"; - case "blocked": - return "blocked"; - case "turn-in": - return "in review"; - } -} - -/** The waypoint's tooltip: its title and status, and what a blocked quest requires. */ -function tooltip(quest: Quest): string { - const { status } = quest; - const line = `${quest.title}: ${text(status)}`; - if (status.kind !== "blocked" || status.requires.length === 0) return line; - return `${line}\nRequired: ${status.requires.map((ref) => ref.title).join(", ")}`; -} - -/** A waypoint, and what its agent is doing when one works on it. */ -function Stop(props: { quest: Quest; agent: string | undefined }) { - const { quest, agent } = props; - const kind = quest.status.kind; - const size = quest.size ? ` ql-${quest.size.toLowerCase()}` : ""; - // Screen readers hear the title first, then the size, status, and agent. - const name = `${quest.title}, size ${quest.size ?? "unknown"}, ${text(quest.status)}${agent ? `, agent: ${agent}` : ""}`; - return ( -
  • - - - {quest.size ?? "?"} - - - {quest.title} - {text(quest.status)} - {agent && Agent: {agent}} - - -
  • - ); -} - -/** The map; `agents` highlights the quests agents work on, saying what each is doing. */ -export function QuestMap(props: { board: Board; agents?: Map }) { - const { board } = props; - const web = board.project.web; - const regions = chart(board); - return ( -
    -
      - {regions.map(({ milestone, href, paths }) => ( -
    1. -
      - {milestone.number !== "" && Milestone {milestone.number}} -

      - {milestone.title} -

      - - {milestone.progress.done} of {milestone.progress.total} quests complete - -
      -
        - {paths.map((path) => ( -
      1. -

        - {path.href ? {path.title} : path.title} - {path.progress && ( - - {" "} - ยท {path.progress.done} of {path.progress.total} complete - - )} -

        -
          - {path.waypoints.map((quest) => ( - - ))} -
        -
      2. - ))} -
      -
    2. - ))} -
    -
    -

    - Size:{" "} - {(["XS", "S", "M", "L", "XL"] as const).map((size) => ( - - ))} -

    -

    - A solid waypoint is ready or in review; a dashed one is blocked.{" "} - {props.agents && "A ringed one has an agent on it. "} - {web && ( - <> - Charted from quest/ on{" "} - {board.project.date}. - - )} -

    -
    -
    - ); -} diff --git a/cloudflare/src/passkey.js b/cloudflare/src/passkey.js deleted file mode 100644 index 85e1d2e4..00000000 --- a/cloudflare/src/passkey.js +++ /dev/null @@ -1,60 +0,0 @@ -// Passkey ceremonies against Better Auth's passkey endpoints, using the -// browser's WebAuthn JSON helpers so there is no client bundle. Served as -// /passkey.js; buttons stay hidden where the browser lacks passkey support. -const status = document.getElementById("passkey-status"); -const supported = - window.PublicKeyCredential && - PublicKeyCredential.parseCreationOptionsFromJSON && - PublicKeyCredential.parseRequestOptionsFromJSON; - -async function call(path, body) { - const init = body && { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }; - const response = await fetch(`/api/auth/passkey/${path}`, init); - const result = await response.json().catch(() => null); - if (!response.ok) throw new Error(result?.message || "Passkey request failed."); - return result; -} - -const ceremonies = { - async "sign-in"(button) { - const options = await call("generate-authenticate-options"); - const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(options); - // The plugin asks for "preferred"; the server rejects unverified sign-ins. - publicKey.userVerification = "required"; - const credential = await navigator.credentials.get({ publicKey }); - await call("verify-authentication", { response: credential.toJSON() }); - location.assign(button.dataset.next || "/"); - }, - async add() { - const options = await call("generate-register-options"); - const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(options); - const credential = await navigator.credentials.create({ publicKey }); - await call("verify-registration", { response: credential.toJSON() }); - location.reload(); - }, -}; - -for (const button of document.querySelectorAll("[data-passkey]")) { - if (!supported) continue; - button.hidden = false; - button.addEventListener("click", async () => { - button.disabled = true; - status.textContent = ""; - try { - await ceremonies[button.dataset.passkey](button); - } catch (error) { - // The browser reports cancellation and timeouts as NotAllowedError. - status.textContent = - error.name === "NotAllowedError" - ? "Passkey cancelled or timed out." - : error.message || "Passkey failed."; - } finally { - button.disabled = false; - } - }); -} -if (!supported && status) status.textContent = "This browser does not support passkeys."; diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx deleted file mode 100644 index 6bcbb39b..00000000 --- a/cloudflare/src/projects.tsx +++ /dev/null @@ -1,545 +0,0 @@ -import type { Activity, ProjectState, Pull } from "./github/state"; -import type { Candidate, Enrollment, ProjectPreview, ProjectSetup } from "./github/service"; -import { GOAL_LIMIT } from "./github/setup"; -import { Difficulty, Marker } from "./layout"; -import { title } from "./board/model"; - -export function projectURL(owner: string, name: string) { - return `/projects/github/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; -} - -export function ProjectStopControls({ project }: { project: Enrollment }) { - const action = projectURL(project.owner, project.name); - return ( - <> - {project.state === "enabled" && ( -
    - - -

    - -

    -
    - )} -
    - - - -
    - - ); -} - -/** One repository the App reaches and the user maintains, with its state or why it cannot be enabled. */ -function CandidateRow({ candidate }: { candidate: Candidate }) { - const href = projectURL(candidate.owner, candidate.name); - // A repository without Quest files enables through its setup pull request. - const blocking = candidate.problems.length - (candidate.setup ? 1 : 0); - const eligible = !candidate.state && blocking === 0; - return ( -
  • - - - {candidate.owner}/{candidate.name} - - -
  • - ); -} - -export function ProjectsPage(props: { - projects: Enrollment[]; - /** The user's projects still setting up. */ - setups: ProjectSetup[]; - /** The repositories to pick from, or null when the user's GitHub token cannot list them. */ - candidates: Candidate[] | null; - /** Whether the user has a linked GitHub account, without which nothing can be listed. */ - linked: boolean; - installation: string | null; - error?: string; -}) { - return ( -
    -

    Project setup

    -

    - Choose a public open-source repository you maintain. Keep its issues, reviews, CI, and merge protections - on GitHub. -

    -

    - Quest isn't open yet. Until the public release, only invited owners' repositories can enroll. -

    - {props.error &&

    {props.error}

    } -
    -

    Your repositories

    -

    - Repositories the GitHub App can reach that you maintain or administer. Enabling one verifies your - authority again and reads Quest files on its default branch. -

    - {!props.linked ? ( -

    - Link your GitHub account to list the repositories you maintain. -

    - ) : props.candidates === null ? ( -

    - Your GitHub repositories could not be listed. Sign out, then sign in with GitHub again. -

    - ) : props.candidates.length ? ( -
      - {props.candidates.map((candidate) => ( - - ))} -
    - ) : ( -

    The App is not installed on any repository you maintain yet.

    - )} - {props.installation ? ( -

    - - Install or configure the App - -

    - ) : ( -

    The operator has not configured the GitHub App yet.

    - )} -

    - Installing the App on a repository does not enable contributions; GitHub returns you here to choose. - Funding a quest does not grant maintainer permissions. -

    -
    -
    -

    Find an enrolled project

    -

    - Select an enrolled project by name to pause or disconnect it, even if its eligibility has changed or - the App no longer reaches it. -

    -
    - - - -
    -
    - {props.projects.length + props.setups.length > 0 && ( -
    -

    Your enrolled projects

    -
      - {props.setups.map((setup) => ( -
    • - - {setup.owner}/{setup.name} - {" "} - ยท Setting up -
    • - ))} - {props.projects.map((project) => ( -
    • - - {project.owner}/{project.name} - {" "} - ยท {project.state === "enabled" ? "Contributions enabled" : "Contributions paused"} -
      - - {project.state === "enabled" && ( - - )} - -
      -
    • - ))} -
    -
    - )} -
    - ); -} - -/** The goal question, the only one enrolling a repository without Quest files asks. */ -function GoalField() { - return ( - <> - - -

    - It becomes the Goal of quest/README.md, which you can edit before merging. -

    - - ); -} - -/** The pull-request CI check's warnings (`./github/ci.ts`), which never block enabling. */ -function CIPanel(props: { problems: string[] }) { - return ( -
    -

    Pull-request CI

    -

    - kix publishes job branches on this repository, where GitHub gives their workflows the repository's - secrets and default token. Keep that CI read-only, so code no maintainer has reviewed meets only a - read-only GITHUB_TOKEN and no write credentials. -

    - {props.problems.length ? ( -
      - {props.problems.map((problem) => ( -
    • {problem}
    • - ))} -
    - ) : ( -

    - The default token is read-only, no repository or organization secrets reach workflows, and - environment secrets deploy only from protected branches. -

    - )} -

    - Jobs still run either way. kix does not read workflow files, so also keep write{" "} - permissions: and secrets out of jobs that pull requests and pushes to unprotected branches - trigger, including pull_request_target and workflow_run jobs that check out a - pull request's head. -

    -
    - ); -} - -/** Enrolling a repository with no Quest files: the setup pull request's one question. */ -export function QuestSetupPage(props: { - repository: { id: number; owner: string; name: string }; - /** The pull-request CI check's warnings, when it ran. */ - ci?: string[]; - error?: string; -}) { - const repo = props.repository; - return ( -
    -

    - Project setup -

    -

    - {repo.owner}/{repo.name} -

    - {props.error &&

    {props.error}

    } -
    -

    Set up Quest

    -

    - This repository has no Quest files on its default branch yet. The App opens a pull request with what{" "} - quest init writes: skill stubs, quest/README.md, one line in the agent - instructions, and a .gitattributes line. Contributions are enabled when you merge it. -

    -
    - - -

    - -

    - -
    - {props.ci && } -
    - ); -} - -/** A project enrolled, setting up: its setup pull request, and what the maintainer can do next. */ -export function SettingUpPage(props: { setup: ProjectSetup; error?: string }) { - const { setup } = props; - const action = projectURL(setup.owner, setup.name); - const pull = `https://github.com/${setup.owner}/${setup.name}/pull/${setup.pull}`; - const retry = setup.pull === null || (setup.state === "closed" && !setup.failure); - return ( -
    -

    - Project setup -

    -

    - {setup.owner}/{setup.name} -

    - {props.error &&

    {props.error}

    } -
    -

    Setting up

    - {setup.failure ? ( - <> -

    - The setup pull request merged, but contributions could not be enabled:{" "} - {setup.failure} -

    -

    - - Check again - -

    - - ) : setup.pull === null ? ( -

    The setup pull request has not been opened yet.

    - ) : setup.state === "open" ? ( -

    - The setup pull request #{setup.pull} is open. Contributions are enabled when - it merges. -

    - ) : ( -

    - The setup pull request #{setup.pull} was closed without merging. Open it - again with the current default branch, or disconnect the project. -

    - )} - {retry && ( -
    - - -

    - -

    - - )} -
    - - - -
    -

    Disconnecting leaves the setup pull request and its branch as they are.

    -
    -
    - ); -} - -export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: Enrollment | null; error?: string }) { - const { preview } = props; - const repo = preview.repository; - const href = `https://github.com/${repo.owner}/${repo.name}`; - const action = projectURL(repo.owner, repo.name); - return ( -
    -

    - Project setup -

    -

    - {repo.owner}/{repo.name} -

    - {props.error &&

    {props.error}

    } -
    -

    - {preview.state === "enabled" - ? "Contributions enabled" - : preview.state === "paused" - ? "Contributions paused" - : "Enable contributions"} -

    -

    - Opting in makes ready quests on the default branch eligible for funded implementation. There is no - second approval flag per quest. Your normal GitHub review rules, CI, and merge protections still - apply. -

    - {preview.licenseError ? ( - - ) : ( -

    - GitHub detects {preview.license?.name} ({preview.license?.spdx_id}), an OSI-approved license. -

    - )} - -
    - - - -
    - {props.enrollment && ( - <> - {preview.state === null && ( -

    - The App installation changed. Enable contributions explicitly for this installation, or - stop the previous enrollment below. -

    - )} -

    - Pause stops new work. Disconnect removes this project's opt-in; it does not uninstall the - App or change existing GitHub contributions. -

    - - - )} -
    - -
    -

    Ready quests

    - {props.enrollment && ( -

    - View project activity and quest board -

    - )} -

    - From {preview.branch} at {preview.commit.slice(0, 7)} - . This preview does not start work. -

    - {preview.quests.length ? ( -
      - {preview.quests.map((quest) => { - const heading = title(quest.title); - return ( -
    • - - - {heading.name} - - -
    • - ); - })} -
    - ) : ( -

    No ready quests on the default branch yet. Update and merge your plans on GitHub.

    - )} -
    -
    - ); -} - -export function ProjectStatePage(props: { - owner: string; - repository: string; - state: ProjectState | null; - /** One page of the activity log, and where the next begins. */ - log: { items: Activity[]; next: string | null }; -}) { - const base = projectURL(props.owner, props.repository); - const state = props.state; - return ( -
    -

    - Project setup -

    -

    - {props.owner}/{props.repository} activity -

    -
    - -
    -

    - GitHub issues and pull requests are source material. Webhooks reread the project in the background; - refreshing does the same by hand. Neither starts work or approves a merge. -

    - {state?.failure &&

    The last background read failed: {state.failure}

    } - {state ? ( - <> -

    - Snapshot{" "} - - {state.project.commit.slice(0, 7)} - - .{" "} - {state.complete - ? "Reconciliation complete." - : "Reconciliation incomplete. Continue to include every page."} -

    -

    - Quest board - {" ยท "} - Landing queue - {" ยท "} - Observed data -

    -

    Claimed quests

    - {state.claims.length ? ( -
      - {state.claims.map((path) => ( -
    • {path}
    • - ))} -
    - ) : ( -

    No observed branch claims.

    - )} -

    Issues and pull requests

    - {props.log.items.length ? ( - - ) : ( -

    No activity observed yet.

    - )} - {props.log.next && ( -

    - Older activity -

    - )} - - ) : ( -

    Refresh to read this enrolled project's quest tree and activity.

    - )} -
    - ); -} diff --git a/cloudflare/src/push/notify.ts b/cloudflare/src/push/notify.ts deleted file mode 100644 index 1cb167c2..00000000 --- a/cloudflare/src/push/notify.ts +++ /dev/null @@ -1,277 +0,0 @@ -// Push notifications for decisions. Only something that waits on a -// maintainer pushes: an agent's question, an account limit, another -// maintainer's answer awaiting the job requester's approval, 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" | "approval" | "failed" | "issue" | "pull"; -const reasons: Record = { - question: "Agent question", - approval: "Answer to approve", - 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 deleted file mode 100644 index e8fdc4b6..00000000 --- a/cloudflare/src/push/webpush.ts +++ /dev/null @@ -1,150 +0,0 @@ -// 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/queue.tsx b/cloudflare/src/queue.tsx deleted file mode 100644 index 41c0f059..00000000 --- a/cloudflare/src/queue.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { Drop, LandingQueue, PullRef } from "./github/queue"; -import { ago } from "./inbox/activity"; -import { projectURL } from "./projects"; - -const MODES: Record = { - "merge-queue": "GitHub's merge queue", - own: "the watcher's queue", -}; -const OUTCOMES: Record["kind"], string> = { - requeued: "Re-enqueued", - handback: "Handed back", - ended: "Fix not applied", - refused: "Refused", - held: "Held", -}; - -function Pull({ pull }: { pull: PullRef }) { - return ( - - #{pull.number} {pull.title} - - ); -} - -function When({ at, now }: { at: string; now: number }) { - return ; -} - -/** Each base's landing queue: the order, the builds in progress, and the last day's drops. */ -export function QueuePage(props: { owner: string; repository: string; queues: LandingQueue[]; now: number }) { - const base = projectURL(props.owner, props.repository); - return ( -
    -

    - Activity - {" ยท "} - Quest board -

    -

    - {props.owner}/{props.repository} landing -

    -

    - Read from GitHub when this page loads. Drops are those the watcher commented on in the last day. -

    - {props.queues.length === 0 &&

    Nothing is landing, and nothing was dropped in the last day.

    } - {props.queues.map((queue) => { - const builds = queue.entries.filter((entry) => entry.build); - return ( - <> -

    - {queue.base} through {MODES[queue.mode]} -

    -

    Building

    - {builds.length ? ( -
      - {builds.map((entry) => ( -
    • - - -
    • - ))} -
    - ) : ( -

    Nothing is building.

    - )} -

    Queue

    - {queue.entries.length ? ( -
      - {queue.entries.map((entry) => ( -
    1. - - -
    2. - ))} -
    - ) : ( -

    Nothing is queued.

    - )} -

    Recent drops

    - {queue.drops.length ? ( - - ) : ( -

    No drops in the last day.

    - )} - - ); - })} -
    - ); -} diff --git a/cloudflare/src/runners.tsx b/cloudflare/src/runners.tsx deleted file mode 100644 index f8c8e83b..00000000 --- a/cloudflare/src/runners.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import type { Platform } from "./jobs/store"; - -// The signed-in user's runners and agent accounts: a secondary view off the -// decision inbox. It shows each runner's presence, its accounts, and the -// preference order routing follows, and approves a `quest runner login`. - -export type RunnerView = { - id: string; - name: string; - platform: Platform; - vm: number; - online: boolean; - accounts: { - runner: string; - id: string; - provider: string; - kind: string; - label: string; - rank: number; - limited: number; - }[]; -}; - -/** A machine asking to connect: what `quest runner login` put in its link. */ -export type Connect = { name: string; platform: Platform; key: string }; - -const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); - -const platforms: Record = { linux: "Linux", darwin: "macOS" }; - -/** Parse the connect link's query, or null when it is not a valid request. */ -export function connectRequest(query: Record): Connect | null { - const { name, platform, key } = query; - if (!name?.trim() || name.length > 100 || (platform !== "linux" && platform !== "darwin")) return null; - if (!key || !/^[0-9a-f]{64}$/.test(key)) return null; - return { name, platform, key }; -} - -function ConnectForm(props: { connect: Connect }) { - const { name, platform, key } = props.connect; - return ( -
    -

    Connect a runner

    -

    - {name} ({platforms[platform]}) asks to run jobs for your projects with its own agent - accounts. -

    - -
    - - - - -
    -
    - ); -} - -export function RunnersPage(props: { runners: RunnerView[]; connect: Connect | null; connected?: string }) { - // The owner's preference order spans their runners, as routing reads it. - const ordered = props.runners - .flatMap((runner) => runner.accounts.map((account) => ({ runner, account }))) - .sort( - (a, b) => - a.account.rank - b.account.rank || - compare(a.account.runner, b.account.runner) || - compare(a.account.id, b.account.id), - ); - return ( -
    -

    Runners

    -

    - Runners are your own Linux and macOS machines running quest runner. Each one drives the - official agent CLIs with the accounts signed in on it; kix never holds their tokens. A runner pools its - Claude subscriptions behind its own proxy, which rotates between them, so they show here as one account. -

    - {props.connected && ( -

    - Connected {props.connected}. It goes online once quest runner starts. -

    - )} - {props.connect && } -
    -

    Machines

    - {props.runners.length ? ( -
      - {props.runners.map((runner) => ( -
    • - {runner.name} - -
    • - ))} -
    - ) : ( -

    - No runners yet. On a Linux or macOS machine with Nix, run quest runner login, then{" "} - quest runner account add and quest runner. -

    - )} -
    -
    -

    Account preference

    - {ordered.length ? ( - <> -

    - Jobs go to the first account here whose runner is online with a free slot. A limited account - is skipped until its runner reports it usable again. -

    -
      - {ordered.map(({ runner, account }, i) => ( -
    1. - {account.label} - -
      - - - - -
      -
    2. - ))} -
    - - ) : ( -

    - No accounts yet. Add one on a runner with quest runner account add. -

    - )} -
    -

    - Back to your decisions. -

    -
    - ); -} diff --git a/cloudflare/src/site.css b/cloudflare/src/site.css deleted file mode 100644 index dfffd7c4..00000000 --- a/cloudflare/src/site.css +++ /dev/null @@ -1,1498 +0,0 @@ -/* Site layout for kix.quest. Identity tokens and components come from /theme/theme.css. */ - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - background: var(--ql-bg); -} - -/* A faint grid and two soft glows behind the top of every page, like a game menu's backdrop. */ -body { - margin: 0; - min-height: 100vh; - display: flex; - flex-direction: column; - background: - radial-gradient(ellipse 60% 40% at 15% 0%, rgb(255 197 61 / 0.09), transparent 70%), - radial-gradient(ellipse 50% 40% at 90% 5%, rgb(77 163 255 / 0.08), transparent 70%), var(--ql-bg); - color: var(--ql-text); - font-family: var(--ql-font-body); - font-size: 1.0625rem; - line-height: 1.65; - /* A stacking context, so the grid below paints above this background. */ - isolation: isolate; -} - -body::before { - content: ""; - position: fixed; - inset: 0 0 auto; - height: 40rem; - z-index: -1; - pointer-events: none; - background-image: - linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgb(255 255 255 / 0.035) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: linear-gradient(180deg, #000, transparent); -} - -main { - flex: 1; -} - -h1, -h2, -h3, -h4 { - font-family: var(--ql-font-display); - font-weight: 700; - color: var(--ql-text); - line-height: 1.05; - letter-spacing: 0.005em; - margin: 1.5em 0 0.5em; -} - -h1 { - font-size: clamp(2.8rem, 6vw, 4.5rem); - text-transform: uppercase; -} - -h2 { - font-size: clamp(2rem, 3.5vw, 2.75rem); - text-transform: uppercase; -} - -h3 { - font-size: 1.5rem; -} - -h4 { - font-size: 1.2rem; -} - -a { - color: var(--ql-link); - text-underline-offset: 0.18em; -} - -code, -pre { - font-family: var(--ql-font-mono); - font-size: 0.88em; -} - -:not(pre) > code { - background: var(--ql-raised); - border: 1px solid var(--ql-rule); - border-radius: 4px; - padding: 0.05em 0.35em; -} - -pre { - margin: 0; - overflow-x: auto; - line-height: 1.55; -} - -:focus-visible { - outline: 2px solid var(--ql-accent); - outline-offset: 2px; -} - -.wrap { - width: min(72rem, 100% - 2rem); - margin-inline: auto; -} - -.muted { - color: var(--ql-muted); -} - -.glyph { - display: inline-block; - width: 0.7em; - height: 1.5em; - vertical-align: middle; -} - -.glyph.token { - width: 1.1em; - height: 1.1em; -} - -/* Buttons: uppercase display type with clipped corners. */ - -.button { - --cut: 8px; - display: inline-block; - font: 700 1rem/1.2 var(--ql-font-display); - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ql-text); - background: var(--ql-raised); - border: 0; - clip-path: polygon( - var(--cut) 0, - 100% 0, - 100% calc(100% - var(--cut)), - calc(100% - var(--cut)) 100%, - 0 100%, - 0 var(--cut) - ); - padding: 0.75em 1.4em; - text-decoration: none; - cursor: pointer; - transition: - background 120ms, - color 120ms; -} - -.button:hover { - background: var(--ql-rule-strong); -} - -/* The clip path hides an outset outline, so draw it inside, in a colour that shows on the accent too. */ -.button:focus-visible { - outline-color: var(--ql-text); - outline-offset: -5px; -} - -.button.primary { - color: var(--ql-bg); - background: var(--ql-accent); -} - -.button.primary:hover { - background: #ffd56e; -} - -.button.quiet { - background: rgb(255 255 255 / 0.06); -} - -.button.quiet:hover { - background: rgb(255 255 255 / 0.12); -} - -/* Header and footer */ - -.site-header { - position: sticky; - top: 0; - z-index: 10; - background: rgb(10 12 17 / 0.8); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--ql-rule); -} - -.nav { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 1.75rem; - padding-block: 0.7rem; -} - -.nav a { - font-family: var(--ql-font-display); - font-size: 1.05rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ql-muted); - text-decoration: none; -} - -.nav a:hover { - color: var(--ql-text); -} - -.nav a.brand { - display: flex; - align-items: center; - gap: 0.6rem; - margin-right: 0.5rem; - font-size: 1.5rem; - font-weight: 700; - letter-spacing: 0.06em; - color: var(--ql-text); -} - -.account { - margin-left: auto; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; - font-size: 0.95rem; -} - -.account form { - margin: 0; -} - -.account .button { - font-size: 0.9rem; - padding: 0.6em 1.1em; -} - -.site-footer { - margin-top: 3rem; - padding-block: 2rem; - border-top: 1px solid var(--ql-rule); - text-align: center; - color: var(--ql-muted); - font-size: 0.95rem; -} - -.site-footer a { - color: var(--ql-muted); -} - -/* Home */ - -.hero { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); - gap: 3.5rem; - align-items: center; - padding-block: 5rem 3rem; -} - -.hero h1 { - margin: 0.5rem 0 0; -} - -.lede { - font-size: 1.3rem; - font-weight: 600; - color: var(--ql-text); -} - -.paste { - margin-block: 1.75rem; -} - -.paste-label { - margin: 0 0 0.4rem; - color: var(--ql-muted); - font-size: 0.95rem; -} - -.paste-box { - display: flex; - align-items: center; - gap: 0.75rem; - background: var(--ql-panel); - border: 1px solid var(--ql-rule); - border-left: 2px solid var(--ql-accent); - border-radius: 4px; - padding: 0.6rem 0.6rem 0.6rem 1rem; -} - -.paste-box code { - flex: 1; - background: none; - border: 0; - padding: 0; - overflow-wrap: anywhere; -} - -.paste-box .button { - font-size: 0.85rem; - padding: 0.55em 1em; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.hero-log { - box-shadow: 0 30px 80px -30px rgb(0 0 0 / 0.8); -} - -.log-title { - margin: 0 0 0.75rem; - font-size: 1.1rem; - letter-spacing: 0.16em; - text-transform: uppercase; - color: var(--ql-muted) !important; -} - -.log { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 0.5rem; -} - -/* A log entry, edged in its size colour like an inventory slot. */ -.entry { - --ql-edge: var(--ql-rule-strong); - display: grid; - grid-template-columns: 1.4rem 1fr; - column-gap: 0.5rem; - padding: 0.65rem 0.9rem 0.65rem 0.6rem; - background: - linear-gradient(90deg, color-mix(in srgb, var(--ql-edge) 9%, transparent), transparent 60%), var(--ql-bg); - border: 1px solid var(--ql-rule); - border-left: 2px solid var(--ql-edge); - border-radius: 4px; -} - -.entry:has(.ql-xs) { - --ql-edge: var(--ql-size-xs); -} - -.entry:has(.ql-s) { - --ql-edge: var(--ql-size-s); -} - -.entry:has(.ql-m) { - --ql-edge: var(--ql-size-m); -} - -.entry:has(.ql-l) { - --ql-edge: var(--ql-size-l); -} - -.entry:has(.ql-xl) { - --ql-edge: var(--ql-size-xl); -} - -.entry-title { - font-weight: 600; -} - -.entry-meta { - grid-column: 2; - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - font-size: 0.92rem; -} - -.ql-marker { - display: inline-flex; - justify-content: center; - font-size: 1.1rem; - line-height: 1; -} - -.section { - padding-block: 3rem 1rem; -} - -.section > h2 { - margin-top: 0.35rem; -} - -.intro { - max-width: 44rem; - margin: 0 0 2rem; - color: var(--ql-muted); -} - -.split { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2.5rem; - align-items: start; -} - -.code-page { - margin: 0; -} - -.code-page figcaption { - margin-bottom: 0.75rem; - color: var(--ql-muted); - font-family: var(--ql-font-mono); - font-size: 0.85rem; -} - -.lifecycle, -.steps { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1rem; - counter-reset: step; -} - -.lifecycle li, -.steps li { - background: var(--ql-panel); - border: 1px solid var(--ql-rule); - border-radius: 6px; - padding: 1.1rem 1.25rem; -} - -.lifecycle p { - margin: 0.25rem 0; -} - -.state { - display: flex; - align-items: center; - gap: 0.4rem; - font-family: var(--ql-font-display); - font-size: 1.15rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.steps li { - counter-increment: step; -} - -.steps h3 { - margin: 0 0 0.5rem; -} - -.steps h3::before { - content: counter(step, decimal-leading-zero); - display: block; - margin-bottom: 0.4rem; - font-size: 2.25rem; - color: var(--ql-accent); -} - -.note { - color: var(--ql-muted); - font-size: 0.95rem; - margin-top: 1.5rem; -} - -.quest-page h4 { - font-size: 1rem; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--ql-muted); - margin: 1.25rem 0 0.25rem; -} - -.quest-page p, -.quest-page ul { - margin: 0.5rem 0; -} - -.quest-head { - display: flex; - align-items: center; - gap: 0.6rem; -} - -.quest-head h3 { - flex: 1; - margin: 0; - font-size: 1.5rem; -} - -.quest-status { - font-weight: 600; - margin-top: 0.25rem !important; -} - -/* - * The quest map. Regions and waypoints sit left to right and wrap rather than - * scroll; on a phone each path runs down the page instead. - * A waypoint's colour is its size, from the theme's .ql- classes. - */ - -.map-regions, -.map-paths, -.trail { - list-style: none; - margin: 0; - padding: 0; -} - -.map-regions { - display: flex; - flex-wrap: wrap; - gap: 1.25rem; -} - -.region { - flex: 1 1 auto; - min-width: 0; - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 6px; - padding: 1rem 1.25rem 1.25rem; -} - -.region-head h3 { - margin: 0.1rem 0 0; - font-size: 1.6rem; - text-transform: uppercase; -} - -.region-head h3 a { - color: var(--ql-text); - text-decoration: none; -} - -.region-head h3 a:hover { - color: var(--ql-accent); -} - -.region-count { - font-size: 0.88rem; -} - -.map-paths { - display: grid; - gap: 1.25rem; - margin-top: 1rem; -} - -.path-name { - margin: 0 0 0.5rem; - font-size: 0.88rem; -} - -.path-name a { - color: var(--ql-text); -} - -.trail { - display: flex; - flex-wrap: wrap; - row-gap: 1rem; -} - -.waypoint { - position: relative; - width: 7.25rem; -} - -/* The path itself: a line through each waypoint's node. */ -.waypoint::before { - content: ""; - position: absolute; - top: 1.25rem; - left: 0; - right: 0; - border-top: 2px solid var(--ql-rule); -} - -.waypoint:first-child::before { - left: 50%; -} - -.waypoint:last-child::before { - right: 50%; -} - -.waypoint a { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - padding-inline: 0.3rem; - text-align: center; - text-decoration: none; - color: inherit; -} - -.waypoint-node { - position: relative; - flex: none; - display: grid; - place-items: center; - width: 2.5rem; - height: 2.5rem; -} - -.waypoint-node::before { - content: ""; - position: absolute; - inset: 0.4rem; - transform: rotate(45deg); - border: 2px solid currentColor; - border-radius: 3px; - background: color-mix(in srgb, currentColor 22%, var(--ql-bg)); - box-shadow: 0 0 12px -2px color-mix(in srgb, currentColor 55%, transparent); -} - -/* Blocked waypoints are drawn dashed, unfilled, and unlit: not yet reachable. */ -.waypoint-blocked .waypoint-node::before { - border-style: dashed; - background: var(--ql-bg); - box-shadow: none; -} - -/* A quest an agent works on wears a ring in the accent colour. */ -.waypoint-agent .waypoint-node::after { - content: ""; - position: absolute; - inset: -0.05rem; - border: 2px solid var(--ql-accent); - border-radius: 50%; -} - -.waypoint-agent-status { - color: var(--ql-accent); -} - -.waypoint-size { - position: relative; - font-family: var(--ql-font-display); - font-size: 0.78rem; - font-weight: 700; -} - -.waypoint-text { - display: flex; - flex-direction: column; - align-items: inherit; - margin-top: 0.35rem; - color: var(--ql-text); -} - -.waypoint-title { - font-size: 0.84rem; - line-height: 1.3; -} - -.waypoint a:hover .waypoint-title { - color: var(--ql-accent); - text-decoration: underline; -} - -.waypoint-status { - font-size: 0.76rem; - font-weight: 600; - line-height: 1.4; -} - -.map-legend { - margin-top: 1.25rem; - font-size: 0.88rem; -} - -.map-legend p { - margin: 0.25rem 0 0; -} - -.legend-sizes { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.4rem; -} - -.offer-amount { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0; - font-family: var(--ql-font-display); - font-size: 1.6rem; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -/* Docs */ - -.docs { - padding-block: 2rem 1rem; -} - -.doc-list { - list-style: none; - padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); - gap: 1rem; -} - -.doc-card { - display: grid; - gap: 0.25rem; - height: 100%; - text-decoration: none; - color: var(--ql-text); - padding-block: 1.25rem; - transition: border-color 120ms; -} - -.doc-card:hover { - border-color: var(--ql-rule-strong); -} - -.doc-title { - font-family: var(--ql-font-display); - font-size: 1.5rem; - font-weight: 700; - color: var(--ql-text); -} - -.doc-card:hover .doc-title { - color: var(--ql-accent); -} - -.doc-nav { - display: flex; - justify-content: space-between; - margin-bottom: 1rem; - font-size: 0.95rem; -} - -.prose { - max-width: 52rem; - margin-inline: auto; -} - -.prose > h1:first-child { - margin-top: 0; -} - -.prose h1 { - font-size: clamp(2.2rem, 5vw, 3rem); -} - -.prose h2 { - font-size: 1.75rem; -} - -.prose pre { - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 4px; - padding: 0.9rem 1rem; - margin-block: 1rem; -} - -.prose table { - border-collapse: collapse; - margin-block: 1rem; - display: block; - overflow-x: auto; -} - -.prose th, -.prose td { - border: 1px solid var(--ql-rule); - padding: 0.4rem 0.75rem; - text-align: left; -} - -.prose th { - background: var(--ql-raised); -} - -.lost { - padding-block: 4rem; - text-align: center; -} - -/* Breadcrumbs */ - -.crumbs ol { - display: flex; - flex-wrap: wrap; - gap: 0.25rem 0.5rem; - list-style: none; - margin: 0; - padding: 1rem 0 0; - font-size: 0.92rem; - color: var(--ql-muted); -} - -.crumbs li + li::before { - content: "/"; - margin-right: 0.5rem; - color: var(--ql-rule-strong); -} - -.crumbs a { - color: var(--ql-muted); -} - -/* Board */ - -.board { - display: grid; - gap: 2rem; - padding-block: 2.5rem 1rem; -} - -.board-head h1 { - margin: 0.25rem 0 0.5rem; -} - -.board-head .lede { - max-width: 48rem; - margin: 0 0 1.25rem; - font-size: 1.15rem; - font-weight: 400; -} - -.tally { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - list-style: none; - margin: 0; - padding: 0; -} - -.tally a { - display: flex; - align-items: center; - gap: 0.4rem; - min-height: 2.25rem; - background: var(--ql-panel); - border: 1px solid var(--ql-rule); - border-radius: 4px; - padding: 0.3rem 0.8rem 0.3rem 0.5rem; - font-size: 0.95rem; - color: inherit; - text-decoration: none; -} - -.tally li:first-child a { - padding-left: 0.8rem; - color: var(--ql-text); -} - -.tally a:hover { - border-color: var(--ql-rule-strong); -} - -.tally a[aria-current] { - border-color: var(--ql-accent); - background: var(--ql-raised); -} - -.milestone-head h2 a { - color: var(--ql-text); - text-decoration: none; -} - -.milestone-head h2 a:hover { - color: var(--ql-accent); -} - -.tally .ql-marker { - min-width: 0.9rem; -} - -.as-of { - margin: 1rem 0 0; - font-size: 0.9rem; -} - -.board h2 { - margin: 0 0 0.25rem; -} - -.board .log { - margin-top: 1rem; -} - -.milestone-summary { - max-width: 48rem; - margin: 0.25rem 0 1rem; -} - -.progress { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 1rem; - margin-block: 0.75rem; -} - -/* An experience bar: a thin track with a lit fill. */ -.progress-bar { - flex: 1 1 14rem; - max-width: 32rem; - height: 0.5rem; - background: var(--ql-raised); - border-radius: 999px; - overflow: hidden; -} - -.progress-bar span { - display: block; - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, var(--ql-accent-dim), var(--ql-accent)); - box-shadow: 0 0 12px rgb(255 197 61 / 0.5); -} - -.progress-text { - margin: 0; - color: var(--ql-muted); - font-size: 0.95rem; -} - -.progress-text strong { - color: var(--ql-text); -} - -a.entry-title { - color: var(--ql-text); - text-decoration: none; -} - -a.entry-title:hover { - color: var(--ql-accent); - text-decoration: underline; -} - -.entry-why { - grid-column: 2; - font-size: 0.92rem; -} - -.epic { - border-top: 1px solid var(--ql-rule); - padding-top: 0.9rem; - margin-top: 0.25rem; -} - -.epic-head { - display: flex; - flex-wrap: wrap; - align-items: baseline; - justify-content: space-between; - gap: 0.25rem 1rem; -} - -.epic-head h3 { - margin: 0; - font-size: 1.4rem; -} - -.epic-head h3 a { - color: var(--ql-text); - text-decoration: none; -} - -.epic-head h3 a:hover { - color: var(--ql-accent); -} - -.epic-count { - font-size: 0.9rem; -} - -.epic > .log { - margin: 0.5rem 0 0.5rem 0.4rem; - padding-left: 1rem; - border-left: 1px solid var(--ql-rule); -} - -.board-foot { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2rem; - align-items: start; -} - -/* Quest page */ - -.quest-view { - display: grid; - grid-template-columns: minmax(0, 1fr) 20rem; - gap: 2rem; - align-items: start; - padding-block: 1.25rem 1rem; -} - -.quest-title { - grid-column: 1 / -1; -} - -.quest-title h1 { - display: flex; - align-items: center; - gap: 0.5rem; - margin: 0.5rem 0 0.25rem; - font-size: clamp(2.1rem, 5vw, 3rem); -} - -.quest-title h1 .ql-marker:empty { - display: none; -} - -.quest-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem; - margin: 0; - font-weight: 600; -} - -.quest-page h2 { - font-size: 1.1rem; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--ql-muted); - margin: 1.75rem 0 0.5rem; - border-bottom: 1px solid var(--ql-rule); - padding-bottom: 0.3rem; -} - -.quest-page h3 { - font-size: 1.2rem; - margin: 1.25rem 0 0.25rem; -} - -.quest-page > h2:first-child { - margin-top: 0; -} - -.quest-body > :first-child { - margin-top: 0; -} - -.quest-body li + li { - margin-top: 0.35rem; -} - -.quest-side { - display: grid; - gap: 1.25rem; -} - -.panel { - padding: 1.5rem 1.25rem; -} - -.panel h2 { - margin: 0 0 0.5rem; - font-size: 1.35rem; - text-transform: uppercase; -} - -.panel p { - margin: 0 0 0.75rem; -} - -.panel .button { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -.panel .button:disabled { - cursor: not-allowed; - opacity: 0.6; -} - -.panel form { - margin: 0; -} - -.panel .paste-box { - flex-direction: column; - align-items: stretch; - padding: 0.75rem; -} - -.note-small { - margin-top: 0.5rem !important; - font-size: 0.9rem; -} - -.panel-links { - padding-top: 1rem; - border-top: 1px solid var(--ql-rule); - text-align: center; - font-size: 0.95rem; -} - -@media (max-width: 52rem) { - .hero, - .split, - .board-foot, - .quest-view { - grid-template-columns: minmax(0, 1fr); - } - - .hero { - padding-top: 2.5rem; - } - - /* What to do next comes before the quest's full text. */ - .quest-title { - order: -2; - } - - .quest-side { - order: -1; - } - - .ql-panel { - padding: 1.5rem 1.25rem; - } -} - -/* On a phone the header scrolls away, and the map turns a quarter: each path runs down the page. */ -@media (max-width: 40rem) { - .site-header { - 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; - } - - .trail { - flex-direction: column; - row-gap: 0; - } - - .waypoint { - width: auto; - } - - .waypoint a { - flex-direction: row; - align-items: flex-start; - gap: 0.6rem; - padding: 0.3rem 0; - text-align: left; - } - - .waypoint::before { - top: 0; - bottom: 0; - left: 1.25rem; - right: auto; - border-top: 0; - border-left: 2px solid var(--ql-rule); - } - - .waypoint:first-child::before { - top: 1.55rem; - left: 1.25rem; - } - - .waypoint:last-child::before { - bottom: calc(100% - 1.55rem); - } - - .waypoint-text { - margin-top: 0.3rem; - } -} - -/* Project setup uses the same panels, quest rows, and theme tokens as accounts. */ -.projects > section { - margin-block: 1.5rem; -} -.project-select { - display: grid; - gap: 0.75rem; - max-width: 30rem; -} -.project-select input { - min-width: 0; - padding: 0.65rem; - color: var(--ql-text); - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 4px; - font: inherit; -} -.project-select button { - justify-self: start; -} - -/* The decision inbox: one active decision thread, phone first. On a wide - screen the agent cards sit beside the decisions; on a phone they follow them. */ -.inbox { - display: grid; - grid-template-columns: minmax(0, 46rem); - column-gap: 2.5rem; - align-items: start; -} -@media (min-width: 72rem) { - .inbox { - grid-template-columns: minmax(0, 46rem) minmax(18rem, 1fr); - /* Extra height from a long card list goes below the decisions. */ - grid-template-rows: auto 1fr; - } - .agents { - grid-column: 2; - grid-row: 1 / span 2; - } -} -.inbox-main > section, -.inbox-rest > section, -.agents { - margin-block: 1.75rem; -} -.agents > h2 { - margin-top: 0; -} -.agent { - display: grid; - gap: 0.4rem; - padding: 0.8rem 0.9rem; - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-left: 2px solid var(--ql-rule-strong); - border-radius: 4px; -} -.agent-waiting, -.agent-limited, -.agent-offline { - border-left-color: var(--ql-accent); -} -.agent h3 { - margin: 0; - font-size: 1rem; - overflow-wrap: anywhere; -} -.agent p { - margin: 0; -} -/* Agent text keeps its line breaks but stays escaped text. */ -.agent-summary { - white-space: pre-wrap; - overflow-wrap: anywhere; -} -.agent-output summary { - cursor: pointer; - font-size: 0.92rem; -} -.agent-output pre { - max-height: 24rem; - overflow: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - font-size: 0.8rem; -} -.decision { - display: grid; - gap: 0.75rem; - margin-block: 1rem 1.5rem; -} -.decision h2 { - margin: 0; - font-size: 1.5rem; - overflow-wrap: anywhere; -} -.decision h3 { - margin: 0 0 0.25rem; - font-size: 1rem; - text-transform: uppercase; - letter-spacing: 0.06em; -} -.decision p { - margin: 0; -} -/* Agent text keeps its line breaks but stays escaped text. */ -.decision .summary p, -.history p { - white-space: pre-wrap; - overflow-wrap: anywhere; -} -/* Entry meta sits in a log row's second column; in the thread it spans the one column. */ -.decision-meta { - grid-column: auto; - color: var(--ql-muted); -} -.decision-form { - display: grid; - gap: 1rem; -} -.decision-form button { - justify-self: start; -} -.ask { - display: grid; - gap: 0.5rem; - margin: 0; - padding: 0; - border: 0; -} -.ask legend { - padding: 0; - margin-bottom: 0.5rem; - font-weight: 600; - white-space: pre-wrap; - overflow-wrap: anywhere; -} -/* Each choice is a full-width tap target. */ -.choice { - display: grid; - grid-template-columns: 1.25rem 1fr; - gap: 0.6rem; - align-items: start; - padding: 0.7rem 0.8rem; - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 4px; - cursor: pointer; -} -.choice:has(input:checked) { - border-color: var(--ql-accent); -} -.choice input { - margin: 0.2rem 0 0; - accent-color: var(--ql-accent); -} -.choice > span { - display: grid; - gap: 0.2rem; - overflow-wrap: anywhere; -} -.choice-why { - color: var(--ql-muted); - font-size: 0.92rem; - white-space: pre-wrap; -} -.tag { - justify-self: start; - padding: 0.05rem 0.4rem; - font: 600 0.75rem/1.4 var(--ql-font-display); - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ql-bg); - background: var(--ql-accent); - border-radius: 2px; -} -.ask .free { - font-size: 0.92rem; - color: var(--ql-muted); -} -.ask textarea { - min-width: 0; - padding: 0.6rem; - color: var(--ql-text); - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 4px; - font: inherit; - resize: vertical; -} -.decision-actions, -.project-actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; -} -.decision-actions form, -.project-actions form { - margin: 0; -} -.decision-actions > p { - flex-basis: 100%; -} -.history summary, -.skipped summary, -.new-plan summary, -.pin summary { - cursor: pointer; -} -/* The new plan box opens under its project, across the row. */ -.new-plan { - display: block; -} -.new-plan[open] summary { - margin-bottom: 0.75rem; -} -/* Where a new job runs is secondary, so it stays folded and small. */ -/* A fieldset is as wide as its content by default; long account names must wrap, not widen the page. */ -.new-plan .ask, -.pin { - min-width: 0; -} -.pin { - display: grid; - gap: 0.4rem; - font-size: 0.92rem; - color: var(--ql-muted); -} -.pin select { - width: 100%; - min-width: 0; - padding: 0.5rem; - color: var(--ql-text); - background: var(--ql-bg); - border: 1px solid var(--ql-rule); - border-radius: 4px; - font: inherit; -} -.history ol { - display: grid; - gap: 0.5rem; - padding-left: 1.25rem; -} -.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 deleted file mode 100644 index 36afc7ce..00000000 --- a/cloudflare/src/sw.js +++ /dev/null @@ -1,66 +0,0 @@ -// 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 deleted file mode 100644 index 8c381727..00000000 --- a/cloudflare/test/app.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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/autostart.test.ts b/cloudflare/test/autostart.test.ts deleted file mode 100644 index 18e12921..00000000 --- a/cloudflare/test/autostart.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Repo } from "../dev/github"; -import type { ProjectState } from "../src/github/state"; -import { app } from "../src/index"; -import { autostart, candidates, digest, milestones, optIns, save } from "../src/jobs/autostart"; -import * as store from "../src/jobs/store"; -import { privateKey } from "./github-key"; -import { origin } from "./login-fixtures"; -import { skip } from "../src/inbox/model"; -import { documents, head, maintainerOf, project } from "./project-fixtures"; - -const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; -const db = env.DB; - -describe("choosing what to start", () => { - const doc = (path: string, body: string) => ({ path, content: `# [S] ${path}\n\n## Goal\n\nDo it.\n${body}` }); - const documents = [ - { path: "quest/README.md", content: "# Project\n\n## Goal\n\nBuild it.\n" }, - { path: "quest/m0/README.md", content: "# Now\n\n## Goal\n\nFirst.\n" }, - { path: "quest/m1/README.md", content: "# Next\n\n## Goal\n\nLater.\n" }, - doc("quest/m1/a.md", ""), - doc("quest/m1/b.md", ""), - doc("quest/m1/c.md", "\n## Required\n\n- [B](/quest/m1/b.md) - first\n"), - doc("quest/m0/d.md", ""), - doc("quest/m0/claimed.md", ""), - ]; - const state = (available: string[] | null) => ({ project: { documents, available } }) as unknown as ProjectState; - const all = { milestones: null }; - const none = new Set(); - - it("ranks unclaimed ready quests as quest unblocked does, within the opted-in milestones", () => { - const available = ["quest/m0/d.md", "quest/m1/a.md", "quest/m1/b.md"]; - // Milestone first, then downstream impact: b unblocks c. - expect(candidates(state(available), all, none, none)).toEqual([ - "quest/m0/d.md", - "quest/m1/b.md", - "quest/m1/a.md", - ]); - expect(candidates(state(available), { milestones: ["m1"] }, none, none)).toEqual([ - "quest/m1/b.md", - "quest/m1/a.md", - ]); - // Unknown occupancy starts nothing. - expect(candidates(state(null), all, none, none)).toEqual([]); - }); - - it("leaves a quest that already had a job, or that the maintainer skipped at its current text", () => { - const available = ["quest/m0/d.md", "quest/m1/b.md"]; - const d = documents.find((x) => x.path === "quest/m0/d.md")!.content; - expect(candidates(state(available), all, new Set(["quest/m0/d.md"]), none)).toEqual(["quest/m1/b.md"]); - expect(candidates(state(available), all, none, new Set([`quest/m0/d.md\n${digest(d)}`]))).toEqual([ - "quest/m1/b.md", - ]); - // A skip of older text lapses once the quest changes. - expect(candidates(state(available), all, none, new Set(["quest/m0/d.md\nold"]))).toHaveLength(2); - }); - - it("reads a milestone list, refusing anything else", () => { - expect(milestones(undefined)).toBeNull(); - expect(milestones("m0, m1 m0")).toEqual(["m0", "m1"]); - expect(() => milestones("m0,../x")).toThrow("Name milestones like m0, m1"); - }); -}); - -describe("starting ready quests on free slots", () => { - let user = ""; - let cookie = ""; - let repo: Repo; - beforeEach(async () => { - ({ user, cookie, repo } = await maintainerOf()); - }); - afterEach(() => vi.restoreAllMocks()); - const sockets: WebSocket[] = []; - afterEach(() => { - for (const ws of sockets.splice(0)) ws.close(); - }); - - function opt(fields: [string, string][]) { - return app.request( - `${origin}/projects/github/upstream/project/autostart`, - { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) }, - testEnv, - ); - } - async function connect(slots: number) { - const { token } = await store.createRunner(db, user, "laptop", "linux", false, 0); - const response = await app.request( - `${origin}/api/runner/connect`, - { headers: { Authorization: `Bearer ${token}`, Upgrade: "websocket" } }, - testEnv, - ); - const ws = response.webSocket!; - ws.accept(); - sockets.push(ws); - const inbox: any[] = []; - const waiting: ((message: any) => void)[] = []; - ws.addEventListener("message", (event) => { - const message = JSON.parse(event.data as string); - const next = waiting.shift(); - if (next) next(message); - else inbox.push(message); - }); - const next = (): Promise => - inbox.length ? Promise.resolve(inbox.shift()) : new Promise((resolve) => waiting.push(resolve)); - const account = { id: "main", provider: "anthropic", kind: "subscription", label: "Main", limited: false }; - ws.send(JSON.stringify({ id: 1, type: "status", accounts: [account], attempts: [], slots })); - expect(await next()).toMatchObject({ type: "reply", id: 1 }); - return next; - } - const jobs = async () => - (await db.prepare("SELECT target,workflow,actor FROM jobs WHERE repository=10").all()).results; - - it("starts the next ready quest once opted in, and only once", async () => { - const next = await connect(2); - // Without an opt-in, a free slot starts nothing. - expect(await autostart(testEnv, user, 2)).toBe(0); - const saved = await opt([["enabled", "1"]]); - expect(saved.status).toBe(303); - expect(await next()).toMatchObject({ - type: "assign", - job: { workflow: "start", target: "quest/first.md", attempt: 1 }, - instructions: expect.stringContaining("worth starting"), - }); - expect(await jobs()).toEqual([{ target: "quest/first.md", workflow: "start", actor: user }]); - // A slot is still free, but nothing else is ready, and a quest with a job is not started again. - expect(await autostart(testEnv, user, 1)).toBe(0); - const [{ id }] = (await db.prepare("SELECT id FROM jobs").all<{ id: string }>()).results; - await store.cancel(db, id!, user, Date.now()); - expect(await autostart(testEnv, user, 1)).toBe(0); - expect(await jobs()).toHaveLength(1); - }); - - it("keeps to the chosen milestones and stops when opted out", async () => { - expect( - ( - await opt([ - ["enabled", "1"], - ["milestones", "m3"], - ]) - ).status, - ).toBe(303); - expect(await db.prepare("SELECT milestones FROM autostart WHERE user=?").bind(user).first()).toEqual({ - milestones: '["m3"]', - }); - expect(await autostart(testEnv, user, 1)).toBe(0); - expect((await opt([["enabled", "0"]])).status).toBe(303); - expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); - expect((await opt([["enabled", "maybe"]])).status).toBe(409); - }); - - it("creates no start the maintainer opted out of while it read GitHub", async () => { - await save(db, 10, user, { milestones: null }, 0); - const github = vi.mocked(globalThis.fetch).getMockImplementation()!; - vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { - // The flake read is the last GitHub request before the job is created. - if (String(input).includes("/contents/flake.nix")) await save(db, 10, user, null, 0); - return github(input, init); - }); - expect(await autostart(testEnv, user, 1)).toBe(0); - expect(await jobs()).toEqual([]); - // Narrowing the milestones works the same way, checked in the same insert. - await save(db, 10, user, { milestones: ["m3"] }, 0); - const start = { - repository: 10, - actor: user, - workflow: "start", - target: "quest/m0/x.md", - revision: head, - branch: "quest/m0/x", - trusted: true, - automatic: { version: "v1" }, - } as const; - await expect(store.createJob(db, start, 0)).rejects.toThrow(store.NotOptedIn); - await save(db, 10, user, { milestones: ["m0"] }, 0); - expect((await store.createJob(db, start, 0)).created).toBe(true); - }); - - it("creates no start the maintainer skipped, or another pass started, while it read GitHub", async () => { - await save(db, 10, user, { milestones: null }, 0); - const quest = documents.find((d) => d.path === "quest/first.md")!; - const github = vi.mocked(globalThis.fetch).getMockImplementation()!; - let during = async () => {}; - vi.mocked(globalThis.fetch).mockImplementation(async (input, init) => { - if (String(input).includes("/contents/flake.nix")) await during(); - return github(input, init); - }); - // A skip at the quest's current text commits while the pass reads GitHub. - during = async () => void (await skip(db, user, 10, quest.path, digest(quest.content))); - expect(await autostart(testEnv, user, 1)).toBe(0); - expect(await jobs()).toEqual([]); - // A skip at an older text no longer applies. - await skip(db, user, 10, quest.path, "stale"); - // Another pass's start commits and is cancelled while this one reads GitHub. - during = async () => { - during = async () => {}; - const start = { ...startOf(quest.path), automatic: { version: digest(quest.content) } }; - const { id } = await store.createJob(db, start, 0); - await store.cancel(db, id, user, 0); - }; - expect(await autostart(testEnv, user, 1)).toBe(0); - expect(await jobs()).toHaveLength(1); - }); - - function startOf(target: string) { - return { - repository: 10, - actor: user, - workflow: "start", - target, - revision: head, - branch: target.replace(/\.md$/, ""), - trusted: true, - } as const; - } - - it("takes turns across projects, the least recently started first", async () => { - await project(11, user); - await save(db, 10, user, { milestones: null }, 0); - await save(db, 11, user, { milestones: null }, 1); - expect((await optIns(db, user)).map((o) => o.project.id)).toEqual([10, 11]); - await store.createJob( - db, - { - repository: 10, - actor: user, - workflow: "start", - target: "quest/x.md", - revision: head, - branch: "quest/x", - trusted: true, - }, - 5, - ); - expect((await optIns(db, user)).map((o) => o.project.id)).toEqual([11, 10]); - }); - - it("ends an opt-in with its membership, so regaining access never resumes it", async () => { - await save(db, 10, user, { milestones: null }, 0); - await db.prepare("DELETE FROM github_project_members WHERE repository=10 AND user=?").bind(user).run(); - expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); - }); - - it("ends the opt-in of a maintainer who lost their role", async () => { - await opt([["enabled", "1"]]); - await db.prepare("DELETE FROM jobs").run(); - for (const login of Object.keys(repo.roles!)) repo.roles![login] = "read"; - expect(await autostart(testEnv, user, 1)).toBe(0); - expect(await db.prepare("SELECT 1 FROM autostart").first()).toBeNull(); - expect(await jobs()).toEqual([]); - }); - - it("marks a handed-back draft ready at the head on screen", async () => { - const pr = repo.pulls[0]!; - pr.draft = true; - const ready = (sha: string) => - app.request( - `${origin}/projects/github/upstream/project/pulls/2/ready`, - { - method: "POST", - headers: { Cookie: cookie, Origin: origin }, - body: new URLSearchParams([["head", sha]]), - }, - testEnv, - ); - expect((await ready("e".repeat(40))).status).toBe(403); - expect(pr.draft).toBe(true); - // Only a draft handed back as GitHub has it now, such as after a stale form, and held by no session. - pr.body = "Still on it."; - expect((await ready(head)).status).toBe(403); - pr.body = "Handed back.\n\n## Waiting\n\n- Maintainer: accept it as is\n"; - pr.labels = ["quest:active"]; - expect((await ready(head)).status).toBe(403); - expect(pr.draft).toBe(true); - pr.labels = []; - // A start job that published this draft keeps its quest as its target, yet may still push to it. - const { id } = await store.createJob(db, startOf("quest/first.md"), 0); - await store.delivered(db, id, 2); - expect((await ready(head)).status).toBe(409); - expect(pr.draft).toBe(true); - await store.cancel(db, id, user, 0); - // So may a job on the pull request itself. - const iterate = await store.createJob( - db, - { ...startOf("quest/first.md"), workflow: "iterate", target: "pulls/2", branch: pr.branch }, - 0, - ); - expect((await ready(head)).status).toBe(409); - await store.cancel(db, iterate.id, user, 0); - expect((await ready(head)).status).toBe(303); - expect(pr.draft).toBe(false); - }); -}); diff --git a/cloudflare/test/board.test.ts b/cloudflare/test/board.test.ts deleted file mode 100644 index cc541f5d..00000000 --- a/cloudflare/test/board.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { finished } from "../scripts/history"; -import { render } from "../src/board/markdown"; -import { type Board, blobHref, findDoc, findGroup, only, questHref, readBoard, trail } from "../src/board/model"; -import type { Project } from "../src/board/project"; - -const docs: Record = { - "quest/README.md": "# Quests\n\n## Goal\n\nShip it.\n", - "quest/m0/README.md": "# First release\n\n## Goal\n\nThe *first* release.\n", - "quest/m0/epic/README.md": "# An epic\n\n## Goal\n\nGrouped work.\n", - "quest/m0/epic/ready.md": "# [S] Ready quest\n\n## Goal\n\nDo it.\n", - "quest/m0/epic/blocked.md": - "# [M] Blocked quest\n\n## Goal\n\nLater.\n\n## Required\n\n- [Ready](/quest/m0/epic/ready.md) - needs it\n", - "quest/m0/epic/taken.md": - "# [XL] Taken quest\n\n## Goal\n\nMine.\n\n## Claim\n\n- Jane Doe (github:jdoe) on https://example.com/jdoe/repo since 2026-10-02\n", - "quest/m0/epic/turned.md": "# [XS] Turned-in quest\n\n## Goal\n\nDone soon.\n", - "quest/m0/solo.md": "# [L] Solo quest\n\n## Goal\n\nAlone.\n", - "quest/m0/loose.md": "# [S] Loose quest\n\n## Goal\n\nNo blockers either.\n", -}; - -function project(overrides: Partial = {}): Project { - return { - name: "demo", - title: "acme/demo", - web: "https://example.com/acme/demo", - commit: "a".repeat(40), - date: "2026-10-07", - documents: Object.entries(docs).map(([path, content]) => ({ path, content })), - completed: [ - { path: "quest/m0/epic/old.md", title: "[L] Old quest", date: "2026-10-01", commit: "b".repeat(40) }, - ], - changes: [{ quest: "quest/m0/epic/turned.md", author: "@sam", href: "/repos/demo/changes/sam" }], - issues: [], - ...overrides, - }; -} - -const status = (board: Board, path: string) => board.quests.get(path)?.status; - -describe("board model", () => { - const board = readBoard(project()); - - it("uses open changes and dependencies, ignoring legacy Claim sections", () => { - expect(status(board, "quest/m0/epic/ready.md")).toEqual({ kind: "available" }); - expect(status(board, "quest/m0/epic/blocked.md")).toEqual({ - kind: "blocked", - requires: [{ title: "Ready quest", href: "/repos/demo/quest/m0/epic/ready" }], - }); - expect(status(board, "quest/m0/epic/taken.md")).toEqual({ kind: "available" }); - expect(status(board, "quest/m0/epic/turned.md")?.kind).toBe("turn-in"); - expect(board.counts).toEqual({ available: 4, pending: 0, blocked: 1, "turn-in": 1, accepted: 0 }); - }); - - it("groups milestones and epics by their directories, in path order", () => { - const [milestone] = board.milestones; - expect(milestone).toMatchObject({ number: "0", title: "First release", summary: "The first release." }); - expect(milestone.items.map((item) => (item.kind === "quest" ? item.quest.path : item.epic.path))).toEqual([ - "quest/m0/epic/README.md", - "quest/m0/loose.md", - "quest/m0/solo.md", - ]); - expect(trail(board, "quest/m0/epic/ready.md").map((group) => group.title)).toEqual([ - "First release", - "An epic", - ]); - expect(trail(board, "quest/m0/epic/README.md").map((group) => group.title)).toEqual(["First release"]); - }); - - it("weights progress by difficulty and counts finished quests from history", () => { - // Finished: L (5). Open: S 2, M 3, XL 8, XS 1 in the epic; L 5 and S 2 outside it. - expect(findGroup(board, "quest/m0/epic/README.md")!.progress).toEqual({ - done: 1, - total: 5, - weight: 5, - totalWeight: 19, - }); - expect(board.milestones[0].progress).toEqual({ done: 1, total: 7, weight: 5, totalWeight: 26 }); - expect(board.finished[0]).toMatchObject({ name: "Old quest", size: "L", milestone: "Milestone 0" }); - }); - - it("filters by status, dropping empty epics", () => { - const items = only(board.milestones[0].items, "blocked"); - expect(items).toHaveLength(1); - expect(items[0].kind === "epic" && items[0].epic.items.length).toBe(1); - }); - - it("names an epic's page by its branch, not its directory", () => { - expect(questHref(project(), "quest/m0/epic/README.md")).toBe("/repos/demo/quest/m0/epic/README"); - expect(findDoc(board, "quest/m0/epic/README")?.path).toBe("quest/m0/epic/README.md"); - expect(findDoc(board, "quest/m0/epic")).toBeNull(); - }); - - it("encodes each board and forge path segment", () => { - const source = project(); - const path = "quest/m0/an epic#/name#?%.md"; - expect(questHref(source, path)).toBe("/repos/demo/quest/m0/an%20epic%23/name%23%3F%25"); - expect(questHref(source, "quest/m0/an epic#/README.md")).toBe("/repos/demo/quest/m0/an%20epic%23/README"); - expect(blobHref(source, path)).toBe( - `https://example.com/acme/demo/blob/${source.commit}/quest/m0/an%20epic%23/name%23%3F%25.md`, - ); - expect(blobHref(source, path, "#goal")).toBe( - `https://example.com/acme/demo/blob/${source.commit}/quest/m0/an%20epic%23/name%23%3F%25.md#goal`, - ); - expect(blobHref(project({ web: null }), path)).toBeNull(); - }); -}); - -describe("quest markdown", () => { - const known = (path: string) => path in docs; - - it("escapes HTML and drops unsafe links and images", () => { - const [goal] = render( - project(), - "quest/m0/solo.md", - '# T\n\n## Goal\n\n\n\n[x](javascript:alert(1)) ![pic](https://example.com/p.png) b\n', - known, - ); - expect(goal.heading).toBe("Goal"); - expect(goal.html).not.toContain(" { - for (const tag of ["code", "pre", "kbd", "script"]) { - for (const close of [``, ""]) { - const [goal] = render( - project(), - "quest/m0/solo.md", - `## Goal\n\na <${tag}> bold\n`, - known, - ); - expect(goal.html).not.toMatch(/<(img|b)\b/); - } - } - }); - - it("opens quest links on the board and other files where the repository is published", () => { - const [plan] = render( - project(), - "quest/m0/solo.md", - "## Plan\n\n[epic](/quest/m0/epic/README.md#goal) [ready](epic/ready.md) [docs](../../docs/a.md) [web](https://example.com)\n", - known, - ); - expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/README#goal"'); - expect(plan.html).toContain('href="/repos/demo/quest/m0/epic/ready"'); - expect(plan.html).toContain(`href="https://example.com/acme/demo/blob/${project().commit}/docs/a.md"`); - expect(plan.html).toContain('href="https://example.com"'); - const [unpublished] = render( - project({ web: null }), - "quest/m0/solo.md", - "## Plan\n\n[docs](/docs/a.md)\n", - known, - ); - expect(unpublished.html).toBe("

    docs

    \n"); - }); - - it("keeps fragments on forge links at the snapshot commit", () => { - const source = project(); - const [plan] = render(source, "quest/m0/solo.md", "## Plan\n\n[docs](/docs/a.md#goal)\n", known); - expect(plan.html).toContain(`href="https://example.com/acme/demo/blob/${source.commit}/docs/a.md#goal"`); - }); -}); - -describe("finished quests from history", () => { - const commit = (sha: string, date: string, ...changes: string[]) => `\0${sha} ${date}\n\n${changes.join("\n")}\n`; - const titles: Record = { - "old^:quest/a0/epic/done.md": "# [M] Done before the rename\n", - "plan^:quest/a0/dropped.md": "# [S] Dropped\n", - }; - const read = (rev: string, path: string) => titles[`${rev}:${path}`]; - - it("counts deletions that shipped work, following later directory renames", () => { - const log = [ - commit("rename", "2026-10-09", "R100\tquest/a0/README.md\tquest/m0/README.md", "M\tsrc/a.ts"), - commit("plan", "2026-10-08", "D\tquest/a0/dropped.md"), - commit("old", "2026-10-07", "D\tquest/a0/epic/done.md", "M\tsrc/b.ts", "D\tquest/AGENTS.md"), - ].join(""); - const now = new Set(["quest/README.md", "quest/m0/README.md", "quest/m0/epic/README.md"]); - expect(finished(log, now, read)).toEqual([ - { path: "quest/m0/epic/done.md", title: "[M] Done before the rename", date: "2026-10-07", commit: "old" }, - ]); - }); - - it("follows a rename made in the same commit that finishes a quest", () => { - // Git lists changes by path, so the deletion can come before the rename. - const log = commit( - "rename", - "2026-10-09", - "D\tquest/a0/a.md", - "R100\tquest/a0/b.md\tquest/m0/b.md", - "M\tsrc/a.ts", - ); - const read = () => "# [S] Finished with the rename\n"; - expect(finished(log, new Set(["quest/m0/b.md"]), read).map((c) => c.path)).toEqual(["quest/m0/a.md"]); - }); - - it("skips a quest that still exists, moved too far for Git to pair the rename", () => { - const log = commit("move", "2026-10-09", "D\tquest/m0/a.md", "A\tquest/m0/epic/a.md", "M\tsrc/a.ts"); - const read = () => "# [S] Moved\n"; - expect(finished(log, new Set(["quest/m0/a.md"]), read)).toEqual([]); - }); -}); diff --git a/cloudflare/test/core.test.ts b/cloudflare/test/core.test.ts deleted file mode 100644 index f5c1da72..00000000 --- a/cloudflare/test/core.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { check, parse, ready } from "quest/core"; -import { describe, expect, it } from "vitest"; - -// The Worker shares the CLI's core. This runs it inside workerd, the runtime -// the Worker deploys to. `tsc -p src/core` is what keeps Node APIs out of it. -describe("quest/core", () => { - it("checks a tree and finds ready work", () => { - const docs = [ - parse("quest/README.md", "# Quests\n\n## Goal\n\nShip it.\n"), - parse("quest/one.md", "# [S] One\n\n## Goal\n\nThe first quest.\n"), - ]; - const paths = new Set(docs.map((doc) => doc.path)); - expect(check(docs, (path) => paths.has(path))).toEqual([]); - expect(ready(docs)).toEqual(["quest/one.md"]); - }); - - it("reports a quest and an epic that share a path", () => { - const docs = [ - parse("quest/README.md", "# Quests\n\n## Goal\n\nShip it.\n"), - parse("quest/one.md", "# [S] One\n\n## Goal\n\nA quest.\n"), - parse("quest/one/README.md", "# [S] Epic\n\n## Goal\n\nAn epic's remaining work.\n"), - ]; - const paths = new Set(docs.map((doc) => doc.path)); - expect(check(docs, (path) => paths.has(path))).toEqual([ - { - path: "quest/one.md", - line: null, - message: "quest path collides with quest/one/README.md; rename one of these documents", - }, - ]); - }); -}); diff --git a/cloudflare/test/env.d.ts b/cloudflare/test/env.d.ts deleted file mode 100644 index 25e3a9e2..00000000 --- a/cloudflare/test/env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/github-key.ts b/cloudflare/test/github-key.ts deleted file mode 100644 index bc4e85a9..00000000 --- a/cloudflare/test/github-key.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Generated exclusively for tests; never a provider credential. PKCS#1, the -// "BEGIN RSA PRIVATE KEY" PEM format GitHub issues for App private keys. -export const privateKey = - "-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAmR256UAl070RKsD69kbSAIyq6Vry3VACqpoQArdIUxjVrVer\ninaukGNCwgXSEwbOgMvmUnoZZPL1t42rVrRMOX/mR5P5KGK7iEDf1LGc2siZeV+l\nIMqoze0R9xcqnhX7b5BI8o6Zrmtb4w0KtaO/XalETNkfftQ/hp7bRI2xsgJfyxYn\n9+gzPRDjVHmOuACCjw5wvDcWFw/TbnZuqf9pY76rBZb2BhrdIEfdiZG/mVS2BvE5\nIDnHfMbIadvglG6lDTFbUlICgt5vTX5Eu94iCsmAnJjc9v1gn4vnU5m7MW8RjqRf\njCXsCdB5cFmU8Lu9L074YRfOTHKbsyqCZIt00QIDAQABAoIBAEQ3WDKxH6k8HL/d\nhnHej4UrJqIcoQM0uFtllCiBm7lZ4Sj/XC+uPIubjYx/p15OtAXx0cWBHHs7O3nx\nphpU7XjYoLHXHTR++Gn17UMHdoiKwsWudRUs9RP5wP1C1vvjc0f7EmTuX020CxYX\nI2RVdda2qPVL7ppE2aCSecILFtXlRHhprXvGRktmzLkc5lLGoBBRxvkmWoKko54X\nHJOLYFBK+3JVmOUNAsA4/cqWY6wCo8qInBeqRfUtmD93fXA04nJyFOrhtaH9ZKh+\niwVpYRyGQIf/x1EwuvqGafRXpRnOx+qMUE4+xDjnEyMbG/sUvRtqldnfrUcE0G+J\nvCYVqvkCgYEAx9WGH69Hg8OiidaK+EJwZU6KNZ075Z0MyYNZbLbknODk9hkbGCGK\nBvxcyxiQOprZUIliBT3Up5bzmSnlGnXnH3T6OsbzhiW3pNdk/HfIXpOyP3NHF2gG\nNvZuI9a8uuhez9pNi8oFqQXqW5gL/mgwXjCA/TnxAKkVYJO2xwd/d18CgYEAxCa/\njeibDlOP+0HXYDCa8Z2UG1I1SYsmxhE/G7QVK9Yz29dGO4tCMRX61haOd5fulqrH\noFI3IebPk8nvZq0ta0Mg80tk7zBJb2sTcn3g2sDMQ4JKpAorS0zVIKobtndJuQWC\nPMptrTr3Y36Ae+C5T/LUoigRS3LptlLmSzDXcc8CgYBK6+A0YugCG8gFu70wpOA0\n60RgI4pAZShVKPzPCKcftiXBqtoAqxpv0hAMSTJR9pqBWnC4nFztVGgSRaXSzN+R\naK8keFD3O47lq6f+C/7AjslmhpbhzKt/0darcyghP1vEvAkU3vJOU5xxKMleYbu4\npKDH0ABuifYs1lTRvBrrKwKBgDAwqN0PaZDCfNwo1+Yc6uQzZ6CyuL7BvhvpPszq\nmEz1yjXXlLyqQO/YS5DvZ78AB7Dicjr0F/FvSE4xzWw3XbI9gsnjGwAcp94Eu3lk\nbaqwtT5cs0PL78kHLlFhGX/hnK4lKVMQFJP7qa269Yv5sO1wZkcUevNDXyHZChUt\nAbFHAoGAA6EDQSF+o5HnLxyzdR9urpvWuH4NcO3Y97lLoT4dBKRobK86iRmRJBA7\nVnmBllr82LikAyrWHuifxxsiUb3YhTAZ1W7jftySA+k1hAXLKMfIUy+a3Zwf90Lb\nI1fW2LKDJIAzwCLK4go4gtWEppZY83g4pg9GoKcmBLllNvqW1dw=\n-----END RSA PRIVATE KEY-----\n"; diff --git a/cloudflare/test/github.test.ts b/cloudflare/test/github.test.ts deleted file mode 100644 index da69e0a3..00000000 --- a/cloudflare/test/github.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { env } from "cloudflare:workers"; -import { createPublicKey, verify } from "node:crypto"; -import { privateKey } from "./github-key"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { app } from "../src/index"; -import { GitHubService, type GitHubRepository } from "../src/github/service"; -import { deliveryRetention } from "../src/github/webhook"; -import { outsider, owner } from "./owners"; -import { delivery } from "./project-fixtures"; - -const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; -const repo: GitHubRepository = { owner: "upstream", name: "project", id: 10 }; - -function installation(action: string) { - return { action, installation: { id: 5, app_id: 123 } }; -} -async function state() { - return env.DB.prepare("SELECT * FROM github_installations WHERE id=5").first<{ - blocked: number; - revision: number; - }>(); -} - -beforeEach(async () => { - await env.DB.batch([ - env.DB.prepare( - "INSERT INTO user(id,name,email,emailVerified,createdAt,updatedAt) VALUES('app-test','Tester','app-test@example.com',1,0,0)", - ), - env.DB.prepare( - "INSERT INTO account(id,accountId,providerId,userId,createdAt,updatedAt) VALUES('app-test-account','7','github','app-test',0,0)", - ), - ]); -}); - -describe("signed GitHub webhooks", () => { - it("verifies exact Unicode bytes before parsing or writing", async () => { - const payload = { ...installation("created"), note: "hello ๐ŸŒ" }; - expect( - (await delivery("installation", payload, undefined, { "X-Hub-Signature-256": "sha256=" + "0".repeat(64) })) - .status, - ).toBe(403); - expect(await state()).toBeNull(); - expect((await delivery("installation", payload)).status).toBe(202); - expect(await state()).toMatchObject({ blocked: 0, revision: 1 }); - }); - it("deduplicates concurrent deliveries and cannot replay an old creation over revocation", async () => { - const id = crypto.randomUUID(); - const responses = await Promise.all([ - delivery("installation", installation("created"), id), - delivery("installation", installation("created"), id), - ]); - expect(responses.every((r) => r.ok)).toBe(true); - expect(await state()).toMatchObject({ blocked: 0, revision: 1 }); - expect((await delivery("installation", installation("deleted"))).status).toBe(202); - expect(await (await delivery("installation", installation("created"), id)).json()).toEqual({ - status: "duplicate", - }); - expect(await state()).toMatchObject({ blocked: 1, revision: 2 }); - }); - it("rejects delivery-ID conflicts, wrong apps, malformed JSON and missing identities", async () => { - const id = crypto.randomUUID(); - await delivery("installation", installation("created"), id); - expect((await delivery("installation", installation("deleted"), id)).status).toBe(409); - expect( - (await delivery("installation", { action: "created", installation: { id: 5, app_id: 999 } })).status, - ).toBe(400); - expect((await delivery("installation", "{")).status).toBe(400); - expect((await delivery("push", { repository: { id: 10 } })).status).toBe(400); - }); - it("rejects conflicting delivery IDs even when their transactions race", async () => { - const id = crypto.randomUUID(); - const responses = await Promise.all([ - delivery("installation", installation("created"), id), - delivery("installation", installation("deleted"), id), - ]); - expect(responses.map((r) => r.status).sort()).toEqual([202, 409]); - expect((await state())?.revision).toBe(1); - }); - it("prunes deliveries past the retention window on insert", async () => { - const [expired, retained] = [crypto.randomUUID(), crypto.randomUUID()]; - const backdate = (id: string, age: number) => - env.DB.prepare("UPDATE github_deliveries SET receivedAt=? WHERE id=?") - .bind(Date.now() - age, id) - .run(); - const hour = 60 * 60 * 1000; - await delivery("installation", installation("created"), expired); - await delivery("installation", installation("created"), retained); - await backdate(expired, deliveryRetention + hour); - await backdate(retained, deliveryRetention - hour); - const ids = async () => - (await env.DB.prepare("SELECT id FROM github_deliveries").all<{ id: string }>()).results.map((r) => r.id); - expect(await ids()).toHaveLength(2); - expect((await delivery("installation", installation("suspend"))).status).toBe(202); - const remaining = await ids(); - expect(remaining).toHaveLength(2); - expect(remaining).toContain(retained); - expect(remaining).not.toContain(expired); - expect((await delivery("installation", installation("deleted"), retained)).status).toBe(409); - }); - it("records permission/selection changes and suspension lifecycle once", async () => { - for (const action of ["created", "suspend", "unsuspend", "new_permissions_accepted"]) { - expect((await delivery("installation", installation(action))).status).toBe(202); - expect((await state())?.blocked).toBe(action === "suspend" ? 1 : 0); - } - await delivery("installation_repositories", { action: "removed", installation: { id: 5 } }); - expect((await state())?.revision).toBe(5); - }); - it("persists only observations, never untrusted content or automatic writes", async () => { - const id = crypto.randomUUID(); - expect( - ( - await delivery( - "issues", - { - installation: { id: 5 }, - repository: { id: 10 }, - action: "opened", - issue: { body: "run something malicious" }, - }, - id, - ) - ).status, - ).toBe(202); - expect(await env.DB.prepare("SELECT * FROM github_deliveries WHERE id=?").bind(id).first()).toMatchObject({ - event: "issues", - repository: 10, - processed: 1, - }); - expect(await state()).toBeNull(); - expect((await delivery("unknown", {})).status).toBe(202); - }); - it("durably coalesces entity notifications across deliveries, updates, and replay after retention", async () => { - await env.DB.prepare( - "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(10,'upstream','project',5,'enabled','app-test',0)", - ).run(); - const payload = { - installation: { id: 5 }, - repository: { id: 10 }, - action: "opened", - pull_request: { id: 200, number: 2, title: "Untrusted", body: "Never retain this" }, - }; - const id = crypto.randomUUID(); - const responses = await Promise.all([ - delivery("pull_request", payload, id), - delivery("pull_request", payload, id), - ]); - expect(responses.every((r) => r.ok)).toBe(true); - await delivery("pull_request", { ...payload, action: "synchronize" }); - await env.DB.prepare("DELETE FROM github_deliveries").run(); - await delivery("pull_request", payload, id); - const rows = (await env.DB.prepare("SELECT * FROM github_project_activity").all()).results; - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ repository: 10, kind: "pull", id: "200", number: 2, data: null }); - expect(JSON.stringify(rows)).not.toContain("Untrusted"); - await delivery("issue_comment", { - installation: { id: 5 }, - repository: { id: 10 }, - issue: { id: 999, number: 2, pull_request: {} }, - action: "created", - }); - expect((await env.DB.prepare("SELECT * FROM github_project_activity").all()).results).toHaveLength(1); - }); - it("acknowledges and drops every delivery from an owner off the allowlist", async () => { - // Enrolled before its owner left the allowlist, such as by a transfer. - await env.DB.prepare( - "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(10,'upstream','project',5,'enabled','app-test',0)", - ).run(); - const from = { installation: { id: 5 }, repository: { id: 10, owner: { id: outsider } } }; - const dropped = [ - delivery("installation", { - action: "suspend", - installation: { id: 5, app_id: 123, account: { id: outsider } }, - }), - delivery("installation_repositories", { - action: "added", - installation: { id: 5, account: { id: outsider } }, - }), - delivery("pull_request", { ...from, action: "opened", pull_request: { id: 200, number: 2 } }), - delivery("push", { ...from, ref: "refs/heads/quest/m0/foo", created: true }), - // A repository that names no owner is not admitted either. - delivery("issues", { - ...from, - repository: { id: 10, owner: {} }, - action: "opened", - issue: { id: 1, number: 1 }, - }), - ]; - for (const response of await Promise.all(dropped)) { - expect(response.status).toBe(202); - expect(await response.json()).toEqual({ status: "ignored" }); - } - for (const table of [ - "github_deliveries", - "github_installations", - "github_project_activity", - "github_project_state", - ]) - expect((await env.DB.prepare(`SELECT * FROM ${table}`).all()).results).toEqual([]); - // The admitted owner's deliveries still land. - await delivery("pull_request", { - ...from, - repository: { id: 10, owner: { id: owner } }, - action: "opened", - pull_request: { id: 200, number: 2 }, - }); - expect((await env.DB.prepare("SELECT * FROM github_project_activity").all()).results).toHaveLength(1); - }); - it("admits an allowlisted user account the same as an organization", async () => { - await env.DB.prepare( - "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(10,'someone','project',5,'enabled','app-test',0)", - ).run(); - const account = { id: owner, login: "someone", type: "User" }; - const created = await delivery("installation", { - action: "created", - installation: { id: 5, app_id: 123, account }, - }); - expect(await created.json()).not.toEqual({ status: "ignored" }); - expect(await state()).toMatchObject({ blocked: 0, revision: 1 }); - await delivery("pull_request", { - action: "opened", - installation: { id: 5 }, - repository: { id: 10, owner: account }, - pull_request: { id: 200, number: 2 }, - }); - expect((await env.DB.prepare("SELECT * FROM github_project_activity").all()).results).toHaveLength(1); - }); - it("ignores entity pointers from a previous installation or an unenrolled repository", async () => { - await env.DB.prepare( - "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(10,'upstream','project',8,'enabled','app-test',0)", - ).run(); - await delivery("issues", { - installation: { id: 5 }, - repository: { id: 10 }, - action: "opened", - issue: { id: 100, number: 1 }, - }); - await delivery("issues", { - installation: { id: 8 }, - repository: { id: 11 }, - action: "opened", - issue: { id: 101, number: 1 }, - }); - expect((await env.DB.prepare("SELECT * FROM github_project_activity").all()).results).toEqual([]); - }); -}); - -function github( - options: { - role?: string; - account?: number; - suspended?: boolean; - missing?: boolean; - permissions?: Record; - merges?: ("squash" | "merge" | "rebase")[]; - cleanupFails?: boolean; - failMutation?: boolean; - } = {}, -) { - const requests: { path: string; method: string; body: any; authorization: string }[] = []; - const request = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const path = new URL(String(input)).pathname; - const method = init?.method ?? "GET"; - const body = init?.body ? JSON.parse(String(init.body)) : undefined; - requests.push({ path, method, body, authorization: new Headers(init?.headers).get("Authorization")! }); - const json = (value: unknown, status = 200) => Response.json(value, { status }); - if (path.endsWith("/installation")) { - if (options.missing) return json({}, 404); - return json({ - id: 5, - app_id: 123, - suspended_at: options.suspended ? "2026-10-13" : null, - permissions: options.permissions ?? { - metadata: "read", - contents: "write", - issues: "write", - pull_requests: "write", - checks: "read", - statuses: "read", - }, - }); - } - if (path.endsWith("/access_tokens")) return json({ token: "upstream-token" }); - if (path === "/installation/token") - return options.cleanupFails ? json({}, 500) : new Response(null, { status: 204 }); - if (path === "/user/7") return json({ id: options.account ?? 7, login: "maintainer" }); - // Pull request #1's head, which another maintainer approved. - if (path === "/graphql" && body.query.includes("headRefOid baseRefName body")) - return json({ - data: { - repository: { - pullRequest: { - state: "OPEN", - headRefOid: "a".repeat(40), - baseRefName: "main", - body: "", - author: { __typename: "User", login: "contributor" }, - userContentEdits: { nodes: [] }, - latestOpinionatedReviews: { - nodes: [ - { - state: "APPROVED", - author: { __typename: "User", login: "reviewer" }, - commit: { oid: "a".repeat(40) }, - }, - ], - }, - }, - }, - }, - }); - if (path.endsWith("/rules/branches/main")) return json([]); - if (path.endsWith("/permission")) - return json({ - user: { id: 7 }, - permission: options.role === "admin" ? "admin" : "write", - role_name: options.role ?? "maintain", - }); - if (path === "/repos/upstream/project") { - return json({ - id: 10, - name: "project", - owner: { login: repo.owner, id: owner }, - archived: false, - disabled: false, - allow_squash_merge: (options.merges ?? ["squash", "merge", "rebase"]).includes("squash"), - allow_merge_commit: (options.merges ?? ["squash", "merge", "rebase"]).includes("merge"), - allow_rebase_merge: (options.merges ?? ["squash", "merge", "rebase"]).includes("rebase"), - }); - } - if (method !== "GET") - return options.failMutation - ? json({ secret: "DO-NOT-LOG" }, 422) - : json({ id: 100, html_url: "https://github.com/upstream/project/pull/100" }); - if (path.includes("/contents/quest/")) return json({ path: "quest/one.md", content: "ZmFrZQ==" }); - if (path.includes("/check-runs")) return json({ total_count: 0, check_runs: [] }); - if (path.endsWith("/status")) return json({ total_count: 0, statuses: [] }); - if (/\/(issues|pulls)\/\d+$/.test(path)) return json({ number: 1 }); - throw new Error(`Unexpected request: ${method} ${path}`); - }); - return { service: new GitHubService(testEnv, request as typeof fetch), requests }; -} - -describe("trusted App operations", () => { - it("accepts current maintain authority and scopes/revokes a comment token", async () => { - const { service, requests } = github(); - expect(await service.comment(repo, "app-test", 1, "Approved")).toMatchObject({ id: 100 }); - expect(requests.find((r) => r.path.endsWith("/access_tokens"))?.body).toEqual({ - repository_ids: [10], - permissions: { metadata: "read", issues: "write" }, - }); - expect(requests.at(-1)).toMatchObject({ - path: "/installation/token", - method: "DELETE", - authorization: "Bearer upstream-token", - }); - const jwt = requests[0]!.authorization.slice(7).split("."); - expect(JSON.parse(Buffer.from(jwt[0]!, "base64url").toString())).toEqual({ alg: "RS256", typ: "JWT" }); - expect(JSON.parse(Buffer.from(jwt[1]!, "base64url").toString())).toMatchObject({ iss: "123" }); - expect( - verify( - "RSA-SHA256", - Buffer.from(jwt.slice(0, 2).join(".")), - createPublicKey(privateKey), - Buffer.from(jwt[2]!, "base64url"), - ), - ).toBe(true); - }); - it.each(["write", "triage", "custom-writer"])("rejects %s role before publishing", async (role) => { - const { service, requests } = github({ role }); - await expect(service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow("maintainer authority"); - expect(requests.some((r) => r.path.endsWith("/comments"))).toBe(false); - expect(requests.at(-1)?.method).toBe("DELETE"); - }); - it("rejects browser-like user claims and mismatched linked identities", async () => { - await expect(github().service.comment(repo, "forged-user", 1, "Approved")).rejects.toThrow( - "Linked GitHub identity", - ); - await expect(github({ account: 99 }).service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow( - "identity changed", - ); - }); - it("gates deleted installations before minting, independently checks live revocation", async () => { - await delivery("installation", installation("deleted")); - const { service, requests } = github(); - await expect(service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow("revoked"); - expect(requests.some((r) => r.path.endsWith("/access_tokens"))).toBe(false); - await delivery("installation", installation("created")); // delayed/new event cannot restore live access - await expect(github({ missing: true }).service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow("404"); - await expect(github({ suspended: true }).service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow( - "unavailable", - ); - }); - it("fails closed on reduced installation permissions", async () => { - const { service, requests } = github({ permissions: { metadata: "read", issues: "read" } }); - await expect(service.comment(repo, "app-test", 1, "Approved")).rejects.toThrow("issues permission"); - expect(requests.length).toBe(1); - }); - it.each([ - [["squash", "merge", "rebase"], "squash"], - [["merge", "rebase"], "merge"], - [["rebase"], "rebase"], - ] as const)("pins explicit merges to the approved commit SHA with %j allowed", async (merges, method) => { - const { service, requests } = github({ merges: [...merges] }); - await service.mergePullRequest(repo, "app-test", 1, "a".repeat(40)); - expect(requests.find((r) => r.path.endsWith("/merge"))?.body).toEqual({ - sha: "a".repeat(40), - merge_method: method, - }); - expect(requests.find((r) => r.path.endsWith("/access_tokens"))?.body.permissions).toEqual({ - metadata: "read", - contents: "write", - pull_requests: "write", - checks: "read", - }); - }); - it("refuses to merge when the repository allows no merge method", async () => { - const { service, requests } = github({ merges: [] }); - await expect(service.mergePullRequest(repo, "app-test", 1, "a".repeat(40))).rejects.toThrow("no merge method"); - expect(requests.some((r) => r.path.endsWith("/merge"))).toBe(false); - }); - it("reads provider data with read-only permissions", async () => { - const { service, requests } = github(); - await service.readQuest(repo, "app-test", "quest/one.md", "a".repeat(40)); - await service.readIssue(repo, "app-test", 1); - await service.readPullRequest(repo, "app-test", 1); - await service.readChecks(repo, "app-test", "a".repeat(40)); - expect( - requests - .filter((r) => r.path.endsWith("/access_tokens")) - .every((r) => Object.values(r.body.permissions).every((p) => p === "read")), - ).toBe(true); - }); - it("does not retry completed writes when token cleanup fails or leak provider errors", async () => { - const log = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - const { service, requests } = github({ cleanupFails: true }); - await expect(service.comment(repo, "app-test", 1, "Approved")).resolves.toMatchObject({ id: 100 }); - expect(requests.filter((r) => r.path.endsWith("/comments"))).toHaveLength(1); - expect(log).toHaveBeenCalledWith({ event: "github_token_revocation_failed" }); - await expect( - github({ failMutation: true }).service.comment(repo, "app-test", 1, "Approved"), - ).rejects.toThrow("GitHub request failed (422)"); - } finally { - log.mockRestore(); - } - }); -}); diff --git a/cloudflare/test/inbox-reads.test.ts b/cloudflare/test/inbox-reads.test.ts deleted file mode 100644 index 2393cddf..00000000 --- a/cloudflare/test/inbox-reads.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { load } from "../src/inbox/model"; -import * as store from "../src/jobs/store"; -import { commit, maintainerOf, project } from "./project-fixtures"; - -const db = env.DB; -let user = ""; -beforeEach(async () => { - ({ user } = await maintainerOf()); -}); -afterEach(() => vi.restoreAllMocks()); - -/** Count executed statements, including each statement inside a D1 batch. */ -function counted() { - let queries = 0; - const originals = new WeakMap(); - const statement = (inner: D1PreparedStatement): D1PreparedStatement => { - const proxy = new Proxy(inner, { - get(target, key) { - if (key === "bind") return (...args: unknown[]) => statement(target.bind(...args)); - const value = Reflect.get(target, key, target); - if (typeof value !== "function") return value; - return (...args: unknown[]) => { - if (["all", "first", "run", "raw"].includes(String(key))) queries++; - return value.apply(target, args); - }; - }, - }); - originals.set(proxy, inner); - return proxy; - }; - return { - db: new Proxy(db, { - get(target, key) { - if (key === "prepare") return (sql: string) => statement(target.prepare(sql)); - if (key === "batch") - return (statements: D1PreparedStatement[]) => { - queries += statements.length; - return target.batch(statements.map((s) => originals.get(s) ?? s)); - }; - const value = Reflect.get(target, key, target); - return typeof value === "function" ? value.bind(target) : value; - }, - }), - queries: () => queries, - }; -} - -/** Many targets, with interleaved attempts, events, answered and unanswered questions. */ -async function seed(count: number, repository = 10) { - const ids: string[] = []; - const statements: D1PreparedStatement[] = []; - for (let i = 0; i < count; i++) { - const id = `job-${repository}-${String(i).padStart(3, "0")}`; - ids.push(id); - statements.push( - db - .prepare( - `INSERT INTO jobs(id,repository,workflow,target,revision,branch,actor,trusted,state,attempt,result,createdAt,updatedAt) - VALUES(?,?,'start',?,?,'quest/work',?,1,?,2,?,?,?)`, - ) - .bind( - id, - repository, - `quest/job-${i}.md`, - commit, - user, - i % 2 ? "completed" : "waiting", - JSON.stringify({ followups: [`Follow-up ${i}`] }), - i, - 100 + i, - ), - ); - if (i % 3 === 0) continue; - // Event order follows insertion, not timestamp or attempt. - for (const attempt of [2, 1]) - statements.push( - db - .prepare("INSERT INTO job_events(job,attempt,kind,body,at) VALUES(?,?,'summary',?,?)") - .bind(id, attempt, JSON.stringify({ summary: `${id}-${attempt}` }), attempt), - ); - // Questions order by askedAt, using rowid to break ties, not by attempt. - for (const attempt of [2, 1]) - statements.push( - db - .prepare( - `INSERT INTO job_questions(id,job,attempt,kind,prompt,answer,answeredBy,askedAt,answeredAt,approvedAt) - VALUES(?,?,?,'agent',?,?,?,?,?,?)`, - ) - .bind( - `${id}-${attempt}`, - id, - attempt, - JSON.stringify({ questions: [{ question: `Choice ${id}-${attempt}?`, options: [] }] }), - attempt === 1 ? JSON.stringify({ text: `Answer ${id}` }) : null, - attempt === 1 ? user : null, - attempt, - attempt === 1 ? 10 : null, - attempt === 1 ? 11 : null, - ), - ); - } - await db.batch(statements); - return ids; -} - -describe("batched inbox reads", () => { - it("hydrates many jobs identically with a constant number of queries", async () => { - const observed = Array.from({ length: 120 }, (_, i) => `quest/job-${i}.md`); - const empty = counted(); - expect(await store.listInboxJobs(empty.db, 10, observed)).toEqual([]); - expect(empty.queries()).toBe(3); - const ids = await seed(120); - await project(11, user); - await seed(2, 11); - const expected = await Promise.all([...ids].reverse().map((id) => store.readJob(db, id))); - const all = counted(); - const result = await store.listInboxJobs(all.db, 10, observed); - expect(all.queries()).toBe(empty.queries()); - expect(result).toEqual(expected); - expect(result.find((job) => job.id === ids[1])).toMatchObject({ - attempt: 2, - result: { followups: ["Follow-up 1"] }, - events: [{ attempt: 2 }, { attempt: 1 }], - questions: [ - { attempt: 1, answer: { text: `Answer ${ids[1]}` }, answeredBy: user, answeredAt: 10, approvedAt: 11 }, - { attempt: 2, answer: null, answeredBy: null, answeredAt: null, approvedAt: null }, - ], - }); - expect(result.find((job) => job.id === ids[0])).toMatchObject({ events: [], questions: [] }); - const active = counted(); - expect(await store.listActivityJobs(active.db, 10, [])).toEqual( - expected.filter((job) => job!.state === "waiting"), - ); - expect(active.queries()).toBe(3); - const recent = counted(); - expect(await store.listJobs(recent.db, 10)).toEqual(expected.slice(0, 50)); - expect(recent.queries()).toBe(3); - }); - - it("keeps the entire inbox load's query count fixed as jobs grow", async () => { - const read = async () => { - const queries = counted(); - const result = await load({ ...env, DB: queries.db }, user); - return { queries: queries.queries(), result }; - }; - const empty = await read(); - await seed(120); - const many = await read(); - expect(many.queries).toBe(empty.queries); - expect(many.result.activity[0]).toHaveLength(60); - expect(many.result.items.filter((item) => item.job)).toHaveLength(60); - }); -}); diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts deleted file mode 100644 index 47a5df79..00000000 --- a/cloudflare/test/inbox.test.ts +++ /dev/null @@ -1,1256 +0,0 @@ -import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; -import { env } from "cloudflare:workers"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Pull, Repo } from "../dev/github"; -import { app } from "../src/index"; -import { QUIET, cards } from "../src/inbox/activity"; -import { STALE, questHref } from "../src/inbox/model"; -import * as output from "../src/jobs/output"; -import * as store from "../src/jobs/store"; -import { INBOX_HEADER } from "../src/jobs/hub"; -import { privateKey } from "./github-key"; -import { origin } from "./login-fixtures"; -import { commit, delivery, failing, graphql, head, maintainerOf, project, pull, rewrite } from "./project-fixtures"; - -const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; -const db = env.DB; -const jobs = "/projects/github/upstream/project/jobs"; - -let user = ""; -let cookie = ""; -let login = ""; -let repo: Repo; -let pr: Pull; -let github: typeof fetch; -let other: Awaited>["other"]; - -beforeEach(async () => { - ({ user, cookie, login, repo, pr, github, other } = await maintainerOf()); -}); -afterEach(() => vi.restoreAllMocks()); -const sockets: WebSocket[] = []; -afterEach(() => { - for (const socket of sockets.splice(0)) socket.close(); -}); - -async function socket(response: Response) { - expect(response.status).toBe(101); - const ws = response.webSocket!; - ws.accept(); - sockets.push(ws); - const messages: string[] = []; - const waiting: ((message: string) => void)[] = []; - ws.addEventListener("message", (event) => { - const value = event.data as string; - const next = waiting.shift(); - if (next) next(value); - else messages.push(value); - }); - return { - ws, - messages, - next: (): Promise => - messages.length ? Promise.resolve(messages.shift()!) : new Promise((resolve) => waiting.push(resolve)), - closed: new Promise((resolve) => ws.addEventListener("close", (event) => resolve(event.code))), - }; -} -function live(session = cookie, site = origin, upgrade = true) { - return app.request( - `${origin}/inbox/live`, - { headers: { Cookie: session, Origin: site, ...(upgrade ? { Upgrade: "websocket" } : {}) } }, - testEnv, - ); -} - -async function inbox(query: Record = {}, session = cookie) { - const response = await app.request( - `${origin}/inbox?${new URLSearchParams(query)}`, - { headers: { Cookie: session } }, - testEnv, - ); - expect(response.status).toBe(200); - return response.text(); -} -/** The merges of #2 requested of GitHub. */ -const merges = () => - vi - .mocked(globalThis.fetch) - .mock.calls.filter(([input, init]) => init?.method === "PUT" && String(input).endsWith("/pulls/2/merge")) - .map(([, init]) => JSON.parse(String(init!.body))); -/** Sign pull request #2's head as the signed-in maintainer, so its jobs run on any runner. */ -async function signed() { - const { accountId } = (await db - .prepare("SELECT accountId FROM account WHERE providerId='github' AND userId=?") - .bind(user) - .first<{ accountId: string }>())!; - repo.commits[head]!.signature = { isValid: true, signer: { databaseId: Number(accountId), login } }; -} -/** Open the project's activity page, which checks maintainer authority. */ -function visit(session = cookie) { - return app.request(`${origin}/projects/github/upstream/project/state`, { headers: { Cookie: session } }, testEnv); -} -async function members() { - const { results } = await db - .prepare("SELECT user FROM github_project_members WHERE repository=10") - .all<{ user: string }>(); - return results.map((row) => row.user); -} -/** How many items the user has read or skipped on the project. */ -async function reads(who = user) { - const row = await db - .prepare("SELECT count(*) AS n FROM inbox_items WHERE repository=10 AND user=?") - .bind(who) - .first<{ n: number }>(); - return row!.n; -} -/** The active decision thread's markup. */ -function thread(html: string) { - return html.match(/
    /)?.[0] ?? ""; -} -/** The agent cards beside the decisions. */ -function agents(html: string) { - return html.match(/