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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/22-only-a-person-approves-an-ai-agent-over → main 2 commits fork
12 files
changed +158
−55
README.md +7 −3
| @@ -65,8 +65,9 @@ list cannot drift from the app. | ||
| 65 | 65 | the check still has findings): it records who signed off and when, the |
| 66 | 66 | header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6, |
| 67 | 67 | 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 | |
| 70 | 71 | read (`seen`, the asset's `updated_at`; the form also sends the box's text): |
| 71 | 72 | if they changed, it answers 409 and approves nothing. Approval is a |
| 72 | 73 | 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. | ||
| 96 | 97 | files on disk under `FORGE_APP_DATA`). The words come from templates written |
| 97 | 98 | from your facts and nothing else — no other broker's MLS data, nothing from |
| 98 | 99 | 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"). | |
| 100 | 104 | |
| 101 | 105 | **What it does not do.** It does not post to social networks, submit to an |
| 102 | 106 | MLS, or edit images. You copy, print and send. The Fair Housing check is a |
contract/openapi.json +18 −3
| @@ -490,7 +490,7 @@ | ||
| 490 | 490 | } |
| 491 | 491 | }, |
| 492 | 492 | "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.", | |
| 494 | 494 | "operationId": "patchApiListingsId", |
| 495 | 495 | "parameters": [ |
| 496 | 496 | { |
| @@ -546,7 +546,7 @@ | ||
| 546 | 546 | }, |
| 547 | 547 | "responses": { |
| 548 | 548 | "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.", | |
| 550 | 550 | "content": { |
| 551 | 551 | "application/json": { |
| 552 | 552 | "schema": { |
| @@ -1078,12 +1078,27 @@ | ||
| 1078 | 1078 | "at": { |
| 1079 | 1079 | "type": "string", |
| 1080 | 1080 | "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 | ] | |
| 1081 | 1095 | } |
| 1082 | 1096 | }, |
| 1083 | 1097 | "required": [ |
| 1084 | 1098 | "by_id", |
| 1085 | 1099 | "by_name", |
| 1086 | "at" | |
| 1100 | "at", | |
| 1101 | "via" | |
| 1087 | 1102 | ] |
| 1088 | 1103 | } |
| 1089 | 1104 | ] |
src/actions.ts +18 −11
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | isTone, |
| 22 | 22 | validate, |
| 23 | 23 | type AssetRow, |
| 24 | type Caller, | |
| 24 | 25 | type Launched, |
| 25 | 26 | type Listing, |
| 26 | 27 | type ListingInput, |
| @@ -52,7 +53,8 @@ export type Failure = | ||
| 52 | 53 | | "forbidden" |
| 53 | 54 | | "not_found" |
| 54 | 55 | | "invalid" |
| 55 | | "conflict"; | |
| 56 | | "conflict" | |
| 57 | | "person_required"; | |
| 56 | 58 | export type Result<T> = |
| 57 | 59 | | { ok: true; value: T } |
| 58 | 60 | | { ok: false; error: Failure; message: string }; |
| @@ -332,7 +334,7 @@ function launched(db: Database, id: number): Launched | undefined { | ||
| 332 | 334 | if (row === null) return undefined; |
| 333 | 335 | const assets = db |
| 334 | 336 | .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", | |
| 336 | 338 | ) |
| 337 | 339 | .all(id); |
| 338 | 340 | return { ...fromRow(row), assets: assetsOf(assets) }; |
| @@ -344,7 +346,7 @@ function writeAssets(db: Database, listing: Listing): void { | ||
| 344 | 346 | const put = db.query( |
| 345 | 347 | `INSERT INTO asset (listing_id, kind, body, edited) VALUES (?1, ?2, ?3, 0) |
| 346 | 348 | 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, | |
| 348 | 350 | updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, |
| 349 | 351 | ); |
| 350 | 352 | for (const kind of KINDS) put.run(listing.id, kind, words[kind]); |
| @@ -586,7 +588,7 @@ function edit( | ||
| 586 | 588 | if (!text.ok) return fail("invalid", text.message); |
| 587 | 589 | db.transaction(() => { |
| 588 | 590 | 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", | |
| 590 | 592 | ).run(text.value, found.value.id, input.kind); |
| 591 | 593 | touch(db, found.value.id); |
| 592 | 594 | })(); |
| @@ -646,13 +648,16 @@ function suggest( | ||
| 646 | 648 | * were. Approving is bound to what was seen: `seen` is the asset's |
| 647 | 649 | * `updated_at` as the person read it, and `shown`, when a form has the box's |
| 648 | 650 | * 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. | |
| 651 | 656 | */ |
| 652 | 657 | export function approveAsset( |
| 653 | 658 | db: Database, |
| 654 | 659 | 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 }, | |
| 656 | 661 | ): Result<Launched> { |
| 657 | 662 | const approving = input.approved === undefined || input.approved === true; |
| 658 | 663 | const done = approve(db, who, input, approving); |
| @@ -669,7 +674,7 @@ export function approveAsset( | ||
| 669 | 674 | function approve( |
| 670 | 675 | db: Database, |
| 671 | 676 | 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 }, | |
| 673 | 678 | approving: boolean, |
| 674 | 679 | ): Result<Launched> { |
| 675 | 680 | const found = listingFor(db, who, "update", input.id, { |
| @@ -678,6 +683,8 @@ function approve( | ||
| 678 | 683 | }); |
| 679 | 684 | if (!found.ok) return found; |
| 680 | 685 | 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."); | |
| 681 | 688 | if (input.approved !== undefined && typeof input.approved !== "boolean") |
| 682 | 689 | return fail("invalid", "approved is true or false."); |
| 683 | 690 | if (!isKind(input.kind)) |
| @@ -697,11 +704,11 @@ function approve( | ||
| 697 | 704 | "These words changed since you read them. Save your changes and read them again before approving.", |
| 698 | 705 | ); |
| 699 | 706 | 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); | |
| 702 | 709 | } else |
| 703 | 710 | 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", | |
| 705 | 712 | ).run(found.value.id, input.kind); |
| 706 | 713 | emit(db, "listing.changed", { id: found.value.id }); |
| 707 | 714 | 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 | ||
| 150 | 150 | const seen = getListing(db, { role: "admin", id: "root", name: "root" }, { id }); |
| 151 | 151 | const updated = seen.ok ? seen.value.assets.find((a) => a.kind === "mls")!.updated_at : ""; |
| 152 | 152 | 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). | |
| 153 | 154 | const called = rpc(db, who, { |
| 154 | 155 | jsonrpc: "2.0", |
| 155 | 156 | id: 1, |
| 156 | 157 | method: "tools/call", |
| 157 | params: { name: "approve_asset", arguments: args }, | |
| 158 | params: { name: "approve_asset", arguments: { id, kind: "mls", approved: false } }, | |
| 158 | 159 | }); |
| 159 | 160 | 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]; | |
| 161 | 162 | }; |
| 162 | 163 | const onMine = attempts(String(mine["author_id"])); |
| 163 | 164 | const onTheirs = attempts(String(theirs["author_id"])); |
src/db.ts +2
| @@ -88,6 +88,8 @@ export const migrations: string[] = [ | ||
| 88 | 88 | `ALTER TABLE asset ADD COLUMN approved_by_id TEXT`, |
| 89 | 89 | `ALTER TABLE asset ADD COLUMN approved_by_name TEXT`, |
| 90 | 90 | `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`, | |
| 91 | 93 | ]; |
| 92 | 94 | |
| 93 | 95 | export function openDb(dir = dataDir): Database { |
src/listing.ts +10
| @@ -61,8 +61,16 @@ export interface Approval { | ||
| 61 | 61 | readonly by_id: string; |
| 62 | 62 | readonly by_name: string; |
| 63 | 63 | readonly at: string; |
| 64 | /** The door the sign-off came through; null for one given before it was recorded. */ | |
| 65 | readonly via: string | null; | |
| 64 | 66 | } |
| 65 | 67 | |
| 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 | ||
| 66 | 74 | /** A listing with its five assets, each checked. */ |
| 67 | 75 | export interface Launched extends Listing { |
| 68 | 76 | readonly assets: readonly Asset[]; |
| @@ -115,6 +123,7 @@ export interface AssetRow { | ||
| 115 | 123 | readonly approved_by_id: string | null; |
| 116 | 124 | readonly approved_by_name: string | null; |
| 117 | 125 | readonly approved_at: string | null; |
| 126 | readonly approved_via: string | null; | |
| 118 | 127 | } |
| 119 | 128 | |
| 120 | 129 | /** The stored assets, in the order a person reads them, each checked now. */ |
| @@ -138,6 +147,7 @@ export const assetsOf = (rows: readonly AssetRow[]): Asset[] => | ||
| 138 | 147 | by_id: row.approved_by_id ?? "", |
| 139 | 148 | by_name: row.approved_by_name ?? "", |
| 140 | 149 | at: row.approved_at, |
| 150 | via: row.approved_via, | |
| 141 | 151 | }, |
| 142 | 152 | }, |
| 143 | 153 | ]; |
src/listings.test.ts +79 −26
| @@ -8,8 +8,9 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 8 | 8 | import { existsSync, mkdtempSync, readdirSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | import { rpc } from "./mcp.ts"; | |
| 13 | 14 | import { validate } from "./listing.ts"; |
| 14 | 15 | import { sniff } from "./photos.ts"; |
| 15 | 16 | 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 | ||
| 334 | 335 | return l.ok ? l.value.assets.find((a) => a.kind === kind)!.updated_at : ""; |
| 335 | 336 | }; |
| 336 | 337 | |
| 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") }); | |
| 338 | 339 | if (!approved.ok) throw new Error(approved.message); |
| 339 | 340 | expect(mls(approved.value)?.by_name).toBe("me"); |
| 340 | 341 | expect(mls(approved.value)?.at).toMatch(/^\d{4}-/); |
| 341 | 342 | expect(approved.value.assets.filter((a) => a.approval !== null).length).toBe(1); |
| 342 | 343 | |
| 343 | 344 | // 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") }); | |
| 345 | 346 | expect(refused.ok).toBe(false); |
| 346 | 347 | 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); | |
| 349 | 350 | |
| 350 | 351 | // 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") }); | |
| 352 | 353 | const edited = editAsset(db, me, { id, kind: "mls", text: "Fresh words." }); |
| 353 | 354 | if (!edited.ok) throw new Error(edited.message); |
| 354 | 355 | 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 | ||
| 360 | 361 | // Approval is bound to what was seen: stale, missing or non-boolean input approves nothing. |
| 361 | 362 | const stale = seen("mls"); |
| 362 | 363 | 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 }); | |
| 364 | 365 | expect(late.ok).toBe(false); |
| 365 | 366 | 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" }); | |
| 367 | 368 | 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." }); | |
| 369 | 370 | expect(typed.ok || typed.error).toBe("conflict"); |
| 370 | 371 | 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") }); | |
| 372 | 373 | expect(bad.ok || bad.error).toBe("invalid"); |
| 373 | 374 | } |
| 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" }); | |
| 375 | 376 | 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 }); | |
| 377 | 378 | |
| 378 | 379 | // 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 }); | |
| 381 | 382 | expect(withdrawn.ok && withdrawn.value.assets.every((a) => a.approval === null)).toBe(true); |
| 382 | 383 | |
| 383 | 384 | const outcomes = db |
| @@ -389,6 +390,46 @@ test("approval is recorded with who and when, cleared by an edit or a rewrite, a | ||
| 389 | 390 | expect(outcomes).toContain("forbidden"); |
| 390 | 391 | }); |
| 391 | 392 | |
| 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 | ||
| 392 | 433 | test("the page says how many are approved, names the flags, and the form and API approve", async () => { |
| 393 | 434 | const alice = as("carol"); |
| 394 | 435 | 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 | ||
| 419 | 460 | page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text(); |
| 420 | 461 | expect(page).toContain("1 of 6 approved"); |
| 421 | 462 | expect(page).toContain("Approved by carol"); |
| 463 | expect(page).toContain("via the page"); | |
| 422 | 464 | |
| 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 | }; | |
| 423 | 471 | const api = (who: Record<string, string>, body: unknown) => |
| 424 | 472 | fetch(`${base}/api/listings/${id}`, { method: "PATCH", headers: { ...who, "content-type": "application/json" }, body: JSON.stringify(body) }); |
| 425 | 473 | 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 | ||
| 427 | 475 | expect((await api(bob, { kind: "email", approved: true, seen: at("email") })).status).toBe(403); |
| 428 | 476 | expect((await api(alice, { kind: "email", approved: "no", seen: at("email") })).status).toBe(422); |
| 429 | 477 | 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"); | |
| 431 | 484 | 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); | |
| 433 | 486 | page = await (await fetch(`${base}/listings/${id}`, { headers: alice })).text(); |
| 434 | 487 | expect(page).toContain("Ready to send"); |
| 435 | 488 | 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 | ||
| 466 | 519 | }); |
| 467 | 520 | |
| 468 | 521 | 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"); | |
| 470 | 523 | const { to } = await launch(alice); |
| 471 | 524 | const id = Number(to.split("/").at(-1)); |
| 472 | 525 | 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 | ||
| 477 | 530 | expect(await (await fetch(`${base}${to}`)).text()).toContain("Draft — not approved"); |
| 478 | 531 | |
| 479 | 532 | 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); | |
| 487 | 540 | for (const path of ["flyer", "email"]) |
| 488 | 541 | expect(await (await fetch(`${base}${to}/${path}`)).text()).not.toContain("Draft — not approved"); |
| 489 | 542 | 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 | ||
| 492 | 545 | expect(summary.approvals).toHaveLength(6); |
| 493 | 546 | expect(summary.approvals.filter((a) => a.approved).map((a) => a.kind).sort()).toEqual(["email", "flyer"]); |
| 494 | 547 | |
| 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); | |
| 496 | 549 | expect(await home()).toContain("Ready to send"); |
| 497 | 550 | }); |
| 498 | 551 | |
| @@ -511,7 +564,7 @@ test("one tap takes the suggested wording: exactly those words change, and it sa | ||
| 511 | 564 | return l.value.assets.find((a) => a.kind === "social_short")!; |
| 512 | 565 | }; |
| 513 | 566 | 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 }); | |
| 515 | 568 | expect(seen().approval).not.toBeNull(); |
| 516 | 569 | |
| 517 | 570 | 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[] = [ | ||
| 173 | 173 | { |
| 174 | 174 | name: "approve_asset", |
| 175 | 175 | 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.", | |
| 177 | 177 | inputSchema: { |
| 178 | 178 | type: "object", |
| 179 | 179 | properties: { |
| 180 | 180 | id: { type: "integer" }, |
| 181 | 181 | 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." }, | |
| 184 | 183 | }, |
| 185 | required: ["id", "kind"], | |
| 184 | required: ["id", "kind", "approved"], | |
| 186 | 185 | }, |
| 187 | 186 | run: (db, who, args) => |
| 188 | 187 | approveAsset(db, who, { |
| @@ -190,6 +189,7 @@ export const tools: readonly Tool[] = [ | ||
| 190 | 189 | kind: String(args.kind ?? ""), |
| 191 | 190 | approved: args.approved, |
| 192 | 191 | seen: args.seen, |
| 192 | door: "mcp", | |
| 193 | 193 | }), |
| 194 | 194 | }, |
| 195 | 195 | { |
src/pages/listing.ts +8 −2
| @@ -87,11 +87,17 @@ export const draftBand = (l: Launched, kind: "flyer" | "email"): string => | ||
| 87 | 87 | ? `<p class="draft-band" role="note">Draft — not approved</p>` |
| 88 | 88 | : ""; |
| 89 | 89 | |
| 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 | ||
| 90 | 96 | /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */ |
| 91 | 97 | export function approval(l: Launched, a: Asset): string { |
| 92 | 98 | const to = `/listings/${l.id}/assets/${a.kind}/approve`; |
| 93 | 99 | 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> | |
| 95 | 101 | <button class="button button-quiet" formaction="${to}" name="approved" value="0">Withdraw</button>`; |
| 96 | 102 | const n = a.check.findings.length; |
| 97 | 103 | return `<input type="hidden" name="seen" value="${escape(a.updated_at)}"> |
| @@ -108,7 +114,7 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 108 | 114 | <div class="row asset-actions"> |
| 109 | 115 | ${editable ? `<button class="button">Save</button>` : ""} |
| 110 | 116 | <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>`} | |
| 112 | 118 | <small class="muted" data-count>${a.text.length} characters</small> |
| 113 | 119 | </div> |
| 114 | 120 | </form> |
src/routes.ts +6 −3
| @@ -237,8 +237,9 @@ export const LISTING: Schema = { | ||
| 237 | 237 | by_id: { type: "string" }, |
| 238 | 238 | by_name: { type: "string" }, |
| 239 | 239 | 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"] }] }, | |
| 240 | 241 | }, |
| 241 | required: ["by_id", "by_name", "at"], | |
| 242 | required: ["by_id", "by_name", "at", "via"], | |
| 242 | 243 | }, |
| 243 | 244 | ], |
| 244 | 245 | }, |
| @@ -288,6 +289,7 @@ const STATUS: Record<Failure, number> = { | ||
| 288 | 289 | not_found: 404, |
| 289 | 290 | invalid: 422, |
| 290 | 291 | conflict: 409, |
| 292 | person_required: 403, | |
| 291 | 293 | }; |
| 292 | 294 | |
| 293 | 295 | /** A result as JSON: the value, or `{ error, message }` with the matching status. */ |
| @@ -515,6 +517,7 @@ export const routes: readonly Route[] = [ | ||
| 515 | 517 | kind, |
| 516 | 518 | approved: approving, |
| 517 | 519 | seen: form.get("seen"), |
| 520 | door: "page", | |
| 518 | 521 | ...(form.has("text") ? { shown: form.get("text") } : {}), |
| 519 | 522 | }); |
| 520 | 523 | const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`; |
| @@ -705,11 +708,11 @@ export const routes: readonly Route[] = [ | ||
| 705 | 708 | body.tone !== undefined && body.kind === undefined |
| 706 | 709 | ? retoneListing(c.db, c.who, { id, tone: body.tone }) |
| 707 | 710 | : 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" }) | |
| 709 | 712 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 710 | 713 | ); |
| 711 | 714 | }, |
| 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 }, | |
| 713 | 716 | }, |
| 714 | 717 | { |
| 715 | 718 | 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 | ||
| 116 | 116 | expect(plain(tty.screen())).toContain("Approved."); |
| 117 | 117 | expect(plain(tty.screen())).toContain("1 of 6 approved"); |
| 118 | 118 | 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"]); | |
| 119 | 121 | tty.press("a"); |
| 120 | 122 | expect(plain(tty.screen())).toContain("Approval withdrawn."); |
| 121 | 123 | 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 } | ||
| 238 | 238 | const asset = assets[cursor]; |
| 239 | 239 | if (asset !== undefined) { |
| 240 | 240 | 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" }); | |
| 242 | 242 | said = done.ok |
| 243 | 243 | ? withdrawing ? "Approval withdrawn." : green("Approved.") |
| 244 | 244 | : done.message; |
new above
offline