Vectrum
Sign In

Add releases and an MCP server with OAuth for agents

- 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
JjohannSocommitted 1 parent 820cb3dcommit488246c

Showing 52 changed files with 4336 additions and 113 deletions.

+52−1README.mdView File
@@ -12,6 +12,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or
1212 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
1313 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
1414 - **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
1516 - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored
1617 - **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
1718 - **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
2425 - Inline editing of every property, with an activity feed of changes next to the markdown comments
2526 - 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
2627 - **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*
2729 - Light and dark mode
2830
2931 ## Getting started
@@ -93,6 +95,28 @@ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add
9395 the host to Docker's `insecure-registries`). A reverse proxy in front of
9496 Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`).
9597
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+
96120 ### Running workflows
97121
98122 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
121145
122146 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.
123147
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+
124170 ## Configuration
125171
126172 | Variable | Default | Purpose |
@@ -142,7 +188,7 @@ Data layout:
142188
143189 ```
144190 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
146192 pocketbase-superuser.json the generated PocketBase superuser login
147193 ssh_host_ed25519_key generated on first start
148194 hooks/ server-side git hooks for every push, rewritten on start
@@ -150,6 +196,7 @@ data/
150196 registry.db container image manifests, tags and blob records (SQLite)
151197 registry/blobs/sha256/… container image layers, shared by all repositories
152198 registry/uploads/ image uploads in progress
199+ releases/<repo>/<release>/… release files, named by their record id
153200 ```
154201
155202 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).
162209 - 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.
163210 - 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`.
164211 - 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.
165214 - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
166215
167216 ## Layout
@@ -171,8 +220,10 @@ kept so pushes in progress don't lose their layers).
171220 - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
172221 - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
173222 - `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
174224 - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories
175225 - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`)
176226 - `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
177228 - `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
178229 - `app/actions/*`: server actions, each re-checking the session and permissions
+208−0SEC_REPORT.mdaddedView File
@@ -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)
+8−0app/.well-known/oauth-authorization-server/route.tsaddedView File
@@ -0,0 +1,8 @@
1+ import { corsJson, preflight } from "@/lib/cors";
2+ import { authorizationServerMetadata } from "@/lib/oauth";
3+
4+ export async function GET() {
5+ return corsJson(await authorizationServerMetadata());
6+ }
7+
8+ export const OPTIONS = preflight;
+1−0app/.well-known/oauth-protected-resource/mcp/route.tsaddedView File
@@ -0,0 +1 @@
1+ export { GET, OPTIONS } from "../route";
+9−0app/.well-known/oauth-protected-resource/route.tsaddedView File
@@ -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;
+6−0app/[owner]/[repo]/archive/[...segments]/route.tsaddedView File
@@ -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+ }
+2−1app/[owner]/[repo]/layout.tsxView File
@@ -3,6 +3,7 @@ import { RepoTabs } from "@/components/repo-tabs";
33 import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui";
44 import { openIssueCount } from "@/lib/issues";
55 import { countImages } from "@/lib/registry";
6+ import { countReleases } from "@/lib/releases";
67 import { getRepo } from "@/lib/repo";
78
89 export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) {
@@ -38,7 +39,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
3839 <VisibilityLabel visibility={repo.visibility} />
3940 {repo.access === "write" && <Label tone="accent">Collaborator</Label>}
4041 </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} />
4243 </div>
4344 </div>
4445 <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div>
+30−4app/[owner]/[repo]/page.tsxView File
@@ -13,11 +13,12 @@ import {
1313 UploadIcon,
1414 } from "@/components/icons";
1515 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";
1818 import { commitCount, listBranches, listContributors, listFilesWithSizes, listTags } from "@/lib/git";
1919 import { openIssueCount } from "@/lib/issues";
2020 import { languageBreakdown, type LanguageShare } from "@/lib/languages";
21+ import { countReleases, latestRelease, releaseTitle } from "@/lib/releases";
2122 import { cloneUrls, getRepo, type Repo } from "@/lib/repo";
2223 import { listCollaborators } from "@/lib/repos";
2324
@@ -145,7 +146,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
145146 ref ? listFilesWithSizes(repo.dir, ref) : [],
146147 ref ? listContributors(repo.dir, ref) : [],
147148 ]);
148- const collaborators = await listCollaborators(repo.id);
149+ const [collaborators, releases, latest] = await Promise.all([listCollaborators(repo.id), countReleases(repo.id), latestRelease(repo.id)]);
149150 const languages = languageBreakdown(files);
150151 const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`;
151152
@@ -170,7 +171,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
170171 <ul>
171172 <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} />
172173 <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} />
174175 <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={await openIssueCount(repo.id)} />
175176 <Stat
176177 href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`}
@@ -181,6 +182,31 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
181182 </ul>
182183 </section>
183184
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+
184210 {languages.length > 0 && <Languages languages={languages} />}
185211
186212 <section>
+20−0app/[owner]/[repo]/releases/download/[...segments]/route.tsaddedView File
@@ -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+ }
+34−0app/[owner]/[repo]/releases/edit/[...tag]/page.tsxaddedView File
@@ -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+ }
+9−0app/[owner]/[repo]/releases/latest/download/[name]/route.tsaddedView File
@@ -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;
+12−0app/[owner]/[repo]/releases/latest/page.tsxaddedView File
@@ -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+ }
+41−0app/[owner]/[repo]/releases/new/page.tsxaddedView File
@@ -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+ }
+103−0app/[owner]/[repo]/releases/page.tsxaddedView File
@@ -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 &amp; 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+ }
+33−0app/[owner]/[repo]/releases/tag/[...tag]/page.tsxaddedView File
@@ -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+ }
+12−1app/actions/guard.tsView File
@@ -1,6 +1,6 @@
11 import "server-only";
22 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";
44
55 export class ActionError extends Error {}
66
@@ -20,3 +20,14 @@ export async function repoForAction(owner: string, name: string, need: "read" |
2020 if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings.");
2121 return { repo, viewer, access };
2222 }
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+ }
+7−63app/actions/issues.tsView File
@@ -2,15 +2,7 @@
22
33 import { revalidatePath } from "next/cache";
44 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";
146 import {
157 addComment,
168 createIssue,
@@ -18,10 +10,10 @@ import {
1810 deleteIssue,
1911 deleteLabel,
2012 getIssue,
13+ IssueError,
2114 LabelError,
22- listLabels,
23- listMembers,
2415 updateIssue,
16+ validateIssuePatch,
2517 } from "@/lib/issues";
2618 import { canWrite, type RepoRecord } from "@/lib/repos";
2719 import { ActionError, repoForAction } from "./guard";
@@ -33,54 +25,6 @@ function revalidateIssues(repo: RepoRecord) {
3325 revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
3426 }
3527
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-
8428 // Permissions: anyone who can read a repository may open issues and comment.
8529 // Changing an issue in any way (title, description, status, triage, order) needs
8630 // push access. Deleting is reserved for the issue's creator and the repository owner.
@@ -92,7 +36,7 @@ export async function createIssueAction(
9236 ): Promise<ActionResult & { number?: number }> {
9337 try {
9438 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 });
9640 if (!title) return { error: "Title can't be blank." };
9741 const number = await createIssue(repo.id, viewer.id, {
9842 title,
@@ -102,7 +46,7 @@ export async function createIssueAction(
10246 revalidateIssues(repo);
10347 return { number };
10448 } catch (e) {
105- if (e instanceof ActionError) return { error: e.message };
49+ if (e instanceof ActionError || e instanceof IssueError) return { error: e.message };
10650 throw e;
10751 }
10852 }
@@ -113,12 +57,12 @@ export async function updateIssueAction(owner: string, name: string, number: num
11357 const issue = await getIssue(repo.id, number);
11458 if (!issue) return { error: "Issue not found." };
11559 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);
11761 await updateIssue(issue, clean, viewer.id);
11862 revalidateIssues(repo);
11963 return {};
12064 } catch (e) {
121- if (e instanceof ActionError) return { error: e.message };
65+ if (e instanceof ActionError || e instanceof IssueError) return { error: e.message };
12266 throw e;
12367 }
12468 }
+35−0app/actions/oauth.tsaddedView File
@@ -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+ }
+91−0app/actions/releases.tsaddedView File
@@ -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+ }
+2−11app/actions/settings.tsView File
@@ -3,10 +3,9 @@
33 import { revalidatePath } from "next/cache";
44 import { renewSession, signIn } from "@/lib/auth";
55 import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys";
6- import { canWrite, findRepoById, repoAccess } from "@/lib/repos";
76 import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens";
87 import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users";
9- import { viewerOrThrow } from "./guard";
8+ import { grantableRepoIds, viewerOrThrow } from "./guard";
109 import type { FormState } from "./types";
1110
1211 export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> {
@@ -63,15 +62,7 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P
6362 const viewer = await viewerOrThrow();
6463 const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write";
6564 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;
7566 try {
7667 const token = await createToken(viewer.id, {
7768 name: String(formData.get("name") ?? ""),
+44−0app/mcp/route.tsaddedView File
@@ -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;
+87−0app/oauth/authorize/form.tsxaddedView File
@@ -0,0 +1,87 @@
1+ "use client";
2+
3+ import { useActionState, useState } from "react";
4+ import { Fieldset } from "@unlocalhosted/metalui";
5+ import type { FormState } from "@/app/actions/types";
6+ import { RepoChecklist, type RepoOption } from "@/components/repo-checklist";
7+ import { ChoiceCards } from "@/components/settings-fields";
8+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
9+ import { Alert, Button } from "@/components/ui";
10+
11+ export function AuthorizeForm({
12+ action,
13+ requested,
14+ repos,
15+ destination,
16+ }: {
17+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
18+ /** What the application asked for; read-only requests can't be widened here. */
19+ requested: "read" | "write";
20+ repos: RepoOption[];
21+ destination: string;
22+ }) {
23+ const [state, formAction, pending] = useActionState(action, undefined);
24+ const [permission, setPermission] = useState(requested);
25+ const [scope, setScope] = useState<"all" | "selected">("all");
26+
27+ return (
28+ <form action={formAction}>
29+ <PlateBody className="gap-6">
30+ <Fieldset>
31+ <Fieldset.Legend>Permissions</Fieldset.Legend>
32+ {requested === "write" ? (
33+ <ChoiceCards
34+ name="permission"
35+ aria-label="Permissions"
36+ value={permission}
37+ onValueChange={setPermission}
38+ columns={1}
39+ choices={[
40+ {
41+ value: "write",
42+ title: "Read & write",
43+ description: "Read code, issues and workflow runs; commit files, create branches and repositories, open and edit issues, run workflows.",
44+ },
45+ { value: "read", title: "Read-only", description: "Read code, issues and workflow runs, but change nothing." },
46+ ]}
47+ />
48+ ) : (
49+ <>
50+ <input type="hidden" name="permission" value="read" />
51+ <p className="type-body text-ink2">Read-only: it can read code, issues and workflow runs, but change nothing.</p>
52+ </>
53+ )}
54+ </Fieldset>
55+
56+ <Fieldset>
57+ <Fieldset.Legend>Repository access</Fieldset.Legend>
58+ <ChoiceCards
59+ name="scope"
60+ aria-label="Repository access"
61+ value={scope}
62+ onValueChange={setScope}
63+ choices={[
64+ { value: "all", title: "All repositories", description: "Every repository you can access, including ones you get later." },
65+ { value: "selected", title: "Only select repositories" },
66+ ]}
67+ />
68+ {scope === "selected" && <RepoChecklist repos={repos} needWrite={permission === "write"} />}
69+ </Fieldset>
70+
71+ {state?.error && <Alert>{state.error}</Alert>}
72+ <p className="type-body text-ink2">
73+ Authorizing sends you back to <b className="text-ink">{destination}</b>. You can disconnect it any time under Settings → Connected
74+ apps.
75+ </p>
76+ </PlateBody>
77+ <PlateFooter>
78+ <Button name="decision" value="deny" disabled={pending}>
79+ Cancel
80+ </Button>
81+ <Button variant="primary" name="decision" value="allow" disabled={pending}>
82+ {pending ? "Authorizing…" : "Authorize"}
83+ </Button>
84+ </PlateFooter>
85+ </form>
86+ );
87+ }
+82−0app/oauth/authorize/page.tsxaddedView File
@@ -0,0 +1,82 @@
1+ import type { Metadata } from "next";
2+ import { redirect } from "next/navigation";
3+ import { authorizeAction } from "@/app/actions/oauth";
4+ import { LogoMark } from "@/components/brand";
5+ import { AlertIcon } from "@/components/icons";
6+ import { Alert, Blankslate, Box } from "@/components/ui";
7+ import { requireViewer } from "@/lib/auth";
8+ import { parseAuthorizeRequest } from "@/lib/oauth";
9+ import { canWrite, listReposForUser, repoAccess } from "@/lib/repos";
10+ import { AuthorizeForm } from "./form";
11+
12+ export const metadata: Metadata = { title: "Authorize application" };
13+
14+ /** Where the application sends the user afterwards, as they'd recognize it. */
15+ function destination(redirectUri: string) {
16+ const url = new URL(redirectUri);
17+ if (url.protocol === "http:" || url.protocol === "https:") {
18+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ? "an app on this computer" : url.host;
19+ }
20+ return `the ${url.protocol.slice(0, -1)}: app`;
21+ }
22+
23+ /** An application (an MCP client, usually an AI agent) asks to act for the signed-in user. */
24+ export default async function AuthorizePage({ searchParams }: PageProps<"/oauth/authorize">) {
25+ // OAuth parameters appear once; a repeated one counts by its first value.
26+ const params: Record<string, string> = {};
27+ for (const [key, value] of Object.entries(await searchParams)) {
28+ const first = Array.isArray(value) ? value[0] : value;
29+ if (typeof first === "string") params[key] = first;
30+ }
31+ const viewer = await requireViewer(`/oauth/authorize?${new URLSearchParams(params)}`);
32+ const parsed = await parseAuthorizeRequest(params);
33+ if ("redirect" in parsed) redirect(parsed.redirect);
34+
35+ if ("fatal" in parsed) {
36+ return (
37+ <main className="mx-auto w-full max-w-[520px] px-4 py-16">
38+ <Box>
39+ <Blankslate icon={<AlertIcon />} title="This connection can’t continue">
40+ {parsed.fatal}
41+ </Blankslate>
42+ </Box>
43+ </main>
44+ );
45+ }
46+
47+ const { request } = parsed;
48+ const repos = await Promise.all(
49+ (await listReposForUser(viewer.id)).map(async (r) => ({
50+ id: r.id,
51+ fullName: `${r.owner}/${r.name}`,
52+ writable: canWrite(await repoAccess(r, viewer.id)),
53+ })),
54+ );
55+
56+ return (
57+ <main className="mx-auto w-full max-w-[560px] px-4 py-12">
58+ <div className="text-center">
59+ <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap">
60+ <LogoMark size={36} />
61+ </span>
62+ <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Authorize {request.client.name}</h1>
63+ <p className="mt-1 type-lead text-ink2">
64+ It wants to work with your repositories as <b className="text-ink">@{viewer.username}</b>, through MCP.
65+ </p>
66+ </div>
67+ <div className="mt-6">
68+ <Alert kind="info">
69+ The name above is chosen by the application itself. Only continue if you just connected an agent or app to Vectrum.
70+ </Alert>
71+ </div>
72+ <Box className="mt-4">
73+ <AuthorizeForm
74+ action={authorizeAction.bind(null, params)}
75+ requested={request.permission}
76+ repos={repos}
77+ destination={destination(request.redirectUri)}
78+ />
79+ </Box>
80+ </main>
81+ );
82+ }
+23−0app/oauth/register/route.tsaddedView File
@@ -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;
+12−0app/oauth/revoke/route.tsaddedView File
@@ -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;
+49−0app/oauth/token/route.tsaddedView File
@@ -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;
+118−0app/settings/applications/page.tsxaddedView File
@@ -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+ }
+2−1app/settings/layout.tsxView File
@@ -1,5 +1,5 @@
11 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";
33 import { Avatar } from "@/components/ui";
44 import { requireViewer } from "@/lib/auth";
55
@@ -22,6 +22,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting
2222 { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> },
2323 { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> },
2424 { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> },
25+ { href: "/settings/applications", label: "Connected apps", icon: <SparkleIcon /> },
2526 ]}
2627 />
2728 <div className="min-w-0">{children}</div>
+3−23app/settings/tokens/form.tsxView File
@@ -1,15 +1,14 @@
11 "use client";
22
33 import { useActionState, useState } from "react";
4- import { Checkbox, Fieldset, FormField, Select } from "@unlocalhosted/metalui";
4+ import { Fieldset, FormField, Select } from "@unlocalhosted/metalui";
55 import { createTokenAction } from "@/app/actions/settings";
66 import { PlusIcon } from "@/components/icons";
7+ import { RepoChecklist, type RepoOption } from "@/components/repo-checklist";
78 import { ChoiceCards, CopyIconButton, TextField } from "@/components/settings-fields";
89 import { PlateBody, PlateFooter } from "@/components/settings-plate";
910 import { Alert, Button } from "@/components/ui";
1011
11- type RepoOption = { id: string; fullName: string; writable: boolean };
12-
1312 const expiries = [
1413 { value: "7", label: "7 days" },
1514 { value: "30", label: "30 days" },
@@ -92,26 +91,7 @@ export function NewTokenForm({ repos, origin, username }: { repos: RepoOption[];
9291 { value: "selected", title: "Only select repositories" },
9392 ]}
9493 />
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"} />}
11595 </Fieldset>
11696
11797 {state?.error && <Alert>{state.error}</Alert>}
+139−0components/releases/release-card.tsxaddedView File
@@ -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+ }
+267−0components/releases/release-form.tsxaddedView File
@@ -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+ }
+32−0components/repo-checklist.tsxaddedView File
@@ -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+ }
+5−2components/repo-tabs.tsxView File
@@ -3,7 +3,7 @@
33 import Link from "next/link";
44 import { usePathname } from "next/navigation";
55 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";
77 import { SlidingIndicator, thumbClass } from "./sliding-indicator";
88
99 /**
@@ -13,22 +13,25 @@ import { SlidingIndicator, thumbClass } from "./sliding-indicator";
1313 export function RepoTabs({
1414 base,
1515 openIssues,
16+ releases,
1617 images,
1718 showSettings,
1819 }: {
1920 base: string;
2021 openIssues: number;
22+ releases: number;
2123 images: number;
2224 showSettings: boolean;
2325 }) {
2426 const pathname = usePathname();
2527 const section = pathname.slice(base.length).split("/")[1] ?? "";
2628 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) },
2830 { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" },
2931 { href: `${base}/actions`, label: "Actions", icon: PlayIcon, active: section === "actions" },
3032 { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" },
3133 { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" },
34+ { href: `${base}/releases`, label: "Releases", icon: TagIcon, count: releases, active: section === "releases" },
3235 { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" },
3336 ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []),
3437 ];
+22−0lib/cors.tsaddedView File
@@ -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 }));
+18−0lib/git-write.tsView File
@@ -87,6 +87,24 @@ export async function commitFiles(
8787 }
8888 }
8989
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+
90108 export async function isValidBranchName(dir: string, name: string) {
91109 if (!name || name.startsWith("-")) return false;
92110 return git(dir, ["check-ref-format", "--branch", name]).then(
+16−0lib/git.test.tsView File
@@ -71,3 +71,19 @@ test("dash-prefixed refs that already exist are ignored and never reach git as o
7171 assert.equal((await git.log(repo, "main"))[0].subject, "ssh-ed25519 AAAA attacker");
7272 assert.equal(await git.commitCount(repo, "main"), 1);
7373 });
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+ });
+8−0lib/git.tsView File
@@ -10,6 +10,7 @@ import { DATA_ROOT, REPOS_ROOT } from "./paths";
1010 const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1111 const RESERVED = new Set([
1212 "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2",
13+ "mcp", "oauth",
1314 ]);
1415
1516 export function isValidName(name: string) {
@@ -251,6 +252,13 @@ export async function readBlob(dir: string, ref: string, filePath: string) {
251252 return { size, tooLarge, binary, content };
252253 }
253254
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+
254262 // ---------------------------------------------------------------------------
255263 // Commits
256264
+55−0lib/issues.tsView File
@@ -1,6 +1,9 @@
11 import { db, filter, findFirst, isUniqueViolation, orNull, withLock, type RecordModel } from "./db";
22 import {
3+ ESTIMATES,
34 isClosedStatus,
5+ isValidDay,
6+ STATUS_IDS,
47 type Issue,
58 type IssueLabel,
69 type IssuePatch,
@@ -54,6 +57,58 @@ const toSummary = (r: RecordModel): IssueSummary => ({
5457
5558 const issues = async () => (await db()).collection("issues");
5659
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+
57112 /** Every issue in a repository, without descriptions. Filtering and grouping happen in the browser. */
58113 export async function listIssues(repoId: string) {
59114 const rows = await (await issues()).getFullList({
+39−0lib/mcp/params.test.tsaddedView File
@@ -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+ });
+104−0lib/mcp/params.tsaddedView File
@@ -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+ }
+133−0lib/mcp/server.tsaddedView File
@@ -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+ }
+1100−0lib/mcp/tools.tsaddedView File

Large diffs are not rendered by default.

+45−0lib/oauth-rules.test.tsaddedView File

Large diffs are not rendered by default.

+83−0lib/oauth-rules.tsaddedView File

Large diffs are not rendered by default.

+396−0lib/oauth.tsaddedView File

Large diffs are not rendered by default.

+234−0lib/release-http.tsaddedView File

Large diffs are not rendered by default.

+290−0lib/releases.tsaddedView File

Large diffs are not rendered by default.

+3−1lib/repos.tsView File

Large diffs are not rendered by default.

+13−5lib/tokens.tsView File

Large diffs are not rendered by default.

+12−0next.config.tsView File

Large diffs are not rendered by default.

+82−0pb_migrations/1790964309_releases.jsaddedView File

Large diffs are not rendered by default.

+105−0pb_migrations/1790964401_oauth.jsaddedView File

Large diffs are not rendered by default.