From db8d9a79d19791748469e0ab84c4dd4b6c3191b9 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 4 Sep 2026 05:50:04 -0700 Subject: [PATCH] =?UTF-8?q?PORTAL-SHOWMODEL=20=E2=80=94=20the=20public=203?= =?UTF-8?q?D=20viewer=20shipped,=20and=20nothing=20could=20mint=20a=20toke?= =?UTF-8?q?n=20that=20reached=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `show_model` is the per-token opt-in that lets a share link fetch `GET /shared/{token}/model.frag`. The backend has supported it end to end since R22-PUBLIC-VIEWER: the route reads `body.get("show_model")`, `model_fragment` gates on it, `_public_row` returns it, and `test_shared_model.py` proves the 200-vs-404 pair and that `show_payments` does not imply it. `createShareToken` sent only `label` and `show_payments`. Every token this product minted had the flag false, so that route 404'd for every link it has ever produced. TWO DEFECTS, and the second is the worse kind. The missing PARAMETER made the capability unreachable. The missing ROW FIELD — `show_model` absent from the `Tok` type — made it unauditable: the value was on the wire the whole time and the type dropped it, so a geometry link would have rendered identically to a digest-only one. R22-PUBLIC-VIEWER's record claims "the owner's token list shows which links carry geometry"; that was true of the JSON and false of the product. Corrected in place in roadmap-completed.md rather than rewritten. WHY NO GATE SAW IT. Both sides were correct in isolation. `test_shared_model.py` mints its own tokens with `json={"show_model": True}` — a body the product never produces — so it passed over a client that could not ask for the thing it tests. A test that builds its own request proves the server honours that request, never that anything sends it. And the failure is invisible from outside: unknown token, revoked token, no opt-in and no published fragment all return an identical 404 by design, so a dark viewer is indistinguishable from a project with no model. Closed in `clientPortal.ts` (fourth argument + the row field) and `masterBuilder.ts` (a 3D-model opt-in beside the payments one, both grants marked on every live link, both reset after each mint). The two flags stay separate arguments — never one "share more" level — because the backend's rule is that granting one must never imply the other. Two new suites, each mutation-checked against the confusion it names. `shareTokenGrants.test.ts` asserts the encoded BODY, not a mock's arguments; `masterBuilder.test.ts` drives the real DOM, because a checkbox that renders and is never read would satisfy the first test completely and leave the capability as dark as it was. Measured: restoring the original defect fails 3 of 4 wire tests; never reading the box fails 1; an unconditional marker fails the audit test; `payCk || mdlCk` fails the independence test. The row-type half is NOT guarded by vitest — deleting the field keeps it 4/4 green and turns `tsc` red in three places — and the test says so rather than implying a coverage it does not have. Scope held: the token still serves the converted fragment, never the source IFC. Nothing here widens what a token grants; it makes the grant the backend already defined askable and visible. Verified: web typecheck clean, eslint clean on all four files, full vitest 206 files / 2069 tests green, and test_claude_md_gates / test_file_sizes / test_shared_model pass. No backend file is touched by this change. Co-Authored-By: Claude Opus 5 --- apps/web/src/api/clientPortal.ts | 41 ++++-- apps/web/src/api/shareTokenGrants.test.ts | 104 ++++++++++++++ .../src/portal/panels/masterBuilder.test.ts | 130 ++++++++++++++++++ apps/web/src/portal/panels/masterBuilder.ts | 40 +++++- docs/roadmap-completed.md | 14 ++ docs/roadmap.md | 40 ++++++ 6 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/api/shareTokenGrants.test.ts create mode 100644 apps/web/src/portal/panels/masterBuilder.test.ts diff --git a/apps/web/src/api/clientPortal.ts b/apps/web/src/api/clientPortal.ts index ea7ceefa..85025553 100644 --- a/apps/web/src/api/clientPortal.ts +++ b/apps/web/src/api/clientPortal.ts @@ -14,20 +14,27 @@ * * `services/api/src/aec_api/routers/client_portal.py` groups the same set — 8 of its 9 routes are * exactly these methods, checked rather than assumed. The ninth, `GET /shared/{token}/model.frag`, - * has no client method BY DESIGN: it is fetched by the server-rendered share page, not by this SPA. + * still has no client method: it is a whole-file response a recipient opens by URL, not JSON this + * SPA parses. What it DOES need from here is the mint-time opt-in that makes it answer at all. * - * ### A capability this file cannot currently reach + * ### The opt-in this file could not reach — fixed 2026-09-04 * * That ninth route serves geometry only to a token minted with `show_model`, which the backend * treats as an opt-in independent of `show_payments` — *"a token may carry payments, or geometry, - * or neither, and granting one never implies the other"*. **`createShareToken` below does not send - * it**, and the row type `shareTokens` returns has no `show_model` field, so every token this - * product mints has it false and that route always 404s for them. The public 3D viewer is dark from - * the UI's side. + * or neither, and granting one never implies the other"*. **`createShareToken` did not send it**, + * and the row type `shareTokens` returns had no `show_model` field, so every token this product + * minted had it false and that route 404'd for all of them. * - * Not fixed here on purpose: this slice's claim is that no behaviour changed, and adding a - * parameter would falsify it. Recorded so the next reader of this file finds it at the method - * rather than in a backlog. + * Both halves are closed below, and they are separate defects with separate consequences. The + * missing PARAMETER made the capability unreachable. The missing ROW FIELD made it unauditable — + * `_public_row` has always returned `show_model`, the wire carried it, and the type simply dropped + * it, so an owner could not have told a geometry link from a digest link even once one existed. + * R22-PUBLIC-VIEWER's shipped record claims *"the owner's token list shows which links carry + * geometry"*; until this change that was true of the JSON and false of the product. + * + * The two flags are passed as separate arguments, never as one "share more" level, because the + * backend's rule is that granting one must never imply the other. A single enum or an ordered + * level would make that rule unexpressible at the call site. * * A mixin, so every call site resolves unchanged; `api/surface.test.ts` is what proves it. */ @@ -37,17 +44,23 @@ type Ctor = new (...args: any[]) => T; export function withClientPortal>(Base: TBase) { return class ClientPortal extends Base { - /** `showPayments` is the explicit opt-in for THIS token's digest to carry the payment schedule. */ - createShareToken(pid: string, label?: string, showPayments?: boolean) { - return this.json<{ token: string; label: string | null; share_path: string; revoked: boolean }>( + /** Mint a read-only share token. `showPayments` and `showModel` are two INDEPENDENT opt-ins, and + * the backend is explicit that granting one never implies the other: `showPayments` lets this + * token's digest carry the owner-invoice payment schedule, `showModel` lets it fetch the project's + * geometry fragment (`GET /shared/{token}/model.frag` — shapes and placements, never the source + * IFC). Both default to false; a token already in somebody's inbox is never widened. */ + createShareToken(pid: string, label?: string, showPayments?: boolean, showModel?: boolean) { + return this.json<{ token: string; label: string | null; share_path: string; revoked: boolean; + show_payments: boolean; show_model: boolean }>( `/projects/${pid}/share-tokens`, - { method: "POST", body: JSON.stringify({ label: label ?? "", show_payments: !!showPayments }) }); + { method: "POST", body: JSON.stringify({ label: label ?? "", show_payments: !!showPayments, + show_model: !!showModel }) }); } /** CLIENT-PORTAL — read-only share tokens for a project-readiness digest. */ shareTokens(pid: string) { type Tok = { token: string; label: string | null; revoked: boolean; created_at: string | null; created_by: string | null; view_count: number; last_viewed_at: string | null; share_path: string; - show_payments: boolean }; + show_payments: boolean; show_model: boolean }; return this.json<{ tokens: Tok[] }>(`/projects/${pid}/share-tokens`); } revokeShareToken(pid: string, token: string) { diff --git a/apps/web/src/api/shareTokenGrants.test.ts b/apps/web/src/api/shareTokenGrants.test.ts new file mode 100644 index 00000000..2413aca5 --- /dev/null +++ b/apps/web/src/api/shareTokenGrants.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ApiClient } from "./client"; + +/** + * A share token's two grants are INDEPENDENT, and the client could only ever send one of them. + * + * THE DEFECT. `show_model` is the per-token opt-in that lets a share link fetch the project's + * geometry — `GET /shared/{token}/model.frag`, gated in `client_portal.model_fragment` on + * `not getattr(row, "show_model", False)`. The backend has supported it end to end since the column + * shipped: the route reads `body.get("show_model")`, `_public_row` returns it, and + * `services/api/test_shared_model.py` already proves the 200-vs-404 pair and that `show_payments` + * does not imply it. **`createShareToken` sent only `label` and `show_payments`**, so every token + * this product minted had the flag false and that route 404'd for all of them. + * + * WHY NOTHING CAUGHT IT. Both sides were correct in isolation, which is the whole shape of the bug. + * The backend test mints its own tokens with `json={"show_model": True}` — a body the product never + * produces — so it passes over a client that cannot ask for the thing it tests. Nothing red, nothing + * logged, and a 404 from a route nobody had a working link to is indistinguishable from a project + * with no published fragment, which is deliberately the same response. + * + * The assertions are about the ENCODED BODY, not a mock's arguments, for the reason + * `publishBody.test.ts` gives: what reaches the server is the only thing the route can be wrong + * about. A test that asserted `createShareToken` was *called* with `true` would have passed against + * a method that accepted the argument and dropped it — which is one keystroke from the bug. + */ + +function captureBody(response: unknown = { token: "t", label: null, share_path: "/shared/t/digest", + revoked: false, show_payments: false, show_model: false }) { + const seen: { url: string; body: string | null }[] = []; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), body: (init?.body as string) ?? null }); + return new Response(JSON.stringify(response), { + status: 200, headers: { "content-type": "application/json" }, + }); + })); + return seen; +} + +const bodyOf = (raw: string | null) => JSON.parse(raw ?? "{}") as Record; + +describe("POST /projects/{pid}/share-tokens — the geometry opt-in reaches the wire", () => { + it("sends show_model:true when the caller asks for geometry", async () => { + const seen = captureBody(); + await new ApiClient("http://x").createShareToken("p1", "Owner review", false, true); + expect(seen).toHaveLength(1); + expect(seen[0]!.url).toContain("/projects/p1/share-tokens"); + expect(bodyOf(seen[0]!.body)).toEqual( + { label: "Owner review", show_payments: false, show_model: true }); + vi.unstubAllGlobals(); + }); + + it("ALWAYS sends the key, never omits it — an absent key is not the same request", async () => { + // `bool(body.get("show_model"))` treats absent and false alike TODAY. This asserts the key is + // present anyway: the client must state the grant it is asking for rather than rely on a + // server-side default, because that default is the one thing a future backend could change + // without touching this file. + const seen = captureBody(); + await new ApiClient("http://x").createShareToken("p1"); + expect(bodyOf(seen[0]!.body)).toHaveProperty("show_model", false); + vi.unstubAllGlobals(); + }); + + it("the two grants are independent in BOTH directions", async () => { + // The backend docstring is explicit: "a token may carry payments, or geometry, or neither, and + // granting one never implies the other." Asserting only the both-true case would pass on a + // client that had collapsed them into one "share more" flag, which is exactly the design error + // the wording exists to prevent. So all four corners are checked. + const seen = captureBody(); + const api = new ApiClient("http://x"); + await api.createShareToken("p1", "a", false, false); + await api.createShareToken("p1", "b", true, false); + await api.createShareToken("p1", "c", false, true); + await api.createShareToken("p1", "d", true, true); + expect(seen.map((s) => { + const b = bodyOf(s.body); + return [b.show_payments, b.show_model]; + })).toEqual([[false, false], [true, false], [false, true], [true, true]]); + vi.unstubAllGlobals(); + }); + + it("the token row carries show_model, so an owner can audit which links grant geometry", async () => { + // The SECOND half of the defect, and a separate one: `_public_row` has always returned + // `show_model` and the row type dropped it, so the value was on the wire and unreadable. + // + // STATE THE GRADE. This assertion is NOT what guards that half, and pretending otherwise would + // be the more dangerous outcome — a reader would trust vitest to catch a regression it cannot + // see. Measured by deleting `show_model` from the `Tok` type: vitest stayed 4/4 GREEN (the + // runtime reads whatever the response object holds; a type is not there at runtime), while + // `tsc --noEmit` went red in THREE places — this line and the two `masterBuilder.ts` reads. + // The typecheck is the gate; this line exists so the field is exercised by a caller at all. + captureBody({ tokens: [ + { token: "aaaaaaaaaa", label: "digest only", revoked: false, created_at: null, created_by: null, + view_count: 0, last_viewed_at: null, share_path: "/shared/aaaaaaaaaa/digest", + show_payments: false, show_model: false }, + { token: "bbbbbbbbbb", label: "with model", revoked: false, created_at: null, created_by: null, + view_count: 3, last_viewed_at: null, share_path: "/shared/bbbbbbbbbb/digest", + show_payments: false, show_model: true }, + ] }); + const { tokens } = await new ApiClient("http://x").shareTokens("p1"); + expect(tokens.map((t) => t.show_model)).toEqual([false, true]); + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/web/src/portal/panels/masterBuilder.test.ts b/apps/web/src/portal/panels/masterBuilder.test.ts new file mode 100644 index 00000000..54f9137b --- /dev/null +++ b/apps/web/src/portal/panels/masterBuilder.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PanelContext } from "../panelContext"; +import { renderMasterBuilder } from "./masterBuilder"; + +/** + * The share-link form is the ONLY place this product mints a share token, so it is the only place + * the geometry opt-in can be offered — and the only place it can be silently dropped. + * + * `api/shareTokenGrants.test.ts` proves the client puts `show_model` on the wire. That is a claim + * about the METHOD. This is the claim about the USE: a checkbox that exists, renders and is never + * read would satisfy the first test completely and leave the capability exactly as unreachable as + * it was. This repo has been bitten by that distinction before — a gate that saw a value + * destructured and stayed green after the calls to it were deleted — so the assertions here drive + * the real DOM: tick the box, click the button, and read what the client was asked for. + */ + +const BRIEF = { + project: "Test project", readiness_pct: 50, ready_steps: 4, gap_steps: 4, step_count: 8, + grounded_in_place: true, jurisdiction: "Miami-Dade, FL", reframe_prompt: "What is this place for?", + place_grounding: { code_family: "IBC", coordinates: null, hemisphere: null, climate_band: null, + hazards_to_verify: [] }, + steps: [], disclaimer: "Not a substitute for licensed judgment.", +}; + +const flush = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); }; + +function ctx(over: Record = {}) { + const api = { + masterBuilderBrief: vi.fn().mockResolvedValue(BRIEF), + masterBuilderBriefMdUrl: () => "/md", + sharedPageUrl: (t: string) => `/shared/${t}`, + shareTokens: vi.fn().mockResolvedValue({ tokens: [] }), + createShareToken: vi.fn().mockResolvedValue({ token: "new" }), + revokeShareToken: vi.fn().mockResolvedValue({ revoked: true }), + ...over, + }; + const c: PanelContext = { + root: document.createElement("div"), + host: { projectId: () => "p1", api } as unknown as PanelContext["host"], + mods: [], activeKey: "__mb__", + bar: (title) => { const b = document.createElement("div"); b.textContent = title; return b; }, + buildNav: () => undefined, renderHome: async () => undefined, openModule: async () => undefined, + navigate: () => undefined, hasDest: () => true, + }; + return { c, api }; +} + +/** The two opt-in checkboxes, found by their labels rather than by DOM order. */ +function boxes(root: HTMLElement) { + const find = (needle: string) => { + for (const l of Array.from(root.querySelectorAll("label"))) { + if ((l.textContent ?? "").includes(needle)) return l.querySelector("input") as HTMLInputElement; + } + throw new Error(`no opt-in labelled ${needle} — found: ` + + Array.from(root.querySelectorAll("label")).map((l) => l.textContent).join(" | ")); + }; + return { pay: find("payment schedule"), model: find("3D model") }; +} + +const createBtn = (root: HTMLElement) => + Array.from(root.querySelectorAll("button")).find((b) => b.textContent?.includes("Create link"))!; + +describe("R22-PUBLIC-VIEWER — the geometry opt-in is offered, and it is READ", () => { + it("offers a 3D-model opt-in beside the payments one, both unchecked", async () => { + const { c } = ctx(); + await renderMasterBuilder(c); await flush(); + const b = boxes(c.root as HTMLElement); + expect(b.pay.checked).toBe(false); + expect(b.model.checked).toBe(false); + }); + + it("passes the ticked box through to createShareToken — the wiring, not the widget", async () => { + const { c, api } = ctx(); + await renderMasterBuilder(c); await flush(); + boxes(c.root as HTMLElement).model.checked = true; + createBtn(c.root as HTMLElement).click(); + await flush(); + expect(api.createShareToken).toHaveBeenCalledTimes(1); + // (pid, label, showPayments, showModel) — the 4th argument is the one that did not exist. + expect(api.createShareToken.mock.calls[0]).toEqual(["p1", undefined, false, true]); + }); + + it("grants stay independent: payments alone never carries geometry", async () => { + // The backend refuses to let one imply the other. A UI that quietly sent both when either was + // ticked would be a widening the backend's rule exists to forbid, and no backend test could see + // it — the request would look like a deliberate double opt-in. + const { c, api } = ctx(); + await renderMasterBuilder(c); await flush(); + boxes(c.root as HTMLElement).pay.checked = true; + createBtn(c.root as HTMLElement).click(); + await flush(); + expect(api.createShareToken.mock.calls[0]).toEqual(["p1", undefined, true, false]); + }); + + it("resets both boxes after a mint, so a grant never carries to the next link", async () => { + const { c } = ctx(); + await renderMasterBuilder(c); await flush(); + const b = boxes(c.root as HTMLElement); + b.model.checked = true; b.pay.checked = true; + createBtn(c.root as HTMLElement).click(); + await flush(); + expect(b.model.checked).toBe(false); + expect(b.pay.checked).toBe(false); + }); + + it("marks which live links grant geometry, so the opt-in can be audited after minting", async () => { + const tok = (over: Record) => ({ + token: "0123456789abcdef", label: null, revoked: false, created_at: null, created_by: null, + view_count: 0, last_viewed_at: null, share_path: "/s", show_payments: false, show_model: false, + ...over, + }); + const { c } = ctx({ shareTokens: vi.fn().mockResolvedValue({ tokens: [ + tok({ token: "aaaaaaaaaaaaaaaa", label: "digest only" }), + tok({ token: "bbbbbbbbbbbbbbbb", label: "with model", show_model: true }), + ] }) }); + await renderMasterBuilder(c); await flush(); + const links = Array.from((c.root as HTMLElement).querySelectorAll("a")) + .filter((a) => (a.textContent ?? "").includes("…")); + expect(links).toHaveLength(2); + const plain = links.find((a) => a.textContent!.includes("digest only"))!; + const withModel = links.find((a) => a.textContent!.includes("with model"))!; + expect(withModel.textContent).toContain("🧊"); + expect(withModel.title).toContain("3D model"); + // The negative half. Without it the marker could be unconditional and every assertion above + // would still pass, which would make the audit trail confidently wrong rather than absent. + expect(plain.textContent).not.toContain("🧊"); + expect(plain.title).not.toContain("3D model"); + }); +}); diff --git a/apps/web/src/portal/panels/masterBuilder.ts b/apps/web/src/portal/panels/masterBuilder.ts index 1c0946c0..bda16bdb 100644 --- a/apps/web/src/portal/panels/masterBuilder.ts +++ b/apps/web/src/portal/panels/masterBuilder.ts @@ -92,11 +92,28 @@ export async function renderMasterBuilder(ctx: PanelContext) { const mkRow = document.createElement("div"); mkRow.style.cssText = "display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap"; const labelI = document.createElement("input"); labelI.className = "portal-filter"; labelI.placeholder = "label (e.g. Owner review)"; labelI.style.cssText = "flex:1 1 160px;font-size:12px"; const mkBtn = document.createElement("button"); mkBtn.className = "tool-btn on"; mkBtn.textContent = "+ Create link"; - const payLbl = document.createElement("label"); payLbl.className = "meta"; payLbl.style.cssText = "display:flex;align-items:center;gap:4px;font-size:12px"; - payLbl.title = "Opt-in: this link's digest also shows the owner-invoice payment schedule (display only)"; - const payCk = document.createElement("input"); payCk.type = "checkbox"; - payLbl.append(payCk, document.createTextNode("💲 payment schedule")); - mkRow.append(labelI, payLbl, mkBtn); share.append(mkRow, shareBody); body.appendChild(share); + // R22-PUBLIC-VIEWER — two INDEPENDENT opt-ins, deliberately not one "share more" control. The + // backend's rule is that a token "may carry payments, or geometry, or neither, and granting one + // never implies the other"; a single toggle or an ordered level could not express that. Both are + // unchecked on every mint, and the form resets them after one, so a geometry link is always a + // decision taken for THAT link rather than a setting that carries over to the next. + const optIn = (title: string, text: string) => { + const l = document.createElement("label"); l.className = "meta"; + l.style.cssText = "display:flex;align-items:center;gap:4px;font-size:12px"; + l.title = title; + const ck = document.createElement("input"); ck.type = "checkbox"; + l.append(ck, document.createTextNode(text)); + return { label: l, box: ck }; + }; + const pay = optIn("Opt-in: this link's digest also shows the owner-invoice payment schedule (display only)", + "💲 payment schedule"); + // Geometry is a real disclosure, so the tooltip states the boundary the backend actually enforces: + // the converted fragment, never the source IFC with its property sets and classifications. + const mdl = optIn("Opt-in: this link may also load the project's 3D geometry (shapes and placements " + + "only — never the source IFC with its property sets, classifications and GlobalIds)", + "🧊 3D model"); + const payCk = pay.box, mdlCk = mdl.box; + mkRow.append(labelI, pay.label, mdl.label, mkBtn); share.append(mkRow, shareBody); body.appendChild(share); const loadTokens = async () => { shareBody.innerHTML = `
loading…
`; try { @@ -108,7 +125,15 @@ export async function renderMasterBuilder(ctx: PanelContext) { const row = document.createElement("div"); row.style.cssText = "display:flex;gap:6px;align-items:center;margin:2px 0;flex-wrap:wrap"; const link = document.createElement("a"); link.href = ctx.host.api.sharedPageUrl(t.token); link.target = "_blank"; link.rel = "noopener"; link.className = "meta"; link.style.cssText = "flex:1 1 200px;word-break:break-all"; - link.textContent = `🔗 ${t.label ? t.label + " · " : ""}…${t.token.slice(-8)}${t.show_payments ? " · 💲" : ""}`; + // Both opt-ins are marked, because an opt-in nobody can audit after minting cannot be + // reviewed or regretted. `show_model` was on the wire from the day the column shipped and + // this row dropped it — the same defect UX-VIEWED fixed for `last_viewed_at` below. + const grants = [t.show_payments ? "💲" : "", t.show_model ? "🧊" : ""].filter(Boolean); + link.textContent = `🔗 ${t.label ? t.label + " · " : ""}…${t.token.slice(-8)}` + + (grants.length ? ` · ${grants.join(" ")}` : ""); + link.title = grants.length + ? `Also grants: ${[t.show_payments ? "payment schedule" : "", t.show_model ? "3D model" : ""].filter(Boolean).join(", ")}` + : "Readiness digest only — no payments, no geometry"; // UX-VIEWED: the chip vocabulary, on data that was already on the wire. `last_viewed_at` // was stored, served and typed, and this row dropped it — the count was rendered as // plain text and the timestamp never read at all. @@ -127,7 +152,8 @@ export async function renderMasterBuilder(ctx: PanelContext) { }; mkBtn.onclick = async () => { mkBtn.disabled = true; - try { await ctx.host.api.createShareToken(pid, labelI.value.trim() || undefined, payCk.checked); labelI.value = ""; payCk.checked = false; await loadTokens(); } + try { await ctx.host.api.createShareToken(pid, labelI.value.trim() || undefined, payCk.checked, mdlCk.checked); + labelI.value = ""; payCk.checked = false; mdlCk.checked = false; await loadTokens(); } catch (e) { alert((e as Error).message); } finally { mkBtn.disabled = false; } }; diff --git a/docs/roadmap-completed.md b/docs/roadmap-completed.md index 27d8d137..c27dddd8 100644 --- a/docs/roadmap-completed.md +++ b/docs/roadmap-completed.md @@ -5886,6 +5886,20 @@ and never the source IFC; unknown, revoked, not-opted-in and no-model-published identical 404 so none of them is an enumeration oracle. The owner's token list shows which links carry geometry, because an opt-in nobody can audit after minting cannot be reviewed or regretted. +**CORRECTION 2026-09-04 — both of those last two sentences were true of the API and false of the +product, for the whole time this entry has stood.** The record is left above exactly as written, +because what it got wrong is the useful part. `createShareToken` in the web client never sent +`show_model`, so no token this product minted could serve geometry and the route 404'd for every +link it has ever produced; and the token row type had no `show_model` field, so the owner's list +could not have shown which links carry geometry even once one existed. `_public_row` returned the +value the entire time — the wire was right and both readers were missing. + +*The lesson is about who constructs the request.* `test_shared_model.py` verified this feature +correctly and could not have caught it: it mints tokens with `json={"show_model": True}`, a body the +product does not produce, so it passed over a client incapable of asking. **A test that builds its +own request proves the server honours that request, never that anything sends it.** Closed under +PORTAL-SHOWMODEL in [`roadmap.md`](roadmap.md) Band 2, which carries the detail. + ## REACH RING — ceiling 131 to 117 in one day ✅ *(v0.3.861–v0.3.880)* The uncalled ceiling fell from 131 to 117 across #269, #271, #272, #273 and #254, with #266 still to diff --git a/docs/roadmap.md b/docs/roadmap.md index ff126d37..3f56f705 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -230,6 +230,46 @@ Seven of eleven engines once shipped with no route. The R32 filing-spine entries band are all closed and recorded in [`roadmap-completed.md`](roadmap-completed.md). The current instances: +- ✅ **PORTAL-SHOWMODEL — the public 3D viewer shipped, and nothing could mint a token that reached + it** *(S — Lane C; **CLOSED**, fix in this change)* + + The canonical Band 2 shape, and the first one where the *unreachable* half was a single missing + key in a JSON body. `show_model` is the per-token opt-in that lets a share link fetch + `GET /shared/{token}/model.frag`. The backend has supported it end to end since R22-PUBLIC-VIEWER + shipped: the route reads `body.get("show_model")`, `client_portal.model_fragment` gates on it, + `_public_row` returns it, and `services/api/test_shared_model.py` proves the 200-vs-404 pair and + that `show_payments` does not imply it. **`createShareToken` sent only `label` and + `show_payments`.** Every token the product minted had the flag false, so that route 404'd for + every link this product has ever produced. + + **Two defects, not one, and the second is the worse kind.** The missing PARAMETER made the + capability unreachable. The missing ROW FIELD — `show_model` absent from the `Tok` type + `shareTokens` returns — made it *unauditable*: the value was on the wire the whole time and the + type dropped it, so even a token minted with geometry some other way would have rendered in the + owner's list identically to a digest-only one. R22-PUBLIC-VIEWER's shipped record claims *"the + owner's token list shows which links carry geometry, because an opt-in nobody can audit after + minting cannot be reviewed or regretted."* That was true of the JSON and false of the product. + + **Why no gate saw it, which is the transferable part.** Both sides were correct in isolation. + `test_shared_model.py` mints its own tokens with `json={"show_model": True}` — a body the product + never produces — so it passed over a client that could not ask for the thing it tests. *A backend + test that constructs its own request cannot tell you the client sends that request.* And the + failure is invisible from outside: unknown token, revoked token, no opt-in and no published + fragment all return an identical 404 **by design**, so a dark viewer is indistinguishable from a + project with no model. + + Closed in `apps/web/src/api/clientPortal.ts` (fourth argument + the row field) and + `apps/web/src/portal/panels/masterBuilder.ts` (a `🧊 3D model` opt-in beside the payments one, and + both grants marked on every live link). Two new suites, each mutation-checked against the + confusion it names: `apps/web/src/api/shareTokenGrants.test.ts` asserts the encoded BODY, and + `apps/web/src/portal/panels/masterBuilder.test.ts` drives the real DOM — because a checkbox that + renders and is never read would satisfy the first test completely and leave the capability exactly + as dark as it was. + + **Scope held deliberately:** the token still serves the converted fragment and never the source + IFC. Nothing here widens what a token grants; it makes the grant the backend already defined + askable and visible. + - ✅ **ROUTE-INTERP — eight routes were frozen as callerless while the UI called them** *(S — Lane C; **CLOSED v0.3.1132**; energy half **SHIPPED v0.3.1133**)*