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

Only a person approves: an AI agent over MCP can draft and withdraw, never sign off #24

Merged opened this

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

Merged

mason/listing-launch:mason/22-only-a-person-approves-an-ai-agent-over → main 2 commits fork

  • Merged. mason/22-only-a-person-approves-an-ai-agent-over was merged into main as e145ea2.

Review

Sign in to review this change.
12 files changed +158 −55
README.md +7 −3
@@ -65,8 +65,9 @@ list cannot drift from the app.
6565 the check still has findings): it records who signed off and when, the
6666 header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6,
6767 and editing an asset or **Rewrite all** clears the approval. The same
68 action is `PATCH /api/listings/:id` with `{ kind, approved }`, the `approve_asset` MCP tool
69 and `a` in the terminal. Approving is bound to the words you
68 action is `a` in the terminal; `PATCH /api/listings/:id` with
69 `{ kind, approved: false }` and the `approve_asset` MCP tool can withdraw,
70 and refuse to approve (`person_required`), as does the JSON API. Approving is bound to the words you
7071 read (`seen`, the asset's `updated_at`; the form also sends the box's text):
7172 if they changed, it answers 409 and approves nothing. Approval is a
7273 record, not a gate: Copy and Print flyer do not look at it.
@@ -96,7 +97,10 @@ never empty; "Start a new one from this" copies any listing into the form.
9697 files on disk under `FORGE_APP_DATA`). The words come from templates written
9798 from your facts and nothing else — no other broker's MLS data, nothing from
9899 the internet, no AI model yet: `generate(listing)` in `src/generate.ts` is
99 the one place a model would plug in later.
100 the one place a model would plug in later. Assistants may draft and edit;
101 only you approve. The `approve_asset` MCP tool can withdraw an approval but
102 refuses to give one (`person_required`); approval comes from the listing page or the terminal, and the page shows
103 which ("Approved by you 2m ago · via the page").
100104
101105 **What it does not do.** It does not post to social networks, submit to an
102106 MLS, or edit images. You copy, print and send. The Fair Housing check is a
contract/openapi.json +18 −3
@@ -490,7 +490,7 @@
490490 }
491491 },
492492 "patch": {
493 "summary": "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.",
493 "summary": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.",
494494 "operationId": "patchApiListingsId",
495495 "parameters": [
496496 {
@@ -546,7 +546,7 @@
546546 },
547547 "responses": {
548548 "200": {
549 "description": "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.",
549 "description": "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.",
550550 "content": {
551551 "application/json": {
552552 "schema": {
@@ -1078,12 +1078,27 @@
10781078 "at": {
10791079 "type": "string",
10801080 "format": "date-time"
1081 },
1082 "via": {
1083 "description": "The door it came through: page or tty; null before it was recorded.",
1084 "oneOf": [
1085 {
1086 "type": "null"
1087 },
1088 {
1089 "enum": [
1090 "page",
1091 "tty"
1092 ]
1093 }
1094 ]
10811095 }
10821096 },
10831097 "required": [
10841098 "by_id",
10851099 "by_name",
1086 "at"
1100 "at",
1101 "via"
10871102 ]
10881103 }
10891104 ]
src/actions.ts +18 −11
@@ -21,6 +21,7 @@ import {
2121 isTone,
2222 validate,
2323 type AssetRow,
24 type Caller,
2425 type Launched,
2526 type Listing,
2627 type ListingInput,
@@ -52,7 +53,8 @@ export type Failure =
5253 | "forbidden"
5354 | "not_found"
5455 | "invalid"
55 | "conflict";
56 | "conflict"
57 | "person_required";
5658 export type Result<T> =
5759 | { ok: true; value: T }
5860 | { ok: false; error: Failure; message: string };
@@ -332,7 +334,7 @@ function launched(db: Database, id: number): Launched | undefined {
332334 if (row === null) return undefined;
333335 const assets = db
334336 .query<AssetRow, [number]>(
335 "SELECT kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at FROM asset WHERE listing_id = ?1",
337 "SELECT kind, body, edited, updated_at, approved_by_id, approved_by_name, approved_at, approved_via FROM asset WHERE listing_id = ?1",
336338 )
337339 .all(id);
338340 return { ...fromRow(row), assets: assetsOf(assets) };
@@ -344,7 +346,7 @@ function writeAssets(db: Database, listing: Listing): void {
344346 const put = db.query(
345347 `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0)
346348 ON CONFLICT (listing_id, kind) DO UPDATE SET body = excluded.body, edited = 0,
347 approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL,
349 approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL,
348350 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
349351 );
350352 for (const kind of KINDS) put.run(listing.id, kind, words[kind]);
@@ -586,7 +588,7 @@ function edit(
586588 if (!text.ok) return fail("invalid", text.message);
587589 db.transaction(() => {
588590 db.query(
589 "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3",
591 "UPDATE asset SET body = ?1, edited = 1, approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?2 AND kind = ?3",
590592 ).run(text.value, found.value.id, input.kind);
591593 touch(db, found.value.id);
592594 })();
@@ -646,13 +648,16 @@ function suggest(
646648 * were. Approving is bound to what was seen: `seen` is the asset's
647649 * `updated_at` as the person read it, and `shown`, when a form has the box's
648650 * text, is what it holds now; if either differs from what is stored, nothing
649 * is approved (`conflict`). Both directions are audit rows, named in the
650 * outcome.
651 * is approved (`conflict`). `door` is where the call came through; a
652 * person's door (page, tty) is stored with an approval, and a caller an
653 * assistant can reach (api, mcp) is refused (`person_required`) when it
654 * approves, and may withdraw. Both directions are audit rows, named in
655 * the outcome.
651656 */
652657 export function approveAsset(
653658 db: Database,
654659 who: Who,
655 input: { id: number; kind: string; approved?: unknown; seen?: unknown; shown?: unknown },
660 input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown },
656661 ): Result<Launched> {
657662 const approving = input.approved === undefined || input.approved === true;
658663 const done = approve(db, who, input, approving);
@@ -669,7 +674,7 @@ export function approveAsset(
669674 function approve(
670675 db: Database,
671676 who: Who,
672 input: { id: number; kind: string; approved?: unknown; seen?: unknown; shown?: unknown },
677 input: { id: number; kind: string; door: Caller; approved?: unknown; seen?: unknown; shown?: unknown },
673678 approving: boolean,
674679 ): Result<Launched> {
675680 const found = listingFor(db, who, "update", input.id, {
@@ -678,6 +683,8 @@ function approve(
678683 });
679684 if (!found.ok) return found;
680685 if (who.role === "anon") return fail("signin_required", "Sign in to approve.");
686 if (approving && (input.door === "api" || input.door === "mcp"))
687 return fail("person_required", "A person approves, on the listing page. An assistant may draft and edit, and may withdraw an approval, never sign one off.");
681688 if (input.approved !== undefined && typeof input.approved !== "boolean")
682689 return fail("invalid", "approved is true or false.");
683690 if (!isKind(input.kind))
@@ -697,11 +704,11 @@ function approve(
697704 "These words changed since you read them. Save your changes and read them again before approving.",
698705 );
699706 db.query(
700 "UPDATE asset SET approved_by_id = ?1, approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE listing_id = ?3 AND kind = ?4",
701 ).run(who.id, who.name, found.value.id, input.kind);
707 "UPDATE asset SET approved_by_id = ?1, approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = ?5 WHERE listing_id = ?3 AND kind = ?4",
708 ).run(who.id, who.name, found.value.id, input.kind, input.door);
702709 } else
703710 db.query(
704 "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL WHERE listing_id = ?1 AND kind = ?2",
711 "UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL WHERE listing_id = ?1 AND kind = ?2",
705712 ).run(found.value.id, input.kind);
706713 emit(db, "listing.changed", { id: found.value.id });
707714 return ok(launched(db, found.value.id)!);
src/contract.test.ts +3 −2
@@ -150,14 +150,15 @@ test("approving an asset answers as the listing's update cell says, at the actio
150150 const seen = getListing(db, { role: "admin", id: "root", name: "root" }, { id });
151151 const updated = seen.ok ? seen.value.assets.find((a) => a.kind === "mls")!.updated_at : "";
152152 const args = { id, kind: "mls", seen: updated };
153 // The MCP door only withdraws, so its cell is probed here with a withdraw; the approve path is probed through the action (page door).
153154 const called = rpc(db, who, {
154155 jsonrpc: "2.0",
155156 id: 1,
156157 method: "tools/call",
157 params: { name: "approve_asset", arguments: args },
158 params: { name: "approve_asset", arguments: { id, kind: "mls", approved: false } },
158159 });
159160 const tool = !(JSON.stringify(called.body).includes('"isError":true'));
160 return [approveAsset(db, who, args).ok, tool];
161 return [approveAsset(db, who, { ...args, door: "page" }).ok, tool];
161162 };
162163 const onMine = attempts(String(mine["author_id"]));
163164 const onTheirs = attempts(String(theirs["author_id"]));
src/db.ts +2
@@ -88,6 +88,8 @@ export const migrations: string[] = [
8888 `ALTER TABLE asset ADD COLUMN approved_by_id TEXT`,
8989 `ALTER TABLE asset ADD COLUMN approved_by_name TEXT`,
9090 `ALTER TABLE asset ADD COLUMN approved_at TEXT`,
91 // Which door the sign-off came through (page, api, tty); null before this was recorded.
92 `ALTER TABLE asset ADD COLUMN approved_via TEXT`,
9193 ];
9294
9395 export function openDb(dir = dataDir): Database {
src/listing.ts +10
@@ -61,8 +61,16 @@ export interface Approval {
6161 readonly by_id: string;
6262 readonly by_name: string;
6363 readonly at: string;
64 /** The door the sign-off came through; null for one given before it was recorded. */
65 readonly via: string | null;
6466 }
6567
68 /** The doors an approval can come through: a person at the page or the terminal. */
69 export type Door = "page" | "tty";
70
71 /** Where an approval call comes from: a person's door, or one an assistant can reach (the JSON API, MCP), which only withdraws. */
72 export type Caller = Door | "api" | "mcp";
73
6674 /** A listing with its five assets, each checked. */
6775 export interface Launched extends Listing {
6876 readonly assets: readonly Asset[];
@@ -115,6 +123,7 @@ export interface AssetRow {
115123 readonly approved_by_id: string | null;
116124 readonly approved_by_name: string | null;
117125 readonly approved_at: string | null;
126 readonly approved_via: string | null;
118127 }
119128
120129 /** The stored assets, in the order a person reads them, each checked now. */
@@ -138,6 +147,7 @@ export const assetsOf = (rows: readonly AssetRow[]): Asset[] =>
138147 by_id: row.approved_by_id ?? "",
139148 by_name: row.approved_by_name ?? "",
140149 at: row.approved_at,
150 via: row.approved_via,
141151 },
142152 },
143153 ];
src/listings.test.ts +79 −26
@@ -8,8 +8,9 @@ import { afterAll, expect, test } from "bun:test";
88 import { existsSync, mkdtempSync, readdirSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { approveAsset, editAsset, getListing, launchListing, retoneListing } from "./actions.ts";
11 import { approveAsset, editAsset, getListing, insertListing, launchListing, retoneListing } from "./actions.ts";
1212 import { openDb } from "./db.ts";
13 import { rpc } from "./mcp.ts";
1314 import { validate } from "./listing.ts";
1415 import { sniff } from "./photos.ts";
1516 import { SAMPLE, seed } from "./sample.ts";
@@ -334,21 +335,21 @@ test("approval is recorded with who and when, cleared by an edit or a rewrite, a
334335 return l.ok ? l.value.assets.find((a) => a.kind === kind)!.updated_at : "";
335336 };
336337
337 const approved = approveAsset(db, me, { id, kind: "mls", seen: seen("mls") });
338 const approved = approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen("mls") });
338339 if (!approved.ok) throw new Error(approved.message);
339340 expect(mls(approved.value)?.by_name).toBe("me");
340341 expect(mls(approved.value)?.at).toMatch(/^\d{4}-/);
341342 expect(approved.value.assets.filter((a) => a.approval !== null).length).toBe(1);
342343
343344 // Someone else is refused; the row stays as it was.
344 const refused = approveAsset(db, other, { id, kind: "email", seen: seen("email") });
345 const refused = approveAsset(db, other, { id, kind: "email", door: "page", seen: seen("email") });
345346 expect(refused.ok).toBe(false);
346347 if (!refused.ok) expect(refused.error).toBe("forbidden");
347 expect(approveAsset(db, { role: "anon" }, { id, kind: "email", seen: seen("email") }).ok).toBe(false);
348 expect(approveAsset(db, me, { id, kind: "nope" }).ok).toBe(false);
348 expect(approveAsset(db, { role: "anon" }, { id, kind: "email", door: "page", seen: seen("email") }).ok).toBe(false);
349 expect(approveAsset(db, me, { id, kind: "nope", door: "page" }).ok).toBe(false);
349350
350351 // An edit clears that asset only; a rewrite clears the rest.
351 approveAsset(db, me, { id, kind: "email", seen: seen("email") });
352 approveAsset(db, me, { id, kind: "email", door: "page", seen: seen("email") });
352353 const edited = editAsset(db, me, { id, kind: "mls", text: "Fresh words." });
353354 if (!edited.ok) throw new Error(edited.message);
354355 expect(mls(edited.value)).toBeNull();
@@ -360,24 +361,24 @@ test("approval is recorded with who and when, cleared by an edit or a rewrite, a
360361 // Approval is bound to what was seen: stale, missing or non-boolean input approves nothing.
361362 const stale = seen("mls");
362363 editAsset(db, me, { id, kind: "mls", text: "Changed after you read it." });
363 const late = approveAsset(db, me, { id, kind: "mls", seen: stale });
364 const late = approveAsset(db, me, { id, kind: "mls", door: "page", seen: stale });
364365 expect(late.ok).toBe(false);
365366 if (!late.ok) expect(late.error).toBe("conflict");
366 const unseen = approveAsset(db, me, { id, kind: "mls" });
367 const unseen = approveAsset(db, me, { id, kind: "mls", door: "page" });
367368 expect(unseen.ok || unseen.error).toBe("invalid");
368 const typed = approveAsset(db, me, { id, kind: "mls", seen: seen("mls"), shown: "Unsaved typing." });
369 const typed = approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen("mls"), shown: "Unsaved typing." });
369370 expect(typed.ok || typed.error).toBe("conflict");
370371 for (const approved of ["no", null, 0, "false"]) {
371 const bad = approveAsset(db, me, { id, kind: "mls", approved, seen: seen("mls") });
372 const bad = approveAsset(db, me, { id, kind: "mls", door: "page", approved, seen: seen("mls") });
372373 expect(bad.ok || bad.error).toBe("invalid");
373374 }
374 const same = approveAsset(db, me, { id, kind: "mls", seen: seen("mls"), shown: "Changed after you read it.\r\n" });
375 const same = approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen("mls"), shown: "Changed after you read it.\r\n" });
375376 expect(same.ok).toBe(true);
376 approveAsset(db, me, { id, kind: "mls", approved: false });
377 approveAsset(db, me, { id, kind: "mls", door: "page", approved: false });
377378
378379 // Withdrawing is the same action.
379 approveAsset(db, me, { id, kind: "flyer", seen: seen("flyer") });
380 const withdrawn = approveAsset(db, me, { id, kind: "flyer", approved: false });
380 approveAsset(db, me, { id, kind: "flyer", door: "page", seen: seen("flyer") });
381 const withdrawn = approveAsset(db, me, { id, kind: "flyer", door: "page", approved: false });
381382 expect(withdrawn.ok && withdrawn.value.assets.every((a) => a.approval === null)).toBe(true);
382383
383384 const outcomes = db
@@ -389,6 +390,46 @@ test("approval is recorded with who and when, cleared by an edit or a rewrite, a
389390 expect(outcomes).toContain("forbidden");
390391 });
391392
393 test("only a person approves: MCP refuses approving and audits it, still withdraws, and each door is recorded", () => {
394 const db = openDb(mkdtempSync(join(tmpdir(), "app-door-")));
395 const me: Who = { role: "user", id: "u1", name: "me" };
396 const id = insertListing(db, me, SAMPLE, [], null).id;
397 const call = (args: Record<string, unknown>) => {
398 const r = rpc(db, me, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind: "mls", ...args } } });
399 return (r.body as { result: { isError?: boolean; content: { text: string }[] } }).result;
400 };
401 const via = () => db.query<{ approved_via: string | null; approved_at: string | null }, [number]>("SELECT approved_via, approved_at FROM asset WHERE listing_id = ?1 AND kind = 'mls'").get(id)!;
402 const seen = () => {
403 const l = getListing(db, me, { id });
404 return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.updated_at : "";
405 };
406
407 // A caller no signed-in answer is owed to gets the permit answer first, not person_required.
408 const stranger = rpc(db, { role: "anon" }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "approve_asset", arguments: { id, kind: "mls", approved: true } } });
409 expect(JSON.stringify(stranger.body)).not.toContain("person_required");
410
411 for (const args of [{ approved: true, seen: seen() }, { seen: seen() }]) {
412 const refused = call(args);
413 expect(refused.isError).toBe(true);
414 expect(refused.content[0]!.text).toContain("person_required");
415 expect(refused.content[0]!.text).toContain("listing page");
416 }
417 expect(via().approved_at).toBeNull();
418 const audited = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome = 'person_required'").all();
419 expect(audited.length).toBe(2);
420 expect(approveAsset(db, me, { id, kind: "mls", door: "api", seen: seen() })).toMatchObject({ ok: false, error: "person_required" });
421 expect(via().approved_at).toBeNull();
422
423 approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen() });
424 expect(via().approved_via).toBe("page");
425 const listed = getListing(db, me, { id });
426 expect(listed.ok && listed.value.assets.find((a) => a.kind === "mls")!.approval?.via).toBe("page");
427 expect(call({ approved: false }).isError).toBeUndefined();
428 expect(via()).toEqual({ approved_via: null, approved_at: null });
429 approveAsset(db, me, { id, kind: "mls", door: "tty", seen: seen() });
430 expect(via().approved_via).toBe("tty");
431 });
432
392433 test("the page says how many are approved, names the flags, and the form and API approve", async () => {
393434 const alice = as("carol");
394435 const { to } = await launch(alice);
@@ -419,7 +460,14 @@ test("the page says how many are approved, names the flags, and the form and API
419460 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
420461 expect(page).toContain("1 of 6 approved");
421462 expect(page).toContain("Approved by carol");
463 expect(page).toContain("via the page");
422464
465 const formApprove = async (kind: string, seen: string) => {
466 const f = new FormData();
467 f.set("approved", "1");
468 f.set("seen", seen);
469 return (await fetch(`${base}/listings/${id}/assets/${kind}/approve`, { method: "POST", body: f, headers: alice, redirect: "manual" })).status;
470 };
423471 const api = (who: Record<string, string>, body: unknown) =>
424472 fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify(body) });
425473 const listed = (await (await fetch(`${base}/api/listings/${id}`)).json()) as { assets: { kind: string; updated_at: string }[] };
@@ -427,9 +475,14 @@ test("the page says how many are approved, names the flags, and the form and API
427475 expect((await api(bob, { kind: "email", approved: true, seen: at("email") })).status).toBe(403);
428476 expect((await api(alice, { kind: "email", approved: "no", seen: at("email") })).status).toBe(422);
429477 expect((await api(alice, { kind: "email", approved: null, seen: at("email") })).status).toBe(422);
430 expect((await api(alice, { kind: "email", approved: true, seen: "2000-01-01T00:00:00.000Z" })).status).toBe(409);
478 // The JSON API never approves: a script holding the session is not the person.
479 const scripted = await api(alice, { kind: "email", approved: true, seen: at("email") });
480 expect(scripted.status).toBeGreaterThanOrEqual(400);
481 expect(JSON.stringify(await scripted.json())).toContain("person_required");
482 expect(await formApprove("email", "2000-01-01T00:00:00.000Z")).toBe(303);
483 expect(await (await fetch(`${base}/listings/${id}`, { headers: alice })).text()).toContain("1 of 6 approved");
431484 for (const kind of ["social_short", "social_medium", "social_carousel", "flyer", "email"])
432 expect((await api(alice, { kind, approved: true, seen: at(kind) })).status).toBe(200);
485 expect(await formApprove(kind, at(kind))).toBe(303);
433486 page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text();
434487 expect(page).toContain("Ready to send");
435488 const back = await api(alice, { kind: "mls", approved: false });
@@ -466,7 +519,7 @@ test("the email preview escapes a hostile address and 404s for an unknown id", a
466519 });
467520
468521 test("the home list says what is approved, and flyer and email wear a draft band until approved", async () => {
469 const alice = as("carol");
522 const alice = as("erin");
470523 const { to } = await launch(alice);
471524 const id = Number(to.split("/").at(-1));
472525 const card = (html: string) => html.slice(html.indexOf(`href="/listings/${id}"`)).split("</li>")[0]!;
@@ -477,13 +530,13 @@ test("the home list says what is approved, and flyer and email wear a draft band
477530 expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved");
478531
479532 const listed = (await (await fetch(`${base}/api/listings/${id}`)).json()) as { assets: { kind: string; updated_at: string }[] };
480 const approve = (kind: string) =>
481 fetch(`${base}/api/listings/${id}`, {
482 method: "PATCH",
483 headers: { ...alice, "content-type": "application/json" },
484 body: JSON.stringify({ kind, approved: true, seen: listed.assets.find((a) => a.kind === kind)!.updated_at }),
485 });
486 for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(200);
533 const approve = (kind: string) => {
534 const f = new FormData();
535 f.set("approved", "1");
536 f.set("seen", listed.assets.find((a) => a.kind === kind)!.updated_at);
537 return fetch(`${base}/listings/${id}/assets/${kind}/approve`, { method: "POST", body: f, headers: alice, redirect: "manual" });
538 };
539 for (const kind of ["flyer", "email"]) expect((await approve(kind)).status).toBe(303);
487540 for (const path of ["flyer", "email"])
488541 expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved");
489542 expect(await home()).toContain("2 of 6 approved");
@@ -492,7 +545,7 @@ test("the home list says what is approved, and flyer and email wear a draft band
492545 expect(summary.approvals).toHaveLength(6);
493546 expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]);
494547
495 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(200);
548 for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(303);
496549 expect(await home()).toContain("Ready to send");
497550 });
498551
@@ -511,7 +564,7 @@ test("one tap takes the suggested wording: exactly those words change, and it sa
511564 return l.value.assets.find((a) => a.kind === "social_short")!;
512565 };
513566 const me: Who = { role: "user", id: "dana", name: "dana" };
514 approveAsset(db, me, { id, kind: "social_short", seen: seen().updated_at });
567 approveAsset(db, me, { id, kind: "social_short", door: "page", seen: seen().updated_at });
515568 expect(seen().approval).not.toBeNull();
516569
517570 const page = await (await fetch(`${base}/listings/${id}`, { headers: carol })).text();
src/mcp.ts +4 −4
@@ -173,16 +173,15 @@ export const tools: readonly Tool[] = [
173173 {
174174 name: "approve_asset",
175175 description:
176 "Sign off one asset's words as the signed-in person (approved: false takes it back). Editing the words clears it; approving needs `seen`, the asset's updated_at you read. Only the listing's agent or an admin can.",
176 "Withdraw the approval of one asset's words (approved: false). Approving is refused (person_required): only a person approves, on the listing page, after reading the words; an assistant may draft and edit, never sign off. get_listing reports each asset's approval. Only the listing's agent or an admin can withdraw.",
177177 inputSchema: {
178178 type: "object",
179179 properties: {
180180 id: { type: "integer" },
181181 kind: { enum: [...KINDS] },
182 approved: { type: "boolean", description: "true (the default) approves; false withdraws." },
183 seen: { type: "string", description: "To approve: the asset's updated_at as get_listing showed it; refused as a conflict if the words changed since." },
182 approved: { const: false, description: "false withdraws. true is refused: a person approves on the listing page." },
184183 },
185 required: ["id", "kind"],
184 required: ["id", "kind", "approved"],
186185 },
187186 run: (db, who, args) =>
188187 approveAsset(db, who, {
@@ -190,6 +189,7 @@ export const tools: readonly Tool[] = [
190189 kind: String(args.kind ?? ""),
191190 approved: args.approved,
192191 seen: args.seen,
192 door: "mcp",
193193 }),
194194 },
195195 {
src/pages/listing.ts +8 −2
@@ -87,11 +87,17 @@ export const draftBand = (l: Launched, kind: "flyer" | "email"): string =>
8787 ? `<p class="draft-band" role="note">Draft — not approved</p>`
8888 : "";
8989
90 const DOORS: Record<string, string> = { page: "the page", api: "the API", tty: "the terminal" };
91
92 /** Where an approval came from, beside who gave it; nothing for one given before it was recorded. */
93 const via = (door: string | null): string =>
94 door !== null && door in DOORS ? ` · via ${DOORS[door]}` : "";
95
9096 /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */
9197 export function approval(l: Launched, a: Asset): string {
9298 const to = `/listings/${l.id}/assets/${a.kind}/approve`;
9399 if (a.approval !== null)
94 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>
100 return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>
95101 <button class="button button-quiet" formaction="${to}" name="approved" value="0">Withdraw</button>`;
96102 const n = a.check.findings.length;
97103 return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">
@@ -108,7 +114,7 @@ function editor(l: Launched, a: Asset, editable: boolean): string {
108114 <div class="row asset-actions">
109115 ${editable ? `<button class="button">Save</button>` : ""}
110116 <button type="button" class="button button-quiet" data-copy hidden>Copy</button>
111 ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`}
117 ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span>`}
112118 <small class="muted" data-count>${a.text.length} characters</small>
113119 </div>
114120 </form>
src/routes.ts +6 −3
@@ -237,8 +237,9 @@ export const LISTING: Schema = {
237237 by_id: { type: "string" },
238238 by_name: { type: "string" },
239239 at: { type: "string", format: "date-time" },
240 via: { description: "The door it came through: page or tty; null before it was recorded.", oneOf: [{ type: "null" }, { enum: ["page", "tty"] }] },
240241 },
241 required: ["by_id", "by_name", "at"],
242 required: ["by_id", "by_name", "at", "via"],
242243 },
243244 ],
244245 },
@@ -288,6 +289,7 @@ const STATUS: Record<Failure, number> = {
288289 not_found: 404,
289290 invalid: 422,
290291 conflict: 409,
292 person_required: 403,
291293 };
292294
293295 /** A result as JSON: the value, or `{ error, message }` with the matching status. */
@@ -515,6 +517,7 @@ export const routes: readonly Route[] = [
515517 kind,
516518 approved: approving,
517519 seen: form.get("seen"),
520 door: "page",
518521 ...(form.has("text") ? { shown: form.get("text") } : {}),
519522 });
520523 const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`;
@@ -705,11 +708,11 @@ export const routes: readonly Route[] = [
705708 body.tone !== undefined && body.kind === undefined
706709 ? retoneListing(c.db, c.who, { id, tone: body.tone })
707710 : body.approved !== undefined && body.text === undefined
708 ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen })
711 ? approveAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), approved: body.approved, seen: body.seen, door: "api" })
709712 : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }),
710713 );
711714 },
712 api: { summary: "Edit one asset's words, sign one off or withdraw it ({ kind, approved }), or write every asset again in a tone.", body: LISTING_CHANGE, answer: LISTING },
715 api: { summary: "Edit one asset's words, withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), or write every asset again in a tone.", body: LISTING_CHANGE, answer: LISTING },
713716 },
714717 {
715718 method: "DELETE",
src/tty.test.ts +2
@@ -116,6 +116,8 @@ test("a approves the asset under the cursor, and withdraws it; a stranger's a is
116116 expect(plain(tty.screen())).toContain("Approved.");
117117 expect(plain(tty.screen())).toContain("1 of 6 approved");
118118 expect(plain(tty.screen())).toContain("approved by me");
119 const stored = db.query<{ approved_via: string }, []>("SELECT approved_via FROM asset WHERE approved_via IS NOT NULL").all();
120 expect(stored.map((r) => r.approved_via)).toEqual(["tty"]);
119121 tty.press("a");
120122 expect(plain(tty.screen())).toContain("Approval withdrawn.");
121123 const theirs = session(db, other);
src/tty.ts +1 −1
@@ -238,7 +238,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
238238 const asset = assets[cursor];
239239 if (asset !== undefined) {
240240 const withdrawing = asset.approval !== null;
241 const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at });
241 const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty" });
242242 said = done.ok
243243 ? withdrawing ? "Approval withdrawn." : green("Approved.")
244244 : done.message;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs