Public: anyone can read this; the crew can change it.
Proposal tokens through MCP and the terminal: mint, list and revoke, writer-only #2217
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2148-proposal-tokens-through-mcp-and-the-term → main 1 commit fork 2 threads · 2 unresolved
9 files
changed +589
−17
README.md +13 −2
| @@ -92,8 +92,7 @@ curl -sX POST localhost:3000/api/v1/repos/you/hello/proposal-tokens \ | ||
| 92 | 92 | `childRepo` (optional) a fork of this repository, and deleting it deletes the |
| 93 | 93 | token, as does deleting the repository. The token expires in 30 days. It is |
| 94 | 94 | listed as `proposal:<child>` among your tokens, and revoking it there ends it. |
| 95 | Minting is API-only for now; a page, a terminal command and an MCP tool are | |
| 96 | part 2 (#2187). Over HTTPS it pushes only `child/<name>/…` branches of this repository (anything | |
| 95 | Over HTTPS it pushes only `child/<name>/…` branches of this repository (anything | |
| 97 | 96 | else is refused with one line) and `POST …/pulls` from one of them, with a |
| 98 | 97 | body of at most 64 KiB and 20 requests a minute. Every other request is a |
| 99 | 98 | 403, decided in `src/http/proposal_gate.ts` before any handler runs. A proposed pull |
| @@ -103,6 +102,18 @@ opened with it says "proposed by child … from …" on every tab of the pull's | ||
| 103 | 102 | page, in the terminal's pull view, and as `proposal` in the pull's JSON |
| 104 | 103 | (`GET …/pulls/:number`, and MCP's `pull` tool). |
| 105 | 104 | |
| 105 | Minting, listing and revoking go through one action | |
| 106 | (`src/actions/proposal_tokens.ts`) at every door, and only a writer of the | |
| 107 | repository may; anyone else is told "no such resource here, or it is not | |
| 108 | visible to you", as the API says it. The secret is shown once, on minting. | |
| 109 | ||
| 110 | - **MCP**: the `proposal_tokens` tool with `repo` and a `verb`: `mint` | |
| 111 | (`child`, `origin`, optionally `childRepo`), `list` (the ones you minted for | |
| 112 | the repository) or `revoke` (`id`). | |
| 113 | - **Terminal**: `ctrl+k`, **Proposal tokens**, then the repository as | |
| 114 | `owner/name`. `n` mints (the child's name, then its https address) and shows | |
| 115 | the secret once on its own screen; `x` revokes the one under the cursor. | |
| 116 | ||
| 106 | 117 | ## Live in the terminal |
| 107 | 118 | |
| 108 | 119 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/actions/proposal_tokens.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * Mint, list and revoke proposal tokens (`core/proposal_token.ts`), for every | |
| 3 | * door: `POST /api/v1/repos/:owner/:name/proposal-tokens`, MCP's | |
| 4 | * `proposal_tokens` and the terminal's Proposal tokens screen. A writer of the | |
| 5 | * repository may; anyone else is told what the API says of a repository they | |
| 6 | * cannot see, so the three doors refuse in the same words. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { | |
| 11 | deleteProposalToken, | |
| 12 | listProposalTokens, | |
| 13 | mintProposalToken, | |
| 14 | type ProposalTokenInfo, | |
| 15 | } from "../core/proposal_token.ts"; | |
| 16 | import { canWrite, getRepo, type Repo } from "../core/repos.ts"; | |
| 17 | import type { User } from "../core/users.ts"; | |
| 18 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 19 | ||
| 20 | export interface ProposalTokenError { | |
| 21 | readonly code: "unauthorized" | "not_found" | "invalid_child" | "invalid_origin"; | |
| 22 | readonly message: string; | |
| 23 | } | |
| 24 | ||
| 25 | /** The API's own words for a repository that is missing or not yours. */ | |
| 26 | export const NOT_FOUND_WORDS = "no such resource here, or it is not visible to you"; | |
| 27 | ||
| 28 | const fail = (code: ProposalTokenError["code"], message: string) => | |
| 29 | err<ProposalTokenError>({ code, message }); | |
| 30 | ||
| 31 | const INVALID_WORDS = "child is lowercase words and hyphens; origin is an https address"; | |
| 32 | ||
| 33 | /** The repository, when the actor writes it. */ | |
| 34 | function mine( | |
| 35 | app: App, | |
| 36 | actor: User | undefined, | |
| 37 | repo: Repo | undefined, | |
| 38 | ): Result<{ repo: Repo; actor: User }, ProposalTokenError> { | |
| 39 | if (!actor) return fail("unauthorized", "authenticate with a bearer token or account credentials"); | |
| 40 | if (!repo || !canWrite(app.db, repo, actor)) return fail("not_found", NOT_FOUND_WORDS); | |
| 41 | return ok({ repo, actor }); | |
| 42 | } | |
| 43 | ||
| 44 | /** A repository by `owner/name`, or undefined for anything else. */ | |
| 45 | export function repoByName(app: App, spec: unknown): Repo | undefined { | |
| 46 | const [owner, name, ...rest] = typeof spec === "string" ? spec.split("/") : []; | |
| 47 | return owner && name && rest.length === 0 ? getRepo(app.db, owner, name) : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | /** Mint a token for `child`; the secret comes back once. */ | |
| 51 | export function mintProposal( | |
| 52 | app: App, | |
| 53 | actor: User | undefined, | |
| 54 | repo: Repo | undefined, | |
| 55 | input: { child: unknown; origin: unknown; childRepo?: unknown }, | |
| 56 | ): Result<{ id: string; token: string }, ProposalTokenError> { | |
| 57 | const found = mine(app, actor, repo); | |
| 58 | if (!found.ok) return found; | |
| 59 | let childRepoId: string | undefined; | |
| 60 | const spec = typeof input.childRepo === "string" ? input.childRepo.trim() : ""; | |
| 61 | if (spec !== "") { | |
| 62 | const child = repoByName(app, spec); | |
| 63 | if (!child || child.forkParentId !== found.value.repo.id) return fail("not_found", NOT_FOUND_WORDS); | |
| 64 | childRepoId = child.id; | |
| 65 | } | |
| 66 | const childName = typeof input.child === "string" ? input.child.trim() : ""; | |
| 67 | const minted = mintProposalToken(app.db, found.value.actor.id, found.value.repo.id, { | |
| 68 | child: childName, | |
| 69 | origin: typeof input.origin === "string" ? input.origin.trim() : "", | |
| 70 | ...(childRepoId ? { childRepoId } : {}), | |
| 71 | }); | |
| 72 | if (!minted.ok) return fail(minted.error, INVALID_WORDS); | |
| 73 | app.bus.append({ | |
| 74 | kind: "token.created", | |
| 75 | actorId: found.value.actor.id, | |
| 76 | payload: { tokenId: minted.value.id, name: `proposal:${childName}`, scope: "rw" }, | |
| 77 | }); | |
| 78 | return ok(minted.value); | |
| 79 | } | |
| 80 | ||
| 81 | /** The proposal tokens the actor minted for this repository. */ | |
| 82 | export function listProposals( | |
| 83 | app: App, | |
| 84 | actor: User | undefined, | |
| 85 | repo: Repo | undefined, | |
| 86 | ): Result<ProposalTokenInfo[], ProposalTokenError> { | |
| 87 | const found = mine(app, actor, repo); | |
| 88 | if (!found.ok) return found; | |
| 89 | return ok(listProposalTokens(app.db, found.value.actor.id, found.value.repo.id)); | |
| 90 | } | |
| 91 | ||
| 92 | /** End one of them. */ | |
| 93 | export function revokeProposal( | |
| 94 | app: App, | |
| 95 | actor: User | undefined, | |
| 96 | repo: Repo | undefined, | |
| 97 | tokenId: unknown, | |
| 98 | ): Result<null, ProposalTokenError> { | |
| 99 | const found = mine(app, actor, repo); | |
| 100 | if (!found.ok) return found; | |
| 101 | if ( | |
| 102 | typeof tokenId !== "string" || | |
| 103 | !deleteProposalToken(app.db, found.value.actor.id, found.value.repo.id, tokenId) | |
| 104 | ) | |
| 105 | return fail("not_found", NOT_FOUND_WORDS); | |
| 106 | app.bus.append({ kind: "token.deleted", actorId: found.value.actor.id, payload: { tokenId } }); | |
| 107 | return ok(null); | |
| 108 | } |
src/core/proposal_token.ts +59
| @@ -120,3 +120,62 @@ export function pullProposalOf( | ||
| 120 | 120 | .get(issueId) ?? undefined |
| 121 | 121 | ); |
| 122 | 122 | } |
| 123 | ||
| 124 | export interface ProposalTokenInfo { | |
| 125 | readonly id: string; | |
| 126 | readonly child: string; | |
| 127 | readonly origin: string; | |
| 128 | readonly createdAt: string; | |
| 129 | readonly expiresAt: string | null; | |
| 130 | readonly lastUsedAt: string | null; | |
| 131 | } | |
| 132 | ||
| 133 | /** The proposal tokens one person minted for one repository, oldest first. */ | |
| 134 | export function listProposalTokens( | |
| 135 | db: Database, | |
| 136 | userId: string, | |
| 137 | repoId: string, | |
| 138 | ): ProposalTokenInfo[] { | |
| 139 | return db | |
| 140 | .query< | |
| 141 | { | |
| 142 | id: string; | |
| 143 | child: string; | |
| 144 | origin: string; | |
| 145 | created_at: string; | |
| 146 | expires_at: string | null; | |
| 147 | last_used_at: string | null; | |
| 148 | }, | |
| 149 | [string, string] | |
| 150 | >( | |
| 151 | `SELECT t.id, p.child, p.origin, t.created_at, t.expires_at, t.last_used_at | |
| 152 | FROM proposal_token p JOIN token t ON t.id = p.token_id | |
| 153 | WHERE t.user_id = ?1 AND p.repo_id = ?2 ORDER BY t.id`, | |
| 154 | ) | |
| 155 | .all(userId, repoId) | |
| 156 | .map((r) => ({ | |
| 157 | id: r.id, | |
| 158 | child: r.child, | |
| 159 | origin: r.origin, | |
| 160 | createdAt: r.created_at, | |
| 161 | expiresAt: r.expires_at, | |
| 162 | lastUsedAt: r.last_used_at, | |
| 163 | })); | |
| 164 | } | |
| 165 | ||
| 166 | /** End one proposal token of this person's for this repository. */ | |
| 167 | export function deleteProposalToken( | |
| 168 | db: Database, | |
| 169 | userId: string, | |
| 170 | repoId: string, | |
| 171 | tokenId: string, | |
| 172 | ): boolean { | |
| 173 | return ( | |
| 174 | db | |
| 175 | .query( | |
| 176 | `DELETE FROM token WHERE id = ?1 AND user_id = ?2 | |
| 177 | AND id IN (SELECT token_id FROM proposal_token WHERE repo_id = ?3)`, | |
| 178 | ) | |
| 179 | .run(tokenId, userId, repoId).changes > 0 | |
| 180 | ); | |
| 181 | } |
src/http/api.ts +4 −15
| @@ -214,10 +214,10 @@ import { | ||
| 214 | 214 | withOutdated, |
| 215 | 215 | } from "../core/reviews.ts"; |
| 216 | 216 | import { createSession } from "../core/sessions.ts"; |
| 217 | import { mintProposal } from "../actions/proposal_tokens.ts"; | |
| 217 | 218 | import { listTokens } from "../core/tokens.ts"; |
| 218 | 219 | import { |
| 219 | 220 | headInNamespace, |
| 220 | mintProposalToken, | |
| 221 | 221 | PROPOSAL_BODY_MAX, |
| 222 | 222 | pullProposalOf, |
| 223 | 223 | recordPullProposal, |
| @@ -3121,24 +3121,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3121 | 3121 | const input = await boundedBody(ctx.request, PROPOSAL_BODY_MAX); |
| 3122 | 3122 | if (input === "too_large") |
| 3123 | 3123 | return apiError("too_large", 413, `at most ${PROPOSAL_BODY_MAX} bytes`); |
| 3124 | let childRepoId: string | undefined; | |
| 3125 | const childSpec = str(input.childRepo); | |
| 3126 | if (childSpec !== "") { | |
| 3127 | const [owner, name, ...rest] = childSpec.split("/"); | |
| 3128 | const child = | |
| 3129 | owner && name && rest.length === 0 | |
| 3130 | ? getRepo(ctx.app.db, owner, name) | |
| 3131 | : undefined; | |
| 3132 | if (!child || child.forkParentId !== repo.id) return notFound(); | |
| 3133 | childRepoId = child.id; | |
| 3134 | } | |
| 3135 | const minted = mintProposalToken(ctx.app.db, ctx.auth.user!.id, repo.id, { | |
| 3124 | const minted = mintProposal(ctx.app, ctx.auth.user, repo, { | |
| 3136 | 3125 | child: str(input.child), |
| 3137 | 3126 | origin: str(input.origin), |
| 3138 | ...(childRepoId ? { childRepoId } : {}), | |
| 3127 | childRepo: str(input.childRepo), | |
| 3139 | 3128 | }); |
| 3140 | 3129 | if (!minted.ok) |
| 3141 | return apiError(minted.error, 422, "child is lowercase words and hyphens; origin is an https address"); | |
| 3130 | return minted.error.code === "not_found" ? notFound() : apiError(minted.error.code, 422, minted.error.message); | |
| 3142 | 3131 | return json(minted.value, 201); |
| 3143 | 3132 | }, |
| 3144 | 3133 | ); |
src/http/mcp.ts +37
| @@ -130,6 +130,7 @@ import { | ||
| 130 | 130 | import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts"; |
| 131 | 131 | import { appsOf, asksAnswer } from "../web/studio/asks.ts"; |
| 132 | 132 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 133 | import { listProposals, mintProposal, repoByName, revokeProposal } from "../actions/proposal_tokens.ts"; | |
| 133 | 134 | import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts"; |
| 134 | 135 | import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts"; |
| 135 | 136 | import type { Ctx, Handler } from "./context.ts"; |
| @@ -692,6 +693,42 @@ const TOOLS: readonly Tool[] = [ | ||
| 692 | 693 | return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message); |
| 693 | 694 | }, |
| 694 | 695 | }, |
| 696 | { | |
| 697 | name: "proposal_tokens", | |
| 698 | description: | |
| 699 | "Hand a child (a fork, or a project on another forge) a credential that proposes and nothing more. `mint` (with `child`: lowercase words and hyphens, `origin`: its https address, optionally `childRepo`: owner/name of a fork of this repository) answers with the secret, shown once; the token pushes only `child/<name>/…` branches of this repository and opens pulls from them, and expires in 30 days. `list` shows the ones you minted for this repository; `revoke` (with `id`) ends one. A writer of the repository only; anyone else is told there is no such resource. The same as POST /api/v1/repos/:owner/:name/proposal-tokens.", | |
| 700 | inputSchema: { | |
| 701 | type: "object", | |
| 702 | properties: { | |
| 703 | repo: REPO_ARG, | |
| 704 | verb: { type: "string", enum: ["mint", "list", "revoke"] }, | |
| 705 | child: { type: "string", maxLength: 40 }, | |
| 706 | origin: { type: "string", maxLength: 200 }, | |
| 707 | childRepo: { type: "string", maxLength: 200 }, | |
| 708 | id: { type: "string", maxLength: 80 }, | |
| 709 | }, | |
| 710 | required: ["repo", "verb"], | |
| 711 | }, | |
| 712 | writes: true, | |
| 713 | run(ctx, user, args) { | |
| 714 | const repo = repoByName(ctx.app, args["repo"]); | |
| 715 | const verb = args["verb"]; | |
| 716 | const done = | |
| 717 | verb === "mint" | |
| 718 | ? mintProposal(ctx.app, user, repo, { | |
| 719 | child: args["child"], | |
| 720 | origin: args["origin"], | |
| 721 | childRepo: args["childRepo"], | |
| 722 | }) | |
| 723 | : verb === "list" | |
| 724 | ? listProposals(ctx.app, user, repo) | |
| 725 | : verb === "revoke" | |
| 726 | ? revokeProposal(ctx.app, user, repo, args["id"]) | |
| 727 | : undefined; | |
| 728 | if (done === undefined) return bad("invalid", "verb is mint, list or revoke"); | |
| 729 | return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message); | |
| 730 | }, | |
| 731 | }, | |
| 695 | 732 | { |
| 696 | 733 | name: "asks_undecide", |
| 697 | 734 | description: |
src/tui/forge.ts +9
| @@ -45,6 +45,7 @@ import { accessScreen } from "./screens/access.ts"; | ||
| 45 | 45 | import { flagsScreen } from "./screens/flags.ts"; |
| 46 | 46 | import { cloudScreen } from "./screens/cloud.ts"; |
| 47 | 47 | import { usageScreen } from "./screens/usage.ts"; |
| 48 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; | |
| 48 | 49 | import { quotasScreen } from "./screens/quotas.ts"; |
| 49 | 50 | import { domainsScreen } from "./screens/domains.ts"; |
| 50 | 51 | import { ownerScreen } from "./screens/owner.ts"; |
| @@ -357,6 +358,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 357 | 358 | hint: "every route, unused ones first", |
| 358 | 359 | run: () => ctx.push(usageScreen(session, ctx)), |
| 359 | 360 | }); |
| 361 | // Proposal tokens for a repository you write, as the API and MCP have them (#2148). | |
| 362 | if (session.user !== undefined) | |
| 363 | commands.push({ | |
| 364 | id: "proposal-tokens", | |
| 365 | title: "Proposal tokens", | |
| 366 | hint: "mint, list and revoke what a child proposes with", | |
| 367 | run: () => ctx.push(proposalTokensPrompt(session, ctx, blank("Proposal tokens"))), | |
| 368 | }); | |
| 360 | 369 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). |
| 361 | 370 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 362 | 371 | commands.push({ |
src/tui/screens/proposal_tokens.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens in the terminal: the ones you minted for a repository, with | |
| 3 | * `n` to mint (a child's name, then where it lives) and `x` to revoke the one | |
| 4 | * under the cursor. The secret is shown once, on a screen of its own. Every | |
| 5 | * rule is the action's (`actions/proposal_tokens.ts`), the same as the API's | |
| 6 | * and MCP's: a repository you do not write is "not visible to you", and | |
| 7 | * nothing is listed or changed. | |
| 8 | */ | |
| 9 | ||
| 10 | import { | |
| 11 | listProposals, | |
| 12 | mintProposal, | |
| 13 | repoByName, | |
| 14 | revokeProposal, | |
| 15 | } from "../../actions/proposal_tokens.ts"; | |
| 16 | import type { Repo } from "../../core/repos.ts"; | |
| 17 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 18 | import { binding } from "../app/keys.ts"; | |
| 19 | import { style } from "../term/style.ts"; | |
| 20 | import { truncate } from "../term/width.ts"; | |
| 21 | import type { Session } from "../session.ts"; | |
| 22 | import { fill, len, split } from "../ui/layout.ts"; | |
| 23 | import { ListState, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 24 | ||
| 25 | /** Ask for `owner/name`, then show its proposal tokens. */ | |
| 26 | export function proposalTokensPrompt(f: Session, ctx: Ctx, parent: Screen): Screen { | |
| 27 | return promptScreen(ctx, parent, { | |
| 28 | title: "Proposal tokens", | |
| 29 | label: "repository, as owner/name", | |
| 30 | onSubmit: (raw) => { | |
| 31 | const repo = repoByName(f.app, raw.trim()); | |
| 32 | const listed = listProposals(f.app, f.user, repo); | |
| 33 | if (!listed.ok || repo === undefined) return listed.ok ? undefined : listed.error.message; | |
| 34 | return later(ctx, () => proposalTokensScreen(f, ctx, repo)); | |
| 35 | }, | |
| 36 | }); | |
| 37 | } | |
| 38 | ||
| 39 | /** The secret, once: nothing here is kept after the screen closes. */ | |
| 40 | function secretScreen(ctx: Ctx, child: string, token: string): Screen { | |
| 41 | return { | |
| 42 | title: `Token for ${child}`, | |
| 43 | keys: [binding(["esc"], "done")], | |
| 44 | render(area, s) { | |
| 45 | const c = ctx.theme.c; | |
| 46 | const lines = ["Shown once. Hand it to the child; it cannot be read again.", "", token]; | |
| 47 | lines.forEach((l, i) => | |
| 48 | s.text(area.x, area.y + i, truncate(l, area.w), style(i === 2 ? c.fg : c.mutedFg, c.bg), area.x + area.w), | |
| 49 | ); | |
| 50 | }, | |
| 51 | key(e) { | |
| 52 | if (e.key === "esc" || e.key === "q" || e.key === "enter") { | |
| 53 | ctx.pop(); | |
| 54 | return true; | |
| 55 | } | |
| 56 | return true; | |
| 57 | }, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | export function proposalTokensScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 62 | const state = new ListState(); | |
| 63 | let note = ""; | |
| 64 | const list = () => listProposals(f.app, f.user, repo); | |
| 65 | const items = () => { | |
| 66 | const r = list(); | |
| 67 | return r.ok ? r.value : []; | |
| 68 | }; | |
| 69 | ||
| 70 | const rows = (): ListRow[] => | |
| 71 | items().map((t) => ({ | |
| 72 | title: t.child, | |
| 73 | secondary: t.origin, | |
| 74 | meta: `expires ${t.expiresAt?.slice(0, 10) ?? "never"}`, | |
| 75 | })); | |
| 76 | ||
| 77 | const screen: Screen = { | |
| 78 | title: `Proposal tokens: ${repo.ownerName}/${repo.name}`, | |
| 79 | keys: [binding(["j", "k"], "move cursor"), binding(["n"], "mint"), binding(["x"], "revoke")], | |
| 80 | render(area, s) { | |
| 81 | const c = ctx.theme.c; | |
| 82 | const r = list(); | |
| 83 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 84 | const text = note !== "" ? note : !r.ok ? r.error.message : r.value.length === 0 ? "None yet. n mints one." : "Tokens you minted for children of this repository."; | |
| 85 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 86 | drawList(s, body!, c, state, rows()); | |
| 87 | }, | |
| 88 | key(e) { | |
| 89 | if (state.key(e, items().length)) return true; | |
| 90 | if (e.ctrl || e.alt) return false; | |
| 91 | if (e.key === "x") { | |
| 92 | const t = items()[state.index]; | |
| 93 | if (t === undefined) return false; | |
| 94 | const done = revokeProposal(f.app, f.user, repo, t.id); | |
| 95 | note = done.ok ? `${t.child}: revoked.` : done.error.message; | |
| 96 | ctx.invalidate(); | |
| 97 | return true; | |
| 98 | } | |
| 99 | if (e.key === "n") { | |
| 100 | ctx.push( | |
| 101 | promptScreen(ctx, screen, { | |
| 102 | title: "Mint a proposal token", | |
| 103 | label: "the child's name (lowercase words and hyphens)", | |
| 104 | onSubmit: (child) => | |
| 105 | later(ctx, () => | |
| 106 | promptScreen(ctx, screen, { | |
| 107 | title: "Mint a proposal token", | |
| 108 | label: "where the child lives (an https address)", | |
| 109 | onSubmit: (origin) => { | |
| 110 | const made = mintProposal(f.app, f.user, repo, { child, origin }); | |
Non-blocking: the terminal never passes |
||
| 111 | if (!made.ok) return made.error.message; | |
| 112 | note = `${child.trim()}: minted.`; | |
| 113 | return later(ctx, () => secretScreen(ctx, child.trim(), made.value.token)); | |
| 114 | }, | |
| 115 | }), | |
| 116 | ), | |
| 117 | }), | |
| 118 | ); | |
| 119 | return true; | |
| 120 | } | |
| 121 | return false; | |
| 122 | }, | |
| 123 | }; | |
| 124 | return screen; | |
| 125 | } |
test/mcp.test.ts +1
| @@ -170,6 +170,7 @@ describe("the forge over MCP", () => { | ||
| 170 | 170 | "asks_act", |
| 171 | 171 | "asks_decide", |
| 172 | 172 | "asks_proposal", |
| 173 | "proposal_tokens", | |
| 173 | 174 | "asks_undecide", |
| 174 | 175 | "seal", |
| 175 | 176 | "now", |
test/proposal-token-doors.test.ts added +233
| @@ -0,0 +1,233 @@ | ||
| 1 | /** | |
| 2 | * Proposal tokens through MCP and the terminal (#2148): mint, list and revoke, | |
| 3 | * writer-only, each door calling the one action the API calls. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { proposalOf } from "../src/core/proposal_token.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { createToken, verifyToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const ORIGIN = "https://kid.example.com/"; | |
| 24 | const NOT_VISIBLE = "no such resource here, or it is not visible to you"; | |
| 25 | ||
| 26 | describe("proposal tokens through MCP and the terminal", () => { | |
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | let lib: Repo; | |
| 33 | let aliceRw: string; | |
| 34 | let bobRw: string; | |
| 35 | ||
| 36 | const mcp = async (token: string, args: Record<string, unknown>) => { | |
| 37 | const res = await fetch(`${servers.url}/mcp`, { | |
| 38 | method: "POST", | |
| 39 | headers: { | |
| 40 | "content-type": "application/json", | |
| 41 | accept: "application/json, text/event-stream", | |
| 42 | authorization: `Bearer ${token}`, | |
| 43 | }, | |
| 44 | body: JSON.stringify({ | |
| 45 | jsonrpc: "2.0", | |
| 46 | id: 1, | |
| 47 | method: "tools/call", | |
| 48 | params: { name: "proposal_tokens", arguments: { repo: "alice/lib", ...args } }, | |
| 49 | }), | |
| 50 | }); | |
| 51 | const body = (await res.json()) as any; | |
| 52 | const text = body.result.content[0].text as string; | |
| 53 | return { error: body.result.isError === true, text, json: () => JSON.parse(text) }; | |
| 54 | }; | |
| 55 | ||
| 56 | function boot(username: string) { | |
| 57 | const terminal = headlessTerminal(140, 32); | |
| 58 | const user = getUserByName(app.db, username)!; | |
| 59 | const { app: tui } = createForgeApp( | |
| 60 | app, | |
| 61 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 62 | { terminal, exitHooks: false }, | |
| 63 | ); | |
| 64 | tui.start(); | |
| 65 | const key = (k: string) => tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 66 | return { | |
| 67 | text: () => tui.screen.toText(), | |
| 68 | press: async (...keys: string[]) => { | |
| 69 | keys.forEach(key); | |
| 70 | await settleApp(tui); | |
| 71 | }, | |
| 72 | ctrlK: async () => { | |
| 73 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 74 | await settleApp(tui); | |
| 75 | }, | |
| 76 | type: async (t: string) => { | |
| 77 | for (const ch of t) key(ch); | |
| 78 | await settleApp(tui); | |
| 79 | }, | |
| 80 | stop: () => tui.stop(), | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | beforeAll(async () => { | |
| 85 | dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-doors-")); | |
| 86 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 87 | servers = await serve(app); | |
| 88 | const people: User[] = []; | |
| 89 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 90 | const made = await createUser(app.db, { | |
| 91 | trusted: true, | |
| 92 | username, | |
| 93 | email: `${username}@forge.local`, | |
| 94 | password: "a-good-passphrase", | |
| 95 | adminIfFirst: first, | |
| 96 | }); | |
| 97 | if (!made.ok) throw new Error(made.error); | |
| 98 | people.push(made.value); | |
| 99 | } | |
| 100 | [alice, bob] = people as [User, User]; | |
| 101 | const made = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 102 | if (!made.ok) throw new Error(String(made.error)); | |
| 103 | lib = made.value; | |
| 104 | aliceRw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 105 | bobRw = createToken(app.db, bob.id, "rw", "rw").token; | |
| 106 | }); | |
| 107 | ||
| 108 | afterAll(async () => { | |
| 109 | await servers.stop(); | |
| 110 | await rm(dataDir, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("MCP: mint shows the secret once, list never does, revoke ends it", async () => { | |
| 114 | const minted = await mcp(aliceRw, { verb: "mint", child: "kid", origin: ORIGIN }); | |
| 115 | expect(minted.error).toBe(false); | |
| 116 | const { id, token } = minted.json(); | |
| 117 | expect(token).toStartWith("forge_"); | |
| 118 | expect(proposalOf(app.db, id)?.child).toBe("kid"); | |
| 119 | const verified = verifyToken(app.db, token); | |
| 120 | expect(verified.ok && verified.value.proposal?.repoId).toBe(lib.id); | |
| 121 | ||
| 122 | const listed = await mcp(aliceRw, { verb: "list" }); | |
| 123 | expect(listed.json()).toHaveLength(1); | |
| 124 | expect(listed.json()[0]).toMatchObject({ id, child: "kid", origin: ORIGIN }); | |
| 125 | expect(listed.text).not.toContain(token); | |
| 126 | ||
| 127 | expect((await mcp(aliceRw, { verb: "revoke", id })).error).toBe(false); | |
| 128 | expect(proposalOf(app.db, id)).toBeUndefined(); | |
| 129 | expect(verifyToken(app.db, token).ok).toBe(false); | |
| 130 | expect((await mcp(aliceRw, { verb: "list" })).json()).toEqual([]); | |
| 131 | expect((await mcp(aliceRw, { verb: "revoke", id })).error).toBe(true); | |
| 132 | }); | |
| 133 | ||
| 134 | test("MCP: bad input is refused in the API's words", async () => { | |
| 135 | const bad = await mcp(aliceRw, { verb: "mint", child: "Kid", origin: ORIGIN }); | |
| 136 | expect(bad.error).toBe(true); | |
| 137 | expect(bad.text).toContain("child is lowercase words and hyphens; origin is an https address"); | |
| 138 | expect((await mcp(aliceRw, { verb: "mint", child: "kid", origin: "http://x.test/" })).error).toBe(true); | |
| 139 | expect((await mcp(aliceRw, { verb: "explode" })).error).toBe(true); | |
| 140 | }); | |
| 141 | ||
| 142 | test("MCP: a non-writer is told what the API tells them", async () => { | |
| 143 | const api = await fetch(`${servers.url}/api/v1/repos/alice/lib/proposal-tokens`, { | |
| 144 | method: "POST", | |
| 145 | headers: { authorization: `Bearer ${bobRw}`, "content-type": "application/json" }, | |
| 146 | body: JSON.stringify({ child: "kid", origin: ORIGIN }), | |
| 147 | }); | |
| 148 | expect(api.status).toBe(404); | |
| 149 | const apiWords = ((await api.json()) as { message: string }).message; | |
| 150 | expect(apiWords).toBe(NOT_VISIBLE); | |
| 151 | ||
| 152 | const mine = (await mcp(aliceRw, { verb: "mint", child: "mine", origin: ORIGIN })).json(); | |
| 153 | for (const args of [ | |
| 154 | { verb: "mint", child: "kid", origin: ORIGIN }, | |
| 155 | { verb: "list" }, | |
| 156 | { verb: "revoke", id: mine.id }, | |
| 157 | ]) { | |
| 158 | const refused = await mcp(bobRw, args); | |
| 159 | expect(refused.error).toBe(true); | |
| 160 | expect(refused.text).toContain(apiWords); | |
| 161 | } | |
| 162 | expect(proposalOf(app.db, mine.id)).toBeDefined(); | |
| 163 | expect(app.db.query("SELECT COUNT(*) AS n FROM proposal_token").get()).toEqual({ n: 1 }); | |
| 164 | }); | |
| 165 | ||
| 166 | test("terminal: mint, list and revoke round trip", async () => { | |
| 167 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM proposal_token").get()!.n; | |
| 168 | const h = boot("alice"); | |
| 169 | await h.ctrlK(); | |
| 170 | await h.type("Proposal tokens"); | |
| 171 | await h.press("enter"); | |
| 172 | await h.type("alice/lib"); | |
| 173 | await h.press("enter"); | |
| 174 | expect(h.text()).toContain("mine"); | |
| 175 | ||
| 176 | await h.press("n"); | |
| 177 | await h.type("terminal-kid"); | |
| 178 | await h.press("enter"); | |
| 179 | await h.type("https://tk.example.com/"); | |
| 180 | await h.press("enter"); | |
| 181 | expect(h.text()).toContain("Shown once"); | |
| 182 | const row = app.db | |
| 183 | .query<{ token_id: string }, []>("SELECT token_id FROM proposal_token WHERE child = 'terminal-kid'") | |
| 184 | .get()!; | |
| 185 | expect(row).toBeDefined(); | |
| 186 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM proposal_token").get()!.n).toBe(before + 1); | |
| 187 | // The secret on screen is the one that works. | |
| 188 | const shown = /forge_[A-Za-z0-9_-]+/.exec(h.text())?.[0] ?? ""; | |
| 189 | const verified = verifyToken(app.db, shown); | |
| 190 | expect(verified.ok && verified.value.proposal?.child).toBe("terminal-kid"); | |
| 191 | ||
| 192 | await h.press("esc"); | |
| 193 | expect(h.text()).toContain("terminal-kid"); | |
| 194 | expect(h.text()).toContain("https://tk.example.com/"); | |
| 195 | expect(h.text()).not.toContain(shown); | |
| 196 | ||
| 197 | // The earlier "mine" token is first; the new one is under the second row. | |
| 198 | await h.press("j"); | |
| 199 | await h.press("x"); | |
| 200 | expect(proposalOf(app.db, row.token_id)).toBeUndefined(); | |
| 201 | expect(h.text()).toContain("terminal-kid: revoked."); | |
| 202 | h.stop(); | |
| 203 | }); | |
| 204 | ||
| 205 | test("terminal: revoke removes the token under the cursor", async () => { | |
| 206 | const h = boot("alice"); | |
| 207 | await h.ctrlK(); | |
| 208 | await h.type("Proposal tokens"); | |
| 209 | await h.press("enter"); | |
| 210 | await h.type("alice/lib"); | |
| 211 | await h.press("enter"); | |
| 212 | const rows = app.db.query<{ child: string }, []>("SELECT child FROM proposal_token ORDER BY token_id").all(); | |
| 213 | expect(rows.length).toBeGreaterThan(0); | |
| 214 | // Revoke every row: the cursor stays on the first. | |
| 215 | for (let i = 0; i < rows.length; i++) await h.press("x"); | |
| 216 | expect(h.text()).toContain("revoked"); | |
| 217 | expect(app.db.query("SELECT 1 FROM proposal_token").all()).toEqual([]); | |
| 218 | h.stop(); | |
| 219 | }); | |
| 220 | ||
| 221 | test("terminal: a non-writer is refused with the API's words and nothing changes", async () => { | |
| 222 | const mine = (await mcp(aliceRw, { verb: "mint", child: "keep", origin: ORIGIN })).json(); | |
| 223 | const h = boot("bob"); | |
| 224 | await h.ctrlK(); | |
| 225 | await h.type("Proposal tokens"); | |
| 226 | await h.press("enter"); | |
| 227 | await h.type("alice/lib"); | |
| 228 | await h.press("enter"); | |
| 229 | expect(h.text()).toContain(NOT_VISIBLE); | |
| 230 | expect(proposalOf(app.db, mine.id)).toBeDefined(); | |
| 231 | h.stop(); | |
| 232 | }); | |
| 233 | }); |
new above
offline
Non-blocking:
xrevokes the moment it is pressed, and a revoked token cannot be brought back.confirmScreenin./common.tsis already used elsewhere for this kind of irreversible action; one enter-to-confirm here would match those screens.