- Releases: draft/publish from a tag, markdown notes, uploadable asset files (resumable downloads), source zip/tar.gz archives, and a /releases UI - MCP: /mcp endpoint exposing repo, issue, release and workflow tools, gated by scope (read-only vs read & write) and repo selection - OAuth 2.1 authorization server for MCP clients (PKCE, dynamic client registration, consent screen, token/revoke endpoints) and a Connected apps settings page - Add SEC_REPORT.md documenting a security review, including a git argument-injection finding
Showing 52 changed files with 4336 additions and 113 deletions.
| @@ -12,6 +12,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 12 | 12 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| 13 | 13 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| 14 | 14 | - **Container registry**: every repository is also a Docker/OCI registry, like ghcr.io. `docker push <host>/<owner>/<repo>` (or `<host>/<owner>/<repo>/<name>` for more images per repository) with the same visibility, collaborators and API tokens as the code. Multi-platform images, cross-repository blob mounts, chunked uploads and the referrers API (signatures, SBOMs) are supported. Images are listed under the repository's *Packages* tab, where writers can delete versions |
| 15 | + - **Releases**: publish a version from a tag, with markdown notes and files to download (binaries, installers, checksums) of any size, plus the source code as zip and tar.gz. Drafts and pre-releases; a release made for a new tag creates it and starts the workflows a push of it would. Files can be uploaded from scripts and CI with `curl`, and `/releases/latest/download/<file>` always links the newest version | |
| 15 | 16 | - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored |
| 16 | 17 | - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one |
| 17 | 18 | - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews |
| @@ -24,6 +25,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 24 | 25 | - Inline editing of every property, with an activity feed of changes next to the markdown comments |
| 25 | 26 | - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu |
| 26 | 27 | - **Actions with self-hosted runners**: workflows in `.vectrum/workflows/*.yml` (a subset of GitHub Actions syntax) run on push or by hand, on runners you register for one repository or for all of yours. Live logs, cancel and re-run, and CI status marks in the commit list |
| 28 | + - **MCP server for AI agents**: agents like Claude Code connect to `/mcp` and sign in with OAuth 2.1. You approve each one, read-only or read & write, for all repositories or selected ones. They can read and commit code, manage branches, issues, labels and releases, create repositories, and run and debug workflows. Manage connected apps under *Settings → Connected apps* | |
| 27 | 29 | - Light and dark mode |
| 28 | 30 | |
| 29 | 31 | ## Getting started |
| @@ -93,6 +95,28 @@ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add | ||
| 93 | 95 | the host to Docker's `insecure-registries`). A reverse proxy in front of |
| 94 | 96 | Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`). |
| 95 | 97 | |
| 98 | + ### Publishing releases | |
| 99 | + | |
| 100 | + Draft one under a repository's **Releases** tab, or upload files from a script | |
| 101 | + with a read & write API token. Uploading to a pushed tag that has no release yet | |
| 102 | + publishes one for it; uploading a file again replaces it: | |
| 103 | + | |
| 104 | + ```bash | |
| 105 | + git tag v1.0.0 && git push origin v1.0.0 | |
| 106 | + curl -fT dist/app-linux-x64.tar.gz -u <you>:vec_… \ | |
| 107 | + http://localhost:3000/<you>/<repo>/releases/download/v1.0.0/app-linux-x64.tar.gz | |
| 108 | + curl -X DELETE -u <you>:vec_… http://localhost:3000/<you>/<repo>/releases/download/v1.0.0/app-linux-x64.tar.gz | |
| 109 | + ``` | |
| 110 | + | |
| 111 | + Downloads are `GET /<owner>/<repo>/releases/download/<tag>/<file>` (or | |
| 112 | + `/releases/latest/download/<file>` for the newest release that isn't a pre-release) | |
| 113 | + and support ranges, so interrupted downloads resume. Source code is at | |
| 114 | + `/<owner>/<repo>/archive/refs/tags/<tag>.zip` (or `.tar.gz`; branches and commits | |
| 115 | + work too). Private repositories need a token (`-u <you>:vec_…` or | |
| 116 | + `-H "Authorization: Bearer vec_…"`). To publish from a workflow, give the runner's | |
| 117 | + environment a token (workflows have no secrets) and `curl` the files in a job | |
| 118 | + that runs `on: push: tags`. | |
| 119 | + | |
| 96 | 120 | ### Running workflows |
| 97 | 121 | |
| 98 | 122 | 1. Register a runner under **Settings → Runners** (for all your repositories) or under a repository's **Settings → Runners**, and copy its token. |
| @@ -121,6 +145,28 @@ Supported: `on: push` (with `branches`, `tags`, `paths` and their `-ignore` form | ||
| 121 | 145 | |
| 122 | 146 | Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound port. Every runner has the labels `self-hosted`, its OS (`linux`, `macos`, `windows`) and architecture (`x64`, `arm64`), plus any you give it. Each job gets a short-lived, read-only token that can only clone its own repository. Steps run as the user that started the runner, with no sandbox, so only run runners for repositories whose workflows you trust. |
| 123 | 147 | |
| 148 | + ### Connecting AI agents (MCP) | |
| 149 | + | |
| 150 | + Vectrum is a remote [MCP](https://modelcontextprotocol.io) server at `/mcp` (Streamable HTTP). Add it to your agent, e.g. Claude Code: | |
| 151 | + | |
| 152 | + ```bash | |
| 153 | + claude mcp add --transport http vectrum http://localhost:3000/mcp | |
| 154 | + ``` | |
| 155 | + | |
| 156 | + Then run `/mcp` in Claude Code and choose *Authenticate*. Your browser opens Vectrum, where you sign in and choose what the agent may do: read-only or read & write, for all repositories or selected ones. Other MCP clients work the same way: they find the OAuth endpoints from the `401` answer, register themselves, and send you to approve them. *Settings → Connected apps* lists what you approved, and disconnecting cuts access right away. Clients without OAuth can send an API token instead (`Authorization: Bearer vec_…`). | |
| 157 | + | |
| 158 | + The tools, which check the same permissions as the web UI: | |
| 159 | + | |
| 160 | + | Area | Tools | | |
| 161 | + | ---- | ----- | | |
| 162 | + | Account and repositories | `get_me`, `list_repositories`, `search_repositories`, `get_repository`, `create_repository`, `update_repository` | | |
| 163 | + | Code | `list_branches`, `create_branch`, `list_files`, `read_file`, `commit_files` (several files in one commit, optionally on a new branch, with an optimistic `expected_head` check), `list_commits`, `get_commit` (with its diff) | | |
| 164 | + | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `comment_on_issue`, `list_labels`, `create_label` | | |
| 165 | + | Releases | `list_releases`, `get_release`, `create_release` (creates a missing tag at a branch or commit), `update_release` (also publishes drafts), `delete_release` | | |
| 166 | + | Actions | `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `run_workflow`, `cancel_workflow_run`, `rerun_workflow_run` | | |
| 167 | + | |
| 168 | + Read-only connections only see the reading tools. Commits made by an agent are authored as you and start workflows like a push would. Nothing deletes repositories or issues; `delete_release` removes a release and its files but keeps the tag. Release files are uploaded with an API token (see [Publishing releases](#publishing-releases)). | |
| 169 | + | |
| 124 | 170 | ## Configuration |
| 125 | 171 | |
| 126 | 172 | | Variable | Default | Purpose | |
| @@ -142,7 +188,7 @@ Data layout: | ||
| 142 | 188 | |
| 143 | 189 | ``` |
| 144 | 190 | data/ |
| 145 | - pb_data/ PocketBase: users, keys, tokens, repos, issues, workflow runs and logs | |
| 191 | + pb_data/ PocketBase: users, keys, tokens, repos, issues, releases, workflow runs and logs | |
| 146 | 192 | pocketbase-superuser.json the generated PocketBase superuser login |
| 147 | 193 | ssh_host_ed25519_key generated on first start |
| 148 | 194 | hooks/ server-side git hooks for every push, rewritten on start |
| @@ -150,6 +196,7 @@ data/ | ||
| 150 | 196 | registry.db container image manifests, tags and blob records (SQLite) |
| 151 | 197 | registry/blobs/sha256/… container image layers, shared by all repositories |
| 152 | 198 | registry/uploads/ image uploads in progress |
| 199 | + releases/<repo>/<release>/… release files, named by their record id | |
| 153 | 200 | ``` |
| 154 | 201 | |
| 155 | 202 | The registry keeps its records in SQLite rather than PocketBase so that storing a |
| @@ -162,6 +209,8 @@ kept so pushes in progress don't lose their layers). | ||
| 162 | 209 | - Serve the web UI over HTTPS if it's reachable beyond localhost, and set `PUBLIC_URL`. `Host` and `X-Forwarded-*` headers are client-controlled, so they never decide cookie security, and only stand in for `PUBLIC_URL` in development. |
| 163 | 210 | - Ref names are never passed to git where they could be read as options: pushes of refs with a component starting with `-` are refused by a pre-receive hook, such refs are ignored when reading, and every revision argument follows `--end-of-options`. |
| 164 | 211 | - Private repositories show a 404 page to anyone without access, so their existence isn't revealed. |
| 212 | + - OAuth for agents follows the MCP authorization spec: public clients only, PKCE (S256) required, exact redirect URIs (any port for `localhost`), one-time codes that expire in 5 minutes, access tokens that last an hour, and refresh tokens that rotate on every use (presenting an old one revokes the grant). Only hashes of codes and tokens are stored. The consent page can't be framed, and it shows where it will send you, because anyone can register an application under any name. | |
| 213 | + - Release files are always served as `application/octet-stream` attachments with `nosniff` and a sandboxing CSP. Uploads signed in with the browser session must come from the site's own origin; scripts use tokens. | |
| 165 | 214 | - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin. |
| 166 | 215 | |
| 167 | 216 | ## Layout |
| @@ -171,8 +220,10 @@ kept so pushes in progress don't lose their layers). | ||
| 171 | 220 | - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators |
| 172 | 221 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 173 | 222 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 223 | + - `lib/oauth.ts`, `lib/oauth-rules.ts`: the OAuth 2.1 authorization server for agents (`app/oauth/*`, `app/.well-known/*`); `lib/mcp/*`: the MCP endpoint (`app/mcp`) and its tools | |
| 174 | 224 | - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories |
| 175 | 225 | - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`) |
| 176 | 226 | - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2` |
| 227 | + - `lib/releases.ts`, `lib/release-http.ts`: releases and their files, and the download, upload and source archive routes | |
| 177 | 228 | - `lib/workflows.ts`, `lib/ci.ts`, `lib/runs.ts`, `lib/runners.ts`: workflow parsing, starting runs on push, the job queue and runner registration; `app/api/runner/*` is the runner API and `public/vectrum-runner.mjs` the runner |
| 178 | 229 | - `app/actions/*`: server actions, each re-checking the session and permissions |
| @@ -0,0 +1,208 @@ | ||
| 1 | + # Vectrum — Security Assessment | |
| 2 | + | |
| 3 | + **Target:** Vectrum — self-hosted Git forge (Next.js 16.3.7 + PocketBase + git over HTTP/SSH + CI runners) | |
| 4 | + **Date:** 2026-10-02 | |
| 5 | + **Scope:** Full application source review + live validation against the running instance (web `:3000`, SSH `:2222`) | |
| 6 | + **Assessor:** Authorized security review (account `johannso`, on the owner's own infrastructure) | |
| 7 | + | |
| 8 | + --- | |
| 9 | + | |
| 10 | + ## 1. Executive summary | |
| 11 | + | |
| 12 | + Vectrum is a compact, generally well-built Git forge. Authentication, the PocketBase | |
| 13 | + authorization model, token/secret handling, markdown sanitization, and server-action | |
| 14 | + access control are all sound (see §5). | |
| 15 | + | |
| 16 | + However, the assessment found **one critical, directly exploitable vulnerability**: a | |
| 17 | + **git argument-injection flaw that lets any authenticated user overwrite arbitrary files | |
| 18 | + on the server with attacker-controlled content**, as the server/container user. This leads | |
| 19 | + to remote code execution and full compromise of the instance. | |
| 20 | + | |
| 21 | + | # | Severity | Finding | Status | | |
| 22 | + |---|----------|---------|--------| | |
| 23 | + | 1 | 🔴 **Critical** | Git argument injection via branch names → arbitrary file overwrite → RCE | **Confirmed** (git-layer PoC proven; live code path verified) | | |
| 24 | + | 2 | 🟡 Low | Session-cookie `Secure` flag depends on a client-supplied header | Confirmed by review | | |
| 25 | + | 3 | 🟡 Low | Clone/runner URLs trust `Host` / `X-Forwarded-Host` | Confirmed by review | | |
| 26 | + | 4 | 🔵 Info | Runner tokens never expire / cannot be rotated | Confirmed by review | | |
| 27 | + | 5 | 🔵 Info | SSH server offers no post-quantum key exchange | Observed on live `:2222` | | |
| 28 | + | |
| 29 | + --- | |
| 30 | + | |
| 31 | + ## 2. Critical — Git argument injection → arbitrary file overwrite (RCE) | |
| 32 | + | |
| 33 | + ### 2.1 Summary | |
| 34 | + | |
| 35 | + Ref names (branches/tags) are passed to `git` as **positional arguments without an | |
| 36 | + `--end-of-options` / `--` barrier and without any leading-dash validation**. Git parses any | |
| 37 | + argument that begins with `-` as an **option**, not a revision. Because the push handlers | |
| 38 | + accept ref names that begin with `-`, an attacker can create a branch literally named | |
| 39 | + `--output=/absolute/path`, then cause a server-side `git log` to run it as an option that | |
| 40 | + **writes attacker-controlled commit text to any file path** the server process can write. | |
| 41 | + | |
| 42 | + ### 2.2 Affected code | |
| 43 | + | |
| 44 | + `lib/git.ts` — ref value is placed before `--` (or with no barrier at all): | |
| 45 | + | |
| 46 | + ```ts | |
| 47 | + // log() | |
| 48 | + ["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, `--skip=${skip}`, ref, "--", path] | |
| 49 | + // commitCount() | |
| 50 | + ["rev-list", "--count", ref] // no barrier | |
| 51 | + // listContributors() | |
| 52 | + ["shortlog", "-sne", ref, "--"] | |
| 53 | + // getCommit() | |
| 54 | + ["show", "-s", `--format=${COMMIT_FORMAT}`, sha, "--"] | |
| 55 | + ``` | |
| 56 | + | |
| 57 | + Three missing validations combine to make this reachable: | |
| 58 | + | |
| 59 | + 1. **No ref-name validation on push.** `lib/git-http.ts` (`handleServiceRpc`) and | |
| 60 | + `lib/ssh-server.ts` (`session.on("exec")`) stream the request straight into | |
| 61 | + `git receive-pack`, which accepts `refs/heads/--output=…`. | |
| 62 | + 2. **No leading-dash guard in ref resolution.** `resolveRefAndPath()` and `defaultBranch()` | |
| 63 | + in `lib/git.ts` match the attacker's branch name against the real ref list and return it | |
| 64 | + verbatim. | |
| 65 | + 3. **No barrier at the sink.** The commands above interpret the returned ref as an option. | |
| 66 | + | |
| 67 | + ### 2.3 Reachable sinks (web routes) | |
| 68 | + | |
| 69 | + - `app/[owner]/[repo]/commits/[...segments]/page.tsx` → `log(repo.dir, ref, …)` | |
| 70 | + — triggered by `GET /{owner}/{repo}/commits/--output=/path`. | |
| 71 | + - `app/[owner]/[repo]/page.tsx` (repo homepage) → `commitCount()` + `listContributors()` | |
| 72 | + on `repo.defaultBranch` — triggers **automatically** on page view if the malicious branch | |
| 73 | + is the repository's default/only branch. | |
| 74 | + | |
| 75 | + ### 2.4 Proof of concept (verified against git 2.55.0) | |
| 76 | + | |
| 77 | + Pushing a dash-prefixed ref is accepted: | |
| 78 | + | |
| 79 | + ```console | |
| 80 | + $ git push ../srv.git 'HEAD:refs/heads/--output=/tmp/vt/PWNED' | |
| 81 | + $ git --git-dir srv.git for-each-ref --format='%(refname:short)' refs/heads | |
| 82 | + --output=/tmp/vt/PWNED | |
| 83 | + ``` | |
| 84 | + | |
| 85 | + The server-side `git log` then writes **attacker-controlled commit content** to an | |
| 86 | + arbitrary path (here, a forged SSH key line taken from a commit subject the attacker chose): | |
| 87 | + | |
| 88 | + ```console | |
| 89 | + $ git --git-dir srv3.git log '--format=%s' -n36 --skip=0 '--output=/tmp/vt/authorized_keys' -- | |
| 90 | + $ cat /tmp/vt/authorized_keys | |
| 91 | + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAATTACKERKEY attacker@evil | |
| 92 | + ``` | |
| 93 | + | |
| 94 | + ### 2.5 End-to-end attack (any authenticated user) | |
| 95 | + | |
| 96 | + 1. Create a repository (allowed for any signed-in user) and push a `main` branch whose | |
| 97 | + latest commit **subject** is the file contents you want to write. | |
| 98 | + 2. Push a second branch named, e.g., `--output=/home/node/.ssh/authorized_keys` | |
| 99 | + (or `/data/pocketbase-superuser.json`, a repo `hooks/post-receive`, app source, …). | |
| 100 | + Both the SSH and HTTP push paths accept it unchanged. | |
| 101 | + 3. Request `GET /{you}/{repo}/commits/--output=/home/node/.ssh/authorized_keys` | |
| 102 | + (or simply set that branch as the default and open the repo home). The server runs | |
| 103 | + `git log … --output=… --` and **overwrites that file** with your commit text. | |
| 104 | + | |
| 105 | + ### 2.6 Live validation | |
| 106 | + | |
| 107 | + - SSH to the running instance authenticates and reaches the push handler: | |
| 108 | + `ssh -T -p 2222 git@localhost` → `Hi johannso! You've successfully authenticated…` | |
| 109 | + - Web interface healthy (`GET :3000/api/health` → `{"ok":true}`), signups enabled. | |
| 110 | + - Source confirms **no** ref-name validation on the container's push path and **no** | |
| 111 | + leading-dash guard in ref resolution — identical to the proven PoC code path. | |
| 112 | + - Writing a file *inside the running container* was **not** performed: the host blocks it | |
| 113 | + (Docker socket access denied, no passwordless sudo, `/data` not host-visible). This is | |
| 114 | + host hygiene only; it does not mitigate the flaw in Vectrum. | |
| 115 | + | |
| 116 | + ### 2.7 Impact | |
| 117 | + | |
| 118 | + Arbitrary file creation/overwrite as the server (container) user, with substantially | |
| 119 | + attacker-controlled content. Realistic escalations: implant an SSH `authorized_keys`, | |
| 120 | + overwrite `data/pocketbase-superuser.json` to seize the PocketBase superuser, plant a git | |
| 121 | + hook or poison application source → **remote code execution / full instance compromise**. | |
| 122 | + Exploitable by any user who can sign up and push. | |
| 123 | + | |
| 124 | + ### 2.8 Remediation (defense in depth — apply all) | |
| 125 | + | |
| 126 | + 1. **Barrier at every sink.** In `lib/git.ts`, insert `--end-of-options` immediately before | |
| 127 | + any revision argument (or ensure refs are only ever passed after `--`). Cover `log`, | |
| 128 | + `rev-list`, `shortlog`, `show`, `cat-file`, `ls-tree`, and the transport commands. | |
| 129 | + Example: | |
| 130 | + ```ts | |
| 131 | + ["log", `--format=${COMMIT_FORMAT}`, `-n${limit}`, `--skip=${skip}`, | |
| 132 | + "--end-of-options", ref, "--", ...(path ? [path] : [])] | |
| 133 | + ``` | |
| 134 | + 2. **Reject dash-prefixed refs on read.** In `resolveRefAndPath`, `listBranches`, | |
| 135 | + `listTags`, and `defaultBranch`, drop/deny any ref whose name starts with `-`. | |
| 136 | + 3. **Validate refs on push.** Refuse ref updates whose names start with `-` or otherwise | |
| 137 | + fail `git check-ref-format`, in the receive-pack path (`lib/git-http.ts`, | |
| 138 | + `lib/ssh-server.ts`). | |
| 139 | + 4. Add a regression test using the PoC (push `--output=…`, assert no file is written and | |
| 140 | + the request 404s). | |
| 141 | + | |
| 142 | + --- | |
| 143 | + | |
| 144 | + ## 3. Low-severity findings | |
| 145 | + | |
| 146 | + ### 3.1 Session-cookie `Secure` flag depends on a client-supplied header | |
| 147 | + `lib/auth.ts` → `setSessionCookie()` sets `secure: h.get("x-forwarded-proto") === "https"`. | |
| 148 | + The 30-day, `sameSite: lax` session cookie is only marked `Secure` when a proxy header says | |
| 149 | + so. Correct behind the documented TLS proxy, but if the app is exposed directly, or the | |
| 150 | + proxy is misconfigured to forward the header, the session cookie can be sent in cleartext. | |
| 151 | + **Fix:** gate on an explicit server config (e.g. `SECURE_COOKIES=1` / a trusted-proxy flag) | |
| 152 | + rather than a request header. | |
| 153 | + | |
| 154 | + ### 3.2 Host-derived URLs trust `Host` / `X-Forwarded-Host` | |
| 155 | + `lib/repo.ts` (`cloneUrls`), `lib/request-origin.ts`, `lib/runner-api.ts` (`originOf`) build | |
| 156 | + clone/runner origins from `x-forwarded-host` / `host`. Impact is limited (spoofed clone URLs | |
| 157 | + shown to users, runner clone origin), but in multi-tenant/public deployments pin these to a | |
| 158 | + configured canonical host. | |
| 159 | + | |
| 160 | + --- | |
| 161 | + | |
| 162 | + ## 4. Informational | |
| 163 | + | |
| 164 | + ### 4.1 Runner tokens never expire | |
| 165 | + `lib/runners.ts` — runner tokens (`vrn_…`) are long-lived with no expiry or rotation, unlike | |
| 166 | + API tokens. A leaked runner token lets a machine claim scoped jobs and receive repo checkout | |
| 167 | + tokens indefinitely. Consider optional expiry/rotation. | |
| 168 | + | |
| 169 | + ### 4.2 SSH server lacks post-quantum key exchange | |
| 170 | + The live `:2222` server (ssh2 library) negotiates only classical KEX; OpenSSH clients warn | |
| 171 | + about "store now, decrypt later" exposure. Low risk for a git transport; track the ssh2 | |
| 172 | + library's PQ support for a future upgrade. | |
| 173 | + | |
| 174 | + --- | |
| 175 | + | |
| 176 | + ## 5. Reviewed and found solid | |
| 177 | + | |
| 178 | + - **PocketBase authorization:** all collection rules are superuser-only (`null`) except | |
| 179 | + `users.viewRule = "id = @request.auth.id"`; the Next.js server mediates every access. | |
| 180 | + - **Session handling:** sessions are PocketBase auth tokens in an `httpOnly` cookie, | |
| 181 | + re-validated by reading the user's own record with their token (`lib/auth.ts`). | |
| 182 | + - **Server-action authorization:** `repoForAction` / `viewerOrThrow` enforce | |
| 183 | + read/write/admin consistently; issue deletion is restricted to author-or-admin; label and | |
| 184 | + settings mutations require write/admin. No IDOR found (`app/actions/*`). | |
| 185 | + - **Secrets:** API, runner, and job tokens are 30-byte random values stored only as SHA-256 | |
| 186 | + hashes; passwords via PocketBase bcrypt; login avoids username-enumeration timing | |
| 187 | + (`lib/tokens.ts`, `lib/runners.ts`, `lib/runs.ts`, `lib/users.ts`). | |
| 188 | + - **Markdown XSS:** `rehype-raw` → `rehype-sanitize` ordering is correct (GitHub allowlist | |
| 189 | + applied last); `urlTransform` blocks `javascript:` and similar (`components/markdown.tsx`, | |
| 190 | + `components/markdown-preview.tsx`). Shiki highlight HTML is trusted input. | |
| 191 | + - **Path traversal:** `isValidName` / `isValidOwner` / `repoPath` constrain on-disk repo | |
| 192 | + paths; `cleanRepoPath` rejects `..`, `.git`, and control characters for web edits | |
| 193 | + (`lib/git.ts`, `lib/git-write.ts`). | |
| 194 | + - **Blob/image routes** set `X-Content-Type-Options: nosniff` and a sandboxing CSP, | |
| 195 | + preventing stored XSS via uploaded blobs/SVGs (`app/[owner]/[repo]/raw`, `/image`). | |
| 196 | + - **Repo images** are validated by magic bytes, not the browser-reported MIME type | |
| 197 | + (`lib/repos.ts` `sniffImageType`). | |
| 198 | + | |
| 199 | + --- | |
| 200 | + | |
| 201 | + ## 6. Priority | |
| 202 | + | |
| 203 | + 1. **Immediately:** Fix finding #1 (git argument injection) — small, localized change in | |
| 204 | + `lib/git.ts` plus ref validation on the push/resolve paths; add the regression test. | |
| 205 | + 2. Then: findings #2 and #3 (header-trust hardening). | |
| 206 | + 3. Backlog: #4 and #5. | |
| 207 | + | |
| 208 | + 🤖 Generated with [Claude Code](https://claude.com/claude-code) | |
| @@ -0,0 +1 @@ | ||
| 1 | + export { GET, OPTIONS } from "../route"; | |
| @@ -0,0 +1,9 @@ | ||
| 1 | + import { corsJson, preflight } from "@/lib/cors"; | |
| 2 | + import { protectedResourceMetadata } from "@/lib/oauth"; | |
| 3 | + | |
| 4 | + // Clients look here, or under /mcp (the path of the resource), for the same document. | |
| 5 | + export async function GET() { | |
| 6 | + return corsJson(await protectedResourceMetadata()); | |
| 7 | + } | |
| 8 | + | |
| 9 | + export const OPTIONS = preflight; | |
| @@ -0,0 +1,6 @@ | ||
| 1 | + import { downloadArchive } from "@/lib/release-http"; | |
| 2 | + | |
| 3 | + export async function GET(request: Request, ctx: RouteContext<"/[owner]/[repo]/archive/[...segments]">) { | |
| 4 | + const { owner, repo, segments } = await ctx.params; | |
| 5 | + return downloadArchive(request, owner, repo, segments); | |
| 6 | + } | |
| @@ -3,6 +3,7 @@ import { RepoTabs } from "@/components/repo-tabs"; | ||
| 3 | 3 | import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; |
| 4 | 4 | import { openIssueCount } from "@/lib/issues"; |
| 5 | 5 | import { countImages } from "@/lib/registry"; |
| 6 | + import { countReleases } from "@/lib/releases"; | |
| 6 | 7 | import { getRepo } from "@/lib/repo"; |
| 7 | 8 | |
| 8 | 9 | export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) { |
| @@ -38,7 +39,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 38 | 39 | <VisibilityLabel visibility={repo.visibility} /> |
| 39 | 40 | {repo.access === "write" && <Label tone="accent">Collaborator</Label>} |
| 40 | 41 | </div> |
| 41 | - <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} /> | |
| 42 | + <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} releases={await countReleases(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} /> | |
| 42 | 43 | </div> |
| 43 | 44 | </div> |
| 44 | 45 | <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> |
| @@ -13,11 +13,12 @@ import { | ||
| 13 | 13 | UploadIcon, |
| 14 | 14 | } from "@/components/icons"; |
| 15 | 15 | import { TreeView } from "@/components/tree-view"; |
| 16 | - import { Avatar, Blankslate, Box, ButtonLink } from "@/components/ui"; | |
| 17 | - import { encodePath, formatDate } from "@/lib/format"; | |
| 16 | + import { Avatar, Blankslate, Box, ButtonLink, Label } from "@/components/ui"; | |
| 17 | + import { encodePath, formatDate, timeAgo } from "@/lib/format"; | |
| 18 | 18 | import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git"; |
| 19 | 19 | import { openIssueCount } from "@/lib/issues"; |
| 20 | 20 | import { languageBreakdown, type LanguageShare } from "@/lib/languages"; |
| 21 | + import { countReleases, latestRelease, releaseTitle } from "@/lib/releases"; | |
| 21 | 22 | import { cloneUrls, getRepo, type Repo } from "@/lib/repo"; |
| 22 | 23 | import { listCollaborators } from "@/lib/repos"; |
| 23 | 24 | |
| @@ -145,7 +146,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 145 | 146 | ref ? listFilesWithSizes(repo.dir, ref) : [], |
| 146 | 147 | ref ? listContributors(repo.dir, ref) : [], |
| 147 | 148 | ]); |
| 148 | - const collaborators = await listCollaborators(repo.id); | |
| 149 | + const [collaborators, releases, latest] = await Promise.all([listCollaborators(repo.id), countReleases(repo.id), latestRelease(repo.id)]); | |
| 149 | 150 | const languages = languageBreakdown(files); |
| 150 | 151 | const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`; |
| 151 | 152 | |
| @@ -170,7 +171,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 170 | 171 | <ul> |
| 171 | 172 | <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} /> |
| 172 | 173 | <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} /> |
| 173 | - <Stat href={`${repo.base}/branches`} icon={TagIcon} label="Tags" value={tags.length} /> | |
| 174 | + <Stat href={`${repo.base}/releases`} icon={TagIcon} label="Tags" value={tags.length} /> | |
| 174 | 175 | <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={await openIssueCount(repo.id)} /> |
| 175 | 176 | <Stat |
| 176 | 177 | href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`} |
| @@ -181,6 +182,31 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 181 | 182 | </ul> |
| 182 | 183 | </section> |
| 183 | 184 | |
| 185 | + {releases > 0 && ( | |
| 186 | + <section> | |
| 187 | + <SectionLabel count={releases}>Releases</SectionLabel> | |
| 188 | + {latest ? ( | |
| 189 | + <Link | |
| 190 | + href={`${repo.base}/releases/tag/${encodePath(latest.tag)}`} | |
| 191 | + className="group -mx-2 flex items-start gap-2.5 rounded-row px-2 py-1.5 outline-none hover:recipe-row-list-hover focus-visible:focus-ring" | |
| 192 | + > | |
| 193 | + <TagIcon width={14} height={14} className="mt-[3px] shrink-0 text-ink3 transition-colors group-hover:text-ink2" /> | |
| 194 | + <span className="flex min-w-0 flex-col gap-1"> | |
| 195 | + <span className="flex min-w-0 items-center gap-2"> | |
| 196 | + <span className="truncate type-ui font-medium text-ink">{releaseTitle(latest)}</span> | |
| 197 | + <Label tone="success">Latest</Label> | |
| 198 | + </span> | |
| 199 | + {latest.publishedAt && <span className="type-meta text-ink3">{timeAgo(latest.publishedAt)}</span>} | |
| 200 | + </span> | |
| 201 | + </Link> | |
| 202 | + ) : ( | |
| 203 | + <Link href={`${repo.base}/releases`} className="type-ui text-ink2 hover:text-ink"> | |
| 204 | + Only pre-releases so far | |
| 205 | + </Link> | |
| 206 | + )} | |
| 207 | + </section> | |
| 208 | + )} | |
| 209 | + | |
| 184 | 210 | {languages.length > 0 && <Languages languages={languages} />} |
| 185 | 211 | |
| 186 | 212 | <section> |
| @@ -0,0 +1,20 @@ | ||
| 1 | + import { downloadAsset, removeAsset, uploadAsset } from "@/lib/release-http"; | |
| 2 | + | |
| 3 | + type Context = RouteContext<"/[owner]/[repo]/releases/download/[...segments]">; | |
| 4 | + | |
| 5 | + export async function GET(request: Request, ctx: Context) { | |
| 6 | + const { owner, repo, segments } = await ctx.params; | |
| 7 | + return downloadAsset(request, owner, repo, segments); | |
| 8 | + } | |
| 9 | + | |
| 10 | + export const HEAD = GET; | |
| 11 | + | |
| 12 | + export async function PUT(request: Request, ctx: Context) { | |
| 13 | + const { owner, repo, segments } = await ctx.params; | |
| 14 | + return uploadAsset(request, owner, repo, segments); | |
| 15 | + } | |
| 16 | + | |
| 17 | + export async function DELETE(request: Request, ctx: Context) { | |
| 18 | + const { owner, repo, segments } = await ctx.params; | |
| 19 | + return removeAsset(request, owner, repo, segments); | |
| 20 | + } | |
| @@ -0,0 +1,34 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { deleteReleaseAssetAction, publishReleaseAction, saveReleaseAction } from "@/app/actions/releases"; | |
| 4 | + import { ReleaseForm } from "@/components/releases/release-form"; | |
| 5 | + import { PageTitle } from "@/components/ui"; | |
| 6 | + import { listBranches, listTags } from "@/lib/git"; | |
| 7 | + import { findRelease, releaseTags, releaseTitle } from "@/lib/releases"; | |
| 8 | + import { getWritableRepo } from "@/lib/repo"; | |
| 9 | + | |
| 10 | + export const metadata: Metadata = { title: "Edit release" }; | |
| 11 | + | |
| 12 | + export default async function EditReleasePage({ params }: PageProps<"/[owner]/[repo]/releases/edit/[...tag]">) { | |
| 13 | + const { owner, repo: repoParam, tag } = await params; | |
| 14 | + const repo = await getWritableRepo(owner, repoParam); | |
| 15 | + const release = await findRelease(repo.id, tag.join("/")); | |
| 16 | + if (!release) notFound(); | |
| 17 | + const [branches, tags, released] = await Promise.all([listBranches(repo.dir), listTags(repo.dir), releaseTags(repo.id)]); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div className="mx-auto max-w-4xl"> | |
| 21 | + <PageTitle title={`Edit ${releaseTitle(release)}`} /> | |
| 22 | + <ReleaseForm | |
| 23 | + base={repo.base} | |
| 24 | + release={release} | |
| 25 | + defaultTarget={repo.defaultBranch ?? ""} | |
| 26 | + branches={branches.map((b) => b.name)} | |
| 27 | + tags={tags.filter((t) => t === release.tag || !released.has(t))} | |
| 28 | + saveAction={saveReleaseAction.bind(null, repo.owner, repo.name)} | |
| 29 | + publishAction={publishReleaseAction.bind(null, repo.owner, repo.name)} | |
| 30 | + deleteAssetAction={deleteReleaseAssetAction.bind(null, repo.owner, repo.name)} | |
| 31 | + /> | |
| 32 | + </div> | |
| 33 | + ); | |
| 34 | + } | |
| @@ -0,0 +1,9 @@ | ||
| 1 | + import { downloadLatestAsset } from "@/lib/release-http"; | |
| 2 | + | |
| 3 | + /** A file of the latest release, so a link to it keeps pointing at the newest version. */ | |
| 4 | + export async function GET(request: Request, ctx: RouteContext<"/[owner]/[repo]/releases/latest/download/[name]">) { | |
| 5 | + const { owner, repo, name } = await ctx.params; | |
| 6 | + return downloadLatestAsset(request, owner, repo, name); | |
| 7 | + } | |
| 8 | + | |
| 9 | + export const HEAD = GET; | |
| @@ -0,0 +1,12 @@ | ||
| 1 | + import { redirect } from "next/navigation"; | |
| 2 | + import { encodePath } from "@/lib/format"; | |
| 3 | + import { latestRelease } from "@/lib/releases"; | |
| 4 | + import { getRepo } from "@/lib/repo"; | |
| 5 | + | |
| 6 | + /** A stable link to whatever the newest release is. */ | |
| 7 | + export default async function LatestReleasePage({ params }: PageProps<"/[owner]/[repo]/releases/latest">) { | |
| 8 | + const { owner, repo: repoParam } = await params; | |
| 9 | + const repo = await getRepo(owner, repoParam); | |
| 10 | + const latest = await latestRelease(repo.id); | |
| 11 | + redirect(latest ? `${repo.base}/releases/tag/${encodePath(latest.tag)}` : `${repo.base}/releases`); | |
| 12 | + } | |
| @@ -0,0 +1,41 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { deleteReleaseAssetAction, publishReleaseAction, saveReleaseAction } from "@/app/actions/releases"; | |
| 3 | + import { TagIcon } from "@/components/icons"; | |
| 4 | + import { ReleaseForm } from "@/components/releases/release-form"; | |
| 5 | + import { Blankslate, Box, PageTitle } from "@/components/ui"; | |
| 6 | + import { listBranches, listTags } from "@/lib/git"; | |
| 7 | + import { releaseTags } from "@/lib/releases"; | |
| 8 | + import { getWritableRepo } from "@/lib/repo"; | |
| 9 | + | |
| 10 | + export const metadata: Metadata = { title: "New release" }; | |
| 11 | + | |
| 12 | + export default async function NewReleasePage({ params, searchParams }: PageProps<"/[owner]/[repo]/releases/new">) { | |
| 13 | + const { owner, repo: repoParam } = await params; | |
| 14 | + const { tag } = await searchParams; | |
| 15 | + const repo = await getWritableRepo(owner, repoParam); | |
| 16 | + const [branches, tags, released] = await Promise.all([listBranches(repo.dir), listTags(repo.dir), releaseTags(repo.id)]); | |
| 17 | + | |
| 18 | + return ( | |
| 19 | + <div className="mx-auto max-w-4xl"> | |
| 20 | + <PageTitle title="New release" description="Tag a version, describe it and attach the files people download." /> | |
| 21 | + {repo.defaultBranch ? ( | |
| 22 | + <ReleaseForm | |
| 23 | + base={repo.base} | |
| 24 | + defaultTag={typeof tag === "string" ? tag : ""} | |
| 25 | + defaultTarget={repo.defaultBranch} | |
| 26 | + branches={branches.map((b) => b.name)} | |
| 27 | + tags={tags.filter((t) => !released.has(t))} | |
| 28 | + saveAction={saveReleaseAction.bind(null, repo.owner, repo.name)} | |
| 29 | + publishAction={publishReleaseAction.bind(null, repo.owner, repo.name)} | |
| 30 | + deleteAssetAction={deleteReleaseAssetAction.bind(null, repo.owner, repo.name)} | |
| 31 | + /> | |
| 32 | + ) : ( | |
| 33 | + <Box> | |
| 34 | + <Blankslate icon={<TagIcon />} title="This repository is empty"> | |
| 35 | + A release is made from a tag. Push some code first. | |
| 36 | + </Blankslate> | |
| 37 | + </Box> | |
| 38 | + )} | |
| 39 | + </div> | |
| 40 | + ); | |
| 41 | + } | |
| @@ -0,0 +1,103 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { CopyButton } from "@/components/clone-button"; | |
| 4 | + import { ChevronLeftIcon, ChevronRightIcon, PlusIcon, TagIcon } from "@/components/icons"; | |
| 5 | + import { ReleaseCard } from "@/components/releases/release-card"; | |
| 6 | + import { Blankslate, Box, ButtonLink, PageTitle } from "@/components/ui"; | |
| 7 | + import { latestRelease, listReleases, tagCommit } from "@/lib/releases"; | |
| 8 | + import { getRepo, type Repo } from "@/lib/repo"; | |
| 9 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Releases" }; | |
| 12 | + | |
| 13 | + const PAGE_SIZE = 10; | |
| 14 | + | |
| 15 | + function UploadInstructions({ repo, origin }: { repo: Repo; origin: string }) { | |
| 16 | + const url = `${origin}${repo.base}/releases`; | |
| 17 | + const commands = `# Upload a file to the release of a tag (a pushed tag without a release gets one) | |
| 18 | + curl -fT dist/app-linux-x64.tar.gz -u ${repo.viewer?.username ?? "<username>"}:$VECTRUM_TOKEN \\ | |
| 19 | + ${url}/download/v1.0.0/app-linux-x64.tar.gz | |
| 20 | + | |
| 21 | + # Always the newest release's file | |
| 22 | + curl -fLO ${url}/latest/download/app-linux-x64.tar.gz`; | |
| 23 | + return ( | |
| 24 | + <Box className="px-6 py-5"> | |
| 25 | + <h3 className="mb-1 type-title text-ink">Publish files from a script or workflow</h3> | |
| 26 | + <p className="mb-4 type-lead text-ink2"> | |
| 27 | + Upload with an{" "} | |
| 28 | + <Link href="/settings/tokens" className="text-ink underline decoration-rule underline-offset-2 hover:decoration-current"> | |
| 29 | + API token | |
| 30 | + </Link>{" "} | |
| 31 | + with read & write access. Uploading a file again replaces it. | |
| 32 | + </p> | |
| 33 | + <div className="flex items-start gap-2"> | |
| 34 | + <pre className="well flex-1 overflow-x-auto rounded-plate px-4 py-3 font-mono text-[11.5px] leading-6 text-ink">{commands}</pre> | |
| 35 | + <CopyButton text={commands} label="Copy commands" /> | |
| 36 | + </div> | |
| 37 | + </Box> | |
| 38 | + ); | |
| 39 | + } | |
| 40 | + | |
| 41 | + export default async function ReleasesPage({ params, searchParams }: PageProps<"/[owner]/[repo]/releases">) { | |
| 42 | + const { owner, repo: repoParam } = await params; | |
| 43 | + const query = await searchParams; | |
| 44 | + const repo = await getRepo(owner, repoParam); | |
| 45 | + const page = Math.max(1, Number(query.page) || 1); | |
| 46 | + | |
| 47 | + const [{ releases, total }, latest] = await Promise.all([ | |
| 48 | + listReleases(repo.id, { drafts: repo.canWrite, page, perPage: PAGE_SIZE }), | |
| 49 | + latestRelease(repo.id), | |
| 50 | + ]); | |
| 51 | + const shas = await Promise.all(releases.map((r) => tagCommit(repo, r.tag))); | |
| 52 | + const pageHref = (p: number) => `${repo.base}/releases?page=${p}`; | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <div className="mx-auto max-w-5xl"> | |
| 56 | + <PageTitle | |
| 57 | + title="Releases" | |
| 58 | + description="Versions of this repository, with their notes and files to download." | |
| 59 | + actions={ | |
| 60 | + repo.canWrite && | |
| 61 | + repo.defaultBranch && ( | |
| 62 | + <ButtonLink href={`${repo.base}/releases/new`} variant="primary" icon={<PlusIcon />}> | |
| 63 | + Draft a New Release | |
| 64 | + </ButtonLink> | |
| 65 | + ) | |
| 66 | + } | |
| 67 | + /> | |
| 68 | + <div className="flex flex-col gap-5"> | |
| 69 | + {releases.length === 0 && ( | |
| 70 | + <Box> | |
| 71 | + <Blankslate icon={<TagIcon />} title="No releases yet"> | |
| 72 | + {repo.canWrite | |
| 73 | + ? repo.defaultBranch | |
| 74 | + ? "Releases bundle a tag with notes and files to download: binaries, installers, checksums." | |
| 75 | + : "Push some code first; a release is made from a tag." | |
| 76 | + : "Nothing has been released here yet."} | |
| 77 | + </Blankslate> | |
| 78 | + </Box> | |
| 79 | + )} | |
| 80 | + {releases.map((r, i) => ( | |
| 81 | + <ReleaseCard key={r.id} repo={repo} release={r} sha={shas[i]} latest={r.id === latest?.id} /> | |
| 82 | + ))} | |
| 83 | + | |
| 84 | + {(page > 1 || page * PAGE_SIZE < total) && ( | |
| 85 | + <div className="flex justify-center gap-2"> | |
| 86 | + {page > 1 && ( | |
| 87 | + <ButtonLink href={pageHref(page - 1)} icon={<ChevronLeftIcon />}> | |
| 88 | + Newer | |
| 89 | + </ButtonLink> | |
| 90 | + )} | |
| 91 | + {page * PAGE_SIZE < total && ( | |
| 92 | + <ButtonLink href={pageHref(page + 1)}> | |
| 93 | + Older <ChevronRightIcon /> | |
| 94 | + </ButtonLink> | |
| 95 | + )} | |
| 96 | + </div> | |
| 97 | + )} | |
| 98 | + | |
| 99 | + {repo.canWrite && <UploadInstructions repo={repo} origin={await serverOrigin()} />} | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | + ); | |
| 103 | + } | |
| @@ -0,0 +1,33 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { ChevronLeftIcon } from "@/components/icons"; | |
| 4 | + import { ReleaseCard } from "@/components/releases/release-card"; | |
| 5 | + import { findRelease, latestRelease, releaseTitle, tagCommit } from "@/lib/releases"; | |
| 6 | + import { getRepo } from "@/lib/repo"; | |
| 7 | + | |
| 8 | + async function load({ params }: PageProps<"/[owner]/[repo]/releases/tag/[...tag]">) { | |
| 9 | + const { owner, repo: repoParam, tag } = await params; | |
| 10 | + const repo = await getRepo(owner, repoParam); | |
| 11 | + const release = await findRelease(repo.id, tag.join("/")); | |
| 12 | + // Drafts are only visible to people who can publish them. | |
| 13 | + if (!release || (release.draft && !repo.canWrite)) notFound(); | |
| 14 | + return { repo, release }; | |
| 15 | + } | |
| 16 | + | |
| 17 | + export async function generateMetadata(props: PageProps<"/[owner]/[repo]/releases/tag/[...tag]">) { | |
| 18 | + const { release } = await load(props); | |
| 19 | + return { title: `Release ${releaseTitle(release)}` }; | |
| 20 | + } | |
| 21 | + | |
| 22 | + export default async function ReleasePage(props: PageProps<"/[owner]/[repo]/releases/tag/[...tag]">) { | |
| 23 | + const { repo, release } = await load(props); | |
| 24 | + const [sha, latest] = await Promise.all([tagCommit(repo, release.tag), latestRelease(repo.id)]); | |
| 25 | + return ( | |
| 26 | + <div className="mx-auto max-w-5xl"> | |
| 27 | + <Link href={`${repo.base}/releases`} className="mb-4 inline-flex items-center gap-1 type-ui text-ink2 transition-colors hover:text-ink"> | |
| 28 | + <ChevronLeftIcon width={14} height={14} /> Releases | |
| 29 | + </Link> | |
| 30 | + <ReleaseCard repo={repo} release={release} sha={sha} latest={release.id === latest?.id} linkTitle={false} /> | |
| 31 | + </div> | |
| 32 | + ); | |
| 33 | + } | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { getViewer } from "@/lib/auth"; |
| 3 | - import { canWrite, findRepo, repoAccess, type Access } from "@/lib/repos"; | |
| 3 | + import { canWrite, findRepo, findRepoById, repoAccess, type Access } from "@/lib/repos"; | |
| 4 | 4 | |
| 5 | 5 | export class ActionError extends Error {} |
| 6 | 6 | |
| @@ -20,3 +20,14 @@ export async function repoForAction(owner: string, name: string, need: "read" | | ||
| 20 | 20 | if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings."); |
| 21 | 21 | return { repo, viewer, access }; |
| 22 | 22 | } |
| 23 | + | |
| 24 | + /** Of the repositories picked in a form, the ones the viewer may hand to a token or app with `permission`. */ | |
| 25 | + export async function grantableRepoIds(viewerId: string, ids: string[], permission: "read" | "write") { | |
| 26 | + const out: string[] = []; | |
| 27 | + for (const id of new Set(ids)) { | |
| 28 | + const repo = await findRepoById(id); | |
| 29 | + const access = repo ? await repoAccess(repo, viewerId) : null; | |
| 30 | + if (access && (permission === "read" || canWrite(access))) out.push(id); | |
| 31 | + } | |
| 32 | + return out; | |
| 33 | + } | |
| @@ -2,15 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { redirect } from "next/navigation"; |
| 5 | - import { | |
| 6 | - ESTIMATES, | |
| 7 | - STATUS_IDS, | |
| 8 | - isValidDay, | |
| 9 | - type ActionResult, | |
| 10 | - type IssueLabel, | |
| 11 | - type IssuePatch, | |
| 12 | - type NewIssueInput, | |
| 13 | - } from "@/lib/issue-meta"; | |
| 5 | + import type { ActionResult, IssueLabel, IssuePatch, NewIssueInput } from "@/lib/issue-meta"; | |
| 14 | 6 | import { |
| 15 | 7 | addComment, |
| 16 | 8 | createIssue, |
| @@ -18,10 +10,10 @@ import { | ||
| 18 | 10 | deleteIssue, |
| 19 | 11 | deleteLabel, |
| 20 | 12 | getIssue, |
| 13 | + IssueError, | |
| 21 | 14 | LabelError, |
| 22 | - listLabels, | |
| 23 | - listMembers, | |
| 24 | 15 | updateIssue, |
| 16 | + validateIssuePatch, | |
| 25 | 17 | } from "@/lib/issues"; |
| 26 | 18 | import { canWrite, type RepoRecord } from "@/lib/repos"; |
| 27 | 19 | import { ActionError, repoForAction } from "./guard"; |
| @@ -33,54 +25,6 @@ function revalidateIssues(repo: RepoRecord) { | ||
| 33 | 25 | revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); |
| 34 | 26 | } |
| 35 | 27 | |
| 36 | - /** | |
| 37 | - * Validates untrusted issue properties from the browser. Only the fields that | |
| 38 | - * are present are checked; anything invalid throws. | |
| 39 | - */ | |
| 40 | - async function validate(repo: RepoRecord, patch: IssuePatch): Promise<IssuePatch> { | |
| 41 | - const out: IssuePatch = {}; | |
| 42 | - if (patch.title !== undefined) { | |
| 43 | - const title = String(patch.title).trim().slice(0, 256); | |
| 44 | - if (!title) throw new ActionError("Title can't be blank."); | |
| 45 | - out.title = title; | |
| 46 | - } | |
| 47 | - if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_BODY); | |
| 48 | - if (patch.status !== undefined) { | |
| 49 | - if (!STATUS_IDS.includes(patch.status)) throw new ActionError("Unknown status."); | |
| 50 | - out.status = patch.status; | |
| 51 | - } | |
| 52 | - if (patch.priority !== undefined) { | |
| 53 | - if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new ActionError("Unknown priority."); | |
| 54 | - out.priority = patch.priority; | |
| 55 | - } | |
| 56 | - if (patch.assigneeId !== undefined) { | |
| 57 | - if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) { | |
| 58 | - throw new ActionError("Only the owner and collaborators can be assigned."); | |
| 59 | - } | |
| 60 | - out.assigneeId = patch.assigneeId; | |
| 61 | - } | |
| 62 | - if (patch.estimate !== undefined) { | |
| 63 | - if (patch.estimate !== null && !(ESTIMATES as readonly number[]).includes(patch.estimate)) throw new ActionError("Unknown estimate."); | |
| 64 | - out.estimate = patch.estimate; | |
| 65 | - } | |
| 66 | - if (patch.dueDate !== undefined) { | |
| 67 | - if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new ActionError("Invalid due date."); | |
| 68 | - out.dueDate = patch.dueDate; | |
| 69 | - } | |
| 70 | - if (patch.labelIds !== undefined) { | |
| 71 | - if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels."); | |
| 72 | - const known = new Set((await listLabels(repo.id)).map((l) => l.id)); | |
| 73 | - const ids = [...new Set(patch.labelIds)]; | |
| 74 | - if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label."); | |
| 75 | - out.labelIds = ids; | |
| 76 | - } | |
| 77 | - if (patch.sortOrder !== undefined) { | |
| 78 | - if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new ActionError("Invalid order."); | |
| 79 | - out.sortOrder = patch.sortOrder; | |
| 80 | - } | |
| 81 | - return out; | |
| 82 | - } | |
| 83 | - | |
| 84 | 28 | // Permissions: anyone who can read a repository may open issues and comment. |
| 85 | 29 | // Changing an issue in any way (title, description, status, triage, order) needs |
| 86 | 30 | // push access. Deleting is reserved for the issue's creator and the repository owner. |
| @@ -92,7 +36,7 @@ export async function createIssueAction( | ||
| 92 | 36 | ): Promise<ActionResult & { number?: number }> { |
| 93 | 37 | try { |
| 94 | 38 | const { repo, viewer, access } = await repoForAction(owner, name, "read"); |
| 95 | - const { title, body, ...properties } = await validate(repo, { ...input, sortOrder: undefined }); | |
| 39 | + const { title, body, ...properties } = await validateIssuePatch(repo, { ...input, sortOrder: undefined }); | |
| 96 | 40 | if (!title) return { error: "Title can't be blank." }; |
| 97 | 41 | const number = await createIssue(repo.id, viewer.id, { |
| 98 | 42 | title, |
| @@ -102,7 +46,7 @@ export async function createIssueAction( | ||
| 102 | 46 | revalidateIssues(repo); |
| 103 | 47 | return { number }; |
| 104 | 48 | } catch (e) { |
| 105 | - if (e instanceof ActionError) return { error: e.message }; | |
| 49 | + if (e instanceof ActionError || e instanceof IssueError) return { error: e.message }; | |
| 106 | 50 | throw e; |
| 107 | 51 | } |
| 108 | 52 | } |
| @@ -113,12 +57,12 @@ export async function updateIssueAction(owner: string, name: string, number: num | ||
| 113 | 57 | const issue = await getIssue(repo.id, number); |
| 114 | 58 | if (!issue) return { error: "Issue not found." }; |
| 115 | 59 | if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." }; |
| 116 | - const clean = await validate(repo, patch); | |
| 60 | + const clean = await validateIssuePatch(repo, patch); | |
| 117 | 61 | await updateIssue(issue, clean, viewer.id); |
| 118 | 62 | revalidateIssues(repo); |
| 119 | 63 | return {}; |
| 120 | 64 | } catch (e) { |
| 121 | - if (e instanceof ActionError) return { error: e.message }; | |
| 65 | + if (e instanceof ActionError || e instanceof IssueError) return { error: e.message }; | |
| 122 | 66 | throw e; |
| 123 | 67 | } |
| 124 | 68 | } |
| @@ -0,0 +1,35 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { approve, deniedRedirect, disconnectApp, parseAuthorizeRequest } from "@/lib/oauth"; | |
| 6 | + import { grantableRepoIds, viewerOrThrow } from "./guard"; | |
| 7 | + import type { FormState } from "./types"; | |
| 8 | + | |
| 9 | + /** | |
| 10 | + * The consent form on /oauth/authorize. The request is checked again here, since the parameters | |
| 11 | + * come back from the browser. Either way, the user ends up back at the application. | |
| 12 | + */ | |
| 13 | + export async function authorizeAction(params: Record<string, string>, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 14 | + const viewer = await viewerOrThrow(); | |
| 15 | + const parsed = await parseAuthorizeRequest(params); | |
| 16 | + if ("fatal" in parsed) return { error: parsed.fatal }; | |
| 17 | + if ("redirect" in parsed) redirect(parsed.redirect); | |
| 18 | + const { request } = parsed; | |
| 19 | + if (formData.get("decision") !== "allow") redirect(await deniedRedirect(request)); | |
| 20 | + | |
| 21 | + // The user may narrow what the application asked for, but not widen it. | |
| 22 | + const permission = request.permission === "write" && formData.get("permission") !== "read" ? "write" : "read"; | |
| 23 | + let repoIds: string[] | null = null; | |
| 24 | + if (formData.get("scope") === "selected") { | |
| 25 | + repoIds = await grantableRepoIds(viewer.id, formData.getAll("repos").map(String), permission); | |
| 26 | + if (repoIds.length === 0) return { error: "Select at least one repository." }; | |
| 27 | + } | |
| 28 | + redirect(await approve(request, viewer.id, { permission, repoIds })); | |
| 29 | + } | |
| 30 | + | |
| 31 | + export async function disconnectAppAction(grantId: string) { | |
| 32 | + const viewer = await viewerOrThrow(); | |
| 33 | + await disconnectApp(viewer.id, String(grantId)); | |
| 34 | + revalidatePath("/settings/applications"); | |
| 35 | + } | |
| @@ -0,0 +1,91 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci"; | |
| 6 | + import { GitError } from "@/lib/git"; | |
| 7 | + import { | |
| 8 | + createRelease, | |
| 9 | + deleteAsset, | |
| 10 | + deleteRelease, | |
| 11 | + findReleaseById, | |
| 12 | + publishRelease, | |
| 13 | + ReleaseError, | |
| 14 | + updateRelease, | |
| 15 | + type ReleaseInput, | |
| 16 | + } from "@/lib/releases"; | |
| 17 | + import { markPushed, type RepoRecord } from "@/lib/repos"; | |
| 18 | + import { ActionError, repoForAction } from "./guard"; | |
| 19 | + | |
| 20 | + export type ReleaseResult = { error?: string; release?: { id: string; tag: string } }; | |
| 21 | + | |
| 22 | + async function releaseOrThrow(repo: RepoRecord, id: string) { | |
| 23 | + const release = await findReleaseById(repo.id, id); | |
| 24 | + if (!release) throw new ActionError("Release not found."); | |
| 25 | + return release; | |
| 26 | + } | |
| 27 | + | |
| 28 | + /** A tag created from the browser starts workflows just like a push of it would. */ | |
| 29 | + async function afterSave(repo: RepoRecord, tag: string, createdTag: string | null, actorId: string) { | |
| 30 | + if (createdTag) { | |
| 31 | + await markPushed(repo.id); | |
| 32 | + startWorkflowsInBackground(onRefsUpdated(repo, new Map(), new Map([[`refs/tags/${tag}`, createdTag]]), actorId)); | |
| 33 | + } | |
| 34 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 35 | + } | |
| 36 | + | |
| 37 | + async function handle(fn: () => Promise<{ id: string; tag: string }>): Promise<ReleaseResult> { | |
| 38 | + try { | |
| 39 | + return { release: await fn() }; | |
| 40 | + } catch (e) { | |
| 41 | + if (e instanceof ActionError || e instanceof ReleaseError || e instanceof GitError) return { error: e.message }; | |
| 42 | + throw e; | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * Creates a release (`releaseId` null) or saves one. The browser uploads the files afterwards, so | |
| 48 | + * when it has files to add it saves a new release as a draft and publishes it once they're in. | |
| 49 | + */ | |
| 50 | + export async function saveReleaseAction(owner: string, name: string, releaseId: string | null, form: ReleaseInput) { | |
| 51 | + return handle(async () => { | |
| 52 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 53 | + const input: ReleaseInput = { | |
| 54 | + tag: String(form.tag ?? "").trim(), | |
| 55 | + target: String(form.target ?? "").trim(), | |
| 56 | + title: String(form.title ?? ""), | |
| 57 | + body: String(form.body ?? ""), | |
| 58 | + prerelease: form.prerelease === true, | |
| 59 | + draft: form.draft === true, | |
| 60 | + }; | |
| 61 | + const { release, createdTag } = releaseId | |
| 62 | + ? await updateRelease(repo, await releaseOrThrow(repo, releaseId), input) | |
| 63 | + : await createRelease(repo, viewer.id, input); | |
| 64 | + await afterSave(repo, release.tag, createdTag, viewer.id); | |
| 65 | + return { id: release.id, tag: release.tag }; | |
| 66 | + }); | |
| 67 | + } | |
| 68 | + | |
| 69 | + export async function publishReleaseAction(owner: string, name: string, releaseId: string) { | |
| 70 | + return handle(async () => { | |
| 71 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 72 | + const { release, createdTag } = await publishRelease(repo, await releaseOrThrow(repo, releaseId)); | |
| 73 | + await afterSave(repo, release.tag, createdTag, viewer.id); | |
| 74 | + return { id: release.id, tag: release.tag }; | |
| 75 | + }); | |
| 76 | + } | |
| 77 | + | |
| 78 | + export async function deleteReleaseAction(owner: string, name: string, releaseId: string) { | |
| 79 | + const { repo } = await repoForAction(owner, name, "write"); | |
| 80 | + await deleteRelease(await releaseOrThrow(repo, releaseId)); | |
| 81 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 82 | + redirect(`/${repo.owner}/${repo.name}/releases`); | |
| 83 | + } | |
| 84 | + | |
| 85 | + export async function deleteReleaseAssetAction(owner: string, name: string, releaseId: string, assetId: string) { | |
| 86 | + const { repo } = await repoForAction(owner, name, "write"); | |
| 87 | + const release = await releaseOrThrow(repo, releaseId); | |
| 88 | + const asset = release.assets.find((a) => a.id === assetId); | |
| 89 | + if (asset) await deleteAsset(release, asset); | |
| 90 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 91 | + } | |
| @@ -3,10 +3,9 @@ | ||
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { renewSession, signIn } from "@/lib/auth"; |
| 5 | 5 | import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; |
| 6 | - import { canWrite, findRepoById, repoAccess } from "@/lib/repos"; | |
| 7 | 6 | import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens"; |
| 8 | 7 | import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users"; |
| 9 | - import { viewerOrThrow } from "./guard"; | |
| 8 | + import { grantableRepoIds, viewerOrThrow } from "./guard"; | |
| 10 | 9 | import type { FormState } from "./types"; |
| 11 | 10 | |
| 12 | 11 | export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| @@ -63,15 +62,7 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P | ||
| 63 | 62 | const viewer = await viewerOrThrow(); |
| 64 | 63 | const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write"; |
| 65 | 64 | const expiry = String(formData.get("expiry") ?? "30"); |
| 66 | - let repoIds: string[] | null = null; | |
| 67 | - if (formData.get("scope") === "selected") { | |
| 68 | - repoIds = []; | |
| 69 | - for (const id of new Set(formData.getAll("repos").map(String))) { | |
| 70 | - const repo = await findRepoById(id); | |
| 71 | - const access = repo ? await repoAccess(repo, viewer.id) : null; | |
| 72 | - if (access && (permission === "read" || canWrite(access))) repoIds.push(id); | |
| 73 | - } | |
| 74 | - } | |
| 65 | + const repoIds = formData.get("scope") === "selected" ? await grantableRepoIds(viewer.id, formData.getAll("repos").map(String), permission) : null; | |
| 75 | 66 | try { |
| 76 | 67 | const token = await createToken(viewer.id, { |
| 77 | 68 | name: String(formData.get("name") ?? ""), |
| @@ -0,0 +1,44 @@ | ||
| 1 | + import { corsJson, preflight, withCors } from "@/lib/cors"; | |
| 2 | + import { handleMcpPost } from "@/lib/mcp/server"; | |
| 3 | + import { authenticateAccessToken } from "@/lib/oauth"; | |
| 4 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 5 | + import { authenticateToken } from "@/lib/tokens"; | |
| 6 | + | |
| 7 | + /** | |
| 8 | + * The MCP endpoint for agents. Credentials are `Authorization: Bearer …` with an OAuth access token | |
| 9 | + * (see lib/oauth.ts), or an API token for agents that can't do OAuth. Without valid credentials the | |
| 10 | + * 401 points to the OAuth metadata, which is how a client finds out where to sign the user in. | |
| 11 | + */ | |
| 12 | + async function authenticate(request: Request) { | |
| 13 | + const secret = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]; | |
| 14 | + if (!secret) return null; | |
| 15 | + const grant = await authenticateAccessToken(secret); | |
| 16 | + if (grant) return grant; | |
| 17 | + const token = await authenticateToken(secret); | |
| 18 | + return token ? { user: token.user, scope: token.token } : "invalid"; | |
| 19 | + } | |
| 20 | + | |
| 21 | + export async function POST(request: Request) { | |
| 22 | + const origin = await serverOrigin(); | |
| 23 | + const auth = await authenticate(request); | |
| 24 | + if (!auth || auth === "invalid") { | |
| 25 | + const challenge = [ | |
| 26 | + ...(auth === "invalid" ? ['error="invalid_token"', 'error_description="The access token is invalid or expired"'] : []), | |
| 27 | + `resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, | |
| 28 | + 'scope="read write"', | |
| 29 | + ]; | |
| 30 | + return corsJson({ error: auth ? "invalid_token" : "unauthorized", error_description: "Sign in with OAuth, or send an API token as a bearer token." }, 401, { | |
| 31 | + "WWW-Authenticate": `Bearer ${challenge.join(", ")}`, | |
| 32 | + }); | |
| 33 | + } | |
| 34 | + return withCors(await handleMcpPost(request, { ...auth, origin })); | |
| 35 | + } | |
| 36 | + | |
| 37 | + // Every answer comes back in the POST's response, so there is no event stream to open, and no | |
| 38 | + // session to end. | |
| 39 | + const notStreaming = () => | |
| 40 | + withCors(new Response("This MCP server answers every request directly. Send JSON-RPC messages with POST.\n", { status: 405, headers: { Allow: "POST" } })); | |
| 41 | + | |
| 42 | + export const GET = notStreaming; | |
| 43 | + export const DELETE = notStreaming; | |
| 44 | + export const OPTIONS = preflight; | |
| @@ -0,0 +1,23 @@ | ||
| 1 | + import { corsJson, preflight } from "@/lib/cors"; | |
| 2 | + import { OAuthError, registerClient } from "@/lib/oauth"; | |
| 3 | + | |
| 4 | + /** Dynamic client registration (RFC 7591): how an agent introduces itself before asking the user. */ | |
| 5 | + export async function POST(request: Request) { | |
| 6 | + let metadata: unknown; | |
| 7 | + try { | |
| 8 | + metadata = await request.json(); | |
| 9 | + } catch { | |
| 10 | + metadata = null; | |
| 11 | + } | |
| 12 | + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { | |
| 13 | + return corsJson({ error: "invalid_client_metadata", error_description: "Send the client metadata as a JSON object." }, 400); | |
| 14 | + } | |
| 15 | + try { | |
| 16 | + return corsJson(await registerClient(metadata as Record<string, unknown>), 201); | |
| 17 | + } catch (e) { | |
| 18 | + if (e instanceof OAuthError) return corsJson({ error: e.code, error_description: e.message }, e.status); | |
| 19 | + throw e; | |
| 20 | + } | |
| 21 | + } | |
| 22 | + | |
| 23 | + export const OPTIONS = preflight; | |
| @@ -0,0 +1,12 @@ | ||
| 1 | + import { corsJson, preflight } from "@/lib/cors"; | |
| 2 | + import { revokeToken } from "@/lib/oauth"; | |
| 3 | + | |
| 4 | + /** Token revocation (RFC 7009): an agent signing out ends its grant. Always answers 200. */ | |
| 5 | + export async function POST(request: Request) { | |
| 6 | + const form = await request.formData().catch(() => null); | |
| 7 | + const token = form?.get("token"); | |
| 8 | + if (typeof token === "string" && token) await revokeToken(token); | |
| 9 | + return corsJson({}); | |
| 10 | + } | |
| 11 | + | |
| 12 | + export const OPTIONS = preflight; | |
| @@ -0,0 +1,49 @@ | ||
| 1 | + import { corsJson, preflight } from "@/lib/cors"; | |
| 2 | + import { exchangeCode, OAuthError, refreshGrant } from "@/lib/oauth"; | |
| 3 | + | |
| 4 | + /** Token request parameters: a form body as OAuth specifies, or JSON, which some clients send. */ | |
| 5 | + async function readParams(request: Request) { | |
| 6 | + const params = new Map<string, string>(); | |
| 7 | + if ((request.headers.get("content-type") ?? "").includes("application/json")) { | |
| 8 | + const body: unknown = await request.json().catch(() => null); | |
| 9 | + if (body && typeof body === "object") for (const [k, v] of Object.entries(body)) if (typeof v === "string") params.set(k, v); | |
| 10 | + } else { | |
| 11 | + const form = await request.formData().catch(() => null); | |
| 12 | + form?.forEach((v, k) => typeof v === "string" && params.set(k, v)); | |
| 13 | + } | |
| 14 | + // Clients are public, but one may still send its id as the Basic auth username. | |
| 15 | + const header = request.headers.get("authorization"); | |
| 16 | + if (header?.startsWith("Basic ") && !params.has("client_id")) { | |
| 17 | + const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); | |
| 18 | + const colon = decoded.indexOf(":"); | |
| 19 | + params.set("client_id", colon === -1 ? decoded : decoded.slice(0, colon)); | |
| 20 | + } | |
| 21 | + return (key: string) => params.get(key) ?? ""; | |
| 22 | + } | |
| 23 | + | |
| 24 | + export async function POST(request: Request) { | |
| 25 | + const param = await readParams(request); | |
| 26 | + const headers = { Pragma: "no-cache" }; | |
| 27 | + try { | |
| 28 | + const clientId = param("client_id"); | |
| 29 | + if (!clientId) throw new OAuthError("invalid_client", "client_id is required.", 401); | |
| 30 | + const resource = param("resource") || undefined; | |
| 31 | + switch (param("grant_type")) { | |
| 32 | + case "authorization_code": | |
| 33 | + return corsJson( | |
| 34 | + await exchangeCode({ code: param("code"), clientId, redirectUri: param("redirect_uri"), codeVerifier: param("code_verifier"), resource }), | |
| 35 | + 200, | |
| 36 | + headers, | |
| 37 | + ); | |
| 38 | + case "refresh_token": | |
| 39 | + return corsJson(await refreshGrant({ refreshToken: param("refresh_token"), clientId, resource }), 200, headers); | |
| 40 | + default: | |
| 41 | + throw new OAuthError("unsupported_grant_type", "grant_type must be authorization_code or refresh_token."); | |
| 42 | + } | |
| 43 | + } catch (e) { | |
| 44 | + if (e instanceof OAuthError) return corsJson({ error: e.code, error_description: e.message }, e.status, headers); | |
| 45 | + throw e; | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + export const OPTIONS = preflight; | |
| @@ -0,0 +1,118 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { Label as MuLabel } from "@unlocalhosted/metalui"; | |
| 3 | + import { disconnectAppAction } from "@/app/actions/oauth"; | |
| 4 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 5 | + import { SparkleIcon } from "@/components/icons"; | |
| 6 | + import { SettingsSection } from "@/components/metal"; | |
| 7 | + import { CopyIconButton } from "@/components/settings-fields"; | |
| 8 | + import { ListRow, PlateBody, PlateSection, RowGlyph } from "@/components/settings-plate"; | |
| 9 | + import { Blankslate, Label, PageTitle } from "@/components/ui"; | |
| 10 | + import { requireViewer } from "@/lib/auth"; | |
| 11 | + import { timeAgo } from "@/lib/format"; | |
| 12 | + import { listConnectedApps, mcpResource } from "@/lib/oauth"; | |
| 13 | + | |
| 14 | + export const metadata: Metadata = { title: "Connected apps" }; | |
| 15 | + | |
| 16 | + function Command({ children }: { children: string }) { | |
| 17 | + return ( | |
| 18 | + <div className="flex items-center gap-2 rounded-row recipe-well-field py-1.5 pr-1.5 pl-3.5"> | |
| 19 | + <code className="flex-1 overflow-x-auto font-mono text-[12px] leading-5 whitespace-pre text-ink">{children}</code> | |
| 20 | + <CopyIconButton text={children} label="Copy" toast="Copied" /> | |
| 21 | + </div> | |
| 22 | + ); | |
| 23 | + } | |
| 24 | + | |
| 25 | + export default async function ConnectedAppsPage() { | |
| 26 | + const viewer = await requireViewer("/settings/applications"); | |
| 27 | + const [apps, url] = await Promise.all([listConnectedApps(viewer.id), mcpResource()]); | |
| 28 | + | |
| 29 | + return ( | |
| 30 | + <div> | |
| 31 | + <PageTitle | |
| 32 | + title="Connected apps" | |
| 33 | + description="AI agents and other MCP clients you’ve allowed to work with your repositories: read code, commit, manage issues and run workflows." | |
| 34 | + /> | |
| 35 | + <div className="flex flex-col gap-7"> | |
| 36 | + {apps.length === 0 ? ( | |
| 37 | + <PlateSection title="Your connected apps"> | |
| 38 | + <Blankslate icon={<SparkleIcon />} title="No connected apps"> | |
| 39 | + Connect an agent with the server address below. It will send you here to approve it. | |
| 40 | + </Blankslate> | |
| 41 | + </PlateSection> | |
| 42 | + ) : ( | |
| 43 | + <SettingsSection title={`Your connected apps · ${apps.length}`}> | |
| 44 | + {apps.map((app) => ( | |
| 45 | + <ListRow | |
| 46 | + key={app.id} | |
| 47 | + lead={ | |
| 48 | + <RowGlyph> | |
| 49 | + <SparkleIcon /> | |
| 50 | + </RowGlyph> | |
| 51 | + } | |
| 52 | + title={ | |
| 53 | + <> | |
| 54 | + <span className="truncate">{app.name}</span> | |
| 55 | + <Label tone={app.permission === "write" ? "accent" : "default"}> | |
| 56 | + {app.permission === "write" ? "Read & write" : "Read-only"} | |
| 57 | + </Label> | |
| 58 | + </> | |
| 59 | + } | |
| 60 | + meta={ | |
| 61 | + <> | |
| 62 | + <span className="truncate type-body text-ink2"> | |
| 63 | + {app.allRepos ? "All repositories" : app.repos.map((r) => r.fullName).join(", ") || "No repositories (deleted)"} | |
| 64 | + </span> | |
| 65 | + <MuLabel variant="engraved"> | |
| 66 | + Connected <b>{timeAgo(app.createdAt)}</b> ·{" "} | |
| 67 | + {app.lastUsedAt ? ( | |
| 68 | + <> | |
| 69 | + Last used <b>{timeAgo(app.lastUsedAt)}</b> | |
| 70 | + </> | |
| 71 | + ) : ( | |
| 72 | + "Never used" | |
| 73 | + )} | |
| 74 | + </MuLabel> | |
| 75 | + </> | |
| 76 | + } | |
| 77 | + > | |
| 78 | + <ConfirmButton | |
| 79 | + action={disconnectAppAction.bind(null, app.id)} | |
| 80 | + message={`Disconnect “${app.name}”? It loses access right away and has to ask you again.`} | |
| 81 | + variant="default" | |
| 82 | + > | |
| 83 | + Disconnect | |
| 84 | + </ConfirmButton> | |
| 85 | + </ListRow> | |
| 86 | + ))} | |
| 87 | + </SettingsSection> | |
| 88 | + )} | |
| 89 | + | |
| 90 | + <PlateSection title="Connect an agent"> | |
| 91 | + <PlateBody className="max-w-2xl"> | |
| 92 | + <div className="flex flex-col gap-1.5"> | |
| 93 | + <p className="type-title text-ink">MCP server address</p> | |
| 94 | + <p className="type-body text-ink2"> | |
| 95 | + Add it to your agent as a remote MCP server (Streamable HTTP). It signs in with OAuth: you approve it here, and choose what it may | |
| 96 | + reach. | |
| 97 | + </p> | |
| 98 | + <Command>{url}</Command> | |
| 99 | + </div> | |
| 100 | + <div className="flex flex-col gap-1.5"> | |
| 101 | + <p className="type-title text-ink">Claude Code</p> | |
| 102 | + <Command>{`claude mcp add --transport http vectrum ${url}`}</Command> | |
| 103 | + <p className="type-body text-ink2"> | |
| 104 | + Then run <code className="font-mono text-[12px]">/mcp</code> in Claude Code and choose <i>Authenticate</i>. | |
| 105 | + </p> | |
| 106 | + </div> | |
| 107 | + <div className="flex flex-col gap-1.5"> | |
| 108 | + <p className="type-title text-ink">Agents without OAuth</p> | |
| 109 | + <p className="type-body text-ink2"> | |
| 110 | + Send an API token from <i>Settings → API tokens</i> instead, as <code className="font-mono text-[12px]">Authorization: Bearer vec_…</code>. | |
| 111 | + </p> | |
| 112 | + </div> | |
| 113 | + </PlateBody> | |
| 114 | + </PlateSection> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + ); | |
| 118 | + } | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { SettingsNav } from "@/components/settings-nav"; |
| 2 | - import { KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon } from "@/components/icons"; | |
| 2 | + import { KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon, SparkleIcon } from "@/components/icons"; | |
| 3 | 3 | import { Avatar } from "@/components/ui"; |
| 4 | 4 | import { requireViewer } from "@/lib/auth"; |
| 5 | 5 | |
| @@ -22,6 +22,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting | ||
| 22 | 22 | { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> }, |
| 23 | 23 | { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> }, |
| 24 | 24 | { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> }, |
| 25 | + { href: "/settings/applications", label: "Connected apps", icon: <SparkleIcon /> }, | |
| 25 | 26 | ]} |
| 26 | 27 | /> |
| 27 | 28 | <div className="min-w-0">{children}</div> |
| @@ -1,15 +1,14 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState, useState } from "react"; |
| 4 | - import { Checkbox, Fieldset, FormField, Select } from "@unlocalhosted/metalui"; | |
| 4 | + import { Fieldset, FormField, Select } from "@unlocalhosted/metalui"; | |
| 5 | 5 | import { createTokenAction } from "@/app/actions/settings"; |
| 6 | 6 | import { PlusIcon } from "@/components/icons"; |
| 7 | + import { RepoChecklist, type RepoOption } from "@/components/repo-checklist"; | |
| 7 | 8 | import { ChoiceCards, CopyIconButton, TextField } from "@/components/settings-fields"; |
| 8 | 9 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 9 | 10 | import { Alert, Button } from "@/components/ui"; |
| 10 | 11 | |
| 11 | - type RepoOption = { id: string; fullName: string; writable: boolean }; | |
| 12 | - | |
| 13 | 12 | const expiries = [ |
| 14 | 13 | { value: "7", label: "7 days" }, |
| 15 | 14 | { value: "30", label: "30 days" }, |
| @@ -92,26 +91,7 @@ export function NewTokenForm({ repos, origin, username }: { repos: RepoOption[]; | ||
| 92 | 91 | { value: "selected", title: "Only select repositories" }, |
| 93 | 92 | ]} |
| 94 | 93 | /> |
| 95 | - {scope === "selected" && ( | |
| 96 | - <div className="max-h-64 overflow-y-auto rounded-plate recipe-well-field p-1" role="group" aria-label="Repositories"> | |
| 97 | - {repos.length === 0 && <p className="px-3 py-3 type-body text-ink3">You don’t have any repositories yet.</p>} | |
| 98 | - {repos.map((r) => { | |
| 99 | - const disabled = permission === "write" && !r.writable; | |
| 100 | - return ( | |
| 101 | - <label | |
| 102 | - key={r.id} | |
| 103 | - className={`flex min-h-8 items-center gap-2.5 rounded-row px-3 py-1.5 ${ | |
| 104 | - disabled ? "cursor-default opacity-50" : "cursor-pointer hover:recipe-row-list-hover" | |
| 105 | - }`} | |
| 106 | - > | |
| 107 | - <Checkbox size="row" name="repos" value={String(r.id)} disabled={disabled} /> | |
| 108 | - <span className="font-mono text-[12px] text-ink">{r.fullName}</span> | |
| 109 | - {disabled && <span className="ml-auto type-label engraved">No push access</span>} | |
| 110 | - </label> | |
| 111 | - ); | |
| 112 | - })} | |
| 113 | - </div> | |
| 114 | - )} | |
| 94 | + {scope === "selected" && <RepoChecklist repos={repos} needWrite={permission === "write"} />} | |
| 115 | 95 | </Fieldset> |
| 116 | 96 | |
| 117 | 97 | {state?.error && <Alert>{state.error}</Alert>} |
| @@ -0,0 +1,139 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { ReactNode } from "react"; | |
| 3 | + import { deleteReleaseAction } from "@/app/actions/releases"; | |
| 4 | + import { CopyButton } from "@/components/clone-button"; | |
| 5 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 6 | + import { ShaChip } from "@/components/file-table"; | |
| 7 | + import { CodeIcon, DownloadIcon, PackageIcon, PencilIcon, TagIcon } from "@/components/icons"; | |
| 8 | + import { Markdown } from "@/components/markdown"; | |
| 9 | + import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; | |
| 10 | + import { Avatar, Box, ButtonLink, Label, RefChip } from "@/components/ui"; | |
| 11 | + import { encodePath, formatBytes, formatDate, timeAgo } from "@/lib/format"; | |
| 12 | + import { releaseTitle, type Release } from "@/lib/releases"; | |
| 13 | + import type { Repo } from "@/lib/repo"; | |
| 14 | + | |
| 15 | + export const releaseHref = (repo: Repo, tag: string) => `${repo.base}/releases/tag/${encodePath(tag)}`; | |
| 16 | + export const assetHref = (repo: Repo, tag: string, name: string) => `${repo.base}/releases/download/${encodePath(tag)}/${encodeURIComponent(name)}`; | |
| 17 | + | |
| 18 | + function Time({ ms, prefix }: { ms: number; prefix?: string }) { | |
| 19 | + return ( | |
| 20 | + <span className="type-meta text-ink3"> | |
| 21 | + {prefix && `${prefix} `} | |
| 22 | + <time dateTime={new Date(ms).toISOString()} title={formatDate(ms)} className="type-readout"> | |
| 23 | + {timeAgo(ms)} | |
| 24 | + </time> | |
| 25 | + </span> | |
| 26 | + ); | |
| 27 | + } | |
| 28 | + | |
| 29 | + function AssetRow({ href, name, icon, children }: { href: string; name: string; icon: ReactNode; children?: ReactNode }) { | |
| 30 | + return ( | |
| 31 | + <li className={`${liftRowClass} flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5`}> | |
| 32 | + <span className="icon-tile size-7 shrink-0 rounded-row">{icon}</span> | |
| 33 | + <a href={href} download className="min-w-0 truncate font-mono text-[12px] text-ink decoration-ink3 underline-offset-4 hover:underline"> | |
| 34 | + {name} | |
| 35 | + </a> | |
| 36 | + {children} | |
| 37 | + </li> | |
| 38 | + ); | |
| 39 | + } | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * A release on its plate: what it is (title, state, tag, commit, who and when), its notes, and its | |
| 43 | + * files with the source code at the tag. | |
| 44 | + */ | |
| 45 | + export function ReleaseCard({ repo, release, sha, latest, linkTitle = true }: { repo: Repo; release: Release; sha: string | null; latest: boolean; linkTitle?: boolean }) { | |
| 46 | + const title = releaseTitle(release); | |
| 47 | + const tagPath = `refs/tags/${encodePath(release.tag)}`; | |
| 48 | + return ( | |
| 49 | + <Box className="overflow-hidden"> | |
| 50 | + <div className="flex flex-col gap-2.5 px-6 pt-5 pb-4"> | |
| 51 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 52 | + <h2 className="min-w-0 text-[19px] leading-7 font-semibold tracking-[-0.02em] text-ink"> | |
| 53 | + {linkTitle ? ( | |
| 54 | + <Link href={releaseHref(repo, release.tag)} className="decoration-ink3 underline-offset-4 hover:underline"> | |
| 55 | + {title} | |
| 56 | + </Link> | |
| 57 | + ) : ( | |
| 58 | + title | |
| 59 | + )} | |
| 60 | + </h2> | |
| 61 | + {release.draft && <Label tone="attention">Draft</Label>} | |
| 62 | + {release.prerelease && <Label tone="attention">Pre-release</Label>} | |
| 63 | + {latest && <Label tone="success">Latest</Label>} | |
| 64 | + {repo.canWrite && ( | |
| 65 | + <div className="ml-auto flex items-center gap-1"> | |
| 66 | + <ButtonLink href={`${repo.base}/releases/edit/${encodePath(release.tag)}`} variant="invisible" icon={<PencilIcon width={14} height={14} />}> | |
| 67 | + Edit | |
| 68 | + </ButtonLink> | |
| 69 | + <ConfirmButton | |
| 70 | + action={deleteReleaseAction.bind(null, repo.owner, repo.name, release.id)} | |
| 71 | + message={`Delete the release ${title}? ${release.assets.length === 1 ? "Its file is" : "Its files are"} deleted with it${sha ? `; the tag ${release.tag} stays` : ""}.`} | |
| 72 | + variant="default" | |
| 73 | + > | |
| 74 | + Delete | |
| 75 | + </ConfirmButton> | |
| 76 | + </div> | |
| 77 | + )} | |
| 78 | + </div> | |
| 79 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5"> | |
| 80 | + <span className="inline-flex items-center gap-1.5"> | |
| 81 | + <TagIcon width={13} height={13} className="text-ink3" /> | |
| 82 | + <RefChip>{release.tag}</RefChip> | |
| 83 | + </span> | |
| 84 | + {sha ? ( | |
| 85 | + <ShaChip sha={sha} href={`${repo.base}/commit/${sha}`} /> | |
| 86 | + ) : ( | |
| 87 | + <span className="type-meta text-ink3"> | |
| 88 | + {release.draft ? <>Tag is created from <RefChip>{release.target}</RefChip> when published</> : "Tag no longer exists"} | |
| 89 | + </span> | |
| 90 | + )} | |
| 91 | + {release.author && ( | |
| 92 | + <Link href={`/${release.author}`} className="inline-flex items-center gap-1.5 type-meta font-medium text-ink2 hover:text-ink"> | |
| 93 | + <Avatar name={release.author} size={18} /> {release.author} | |
| 94 | + </Link> | |
| 95 | + )} | |
| 96 | + {release.publishedAt ? <Time ms={release.publishedAt} prefix="released" /> : <Time ms={release.createdAt} prefix="drafted" />} | |
| 97 | + </div> | |
| 98 | + </div> | |
| 99 | + | |
| 100 | + {release.body.trim() && ( | |
| 101 | + <div className="rule-t px-6 py-5"> | |
| 102 | + <Markdown context={sha ? { base: repo.base, gitRef: release.tag, dir: "" } : undefined}>{release.body}</Markdown> | |
| 103 | + </div> | |
| 104 | + )} | |
| 105 | + | |
| 106 | + <div className="rule-t p-1.5"> | |
| 107 | + <h3 className={plateHeadingClass}> | |
| 108 | + Assets <span className="type-readout text-ink3 [text-shadow:none]">{release.assets.length + (sha ? 2 : 0)}</span> | |
| 109 | + </h3> | |
| 110 | + <ul> | |
| 111 | + {release.assets.map((a) => ( | |
| 112 | + <AssetRow key={a.id} href={assetHref(repo, release.tag, a.name)} name={a.name} icon={<PackageIcon width={14} height={14} />}> | |
| 113 | + <span className="ml-auto flex shrink-0 items-center gap-0.5 max-md:hidden"> | |
| 114 | + <code className="type-readout text-ink3" title={`SHA-256 ${a.sha256}`}> | |
| 115 | + sha256:{a.sha256.slice(0, 12)} | |
| 116 | + </code> | |
| 117 | + <CopyButton text={a.sha256} label="Copy SHA-256 checksum" /> | |
| 118 | + </span> | |
| 119 | + <span className="w-20 shrink-0 text-right type-readout text-ink2 max-md:ml-auto">{formatBytes(a.size)}</span> | |
| 120 | + <span className="flex w-14 shrink-0 items-center justify-end gap-1 type-readout text-ink3" title={`${a.downloads} downloads`}> | |
| 121 | + <DownloadIcon width={12} height={12} /> {a.downloads} | |
| 122 | + </span> | |
| 123 | + <span className="w-24 shrink-0 text-right max-sm:hidden"> | |
| 124 | + <Time ms={a.updatedAt} /> | |
| 125 | + </span> | |
| 126 | + </AssetRow> | |
| 127 | + ))} | |
| 128 | + {sha && ( | |
| 129 | + <> | |
| 130 | + <AssetRow href={`${repo.base}/archive/${tagPath}.zip`} name="Source code (zip)" icon={<CodeIcon width={14} height={14} />} /> | |
| 131 | + <AssetRow href={`${repo.base}/archive/${tagPath}.tar.gz`} name="Source code (tar.gz)" icon={<CodeIcon width={14} height={14} />} /> | |
| 132 | + </> | |
| 133 | + )} | |
| 134 | + </ul> | |
| 135 | + {release.assets.length === 0 && !sha && <p className="px-3 pb-3 type-meta text-ink3">No files yet.</p>} | |
| 136 | + </div> | |
| 137 | + </Box> | |
| 138 | + ); | |
| 139 | + } | |
| @@ -0,0 +1,267 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useRouter } from "next/navigation"; | |
| 4 | + import { useId, useState } from "react"; | |
| 5 | + import { Attachment, DropZone, Select, Switch, TabList, TabPanel, Tabs, Textarea } from "@unlocalhosted/metalui"; | |
| 6 | + import type { ReleaseResult } from "@/app/actions/releases"; | |
| 7 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 8 | + import { BranchIcon, CodeIcon, CommitIcon, EyeIcon, PackageIcon, TagIcon, UploadIcon } from "@/components/icons"; | |
| 9 | + import { MarkdownPreview } from "@/components/markdown-preview"; | |
| 10 | + import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; | |
| 11 | + import { TextField } from "@/components/settings-fields"; | |
| 12 | + import { PlateBody } from "@/components/settings-plate"; | |
| 13 | + import { Alert, Box, Button, ButtonLink } from "@/components/ui"; | |
| 14 | + import { encodePath, formatBytes } from "@/lib/format"; | |
| 15 | + import type { ReleaseAsset, ReleaseInput } from "@/lib/releases"; | |
| 16 | + | |
| 17 | + type Pending = { key: string; file: File; progress?: number; error?: string; done?: boolean }; | |
| 18 | + | |
| 19 | + /** PUTs a file to the release's download URL, reporting progress; rejects with the server's reason. */ | |
| 20 | + function upload(url: string, file: File, onProgress: (percent: number) => void) { | |
| 21 | + return new Promise<void>((resolve, reject) => { | |
| 22 | + const xhr = new XMLHttpRequest(); | |
| 23 | + xhr.open("PUT", url); | |
| 24 | + xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(Math.round((e.loaded / e.total) * 100)); | |
| 25 | + xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText.trim() || `Failed (${xhr.status})`))); | |
| 26 | + xhr.onerror = () => reject(new Error("Network error")); | |
| 27 | + xhr.send(file); | |
| 28 | + }); | |
| 29 | + } | |
| 30 | + | |
| 31 | + export type EditableRelease = Omit<ReleaseInput, "draft"> & { id: string; draft: boolean; assets: ReleaseAsset[] }; | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * Drafting or editing a release. Saving goes through a server action; the files go straight to | |
| 35 | + * the release's download URLs afterwards, so they can be as large as the disk allows. A new release | |
| 36 | + * with files is saved as a draft until they are all uploaded, then published. | |
| 37 | + */ | |
| 38 | + export function ReleaseForm({ | |
| 39 | + base, | |
| 40 | + release, | |
| 41 | + defaultTag = "", | |
| 42 | + defaultTarget, | |
| 43 | + branches, | |
| 44 | + tags, | |
| 45 | + saveAction, | |
| 46 | + publishAction, | |
| 47 | + deleteAssetAction, | |
| 48 | + }: { | |
| 49 | + base: string; | |
| 50 | + release?: EditableRelease; | |
| 51 | + defaultTag?: string; | |
| 52 | + defaultTarget: string; | |
| 53 | + branches: string[]; | |
| 54 | + /** Existing tags, offered as suggestions. */ | |
| 55 | + tags: string[]; | |
| 56 | + saveAction: (releaseId: string | null, input: ReleaseInput) => Promise<ReleaseResult>; | |
| 57 | + publishAction: (releaseId: string) => Promise<ReleaseResult>; | |
| 58 | + deleteAssetAction: (releaseId: string, assetId: string) => Promise<void>; | |
| 59 | + }) { | |
| 60 | + const router = useRouter(); | |
| 61 | + const tagListId = useId(); | |
| 62 | + const [tag, setTag] = useState(release?.tag ?? defaultTag); | |
| 63 | + const [target, setTarget] = useState(release?.target || defaultTarget); | |
| 64 | + const [title, setTitle] = useState(release?.title ?? ""); | |
| 65 | + const [body, setBody] = useState(release?.body ?? ""); | |
| 66 | + const [prerelease, setPrerelease] = useState(release?.prerelease ?? false); | |
| 67 | + const [files, setFiles] = useState<Pending[]>([]); | |
| 68 | + const [error, setError] = useState<string | null>(null); | |
| 69 | + const [busy, setBusy] = useState<"draft" | "publish" | null>(null); | |
| 70 | + // Once saved here (say, before an upload failed), saving again updates that release. | |
| 71 | + const [saved, setSaved] = useState<{ id: string; draft: boolean } | null>(release ? { id: release.id, draft: release.draft } : null); | |
| 72 | + | |
| 73 | + const tagExists = tags.includes(tag.trim()); | |
| 74 | + const isDraft = saved?.draft ?? true; | |
| 75 | + const targets = branches.includes(target) || !target ? branches : [target, ...branches]; | |
| 76 | + | |
| 77 | + const patch = (key: string, change: Partial<Pending>) => setFiles((current) => current.map((f) => (f.key === key ? { ...f, ...change } : f))); | |
| 78 | + | |
| 79 | + async function submit(intent: "draft" | "publish") { | |
| 80 | + setError(null); | |
| 81 | + setBusy(intent); | |
| 82 | + try { | |
| 83 | + const pending = files.filter((f) => !f.done); | |
| 84 | + // Hold back publishing until the files are in, so nobody sees the release without them. | |
| 85 | + const holdForFiles = intent === "publish" && isDraft && pending.length > 0; | |
| 86 | + const draft = isDraft && (intent === "draft" || holdForFiles); | |
| 87 | + const result = await saveAction(saved?.id ?? null, { tag, target, title, body, prerelease, draft }); | |
| 88 | + if (!result.release) return setError(result.error ?? "The release couldn’t be saved."); | |
| 89 | + const { id, tag: savedTag } = result.release; | |
| 90 | + setSaved({ id, draft }); | |
| 91 | + | |
| 92 | + let failed = 0; | |
| 93 | + for (const f of pending) { | |
| 94 | + patch(f.key, { progress: 0, error: undefined }); | |
| 95 | + try { | |
| 96 | + await upload(`${base}/releases/download/${encodePath(savedTag)}/${encodeURIComponent(f.file.name)}`, f.file, (progress) => patch(f.key, { progress })); | |
| 97 | + patch(f.key, { progress: undefined, done: true }); | |
| 98 | + } catch (e) { | |
| 99 | + failed++; | |
| 100 | + patch(f.key, { progress: undefined, error: e instanceof Error ? e.message : "Failed" }); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + if (failed) { | |
| 104 | + return setError( | |
| 105 | + `${failed === 1 ? "A file" : `${failed} files`} didn’t upload. The release is saved${draft ? " as a draft" : ""}; try again to upload the rest.`, | |
| 106 | + ); | |
| 107 | + } | |
| 108 | + if (holdForFiles) { | |
| 109 | + const published = await publishAction(id); | |
| 110 | + if (published.error) return setError(published.error); | |
| 111 | + } | |
| 112 | + router.push(`${base}/releases/tag/${encodePath(savedTag)}`); | |
| 113 | + router.refresh(); | |
| 114 | + } finally { | |
| 115 | + setBusy(null); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + | |
| 119 | + const add = (taken: File[]) => | |
| 120 | + setFiles((current) => { | |
| 121 | + // A file with the name of one already chosen replaces it. | |
| 122 | + const names = new Set(taken.map((f) => f.name)); | |
| 123 | + return [...current.filter((f) => !names.has(f.file.name)), ...taken.map((file) => ({ key: `${file.name}:${file.size}:${file.lastModified}`, file }))]; | |
| 124 | + }); | |
| 125 | + | |
| 126 | + return ( | |
| 127 | + // Not a <form>: saving is done by the buttons (Enter in a field mustn't publish a release), and | |
| 128 | + // the files' delete buttons are forms of their own. | |
| 129 | + <div className="flex flex-col gap-4"> | |
| 130 | + <Box> | |
| 131 | + <PlateBody> | |
| 132 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 133 | + <TextField | |
| 134 | + label="Tag" | |
| 135 | + icon={<TagIcon />} | |
| 136 | + mono | |
| 137 | + required | |
| 138 | + value={tag} | |
| 139 | + onChange={(e) => setTag(e.target.value)} | |
| 140 | + placeholder="v1.0.0" | |
| 141 | + list={tagListId} | |
| 142 | + autoFocus={!release} | |
| 143 | + description={tagExists ? "An existing tag." : tag.trim() ? "A new tag, created when the release is published." : "Choose a tag, or name a new one."} | |
| 144 | + /> | |
| 145 | + <datalist id={tagListId}> | |
| 146 | + {tags.map((t) => ( | |
| 147 | + <option key={t} value={t} /> | |
| 148 | + ))} | |
| 149 | + </datalist> | |
| 150 | + {!tagExists && ( | |
| 151 | + <label className="flex flex-col gap-1.5"> | |
| 152 | + <span className="type-ui text-ink">Create the tag from</span> | |
| 153 | + <Select | |
| 154 | + aria-label="Target branch" | |
| 155 | + value={target} | |
| 156 | + onValueChange={(v) => setTarget(String(v))} | |
| 157 | + options={targets.map((b) => ({ value: b, label: branches.includes(b) ? b : b.slice(0, 7), lead: branches.includes(b) ? <BranchIcon /> : <CommitIcon /> }))} | |
| 158 | + /> | |
| 159 | + <span className="type-meta text-ink3">The tag points at this branch’s latest commit.</span> | |
| 160 | + </label> | |
| 161 | + )} | |
| 162 | + </div> | |
| 163 | + <TextField label="Title" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={256} placeholder={tag.trim() || "Release title"} optional /> | |
| 164 | + <div className="flex flex-col gap-1.5"> | |
| 165 | + <span className="type-ui text-ink">Release notes</span> | |
| 166 | + <Tabs defaultValue="write"> | |
| 167 | + <div className="overflow-hidden rounded-plate recipe-well-field"> | |
| 168 | + <div className="rule-b px-2 py-1.5"> | |
| 169 | + <TabList | |
| 170 | + aria-label="Notes view" | |
| 171 | + size="compact" | |
| 172 | + items={[ | |
| 173 | + { value: "write", label: "Write", icon: <CodeIcon /> }, | |
| 174 | + { value: "preview", label: "Preview", icon: <EyeIcon /> }, | |
| 175 | + ]} | |
| 176 | + /> | |
| 177 | + </div> | |
| 178 | + <TabPanel value="write" keepMounted className="p-1"> | |
| 179 | + <Textarea | |
| 180 | + value={body} | |
| 181 | + onChange={(e) => setBody(e.target.value)} | |
| 182 | + minRows={8} | |
| 183 | + maxRows={30} | |
| 184 | + maxLength={65536} | |
| 185 | + placeholder="What’s new, what changed, how to upgrade… Markdown is supported." | |
| 186 | + aria-label="Release notes" | |
| 187 | + /> | |
| 188 | + </TabPanel> | |
| 189 | + <TabPanel value="preview" className="min-h-48 px-5 py-4"> | |
| 190 | + <MarkdownPreview>{body}</MarkdownPreview> | |
| 191 | + </TabPanel> | |
| 192 | + </div> | |
| 193 | + </Tabs> | |
| 194 | + </div> | |
| 195 | + <label className="flex cursor-pointer items-start gap-3 select-none"> | |
| 196 | + <Switch size="small" checked={prerelease} onCheckedChange={setPrerelease} className="mt-0.5" /> | |
| 197 | + <span className="flex flex-col gap-0.5"> | |
| 198 | + <span className="type-ui text-ink">This is a pre-release</span> | |
| 199 | + <span className="type-meta text-ink3">Shown as not ready for production; the latest release stays the one before it.</span> | |
| 200 | + </span> | |
| 201 | + </label> | |
| 202 | + </PlateBody> | |
| 203 | + </Box> | |
| 204 | + | |
| 205 | + <Box className="p-1.5"> | |
| 206 | + <h2 className={plateHeadingClass}>Files</h2> | |
| 207 | + {release && release.assets.length > 0 && ( | |
| 208 | + <ul className="mb-1.5"> | |
| 209 | + {release.assets.map((a) => ( | |
| 210 | + <li key={a.id} className={`${liftRowClass} flex min-h-11 items-center gap-3 px-3 py-1.5`}> | |
| 211 | + <span className="icon-tile size-7 shrink-0 rounded-row"> | |
| 212 | + <PackageIcon width={14} height={14} /> | |
| 213 | + </span> | |
| 214 | + <span className="min-w-0 truncate font-mono text-[12px] text-ink">{a.name}</span> | |
| 215 | + <span className="ml-auto shrink-0 type-readout text-ink2">{formatBytes(a.size)}</span> | |
| 216 | + <ConfirmButton action={deleteAssetAction.bind(null, release.id, a.id)} message={`Delete ${a.name}? Links to it stop working.`} variant="default"> | |
| 217 | + Delete | |
| 218 | + </ConfirmButton> | |
| 219 | + </li> | |
| 220 | + ))} | |
| 221 | + </ul> | |
| 222 | + )} | |
| 223 | + <div className="flex flex-col gap-2.5 px-1.5 pb-1.5"> | |
| 224 | + <DropZone | |
| 225 | + onFiles={add} | |
| 226 | + icon={<UploadIcon />} | |
| 227 | + title="Drop binaries, installers or archives here" | |
| 228 | + description="Any file, any size. A file named like an existing one replaces it." | |
| 229 | + chooseLabel="or browse your computer" | |
| 230 | + overTitle="Let go to attach them" | |
| 231 | + disabled={busy !== null} | |
| 232 | + /> | |
| 233 | + {files.length > 0 && ( | |
| 234 | + <div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2"> | |
| 235 | + {files.map((f) => ( | |
| 236 | + <Attachment | |
| 237 | + key={f.key} | |
| 238 | + name={f.file.name} | |
| 239 | + size={f.file.size} | |
| 240 | + progress={f.progress} | |
| 241 | + error={f.error} | |
| 242 | + onRemove={busy || f.done ? undefined : () => setFiles((current) => current.filter((x) => x.key !== f.key))} | |
| 243 | + /> | |
| 244 | + ))} | |
| 245 | + </div> | |
| 246 | + )} | |
| 247 | + </div> | |
| 248 | + </Box> | |
| 249 | + | |
| 250 | + {error && <Alert>{error}</Alert>} | |
| 251 | + | |
| 252 | + <div className="flex flex-wrap items-center justify-end gap-2"> | |
| 253 | + <ButtonLink href={release ? `${base}/releases/tag/${encodePath(release.tag)}` : `${base}/releases`} variant="invisible" className="mr-auto"> | |
| 254 | + Cancel | |
| 255 | + </ButtonLink> | |
| 256 | + {isDraft && ( | |
| 257 | + <Button type="button" disabled={busy !== null} onClick={() => submit("draft")}> | |
| 258 | + {busy === "draft" ? "Saving…" : "Save Draft"} | |
| 259 | + </Button> | |
| 260 | + )} | |
| 261 | + <Button type="button" variant="primary" icon={<TagIcon />} disabled={busy !== null || !tag.trim()} onClick={() => submit("publish")}> | |
| 262 | + {busy === "publish" ? (isDraft ? "Publishing…" : "Saving…") : isDraft ? "Publish Release" : "Update Release"} | |
| 263 | + </Button> | |
| 264 | + </div> | |
| 265 | + </div> | |
| 266 | + ); | |
| 267 | + } | |
| @@ -0,0 +1,32 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { Checkbox } from "@unlocalhosted/metalui"; | |
| 4 | + | |
| 5 | + export type RepoOption = { id: string; fullName: string; writable: boolean }; | |
| 6 | + | |
| 7 | + /** | |
| 8 | + * The repositories a token or a connected app may reach, as checkboxes named `repos`. With | |
| 9 | + * `needWrite`, ones the user can't push to can't be picked. | |
| 10 | + */ | |
| 11 | + export function RepoChecklist({ repos, needWrite }: { repos: RepoOption[]; needWrite: boolean }) { | |
| 12 | + return ( | |
| 13 | + <div className="max-h-64 overflow-y-auto rounded-plate recipe-well-field p-1" role="group" aria-label="Repositories"> | |
| 14 | + {repos.length === 0 && <p className="px-3 py-3 type-body text-ink3">You don’t have any repositories yet.</p>} | |
| 15 | + {repos.map((r) => { | |
| 16 | + const disabled = needWrite && !r.writable; | |
| 17 | + return ( | |
| 18 | + <label | |
| 19 | + key={r.id} | |
| 20 | + className={`flex min-h-8 items-center gap-2.5 rounded-row px-3 py-1.5 ${ | |
| 21 | + disabled ? "cursor-default opacity-50" : "cursor-pointer hover:recipe-row-list-hover" | |
| 22 | + }`} | |
| 23 | + > | |
| 24 | + <Checkbox size="row" name="repos" value={String(r.id)} disabled={disabled} /> | |
| 25 | + <span className="font-mono text-[12px] text-ink">{r.fullName}</span> | |
| 26 | + {disabled && <span className="ml-auto type-label engraved">No push access</span>} | |
| 27 | + </label> | |
| 28 | + ); | |
| 29 | + })} | |
| 30 | + </div> | |
| 31 | + ); | |
| 32 | + } | |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import Link from "next/link"; |
| 4 | 4 | import { usePathname } from "next/navigation"; |
| 5 | 5 | import { useState } from "react"; |
| 6 | - import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon, PlayIcon } from "./icons"; | |
| 6 | + import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon, PlayIcon, TagIcon } from "./icons"; | |
| 7 | 7 | import { SlidingIndicator, thumbClass } from "./sliding-indicator"; |
| 8 | 8 | |
| 9 | 9 | /** |
| @@ -13,22 +13,25 @@ import { SlidingIndicator, thumbClass } from "./sliding-indicator"; | ||
| 13 | 13 | export function RepoTabs({ |
| 14 | 14 | base, |
| 15 | 15 | openIssues, |
| 16 | + releases, | |
| 16 | 17 | images, |
| 17 | 18 | showSettings, |
| 18 | 19 | }: { |
| 19 | 20 | base: string; |
| 20 | 21 | openIssues: number; |
| 22 | + releases: number; | |
| 21 | 23 | images: number; |
| 22 | 24 | showSettings: boolean; |
| 23 | 25 | }) { |
| 24 | 26 | const pathname = usePathname(); |
| 25 | 27 | const section = pathname.slice(base.length).split("/")[1] ?? ""; |
| 26 | 28 | const tabs = [ |
| 27 | - { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "branches", "commits", "commit", "packages", "settings"].includes(section) }, | |
| 29 | + { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "branches", "commits", "commit", "releases", "packages", "settings"].includes(section) }, | |
| 28 | 30 | { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" }, |
| 29 | 31 | { href: `${base}/actions`, label: "Actions", icon: PlayIcon, active: section === "actions" }, |
| 30 | 32 | { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" }, |
| 31 | 33 | { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" }, |
| 34 | + { href: `${base}/releases`, label: "Releases", icon: TagIcon, count: releases, active: section === "releases" }, | |
| 32 | 35 | { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" }, |
| 33 | 36 | ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []), |
| 34 | 37 | ]; |
| @@ -0,0 +1,22 @@ | ||
| 1 | + // The MCP endpoint, OAuth's token endpoints and their metadata are called by agents, including | |
| 2 | + // ones running in a browser on another origin. They read no cookies (only bearer tokens, or nothing), | |
| 3 | + // so answering any origin can't let a web page act as a signed-in user. | |
| 4 | + | |
| 5 | + const CORS_HEADERS = { | |
| 6 | + "Access-Control-Allow-Origin": "*", | |
| 7 | + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", | |
| 8 | + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, Mcp-Protocol-Version, Mcp-Session-Id, Last-Event-ID", | |
| 9 | + "Access-Control-Expose-Headers": "WWW-Authenticate, Mcp-Protocol-Version, Mcp-Session-Id", | |
| 10 | + "Access-Control-Max-Age": "86400", | |
| 11 | + }; | |
| 12 | + | |
| 13 | + export function withCors(response: Response) { | |
| 14 | + for (const [key, value] of Object.entries(CORS_HEADERS)) response.headers.set(key, value); | |
| 15 | + return response; | |
| 16 | + } | |
| 17 | + | |
| 18 | + export const corsJson = (body: unknown, status = 200, headers: Record<string, string> = {}) => | |
| 19 | + withCors(Response.json(body, { status, headers: { "Cache-Control": "no-store", ...headers } })); | |
| 20 | + | |
| 21 | + /** The answer to a CORS preflight request. */ | |
| 22 | + export const preflight = () => withCors(new Response(null, { status: 204 })); | |
| @@ -87,6 +87,24 @@ export async function commitFiles( | ||
| 87 | 87 | } |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | + /** A tag name git accepts, without path components starting with "-" (pushes of those are refused too). */ | |
| 91 | + export async function isValidTagName(dir: string, name: string) { | |
| 92 | + if (!name || name.length > 255 || name.split("/").some((c) => c.startsWith("-"))) return false; | |
| 93 | + return git(dir, ["check-ref-format", `refs/tags/${name}`]).then( | |
| 94 | + () => true, | |
| 95 | + () => false, | |
| 96 | + ); | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** Creates a lightweight tag at a commit, failing if the tag already exists. */ | |
| 100 | + export async function createTag(dir: string, name: string, sha: string) { | |
| 101 | + try { | |
| 102 | + await git(dir, ["update-ref", "-m", "release", `refs/tags/${name}`, sha, ZERO_SHA]); | |
| 103 | + } catch { | |
| 104 | + throw new GitError(`Tag ${name} already exists.`); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 90 | 108 | export async function isValidBranchName(dir: string, name: string) { |
| 91 | 109 | if (!name || name.startsWith("-")) return false; |
| 92 | 110 | return git(dir, ["check-ref-format", "--branch", name]).then( |
| @@ -71,3 +71,19 @@ test("dash-prefixed refs that already exist are ignored and never reach git as o | ||
| 71 | 71 | assert.equal((await git.log(repo, "main"))[0].subject, "ssh-ed25519 AAAA attacker"); |
| 72 | 72 | assert.equal(await git.commitCount(repo, "main"), 1); |
| 73 | 73 | }); |
| 74 | + | |
| 75 | + test("release tags can't be named like options", async () => { | |
| 76 | + const { createTag, isValidTagName } = await import("./git-write"); | |
| 77 | + for (const bad of ["-x", "rel/-x", evilBranch, "a b", "a..b", ""]) assert.equal(await isValidTagName(repo, bad), false, bad); | |
| 78 | + assert.equal(await isValidTagName(repo, "v1.0.0"), true); | |
| 79 | + assert.equal(await isValidTagName(repo, "release/2026-10"), true); | |
| 80 | + | |
| 81 | + const sha = (await git.revParse(repo, "refs/heads/main"))!; | |
| 82 | + await createTag(repo, "v1.0.0", sha); | |
| 83 | + assert.equal(await git.revParse(repo, "refs/tags/v1.0.0"), sha); | |
| 84 | + await assert.rejects(createTag(repo, "v1.0.0", sha), git.GitError); | |
| 85 | + | |
| 86 | + // Archives take a resolved commit; a dash-prefixed revision is refused before git sees it. | |
| 87 | + assert.throws(() => git.spawnArchive(repo, `--output=${target}`, "zip", "x"), git.GitError); | |
| 88 | + assert.equal(existsSync(target), false); | |
| 89 | + }); | |
| @@ -10,6 +10,7 @@ import { DATA_ROOT, REPOS_ROOT } from "./paths"; | ||
| 10 | 10 | const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; |
| 11 | 11 | const RESERVED = new Set([ |
| 12 | 12 | "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2", |
| 13 | + "mcp", "oauth", | |
| 13 | 14 | ]); |
| 14 | 15 | |
| 15 | 16 | export function isValidName(name: string) { |
| @@ -251,6 +252,13 @@ export async function readBlob(dir: string, ref: string, filePath: string) { | ||
| 251 | 252 | return { size, tooLarge, binary, content }; |
| 252 | 253 | } |
| 253 | 254 | |
| 255 | + export type ArchiveFormat = "zip" | "tar.gz"; | |
| 256 | + | |
| 257 | + /** Streams the tree of a commit as a zip or gzipped tarball, with its files under `prefix/`. */ | |
| 258 | + export function spawnArchive(dir: string, sha: string, format: ArchiveFormat, prefix: string) { | |
| 259 | + return spawn("git", ["--git-dir", dir, "archive", `--format=${format}`, `--prefix=${prefix}/`, "--end-of-options", rev(sha)]); | |
| 260 | + } | |
| 261 | + | |
| 254 | 262 | // --------------------------------------------------------------------------- |
| 255 | 263 | // Commits |
| 256 | 264 | |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { db, filter, findFirst, isUniqueViolation, orNull, withLock, type RecordModel } from "./db"; |
| 2 | 2 | import { |
| 3 | + ESTIMATES, | |
| 3 | 4 | isClosedStatus, |
| 5 | + isValidDay, | |
| 6 | + STATUS_IDS, | |
| 4 | 7 | type Issue, |
| 5 | 8 | type IssueLabel, |
| 6 | 9 | type IssuePatch, |
| @@ -54,6 +57,58 @@ const toSummary = (r: RecordModel): IssueSummary => ({ | ||
| 54 | 57 | |
| 55 | 58 | const issues = async () => (await db()).collection("issues"); |
| 56 | 59 | |
| 60 | + export const MAX_ISSUE_BODY = 65_536; | |
| 61 | + | |
| 62 | + export class IssueError extends Error {} | |
| 63 | + | |
| 64 | + /** | |
| 65 | + * Validates untrusted issue properties (from the browser or an agent). Only the fields that | |
| 66 | + * are present are checked; anything invalid throws. | |
| 67 | + */ | |
| 68 | + export async function validateIssuePatch(repo: { id: string; ownerId: string }, patch: IssuePatch): Promise<IssuePatch> { | |
| 69 | + const out: IssuePatch = {}; | |
| 70 | + if (patch.title !== undefined) { | |
| 71 | + const title = String(patch.title).trim().slice(0, 256); | |
| 72 | + if (!title) throw new IssueError("Title can't be blank."); | |
| 73 | + out.title = title; | |
| 74 | + } | |
| 75 | + if (patch.body !== undefined) out.body = String(patch.body).trim().slice(0, MAX_ISSUE_BODY); | |
| 76 | + if (patch.status !== undefined) { | |
| 77 | + if (!STATUS_IDS.includes(patch.status)) throw new IssueError("Unknown status."); | |
| 78 | + out.status = patch.status; | |
| 79 | + } | |
| 80 | + if (patch.priority !== undefined) { | |
| 81 | + if (!Number.isInteger(patch.priority) || patch.priority < 0 || patch.priority > 4) throw new IssueError("Unknown priority."); | |
| 82 | + out.priority = patch.priority; | |
| 83 | + } | |
| 84 | + if (patch.assigneeId !== undefined) { | |
| 85 | + if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) { | |
| 86 | + throw new IssueError("Only the owner and collaborators can be assigned."); | |
| 87 | + } | |
| 88 | + out.assigneeId = patch.assigneeId; | |
| 89 | + } | |
| 90 | + if (patch.estimate !== undefined) { | |
| 91 | + if (patch.estimate !== null && !(ESTIMATES as readonly number[]).includes(patch.estimate)) throw new IssueError("Unknown estimate."); | |
| 92 | + out.estimate = patch.estimate; | |
| 93 | + } | |
| 94 | + if (patch.dueDate !== undefined) { | |
| 95 | + if (patch.dueDate !== null && !isValidDay(String(patch.dueDate))) throw new IssueError("Invalid due date."); | |
| 96 | + out.dueDate = patch.dueDate; | |
| 97 | + } | |
| 98 | + if (patch.labelIds !== undefined) { | |
| 99 | + if (!Array.isArray(patch.labelIds)) throw new IssueError("Invalid labels."); | |
| 100 | + const known = new Set((await listLabels(repo.id)).map((l) => l.id)); | |
| 101 | + const ids = [...new Set(patch.labelIds)]; | |
| 102 | + if (!ids.every((id) => known.has(id))) throw new IssueError("Unknown label."); | |
| 103 | + out.labelIds = ids; | |
| 104 | + } | |
| 105 | + if (patch.sortOrder !== undefined) { | |
| 106 | + if (typeof patch.sortOrder !== "number" || !Number.isFinite(patch.sortOrder)) throw new IssueError("Invalid order."); | |
| 107 | + out.sortOrder = patch.sortOrder; | |
| 108 | + } | |
| 109 | + return out; | |
| 110 | + } | |
| 111 | + | |
| 57 | 112 | /** Every issue in a repository, without descriptions. Filtering and grouping happen in the browser. */ |
| 58 | 113 | export async function listIssues(repoId: string) { |
| 59 | 114 | const rows = await (await issues()).getFullList({ |
| @@ -0,0 +1,39 @@ | ||
| 1 | + // Run with `bun test`. | |
| 2 | + import assert from "node:assert/strict"; | |
| 3 | + import { test } from "node:test"; | |
| 4 | + import { array, boolean, integer, object, objectSchema, oneOf, parseArgs, string, ToolError } from "./params"; | |
| 5 | + | |
| 6 | + const props = { | |
| 7 | + owner: string("Owner.", { min: 1, max: 39 }), | |
| 8 | + limit: integer("Limit.", { min: 1, max: 50 }).default(20), | |
| 9 | + state: oneOf("State.", ["open", "closed"] as const).optional(), | |
| 10 | + files: array("Files.", object("A file.", { path: string("Path."), binary: boolean("Binary.").default(false) })).default([]), | |
| 11 | + }; | |
| 12 | + | |
| 13 | + test("parses arguments, filling in defaults", () => { | |
| 14 | + assert.deepEqual(parseArgs(props, { owner: "joni", files: [{ path: "a" }] }), { | |
| 15 | + owner: "joni", | |
| 16 | + limit: 20, | |
| 17 | + state: undefined, | |
| 18 | + files: [{ path: "a", binary: false }], | |
| 19 | + }); | |
| 20 | + // Agents send null for "not given", and numbers as strings. | |
| 21 | + assert.deepEqual(parseArgs(props, { owner: "joni", limit: "5", state: null }), { owner: "joni", limit: 5, state: undefined, files: [] }); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + test("refuses bad arguments with messages an agent can act on", () => { | |
| 25 | + const fails = (args: unknown, message: RegExp) => assert.throws(() => parseArgs(props, args), (e) => e instanceof ToolError && message.test(e.message)); | |
| 26 | + fails({}, /owner is required/); | |
| 27 | + fails({ owner: "" }, /owner can't be empty/); | |
| 28 | + fails({ owner: "a", limit: 99 }, /limit must be between 1 and 50/); | |
| 29 | + fails({ owner: "a", state: "merged" }, /state must be one of open, closed/); | |
| 30 | + fails({ owner: "a", file: [] }, /Unknown argument file/); | |
| 31 | + fails({ owner: "a", files: [{ path: 1 }] }, /files\[0\]\.path must be a string/); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + test("describes itself as JSON Schema", () => { | |
| 35 | + const schema = objectSchema(props); | |
| 36 | + assert.deepEqual(schema.required, ["owner"]); | |
| 37 | + assert.equal(schema.additionalProperties, false); | |
| 38 | + assert.deepEqual((schema.properties as Record<string, { default?: unknown }>).limit.default, 20); | |
| 39 | + }); | |
| @@ -0,0 +1,104 @@ | ||
| 1 | + // Tool parameters, declared once: each gives the JSON Schema that tools/list advertises and checks | |
| 2 | + // the value an agent sends. Bad arguments come back as tool errors, so the agent can correct itself. | |
| 3 | + | |
| 4 | + export class ToolError extends Error {} | |
| 5 | + | |
| 6 | + type Schema = Record<string, unknown>; | |
| 7 | + | |
| 8 | + export type Param<T> = { schema: Schema; required: boolean; parse: (value: unknown, name: string) => T }; | |
| 9 | + | |
| 10 | + export type Props = Record<string, Param<unknown>>; | |
| 11 | + | |
| 12 | + export type Args<P extends Props> = { [K in keyof P]: P[K] extends Param<infer T> ? T : never }; | |
| 13 | + | |
| 14 | + /** A required parameter, with .optional() and .default() variants. Agents often send null for "not given". */ | |
| 15 | + function param<T>(schema: Schema, check: (value: unknown, name: string) => T) { | |
| 16 | + return { | |
| 17 | + schema, | |
| 18 | + required: true, | |
| 19 | + parse: check, | |
| 20 | + optional(): Param<T | undefined> { | |
| 21 | + return { schema, required: false, parse: (v, name) => (v === undefined || v === null ? undefined : check(v, name)) }; | |
| 22 | + }, | |
| 23 | + default(fallback: T): Param<T> { | |
| 24 | + return { schema: { ...schema, default: fallback }, required: false, parse: (v, name) => (v === undefined || v === null ? fallback : check(v, name)) }; | |
| 25 | + }, | |
| 26 | + }; | |
| 27 | + } | |
| 28 | + | |
| 29 | + export function string(description: string, opts: { max?: number; min?: number } = {}) { | |
| 30 | + const { max = 1000, min = 0 } = opts; | |
| 31 | + return param({ type: "string", description, ...(min ? { minLength: min } : {}), maxLength: max }, (v, name) => { | |
| 32 | + if (typeof v !== "string") throw new ToolError(`${name} must be a string.`); | |
| 33 | + if (v.length > max) throw new ToolError(`${name} can be at most ${max} characters.`); | |
| 34 | + if (v.trim().length < min) throw new ToolError(`${name} can't be empty.`); | |
| 35 | + return v; | |
| 36 | + }); | |
| 37 | + } | |
| 38 | + | |
| 39 | + export function oneOf<const V extends string>(description: string, values: readonly V[]) { | |
| 40 | + return param({ type: "string", description, enum: values }, (v, name) => { | |
| 41 | + if (!values.includes(v as V)) throw new ToolError(`${name} must be one of ${values.join(", ")}.`); | |
| 42 | + return v as V; | |
| 43 | + }); | |
| 44 | + } | |
| 45 | + | |
| 46 | + export function integer(description: string, opts: { min?: number; max?: number } = {}) { | |
| 47 | + const { min = 0, max = Number.MAX_SAFE_INTEGER } = opts; | |
| 48 | + return param({ type: "integer", description, minimum: min, ...(opts.max !== undefined ? { maximum: max } : {}) }, (v, name) => { | |
| 49 | + // Some agents send numbers as strings. | |
| 50 | + const n = typeof v === "string" && /^-?\d+$/.test(v) ? Number(v) : v; | |
| 51 | + if (typeof n !== "number" || !Number.isInteger(n)) throw new ToolError(`${name} must be a whole number.`); | |
| 52 | + if (n < min || n > max) throw new ToolError(`${name} must be between ${min} and ${max}.`); | |
| 53 | + return n; | |
| 54 | + }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + export function boolean(description: string) { | |
| 58 | + return param({ type: "boolean", description }, (v, name) => { | |
| 59 | + if (typeof v !== "boolean") throw new ToolError(`${name} must be true or false.`); | |
| 60 | + return v; | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + export function array<T>(description: string, item: Param<T>, opts: { max?: number } = {}) { | |
| 65 | + const { max = 1000 } = opts; | |
| 66 | + return param({ type: "array", description, items: item.schema, maxItems: max }, (v, name) => { | |
| 67 | + if (!Array.isArray(v)) throw new ToolError(`${name} must be an array.`); | |
| 68 | + if (v.length > max) throw new ToolError(`${name} can have at most ${max} items.`); | |
| 69 | + return v.map((x, i) => item.parse(x, `${name}[${i}]`)); | |
| 70 | + }); | |
| 71 | + } | |
| 72 | + | |
| 73 | + export function object<P extends Props>(description: string, props: P) { | |
| 74 | + return param(objectSchema(props, description), (v, name) => parseArgs(props, v, `${name}.`)); | |
| 75 | + } | |
| 76 | + | |
| 77 | + export function objectSchema(props: Props, description?: string): Schema { | |
| 78 | + return { | |
| 79 | + type: "object", | |
| 80 | + ...(description ? { description } : {}), | |
| 81 | + properties: Object.fromEntries(Object.entries(props).map(([k, p]) => [k, p.schema])), | |
| 82 | + required: Object.entries(props) | |
| 83 | + .filter(([, p]) => p.required) | |
| 84 | + .map(([k]) => k), | |
| 85 | + additionalProperties: false, | |
| 86 | + }; | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** Checks arguments against their parameters. Unknown names are refused, so typos don't go unnoticed. */ | |
| 90 | + export function parseArgs<P extends Props>(props: P, value: unknown, prefix = ""): Args<P> { | |
| 91 | + const input = value ?? {}; | |
| 92 | + if (typeof input !== "object" || Array.isArray(input)) throw new ToolError(`${prefix || "arguments "}must be an object.`); | |
| 93 | + const unknown = Object.keys(input).filter((k) => !(k in props)); | |
| 94 | + if (unknown.length) { | |
| 95 | + throw new ToolError(`Unknown argument ${unknown.map((k) => prefix + k).join(", ")}. Expected: ${Object.keys(props).join(", ")}.`); | |
| 96 | + } | |
| 97 | + const out: Record<string, unknown> = {}; | |
| 98 | + for (const [key, p] of Object.entries(props)) { | |
| 99 | + const v = (input as Record<string, unknown>)[key]; | |
| 100 | + if (p.required && (v === undefined || v === null)) throw new ToolError(`${prefix}${key} is required.`); | |
| 101 | + out[key] = p.parse(v, prefix + key); | |
| 102 | + } | |
| 103 | + return out as Args<P>; | |
| 104 | + } | |
| @@ -0,0 +1,133 @@ | ||
| 1 | + import "server-only"; | |
| 2 | + import { CiError } from "../ci"; | |
| 3 | + import { GitError } from "../git"; | |
| 4 | + import { IssueError, LabelError } from "../issues"; | |
| 5 | + import { ReleaseError } from "../releases"; | |
| 6 | + import { RepoError } from "../repos"; | |
| 7 | + import { ToolError } from "./params"; | |
| 8 | + import { TOOLS, type ToolContext } from "./tools"; | |
| 9 | + | |
| 10 | + /* | |
| 11 | + * The Model Context Protocol over Streamable HTTP, statelessly: every POST carries one JSON-RPC | |
| 12 | + * message (or, before protocol 2025-06-18, a batch), and the answer comes back as plain JSON. No | |
| 13 | + * sessions and no server-sent events, since every tool answers right away. | |
| 14 | + */ | |
| 15 | + | |
| 16 | + const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 17 | + | |
| 18 | + const INSTRUCTIONS = `Vectrum is a self-hosted git forge, like GitHub. Repositories are addressed by owner and repo name. | |
| 19 | + - Read code with list_files and read_file; change it with commit_files, which makes one commit with any number of files on a branch (creating the branch if needed). Pushing a commit starts the repository's workflows. | |
| 20 | + - Issues work like Linear: statuses backlog, todo, in_progress, in_review, done, canceled; priorities 0 (none), 1 (urgent) to 4 (low). | |
| 21 | + - Releases belong to a tag; publishing one for a tag that doesn't exist creates the tag. Deleting a release keeps its tag. | |
| 22 | + - Workflows live in .vectrum/workflows/*.yml and run on self-hosted runners. Check results with list_workflow_runs, get_workflow_run and get_job_logs. | |
| 23 | + What you can reach depends on what the user approved: read-only or read & write, for all their repositories or selected ones.`; | |
| 24 | + | |
| 25 | + class RpcError extends Error { | |
| 26 | + constructor( | |
| 27 | + readonly code: number, | |
| 28 | + message: string, | |
| 29 | + ) { | |
| 30 | + super(message); | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + type Id = string | number | null; | |
| 35 | + | |
| 36 | + /** What the lib functions throw about bad input or state, worded for the user. */ | |
| 37 | + const USER_ERRORS = [ToolError, GitError, RepoError, CiError, IssueError, LabelError, ReleaseError]; | |
| 38 | + | |
| 39 | + const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v); | |
| 40 | + | |
| 41 | + function initialize(params: unknown) { | |
| 42 | + const requested = isObject(params) ? params.protocolVersion : undefined; | |
| 43 | + return { | |
| 44 | + protocolVersion: typeof requested === "string" && PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], | |
| 45 | + capabilities: { tools: { listChanged: false } }, | |
| 46 | + serverInfo: { name: "vectrum", title: "Vectrum", version: "1.0.0" }, | |
| 47 | + instructions: INSTRUCTIONS, | |
| 48 | + }; | |
| 49 | + } | |
| 50 | + | |
| 51 | + /** Read-only connections only see the tools they can use. */ | |
| 52 | + const availableTools = (ctx: ToolContext) => TOOLS.filter((t) => t.access === "read" || ctx.scope.permission === "write"); | |
| 53 | + | |
| 54 | + function listTools(ctx: ToolContext) { | |
| 55 | + return availableTools(ctx).map((t) => ({ | |
| 56 | + name: t.name, | |
| 57 | + title: t.title, | |
| 58 | + description: t.description, | |
| 59 | + inputSchema: t.inputSchema, | |
| 60 | + annotations: { | |
| 61 | + title: t.title, | |
| 62 | + readOnlyHint: t.access === "read", | |
| 63 | + destructiveHint: t.access !== "read" && !!t.destructive, | |
| 64 | + idempotentHint: t.access === "read" || !!t.idempotent, | |
| 65 | + openWorldHint: false, | |
| 66 | + }, | |
| 67 | + })); | |
| 68 | + } | |
| 69 | + | |
| 70 | + async function callTool(params: unknown, ctx: ToolContext) { | |
| 71 | + if (!isObject(params) || typeof params.name !== "string") throw new RpcError(-32602, "tools/call needs a tool name."); | |
| 72 | + const tool = TOOLS.find((t) => t.name === params.name); | |
| 73 | + if (!tool) throw new RpcError(-32602, `Unknown tool: ${params.name}`); | |
| 74 | + try { | |
| 75 | + if (tool.access !== "read" && ctx.scope.permission !== "write") { | |
| 76 | + throw new ToolError("This connection is read-only. Reconnect with read & write access to make changes."); | |
| 77 | + } | |
| 78 | + const result = await tool.call(params.arguments, ctx); | |
| 79 | + return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; | |
| 80 | + } catch (e) { | |
| 81 | + // Errors the agent can act on come back as the tool's result, not as protocol errors. | |
| 82 | + if (e instanceof Error && USER_ERRORS.some((type) => e instanceof type)) { | |
| 83 | + return { content: [{ type: "text", text: e.message }], isError: true }; | |
| 84 | + } | |
| 85 | + throw e; | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + async function handleMessage(message: unknown, ctx: ToolContext): Promise<object | null> { | |
| 90 | + if (!isObject(message) || message.jsonrpc !== "2.0") return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid request" } }; | |
| 91 | + // Responses (this server never asks the client anything) and notifications get no answer. | |
| 92 | + if (typeof message.method !== "string" || message.id === undefined) return null; | |
| 93 | + const id = message.id as Id; | |
| 94 | + try { | |
| 95 | + switch (message.method) { | |
| 96 | + case "initialize": | |
| 97 | + return { jsonrpc: "2.0", id, result: initialize(message.params) }; | |
| 98 | + case "ping": | |
| 99 | + return { jsonrpc: "2.0", id, result: {} }; | |
| 100 | + case "tools/list": | |
| 101 | + return { jsonrpc: "2.0", id, result: { tools: listTools(ctx) } }; | |
| 102 | + case "tools/call": | |
| 103 | + return { jsonrpc: "2.0", id, result: await callTool(message.params, ctx) }; | |
| 104 | + default: | |
| 105 | + throw new RpcError(-32601, `Method not found: ${message.method}`); | |
| 106 | + } | |
| 107 | + } catch (e) { | |
| 108 | + if (e instanceof RpcError) return { jsonrpc: "2.0", id, error: { code: e.code, message: e.message } }; | |
| 109 | + console.error(`[mcp] ${message.method} failed:`, e); | |
| 110 | + return { jsonrpc: "2.0", id, error: { code: -32603, message: "Internal error" } }; | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + /** Answers a POST to /mcp from an authenticated agent. */ | |
| 115 | + export async function handleMcpPost(request: Request, ctx: ToolContext) { | |
| 116 | + const version = request.headers.get("mcp-protocol-version"); | |
| 117 | + if (version && !PROTOCOL_VERSIONS.includes(version)) { | |
| 118 | + return Response.json( | |
| 119 | + { jsonrpc: "2.0", id: null, error: { code: -32600, message: `Unsupported protocol version ${version}. Supported: ${PROTOCOL_VERSIONS.join(", ")}.` } }, | |
| 120 | + { status: 400 }, | |
| 121 | + ); | |
| 122 | + } | |
| 123 | + let body: unknown; | |
| 124 | + try { | |
| 125 | + body = await request.json(); | |
| 126 | + } catch { | |
| 127 | + return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, { status: 400 }); | |
| 128 | + } | |
| 129 | + const messages = Array.isArray(body) ? body : [body]; | |
| 130 | + const answers = (await Promise.all(messages.map((m) => handleMessage(m, ctx)))).filter((a) => a !== null); | |
| 131 | + if (answers.length === 0) return new Response(null, { status: 202 }); | |
| 132 | + return Response.json(Array.isArray(body) ? answers : answers[0], { headers: { "Cache-Control": "no-store" } }); | |
| 133 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.