Public: anyone can read this; the crew can change it.

Proposal tokens through MCP and the terminal: mint, list and revoke, writer-only #2217

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2148-proposal-tokens-through-mcp-and-the-term → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/2148-proposal-tokens-through-mcp-and-the-term was merged into main as 66acaa0.

Review

Sign in to review this change.
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 \
9292 `childRepo` (optional) a fork of this repository, and deleting it deletes the
9393 token, as does deleting the repository. The token expires in 30 days. It is
9494 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
9796 else is refused with one line) and `POST …/pulls` from one of them, with a
9897 body of at most 64 KiB and 20 requests a minute. Every other request is a
9998 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
103102 page, in the terminal's pull view, and as `proposal` in the pull's JSON
104103 (`GET …/pulls/:number`, and MCP's `pull` tool).
105104
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
106117 ## Live in the terminal
107118
108119 `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(
120120 .get(issueId) ?? undefined
121121 );
122122 }
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 {
214214 withOutdated,
215215 } from "../core/reviews.ts";
216216 import { createSession } from "../core/sessions.ts";
217 import { mintProposal } from "../actions/proposal_tokens.ts";
217218 import { listTokens } from "../core/tokens.ts";
218219 import {
219220 headInNamespace,
220 mintProposalToken,
221221 PROPOSAL_BODY_MAX,
222222 pullProposalOf,
223223 recordPullProposal,
@@ -3121,24 +3121,13 @@ export function apiRoutes(router: Router<Handler>): void {
31213121 const input = await boundedBody(ctx.request, PROPOSAL_BODY_MAX);
31223122 if (input === "too_large")
31233123 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, {
31363125 child: str(input.child),
31373126 origin: str(input.origin),
3138 ...(childRepoId ? { childRepoId } : {}),
3127 childRepo: str(input.childRepo),
31393128 });
31403129 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);
31423131 return json(minted.value, 201);
31433132 },
31443133 );
src/http/mcp.ts +37
@@ -130,6 +130,7 @@ import {
130130 import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts";
131131 import { appsOf, asksAnswer } from "../web/studio/asks.ts";
132132 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
133 import { listProposals, mintProposal, repoByName, revokeProposal } from "../actions/proposal_tokens.ts";
133134 import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts";
134135 import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts";
135136 import type { Ctx, Handler } from "./context.ts";
@@ -692,6 +693,42 @@ const TOOLS: readonly Tool[] = [
692693 return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message);
693694 },
694695 },
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 },
695732 {
696733 name: "asks_undecide",
697734 description:
src/tui/forge.ts +9
@@ -45,6 +45,7 @@ import { accessScreen } from "./screens/access.ts";
4545 import { flagsScreen } from "./screens/flags.ts";
4646 import { cloudScreen } from "./screens/cloud.ts";
4747 import { usageScreen } from "./screens/usage.ts";
48 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
4849 import { quotasScreen } from "./screens/quotas.ts";
4950 import { domainsScreen } from "./screens/domains.ts";
5051 import { ownerScreen } from "./screens/owner.ts";
@@ -357,6 +358,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
357358 hint: "every route, unused ones first",
358359 run: () => ctx.push(usageScreen(session, ctx)),
359360 });
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 });
360369 // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191).
361370 if (session.user?.isAdmin === true && session.user.kind !== "agent")
362371 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") {

Non-blocking: x revokes the moment it is pressed, and a revoked token cannot be brought back. confirmScreen in ./common.ts is already used elsewhere for this kind of irreversible action; one enter-to-confirm here would match those screens.

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 childRepo, so a token minted here cannot be tied to a fork. That also means deleting the fork will not end the token. The API and MCP can tie it. An optional third prompt, fork as owner/name, or empty, would match the other doors.

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", () => {
170170 "asks_act",
171171 "asks_decide",
172172 "asks_proposal",
173 "proposal_tokens",
173174 "asks_undecide",
174175 "seal",
175176 "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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs