feat(console): real consent flow with field narrowing, time limits, and grant binding - #57
Draft
tnunamak wants to merge 56 commits into
Draft
feat(console): real consent flow with field narrowing, time limits, and grant binding#57tnunamak wants to merge 56 commits into
tnunamak wants to merge 56 commits into
Conversation
…n tokens The hosted consent/device/login pages (server/hosted-ui.ts) carried a frozen pre-refresh palette (rounded 0.5rem corners, gradient body wash, Geist font stack, oklch(0.580 0.172 253.7) blue) from before the console's Ink Carbon design system landed. The owner's complaint that the consent screen "looks like the old UI" and doesn't match the console's refresh traces to this literal fact: HOSTED_UI_CSS and apps/console/src/styles/ink-carbon.css are two independently hand-maintained token sets that drifted. This resyncs HOSTED_UI_CSS's custom-property values, radii, and component rules to Ink Carbon's actual tokens: square surfaces (--radius: 0px, --radius-control: 2px for buttons/inputs only), the two-temperature primary(blue)/human(copper) palette, Inter/JetBrains Mono font stack, and a flat (non-gradient) page background. No web font fetch is added — the hosted UI is a security-sensitive first-party surface and stays off third-party font CDNs, same as before. No structural HTML or copy changed; only the values the existing hosted-ui-* classes and [data-surface]/[data-authorship] selectors resolve to. Ports Ink Carbon's "approve" affordance convention too: the primary action button now uses --human (the owner's-act color) instead of --primary (the protocol/machine color), matching the two-temperature rule documented in ink-carbon.css. All 22 render-layer picker tests, 55 hosted-ui/security-consent tests, and 63 live hosted-mcp-oauth route tests pass unchanged except one assertion in hosted-ui-theme.test.ts pinned to the old dark-mode literal oklch values, updated to match. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 3b9b0644ad4bcc5593974eb5481cc9c164426f27)
… what Ten defects on the one screen a real MCP client (ChatGPT, Claude) actually reaches. They landed together because they are the same defect wearing different clothes: the page stated things no party had said, and printed protocol internals where owner-facing copy belonged. Retention (the one that cannot be fixed by building a feature). The page said "No retention commitment was declared by this app. Your server's default applies: data it reads is deleted within 90 days." The second sentence's subject is what the *app* does, so it told the owner ChatGPT deletes their data on a schedule ChatGPT never accepted. spec-core.md:951 classes retention as a commitment by the recipient, which PDPP does not enforce; :948 says the protocol does not reach into client-side stores. `HOSTED_MCP_PICKER_RETENTION` was also a hardcoded P90D/delete written into every issued grant, recording that fiction as ChatGPT's own promise. The constant is now null, the key is omitted from authorization_details rather than nulled, and the screen states the absence: "ChatGPT did not say how long it keeps the data it receives." Identity. spec-core.md:673 requires the resolved display name when available and makes client_id the fallback; ChatGPT's metadata document carries "client_name": "ChatGPT", and we headlined `https://chatgpt.com` anyway. The name now leads, the origin is a quiet second line, and the monogram is a two-letter mark from the name (CH) rather than a "C" derived from the URL. The metadata-document URL — a client_id with a token_endpoint_auth_method query parameter — leaves the owner surface for the audit record. Trust. "Unverified app" was unconditional: no client could ever escape it, so it carried no information and read as an accusation. It becomes a neutral fact line naming what the server does and does not know. Purpose. Three rows for one idea, one of them a URI. Now one sentence that names its own origin, with the registry code kept in the grant. Header. Instance name alone. The `TD` monogram was unstyled (the class never appeared in the stylesheet) and inverted spec-core.md:676, whose monogram rule protects the *client*, not the operator. PDPP moves to a footer attribution. Copy. Engineering prose is suppressed rather than shipped ("QFX ACCTINFO", "dashboard scrape", "milliunits" were rendering verbatim) and stream keys are humanized; the ~70 words explaining what a checkbox does are gone along with the 54 per-source buttons the tri-state parent already replaced; the `connector` badge stated a classification the owner has no decision about; the expiry note no longer restates and contradicts the access mode (spec-core.md:889); "no date-range limit" no longer phrases an unbuilt feature as a protocol constraint; and the per-source revoke promise is cut to the package-level one the product actually delivers. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit b1b7e6ad8c9e9039009dcdb7435186fb35120d80)
The hosted consent picker had 59 buttons and every one of them was affirmative. Grepping the branch for `Deny`, `Cancel`, `access_denied`, or `Not now` returned nothing. The only exit from the most critical screen in the server was to approve or close the tab. Closing the tab is not a refusal the client can observe. RFC 6749 §4.1.2.1 requires the AS to redirect back to the client with `error=access_denied` when the owner declines, and no code path in the reference implementation could return an OAuth error to any client at all — both redirect builders set only `code` and `state`. A user who abandoned the flow left ChatGPT waiting for a response that never came. A `Deny` affordance does exist, in the `/consent` pending-row flow, but that flow writes a pending-consent row the picker never creates (the picker mints straight from its POST), so it cannot serve this surface. Adds `POST /oauth/authorize/mcp-package/cancel`: same owner session, same CSRF token, same client and redirect_uri registration checks as an approval, then a 302 to the client's registered redirect_uri carrying `error=access_denied` and the original `state`. Nothing is minted. The registration check matters as much as the redirect: the route echoes the client's own redirect_uri back as a redirect target, so an unregistered URI is an open-redirect vector whether it carries a code or an error. It throws rather than redirecting, and a test pins that an attacker-supplied origin gets a 400 with no Location header. `Cancel`, not `Deny` — declining is not an error and should not be dressed as one. It renders as a first-class action beside `Allow access`, with `formnovalidate` and a submitter check in the picker script so selection validation can never block a refusal; an owner with nothing selected is exactly the owner most likely to be refusing. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 36c4552db6bf75b7f2e6aa0ca7827b2ab99fd0a9)
No page in the hosted MCP flow was the approval artifact spec-core.md requires. :873-877 says the final artifact must carry the exact resolved terms; :881-885 and AS-conformance #15 say the approval must bind to an immutable review digest over those terms, and that a stale review must fail. Two digests existed and neither did that. `review_digest` covers what the GET rendered as *choosable* — source and stream eligibility, purpose, retention, the set of available access modes. If the owner checked three streams or thirty, the value was identical. It detects drift in the menu and is blind to the order. Its handler also opened with `if (!carriedDigest) return false;`, so omitting the field skipped the check rather than failing it — an approval that never claimed to have reviewed anything was the one approval that sailed through. `computeHostedMcpPickerReviewDigest` does cover the exact selection, but runs server-side after the POST. Its own comment concedes it "cannot itself reject anything stale, because nothing is compared against it". Good audit evidence; not something the owner reviewed and submitted. So the picker showed available choices, the grant was minted straight from the POST, and the exact terms were recorded only afterward. The owner never saw, and never bound to, a statement of what they had granted. This adds that statement and that binding, without a second click. The page renders a live summary — scope, coverage, duration, expiry, and what the recipient said about keeping the data — that updates as the owner selects, and the same script canonicalizes the decision into a hidden `decision_digest`. The POST recomputes that digest from the decision it independently resolved (its own manifest re-reads, its own narrowing) and requires a match before minting. A missing digest is rejected, not skipped. The submitted digest is a claim about what was reviewed; it never widens a grant. Everything minted still derives from the server's own resolution, so the check can only narrow or fail. Where SubtleCrypto is unavailable — any non-secure context, which includes reaching a local instance over plain HTTP — the field stays empty and the server's fail-closed check re-renders with a message. The submit guard retries exactly once, so a browser that can never compute the digest reaches that honest rejection instead of a button that silently does nothing. Mutation-checked: with the comparison disabled, the three negative tests (absent digest, widened streams, flipped access mode) all fail and the positive control still passes, so they are not asserting a blanket refusal. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit ea214c7ee3c8f8d0678fcd6210f79c45818b1ea1)
Three owner-facing strings promised reversibility. Two granularities:
picker: "you can revoke any source you approve here later"
result: "You can revoke this access any time from the grants dashboard."
result: "You can revoke any single source grant independently from the
grants dashboard."
`POST /grants/:grantId/revoke` exists in the reference server and the console
proxies it, but grepping `apps/console/src` for a call site returns only the
proxy plumbing. No UI calls it. The only revoke control that ships is the
all-or-nothing package cascade at `/grants/packages/:packageId`; the grants
pages otherwise just render revoked *status*. The hosted UI has no grants
dashboard to link to at all.
So the per-source promise is not deliverable today. Reversibility is the
promise that makes "yes" feel safe on a consent screen — an owner reads it and
approves more than they otherwise would. Promising a granularity they cannot
reach is worse than saying less, so all three now state only the
package-level promise the product keeps, worded identically:
"You can revoke this access later from your grants page."
Shipping real per-source revoke is the better fix, but it is console UI work
outside this slice, and the honest copy should not wait on it.
Guarded by a source-level unit test rather than a rendered-HTML one: it runs
in milliseconds with no server boot, strips comments so the prose explaining
the removal does not trip it, and fails if any surface reintroduces "revoke
any source" or "revoke ... independently". Mutation-checked against the
original sentence.
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
(cherry picked from commit ba251a44cfd810af7538e68d389364db02aa557d)
… route The consent screen is the most important UI in the server, and the design that replaces it is specified but not built. Waiting for the implementation to land before the owner can react to the design means the feedback arrives after the expensive part is done. Adds `GET /_ref/design/consent`, owner-session gated, rendering the design from `local/CONSENT-UI-REDTEAM-DESIGN-0902.md` with mock data shaped like a real ChatGPT MCP authorization request — a plain OAuth client that declares no purpose, no streams, and no retention, so the design's hardest cases are the default rather than an edge case. Copy is §4.2 verbatim. Every variant is reachable by URL: `?width=mobile` forces the mobile rendering at any viewport, `?trust=verified` shows the positive trust signal so both limbs of spec-core.md:675 are reviewable, and `?state=` covers sign-in, cancelled, error, and the post-approval receipt. The route is inert by construction. It contains no form, no POST target, and no mutating handler; Allow and Cancel are links that switch the preview's own `?state=`. It reads no owner data — every source, stream, and account label is a literal — so the preview is identical on an empty instance and a connected one. And it shares no code with the live consent path: the real picker renders through `as-consent-ui-helpers.ts`, which this change does not touch, so the design can move ahead of the implementation without any risk to the surface that issues grants. The live hosted stylesheet is a fixed 640px column with no width breakpoints, which cannot host a sticky review panel, so the route carries its own layout CSS layered on the shared Ink Carbon token sheet. Tokens are inherited, never redefined. Copper stays reserved for the one owner consent act. Verified against a running instance: unauthenticated requests get 401, all seven variants return 200 behind an owner session, and all six rendered documents contain zero `<form>` elements. tsc clean; the file is biome-clean (the 7 remaining `index.ts` findings are pre-existing and unchanged). Screenshots of every variant, light and dark, at ~/.tmp/consent-design-mock-0902/. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 90470a5e4d1f50ece29c1e55e6184ed5fa52e4c1)
…n it forgot The hosted stylesheet had no width breakpoints at all — its only two @media blocks were prefers-color-scheme. A consent screen is a thing people reach from a phone, mid-task, from an app that just redirected them, and this one had no mobile design. Four defects, all of them things that shipped: The Cancel button renders data-variant="ghost" and no rule for that variant existed, so the single refusal affordance on the page fell through to the default button style and read as a peer of Allow. Ink Carbon reserves copper --human for the owner's consent act; the refusal is now visibly quieter. The accordion's disclosure was ::after generated text sharing one row with a checkbox that does something different — the checkbox grants the source, the disclosure only reveals its streams. On a phone one tap had two plausible outcomes, and generated text cannot be labelled, focused, or sized. It is a real control now with its own 44px hit area, beside the checkbox. Suppressing the <details> toggle needed care: preventDefault() on <summary> cancels the checkbox's own activation too, because both are default actions of the same click. A blanket suppression leaves the selection control dead. Verified that in Chromium rather than assuming it, and scoped the suppression to clicks that did not land on the checkbox. The font stack named "Inter" and "JetBrains Mono" and imported neither, so every hosted page rendered in system UI under a stylesheet claiming otherwise. This surface deliberately fetches no third-party font CDN, so the honest stack is the system one. Also drops the rules for the connector badge and uniform source-kind summary, both removed from the markup earlier; dead CSS on a hand-maintained stylesheet is how the next person concludes the badge still ships. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 339d87151f6dd47d79e6c73dfca7b4414f5cf662)
A real deployment renders 27 collapsed sources on a very long, very thin scroll with the approve button past the end of all of it. Two additions make that list usable without shortening what the owner is allowed to see. A filter appears once the list stops being scannable (above eight sources; on four it is chrome that costs a control and buys nothing). It matches the source name, the connected account, and the data types the source holds -- the three things someone would actually type. It carries no `name` attribute: this form's field set IS the grant, and a named input would post the owner's search string into the authorization request. Filtering hides rows, never changes what is selected -- a source already checked stays checked and stays in the grant while filtered out of view, because hiding a row must not silently narrow the decision. The reverse matters more: bulk select now applies only to rows the owner can see, so selecting behind an active filter cannot grant sources they never looked at. Clear stays unscoped, since clearing can only ever narrow. A live counter states the running answer to "what am I about to allow", starting honest at "Nothing selected yet." It recomputes from the checkboxes rather than tracking incrementally, so there is no second source of truth to drift. Also removes the connector key from each row's meta line. It read "5 data types - chase-bank": a registry identifier whose audience is an engineer inspecting a registration, not the person deciding whether to share their bank transactions. Same defect as the metadata-document URL and the `connector` badge, both already removed from this surface, wearing a shorter string. It stays in the form value and the audit record. Driven in a real browser, not only asserted: the counter tracks selection with correct pluralization, the filter narrows without touching the selection, the chevron toggles disclosure without changing what is checked, and every interactive control clears 44px on a 390px viewport. One of those browser runs caught a bug every string assertion had missed -- adding the filter attribute dropped the <details> tag's closing bracket, so the summary, checkbox and chevron were swallowed into the open tag and the chevron rendered invisible. Substring matching cannot tell well-formed markup from a tag that never closed, so there is now a structural test that can. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 8db0901ee338e44bc0e9cc74c5ac3696f15b5ada)
…r a door About thirty distinct failures on the authorize path return a raw JSON body to the browser -- `Unknown client_id`, `redirect_uri does not match a registered redirect URI`, `code_challenge_method must be S256`, `Unknown connector: <id>`, `access_mode must be 'single_use' or 'continuous'`. Only three conditions rendered HTML. An owner who hits any of the rest sees a JSON blob mid-consent, on the most critical UI in the server. Adds the renderer and the negotiation predicate those failures need. Two rules govern the copy. The owner reads a consequence, never a protocol string: `code_challenge_method must be S256` tells the person deciding whether to share their bank transactions nothing they can act on, and the developer who needs it still has it in the log and in the JSON body an API client continues to receive. And every terminal failure states the one fact the owner most needs -- that nothing was shared. An unmapped failure falls back to that same safe sentence rather than to a blank page or the developer's own error text, since the thirty are not individually enumerable and the next one must not leak. Content negotiation is deliberately narrow: only an explicit `text/html` flips the response. A bare catch-all Accept (curl's default) and a missing Accept header both keep the JSON contract, so this can only add a page where there was an unreadable blob -- it can never take JSON away from something that was getting it. The empty picker gains a console link beside its Cancel. The link renders only when the route resolves a URL; this renderer never constructs one it cannot stand behind, and the refusal is present either way, so the link can only ever add an exit. NOT WIRED YET, and deliberately so: `oauthError` is a shared emitter in index.ts with no request reference, so threading the Accept header through it is a change to route plumbing this lane does not own. The renderer and predicate are complete and tested; see the report for the exact wiring. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 5fb45b7799308dd2bad42b1b2ee8acd24622a0d4)
The denial page read "Access Denied", then "Request rejected", then "The pending data access request was rejected and cleared." One fact, three times, in the passive voice, in the register of a system log -- and none of the three answered the question an owner actually has after refusing, which is what happened to their data. It now says: "You didn't share anything. The app didn't get any of your data. You can close this tab." The result state also stops rendering in the danger tone. Refusing is a normal, correct outcome; painting the owner's own decision in the error colour tells them they did something wrong. `Access denied` survives as the page title. It is the OAuth-facing name of the outcome, and five suites across other lanes assert on it -- changing it would be a cross-lane break for no owner benefit, since the words the owner reads are the ones below it. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 711af72ea36baeb086e333ba620e66e6e6313a53)
The owner read that eyebrow heading two consecutive blocks -- purpose, then retention -- which are two facts of a single category. Both are things this server says, so they are one register and now one block with one heading. spec-core.md:716 requires the three authorship classes stay DISTINCT. It does not require a printed banner above every group: typography, placement and one attribution carry a distinction perfectly well, and repeating the label per block is what turned the trust model into the visual noise that made this page read as a debug dump. The `data-authorship="manifest"` class the two existing spec tests actually assert on is unchanged -- only the heading merged. The block also stops rendering on an empty picker. The terms of a grant that cannot be made are noise in front of the one thing that page needs to do, which is let the owner leave. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit cde163eaa1640490084a3c906874964a9cb0c539)
…ed to have The live-server assertion matched `aria-label="Data retention"`, which broke when purpose and retention merged into one server-authored block to stop the page saying "Your server describes" twice in a row. The obligation was never the heading. It is that retention renders as a structured policy declaration and not under "Your server enforces", so the assertion now also checks the substance directly: the retention sentence must not appear inside the protocol-enforced block. That holds regardless of what either block is called next. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit d56a144d9d28d7f2689e81b3aba93f17a41fd899)
The key/value grid is a max-content label column beside prose -- a desktop shape. At 390px it left each value about twenty characters of width and wrapped every sentence to five lines, including the two the owner most needs to read: what this server set the purpose to, and what the app said about keeping their data. Labels now stack above their values below 600px. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 246fd86d3ee326b07f40a1d701b1b271c7bf244f)
…ts domain The consent surface hardcoded isUnverified: true on every path, so "Unverified app" was a badge no client could ever escape. A marker every client wears is not a warning; it never varies, so it carries no signal. spec-core.md:675 requires a positive trust signal to render distinctly AND requires clients without one to be treated as unverified — the two halves depend on each other, and only the second was implemented. The signal is domain control, established automatically. A client whose client_id is an https URL serving a valid client-metadata document has demonstrated it controls that domain: validateCimdUrl requires https with no userinfo, the fetch is SSRF-guarded, and validateCimdRedirectUris pins every redirect target to the client_id origin. Someone who cannot publish at chatgpt.com cannot complete that exchange. So the copy says "Verified domain: chatgpt.com", not "Verified app". The claim rendered is the claim proven: control of a domain, not that the application is honest or safe. Overclaiming would be worse than the wallpaper badge it replaces, because an owner would act on it. This is automatic for any conforming client rather than a list of blessed applications. A list would demo a mechanism real clients never touch and leave every unlisted client permanently unverifiable. Operator pre-registration stays available as an override layer (spec-core.md:679-683, first in the :672 precedence) for vouching beyond what the protocol proves. Logo policy enforces both halves of spec-core.md:676 rather than either: the client must be domain-verified AND the asset must be https from the client's own domain or an operator-allow-listed host. Controlling chatgpt.com proves nothing about oaistatic.com, so serving from a CDN is an operator decision. An exact allow-list host does not cover its subdomains, since whoever controls a domain can mint any subdomain under it; an operator wanting the tree writes ".oaistatic.com". Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit b08f873d90f1b2622db406e0f365cb6d705fa607)
The existing cimd.test.ts covers the draft's rules with synthetic
documents. This covers the same rules with the bytes chatgpt.com actually
serves, captured once into a fixture.
The real document has a property none of the synthetic fixtures had: its
client_id carries a query string (?token_endpoint_auth_method=none), and
it is that query-bearing form the document must match. A comparison that
normalized URLs or compared origins would pass every synthetic test in the
suite and still mis-handle the one real client we care about. Mutating the
byte-exact check to an origin comparison fails exactly one test here, which
is the check that mattered.
No production code changed. Every requirement these tests assert was
already implemented and already correct:
- client_id must be an https URL validateCimdUrl, cimd.ts:196
- fetch timeout cimd.ts:366, 5s default
- body size cap cimd.ts:484, 5 KB
- no redirects followed cimd.ts:412 redirect: "manual",
backstopped by the 3xx rejection
at cimd.ts:462
- response caching cimd.ts:552, TTL from
Cache-Control, bounded 60s..24h
- document client_id === request cimd.ts:511, strict !== on the
client_id, byte-exact whole string
- request redirect_uri must be listed buildCimdRegisteredClient copies
in the document redirect_uris into metadata;
requireRegisteredRedirectUri
enforces membership at
as-authorize.ts:785 and :886
The gap was in what we could demonstrate, not in what the server did. The
suite additionally pins that the document's absence of policy_uri/tos_uri
stays absent rather than being invented, and that ChatGPT's logo — served
from persistent.oaistatic.com, not chatgpt.com — needs an explicit operator
allow-list rather than riding on the domain verification.
One honest limit: fetchImpl is stubbed, so these tests exercise the 3xx
status rejection rather than the redirect: "manual" option itself. Both
defenses are present; only the second is under test here.
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
(cherry picked from commit 49407535a7f5f0bc2d935522f2c0ee2430b03968)
…ation
The picker granted every field of every checked stream with no date bound
and told the owner "Everything in each data type you check, with no date
limit" — an unbuilt feature phrased as a property of the protocol. It is
not one. spec-core.md:761 makes `fields` a protocol-enforced allowlist,
spec-core.md:758-759 make `time_range` a protocol-enforced window, and the
resource server already enforces both. The controls were missing, not the
mechanism: core-source-authorization.ts has resolved both correctly all
along and simply never received non-default input from this flow.
This is the middle of that path — what a stream is capable of offering, and
how a submitted narrowing is validated back into a selection the existing
resolver accepts.
Capability is read from the declaration, never assumed. `selection.fields`
gates field narrowing and `consent_time_field` gates date narrowing;
spec-core.md:547 makes the latter's absence the normative signal that a
stream has no temporal scope. Offering a control the manifest does not
support would produce a 400 at issuance, after the owner had already
chosen, so the check happens before anything renders and an incapable
stream shows nothing at all.
Schema-required fields are the consent floor (spec-core.md:764): a
submission that omits them is completed rather than rejected, and the UI
will render them checked and disabled so the owner sees what they cannot
exclude instead of being quietly overruled after unchecking it.
Two details worth naming. `until` is exclusive, so a date bound covers the
whole day the owner picked — otherwise choosing a single day as both bounds
would authorize an empty window, which is never what that gesture means.
And selecting every field omits the allowlist rather than restating it,
keeping the grant honest that nothing was narrowed.
Verified against the real ChatGPT manifest: all 6 streams declare both
capabilities, and a narrowing of `messages` to `content` since 2026-03-01
resolves through resolveCoreSelection to fields [content, conversation_id,
id] with time_constraint {field: create_time, since: 2026-03-01T00:00:00Z,
until: 2026-04-01T00:00:00Z}.
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
(cherry picked from commit cc731d2cc98f56966a4cd5f2b4b273c85762b508)
The picker hardcoded no expiry and explained it in a footnote: "This authorization has no scheduled end date." So the only outcome available was indefinite access, and the sentence read as a property of the protocol rather than the default it actually was. expires_at is set by the AS; the spec neither requires null nor forbids asking the owner. 90 days is the default — long enough that a working agent integration does not break in normal use, short enough that a forgotten grant closes by itself within a quarter. Google is the precedent worth copying here: it states a concrete resolved date and makes indefinite access an explicit opt-in. That polarity is the point. An owner who wants to grant forever should have to say so; an owner who does nothing should end up with a window that closes on its own. Three details that would be easy to get wrong later, so they are pinned by tests: "No end date" resolves to null, which callers must render as an ABSENT expires_at — ResolvedGrant treats absence as no-expiry and rejects null as a value. An unrecognized submission is an error, not a fallback to the default. Guessing would hand the owner an expiry they never chose, on the one control whose whole purpose is bounding how long access lasts. The copy never restates the access mode. That was precisely the footnote's defect: "access lasts until you revoke it, whichever access mode you choose above" is false under One-time access, since a single_use grant is consumed at first token issuance (spec-core.md:920). spec-core.md:889 lists grant validity, data temporal scope, and access pattern as three orthogonal concepts that must not be conflated, and this control speaks only to the first. For single_use the control is suppressed rather than shown with a note that contradicts it. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit be53dd60d030426bcc7de69d45a3201b742ea928)
Scope controls travel as their own named inputs rather than inside the stream checkbox's value. The checkbox value identifies WHICH stream; these carry HOW MUCH of it. Folding them together would make the submitted identity of a stream change whenever the owner edits a date, which breaks the tri-state parent logic that keys on that value. Neither encoding is a security boundary — both are equally client-controlled — which is why resolveStreamScopeSelection validates everything against the declaration regardless. The narrow_*_<sourceIndex>__<encodedStream> shape matches the sibling per-source narrowing controls in the non-picker consent flow, so the two surfaces read the same way. Parsing is scoped to one source index, so one source's controls can never narrow another's — two sources with a same-named stream would otherwise silently cross-apply. The stream name is base64url-encoded because it has to survive the `__` separator, and decoding round-trips rather than trusting Buffer: Node's base64url decoder is lenient, dropping out-of-alphabet characters and decoding the remainder, so `!!!junk!!!` produced mojibake that was accepted as a stream name. It was harmless downstream — no declared stream matches mojibake — but it would have surfaced in an error message the owner reads. A test caught this; the fix is to accept only keys this encoder could have produced. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit ae59dde2c98d1a22c946cc331318d7727fd8f1f3)
…rant
Wires the scope resolver into the picker's POST path, so a narrowing the
owner submits actually reaches the grant instead of being validated and
discarded.
Three seams changed.
ConsentPickerManifest and HostedMcpPickerRow.streams widen to carry the
per-stream capability signals. The stored manifest always had
selection.fields, schema.required and consent_time_field; the route layer
projected them away at buildConnectorPickerRows, which is the actual reason
the picker could only ever offer all-fields-no-dates. Capability is
resolved once per row rather than at render time, so no surface can offer a
control the declaration does not support.
buildHostedMcpAuthorizationDetailForConnector takes an optional per-stream
scope map. Absent entries mean no narrowing, which is the pre-existing
behavior — the resolver reads an omitted `fields` as every field and an
omitted `time_range` as no bound (spec-core.md:775) — so the default path
is unchanged. A wildcard selection carries no scope, since there is no named
stream to attach one to until the wildcard is expanded.
The snapshot digest grows to cover the new choosable surface. This is the
TOCTOU guard, and it binds what the GET rendered as choosable; leaving it
alone would have meant a page that offers field and date controls whose
drift nothing detects. If a manifest revision changes a stream's field list
or drops either capability between page-load and submission, the owner
reviewed controls the server would no longer honor, and the submission is
now rejected rather than minted. The stale comment claiming the picker has
no fields for these is corrected.
Inputs are keyed by the source's stable (connectorId, connectionId)
identity, not by render position: the POST iterates submitted selections,
whose order comes from the form body rather than the GET's row order, so an
index would not survive the round trip — and two connected accounts of the
same connector share every stream name, so a mismatch would silently apply
one account's window to the other.
Validation happens per source against that source's own declaration, before
anything reaches the grant engine, so an impossible narrowing surfaces as a
correction rather than an opaque 400 after the owner pressed Allow.
Proven end to end against the real ChatGPT manifest: a narrowing of
`messages` to `content` since 2026-03-01 resolves through resolveCoreSelection
to fields [content, conversation_id, id] — the schema-required consent floor
survived — with time_constraint {field: create_time, since:
2026-03-01T00:00:00.000Z}. All 6 shipped ChatGPT streams narrow correctly on
both axes. Mutating the detail builder to drop the scope fails 4 of the 6
integration tests, which is the "built but silently ignored" regression this
file exists to catch.
tsc --noEmit clean; 129 unit tests pass.
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
(cherry picked from commit 8f018fae35bfd9e902f314d0c189f98edba667f1)
Threads grant_expiry from the picker POST through createHostedMcpGrantPackage to persistChildGrantForPackage, where expires_at is set. continuous grants now honor the choice, defaulting to null when none was made — which is exactly what this flow did before the control existed, so callers that never send grant_expiry are unaffected. single_use keeps its 24h backstop rather than taking the owner's value. The grant is consumed at first token issuance (spec-core.md:920), so a longer window would only widen the period an unused code stays live; it would not give the owner more access. The picker suppresses the control for that mode for the same reason. An unparseable choice returns a typed 400 rather than falling back to the default, and is resolved after the source selections so it cannot mask a more specific error the owner would rather see first. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 6bd3ddf18328452f6819a32c95d8654409a09044)
buildConsentClientDisplay hardcoded isUnverified: true on both branches, so the identity block's data-trust="registered" path was unreachable and every client — including one that had proven control of its own domain — got the same "self-reported" line. spec-core.md:675 requires both limbs: render a positive signal distinctly when one exists, and treat a client with none as unverified. Only the second was implemented, which made the first unfalsifiable. The trust status now varies, and the copy names what was actually proven: Verified domain: chatgpt.com — this app controls that domain. Your server hasn't checked anything else about it. Not "Verified app". Publishing a valid metadata document at an https client_id proves who controls the domain; it proves nothing about whether the application is honest, competent, or safe. "Verified app" is the more flattering phrasing and the more dangerous one, because an owner would act on it. The second sentence exists to stop the badge from being read as an endorsement. An operator override renders as "Registered with your server by you." — attributed, because that is a decision the operator made rather than something the protocol established. Two render tests change. The H1 test asserted the old always-unverified string for a CIMD client; that assertion is now factually wrong for the fixture it uses, so it asserts the domain-verified line and additionally pins that "Verified app" never appears. A new test covers the other side: a client that proved no domain still renders the unverified line, a two-letter monogram, and no <img> — spec-core.md:676 forbids a remote logo for a client with no positive trust signal, and that is the case the monogram exists for. tsc --noEmit clean; 164 unit tests pass. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 5811e2d1b1c77e29872773907f0974fa313ce2d6)
Renders the controls whose plumbing the previous commits landed, so the picker stops describing an unbuilt feature and starts offering it. Per-stream scope sits behind a disclosure that is closed by default. The common path — check a data type, get all of it — takes the same clicks it always did, and the summary line states the resolved scope so the closed state is never ambiguous. Rendering a dozen field checkboxes inline across 150-odd streams would produce a page nobody can read. A control appears only where the declaration supports it: selection.fields for the field list, consent_time_field for the dates. A stream that supports neither renders nothing, because silence is the correct rendering of an inapplicable control and offering one anyway would 400 at issuance after the owner had already chosen. Schema-required fields render checked and disabled rather than hidden (spec-core.md:764 makes them the consent floor). Only optional fields carry a form value, so the floor cannot be unchecked even by a crafted submission. Showing them greyed means the owner sees what they cannot exclude instead of unchecking something and being silently overruled. Grant expiry replaces the footnote with a radio group defaulting to 90 days. It never restates the access mode — that was the footnote's defect (spec-core.md:889 forbids conflating grant validity with access pattern). Three sibling render tests changed, all because they asserted behavior this slice was asked to replace: The fields/time-range copy asserted "Everything in each data type you check, with no date limit." The comment above that string documented it as an unbuilt feature phrased as a constraint. It now states the default and points at the control. The expiry test asserted the static note; it now asserts the control, and keeps its real invariant — that expiry renders after, and inside the same protocol-enforced block as, the access-mode fieldset. The retention test banned the literal string "90 days" anywhere on the page as a proxy for "no fabricated retention window". That proxy broke once expiry became a real control whose bounded default is 90 days. The two are different facts: retention is a promise the recipient makes about data it already holds and this server cannot enforce (spec-core.md:951); grant expiry is when this server stops honoring the grant, which it does enforce. The assertion now targets the fabrication rather than the number — the retention block must contain no window, and every "90 days" on the page must occur inside the expiry control. tsc --noEmit clean; 171 unit tests pass. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit d066e66f4b38d8fdf7c1b988f524ee3424638db6)
spec-core.md:676 permits a client-supplied logo only when the client is verified, or the asset has been proxied, cached, and approved under local policy. client-trust-registry.ts made the approval decision; this is the proxy-and-cache half that makes acting on it safe. Emitting the client's URL in an <img> would make the OWNER's browser fetch it, handing a third party the owner's IP, user agent, and the fact that they are at that moment looking at a consent screen for this client. It would also let the image be swapped after approval, so what the owner reviewed and what a later screenshot shows need not match. Fetching server-side and re-serving frozen bytes removes all three. The fetch treats logo_uri as the attacker-controlled input it is, reusing the CIMD document fetch's protections: DNS restricted to global unicast addresses, the connection pinned to those exact addresses so a second resolution cannot race the check, no redirects, a 5s timeout, and a 256 KB cap enforced against bytes actually received rather than a declared Content-Length. SVG is excluded despite being an image type. It is an executable document that can carry script and external references, and re-serving one from this server's origin would place client-controlled content inside the consent page's own origin — the last place it should run. Every failure returns null rather than throwing. A missing logo has an obvious fallback in the monogram, and a consent screen must never fail to render because a third party's CDN is down. The cache expires daily so an operator withdrawing an approval is not defeated by it, and can be invalidated outright. tsc --noEmit clean; 12 new unit tests. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit b355f4cdf119313dbd2b19fb0a658b252e32375a)
…y declares The first version of describeTimeField handled a handful of guessed shapes and fell back to "dated" for everything else. Running it over the shipped manifests showed why that was not good enough: of 41 distinct consent_time_field values, only a third resolved to a specific verb, so real streams rendered as "whenever they were dated" where "whenever they were ordered" or "watched" was available. The stem list now comes from the fleet rather than from imagination: created_at, order_date, timestamp, created_utc, create_time, start_date, observed_on, sent_at, and a long tail of variants. Two structural facts drove the parsing change — affixes appear on both sides (date_received as well as received_at), and qualifiers appear in front (message_received_at, rtime_last_played) — so the field is reduced to its last meaningful segment rather than pattern-matched whole. 33 of 41 now resolve to a specific verb. The remaining 8 are genuinely generic — date, day, month, as_of, friend_since, date_first, and two *_message_* variants — and "dated" is the honest rendering for them. An unrecognized field still never prints raw: "rtime_last_played" is not something to show someone deciding whether to share their data, and a slightly generic sentence beats a leaked identifier. A test asserts that property across the fleet rather than only for the enumerated cases. Also updates the hosted-mcp-oauth integration test that asserted the old always-unverified copy. Its comment justified the assertion with "no client can ever escape it today (there is no trust registry)" — which is precisely what this slice changed. The client in that test reaches the picker through CIMD resolution, so it has proven domain control, and the test now asserts the verified-domain line plus the guard that the claim never widens to "Verified app". Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit a07f363eb5e2363a1e141334a46b0bcd9f9c68d7)
…stem The owner viewed the deployed pre-revision mock and asked for a rethink, not a patch: duration living at the bottom of a long page was a symptom that the whole composition failed, and the mock's colors didn't match pdpp.vivid.fish because it defined its own bespoke stylesheet instead of consuming the console's actual Ink Carbon tokens and component classes. Rebuilds `ref-design-consent-mock.ts` on the original commit's mock-data model, composing hosted-ui.ts's existing shared classes (.hosted-ui-surface, .hosted-ui-client-identity, .hosted-ui-option-source, .hosted-ui-button, etc.) instead of a parallel .design-* vocabulary. The only new CSS is structural — a sticky two-column decision rail and its mobile collapse to a fixed bottom bar — and every value in it is a var(--token) reference into the same shared sheet. Fixes two real bugs found along the way, not just style drift: renderPdppMark in hosted-ui.ts hardcoded mark colors that had drifted from the real pdpp-mark.svg/pdpp-mark-dark.svg files (now theme-aware and byte-matched to the source SVGs), and the footer's "Secured by PDPP" had no link (now points to pdpp.dev). Also fixes a sticky-bar/Retention-text overlap bug: Allow/ Cancel now live in the rail on desktop instead of floating over arbitrary body content, and the mobile fixed bar's matching body padding keeps nothing rendering underneath it. Distinguishes the two independent date axes the spec actually has — grant validity (Grant.expires_at, one value, lives only in the rail) and per-stream data time range (StreamGrant.time_constraint, lives only in each stream's field-narrowing disclosure) — which the original mock's single "duration" concept conflated. Adds a third trust tier: unverified (monogram) and verified (operator allowlist) existed before; domain-verified (automatic CIMD metadata check, no client participation) is new. Carries forward the working search filter, real ISO-8601 expiry input, and platform-icon rendering from the round-1 revision, re-skinned onto the real component vocabulary. Drops three PNGs (heb, oura, wholefoods) from the icon-serving route's file list — this repo's .gitignore excludes *.png repo-wide, so shipping a readFileSync against them would have crashed server boot on any fresh checkout; none of the 27 mock sources need those three. 31 new adapter-level tests for the mock and icon routes, 3 new tests for the mark/footer fix; full run across both files plus the existing hosted-ui suites is 53/53. Screenshots and the per-feedback-item writeup are at local/CONSENT-DESIGN-REVISE-0902.md in the shared repo. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit afa68a2a37940be921d2cb51c090501129099e76)
.consent-rail-mobile-summary — the rail's one-line echo of the grant expiry for the mobile sticky bottom bar — only got display:none inside the mobile media query and the ?width=mobile force-mobile class. On a plain desktop viewport nothing hid it, so it rendered inline right above the full grant-validity block, which shows the same date again under the "No end date" checkbox. Add it to the same base-case display:none rule .consent-mobile-expiry already uses, so the desktop default is single-source like the mobile summary line is meant to be. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 4dbe7fa9f6c50ae5466b620bc297434fa3e2f96a)
Two rounds of this design preview lived in reference-implementation/server, composing either a bespoke stylesheet or hosted-ui.ts's hand-copied token sheet. The owner correctly rejected both: hand-mirroring the console's React components as server-rendered HTML strings is a second copy, and it will drift from the real components exactly the way hosted-ui.ts already had (drifted PDPP mark colors, a missing pdpp.dev link — both traced and fixed in the prior round, but the underlying copy-not-import structure guarantees the next drift). Adds /design-consent-preview inside apps/console, composed directly from the real @pdpp/brand-react components (Sheet primitives, HumanSurface, IcButton, IcInput, ConnectorIcon) and the real PdppLogo — importing the actual modules the console's /sources and /explore pages render, not a transcription of their output. The only new CSS is consent-preview.module.css, and it is layout-only (the sticky decision rail, the two-column grid, the mobile bottom-bar collapse) — every color/radius/spacing value in it is a var(--token) reference into the same sheet those components already use. Gated by the console's own DAL check (verifyDashboardSession), the same authoritative owner-session gate every page under (console) uses — not a new gate. Lives outside the (console) route group on purpose, since a consent screen is a standalone human surface, not a page inside the owner's sidebar shell; it still inherits the root layout's fonts and globals.css exactly like the existing (deliberately ungated) /design-system route does, so typefaces and the token cascade match the console pages exactly. Verified against a live, authenticated console instance, not by inspection: a computed-style probe of an unmodified .pdpp-btn and .pdpp-input inserted into /grants, /sources, /design-consent-preview, and /explore's live DOM shows byte-identical font-family/size/weight/radius/border/background on every page. Two real bugs surfaced only by that live check, not by review: a text-like input (the search box, the grant-expiry date input) had no pdpp-input class at all and rendered fully unstyled; and the mobile sticky bar showed "Access ends <date>" twice because .railEnds was a sibling of .grantExpiry rather than nested inside it, so hiding .grantExpiry on mobile didn't hide it — the same defect class the reference-server round fixed at 4dbe7fa9f. Both are fixed and covered by new regression tests. Preserves all 14 owner feedback items and the 27-source mock scenario (ported from reference-implementation/server/routes/ref-design-consent-mock.ts at 4dbe7fa9f) so this rebuild stays comparable to the owner's prior feedback: arbitrary ISO-8601 grant expiry with quick-fill chips, three trust tiers (unverified/domain-verified/verified), keyboard-navigable search, inline field disclosure, the sticky decision rail with duration folded in, real platform icons with a neutral-placeholder fallback (never initials where a real logo exists), and the two distinct date axes (grant validity in the rail vs per-stream data range in the body, plus apply-to-all-selected). The old reference-server route (ref-design-consent-mock.ts) is deliberately untouched in this commit, per the owner's instruction — it retires in a separate follow-up commit once this route is proven live, so the history shows "add the real thing" and "remove the old thing" as distinct steps. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit c0abe6c43abcc5dc476723e39ee843da8774340d)
The consent design preview now lives at /design-consent-preview inside apps/console (c0abe6c43), built from the console's real @pdpp/brand-react components instead of a hand-mirrored copy. It is live on production and owner-proven, so the reference-server version this replaces has no reason to keep existing — the owner's standing rule is no legacy left behind. Removes GET /_ref/design/consent and its icon-serving sibling wholesale: server/routes/ref-design-consent-mock.ts, server/routes/ref-design-consent- icons.ts, their mounts and imports in server/index.ts, the bundled connector SVG/PNG assets under server/assets/source-icons/ (superseded by the console's own icon handling), and the route-level test file. Confirmed nothing else in the reference server or the console references any of it, the server still boots clean, and GET /_ref/design/consent now 404s instead of serving the retired route. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 7ae7caed555e0545218ad25db4c301fc16df9e18)
Retiring the reference-server route (7ae7caed5) deleted reference-implementation/server/assets/source-icons/, but design-consent-preview/icons.ts still read connector icon SVGs from that exact path — the retirement commit's own test suite caught this (two failures: the icon-existence check and the now-stale "old route is untouched" assertion, both flagged by re-running tests before declaring the retirement done). Fails safe either way — sourceIcon()/clientLogoSvg() catch a missing file and fall back to ConnectorIcon's Monogram — but every icon on the live page would have silently degraded to a placeholder instead of showing the real connector logos the owner asked for. Moves the 15 SVGs into design-consent-preview/icons/ (recovered from git history at 4dbe7fa9f) so this page owns the assets it depends on, instead of reaching into a sibling app's directory. Updates icons.ts's path and the test suite: the icon-existence check now points at the new local directory, and "old route untouched" becomes "old route no longer exists" now that 7ae7caed5 actually removed it. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 101d875fe97b210989949065e001618253945eed)
…would be The owner quoted the default-variant trust line verbatim and flagged two problems with it. Wrong default: a real ChatGPT publishes its client metadata document at chatgpt.com, so automatic domain verification would actually succeed for it. Defaulting the mock to the unverified tier showed the owner an example that doesn't match what the client it's modeled on would really look like. parseTrust() now defaults to "domain"; unverified stays fully reachable at ?trust=unverified, it just isn't the first thing shown. Inaccurate and alarming wording: "isn't registered with your server" is false for a dynamically registered client — DCR doesn't require registration to fail — and "self-reported" alone doesn't say what was or wasn't checked, so it reads as a warning about an app that did nothing wrong. Each tier is now one sentence that names the actual check: unverified says nothing has been checked; domain-verified says the identity document was fetched from the client's own domain, so domain control is what was proven; verified attributes the check to an operator, not an automatic process. Added an inline "What was checked" <details> disclosure (no modal) with one more sentence of specificity per tier, matching the factual, non-alarming register of Plaid's and GitHub's authorize pages. Verified against a live, authenticated instance: booted the reference server and the console (memory-capped per the documented NODE_OPTIONS bypass), signed in, and screenshotted both the new default and ?trust=unverified to confirm the copy and default actually render as intended, not just as source text. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> (cherry picked from commit 32f266c47c82fd86c2ecab1bd255ad6cec1b294e)
Porting the presentation slice (which merged purpose and retention into one server-authored terms block) on top of the approval-artifact slice (which moved retention onto the review panel the decision digest binds) left the retention sentence on the page twice, and the render suite's own 'no owner-facing sentence appears twice' guard caught it. Keep the later decision: retention is one of the exact terms the owner binds to (spec-core.md:873-877), so it lives on the artifact and the terms block carries purpose alone. The terms-block test now asserts the block does not restate it, instead of asserting the row it used to have. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The console test script still preloaded packages/operator-ui/css-stub-register.ts, a path that existed in pdpp but not here: operator-ui moved under reference-implementation/vendor/ with the server. Every console test run failed at module resolution before it reached a test. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Cherry-picks pdpp 872d2b428 (branch waspflow/consent-design-revise-0902), the only design commit past 32f266c47 and the last state of the consent screen before it becomes a real page. All four blobs it touches were byte-identical to this repo's HEAD, so the patch applied without conflict. Eight owner-flagged items: client name repetition cut to the two places that need a proper noun; the Terms box restated as plain sentences on a neutral sheet instead of a copper tint that read as a warning; the duplicate selection count removed from the section heading; one human date format everywhere a date renders as text; sources collapsed behind <details> with only the pre-selected three open; an Endorse-pattern trust chip; mono account handles; .pdpp-eyebrow micro-caps labels. next.config.mjs sets devIndicators: false — Next's dev badge sits bottom-left and covered the mobile Allow button. Per Next's docs the badge never ships in a production build regardless of the flag, so this is dev-experience only. Lands the design as-is so the port to the real /consent route is a behavior-preserving move against a baseline whose tests already pass: 34/34 in design-consent-preview.test.ts. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The hosted-MCP picker branch resolved every fact the owner needs — client identity and trust basis, purpose, retention, the eligible sources and their streams — and then spent them rendering HTML. Nothing else could read them. Moving the consent screen into the console needs those same facts as data. buildHostedMcpConsentChallengeModel returns them, resolved by the SAME helpers the HTML picker uses, so the two surfaces cannot disagree about what the owner is being asked. It carries facts only: no copy the console can write for itself, no markup. Where only the server knows whether a statement is true — the registry purpose description, whether the client made a retention promise — the statement itself travels in the model. Three things it states more precisely than the HTML did: - Trust is the three-way basis resolveClientTrust already computes, not a boolean. "domain" means a client identity document was fetched from the client's own origin; "verified" means an operator registered it. Untrusted is the DEFAULT, so a client that proved nothing can never fall through into a badge claiming otherwise. - "domain" is null when the client proved no domain, rather than falling back to the app's own name — repeating "ChatGPT" on a line that reads as a domain dresses a self-asserted name as a checked one. "returnTo" is a separate field for where the browser actually goes. - Connector icons come from each row's manifest, the same value /sources passes to ConnectorIcon, so a connector the console has never heard of renders exactly as well as one it has. computeHostedMcpDecisionDigest moves to its own dependency-light module. The approval artifact digest binds what the APPROVING SURFACE displayed (AS-conformance #15), so now that the console is about to become an approving surface it has to compute the same bytes — and it cannot import a 3,600-line HTML renderer to do it. Re-exported from the old path, so every existing caller and test is unchanged. No behavior change: nothing calls the new builder yet. hosted-mcp-oauth stays at 72/72. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The authorize request now pauses at the picker branch instead of rendering it. Everything the owner is about to be asked — the client, the authorize params, the eligibility snapshot — is parked server-side under an opaque id, and only that id travels to the console, which renders the decision and posts it back. This is the Ory Hydra login-and-consent-app shape: the UI is a separate application the AS delegates to and never trusts. Holding the params server-side is the property that matters. The owner's browser cannot alter client_id, redirect_uri, or the PKCE challenge between the authorize request and the approval, because it never carries them. Accept and reject do NOT reimplement the protocol. captureRedirectResponse runs the existing approval and refusal handlers against a response whose redirect is captured rather than emitted, so every validation, the grant creation, and the audit trail are the same code the form POST runs — the only difference is whether the final redirect reaches the browser as a 302 or as a URL for the console to follow. A second implementation of the mint path is the bug this avoids. Three things the JSON surface needed that the HTML one did not: - Both mutating routes require CSRF, like the form POSTs they stand in for. Owner auth alone is not enough for a state-changing request reachable from a browser: without it, a page on another origin could drive an approval on the owner's ambient cookie. - renderHostedMcpPickerValidationPage content-negotiates. There are two approving surfaces now needing the same rejection DECISIONS delivered two ways; re-rendering picker HTML into a JSON response hands the console a page it cannot use. req.wantsJson is set only by these routes, never read from the wire, so the form path is byte-identical. - decision_digest is the console's, never recomputed here. A digest the server computed for itself would always match its own recomputation and bind nothing. The challenge is consumed before minting, so two concurrent accepts cannot both issue a grant for one authorize request. Storage is bounded: a 30-minute TTL and a 256-entry cap swept on write, so an unauthenticated flood of authorize requests cannot grow it without limit. It is still process-local — see CONSENT-REAL-FLOW-REPORT.md for what that costs. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…s the design /consent stops proxying the server-rendered picker and becomes a console page that loads the AS's consent challenge, renders it, and posts the owner's decision back. The design preview is deleted in the same change: there is no mock to drift from, because the page an owner uses IS the design. The component tree is the round-4 preview, moved under components/consent-screen and driven by the challenge model instead of mock module-globals. It still renders the real @pdpp/brand-react modules — ConnectorIcon, Endorse, IcButton, IcInput — so it cannot drift from /sources and /explore by construction, and the CSS is still structure only, every value a var(--token). Four things changed in the move, all because real data is not mock data: - Connector icons come from each source's manifest declaration, passed straight to ConnectorIcon, which falls back to its Monogram. The preview's page-local icons/ directory and its connector-id -> file map are gone. No connector in this repo declares an icon yet, so today every source renders the Monogram — that is the documented common case, not a degraded one. - Initial selection comes from the model's own `selected` flag rather than a hardcoded three. The server pre-selects nothing, so consent is an affirmative act; whether that stays true is now a server policy the console reports rather than a default it invents. - Grant-expiry chips are built from the server's bounded option set, so the page cannot offer a duration the accept route would reject. - Per-field narrowing is stated, not offered. The manifest declares the field list but this flow's accept request does not yet carry a narrowed one; showing checkboxes the submission drops would be a lie about what the grant covers. The approval artifact digest is computed in the Server Action, using the reference implementation's own function rather than a second implementation. That is what makes it binding: it commits to what THIS surface displayed, and the AS independently recomputes it. The rail carries the console's own ThemeToggle, the same component the sidebar renders against the same persisted next-themes choice, so a theme chosen anywhere in the console is honored here on load. Owner gating is the real DAL check, on the page AND independently inside both Server Actions — an action is reachable on its own and does not inherit the page's gate. An unauthenticated visitor returns to the SAME challenge after login, not the dashboard, or the client's authorize request is stranded. 23 console tests, including one that pins the console's model type to the AS's field-for-field: the two are joined by an HTTP boundary, so a rename would otherwise read as undefined here with no type error anywhere. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…t used to render The picker branch answers with a challenge now, so 28 tests that fetched GET /oauth/authorize and matched its markup were asserting a page that no longer exists. Each one's GUARANTEE is kept and re-aimed at the surface that carries it; none was weakened to pass. Seven new tests drive the challenge API end to end: the authorize redirect, the model's contents, accept-and-mint, single-use (a second accept 404s and mints nothing), reject, a tampered source_id, and a stale review_digest. Most of the rewrite was three shared helpers, which fixed 13 tests at once. Where a test only fetched the HTML as SETUP for a mint, token, or revocation assertion, the setup moved to the challenge API and every downstream assertion is untouched. Where it drove POST /oauth/authorize/mcp-package directly — that route still exists and still works — only the incidental HTML fetch was replaced, which is the smallest change that preserves the guarantee. Some guarantees genuinely moved to the console and cannot be asserted here. Each is marked with a // GAP: comment naming exactly what is no longer covered rather than quietly dropped: the authorship CSS classes, the picker's own selection chrome, the trust SENTENCE (the tier is still asserted, which is what the wording must not overstate), and the purpose copy (the registry code and description are still asserted). Two tests are deleted, both pure page chrome with no surviving surface — the hosted-ui option styles and the instance-name header. Their contracts are covered by test/hosted-ui.test.ts and test/hosted-ui-theme-and-mark.test.ts against the shared render helpers; each deletion leaves a comment saying so. One gap is real and worth naming: the model carries no source-kind field, so "surfaces each row's resolved source kind as a protocol fact" now asserts the absence structurally instead of the badge copy. 72 before, 77 after, all passing. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The consent screen offers a real date picker — owner feedback round 2 asked for "arbitrary ISO-8601 grant expiry" and the design has shipped that control since. resolveGrantExpiry only ever understood three fixed option ids, so every date an owner typed came back "Choose how long this access should last." Found by driving the live flow: pick a date, press Allow, get told to choose a duration you just chose. The quick-fill chips are a shortcut for common windows, not the only windows allowed. A bare YYYY-MM-DD now resolves ahead of the fixed vocabulary, and the vocabulary is untouched behind it, so the form POST path is byte-identical. Two bounds, both because the failure is silent otherwise: - Resolved to the END of the chosen day. An owner who picks December 2 means access lasts through December 2; midnight would quietly cut the last day off the grant. - Capped at five years. A mis-typed year — 2226 for 2026 — would otherwise become a two-century grant that looks, on the confirmation, exactly like a correct one. An owner who genuinely wants unbounded access has "No end date", which says so out loud instead of hiding behind a far-future date. A refused date is answered as a date problem, never by falling through to the keyword error: telling someone who picked a real date to "choose how long this access should last" does not describe what they did wrong. 6 new tests, 14 total in the file. hosted-mcp-oauth stays at 77/77. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
…hind Found by looking at the four darshana captures (light/dark x desktop/mobile) against the running page, which is the point of running them. Two real defects on a phone: - The last source row sat under the fixed decision bar with no way to scroll it into view. The 6.5rem clearance predated the empty-selection hint the bar now carries; the bar measures ~120px, so the reserve is 11rem. Verified on a real 390px viewport scrolled to the bottom: the last row clears the bar by 128px, overlap 0. - Hiding .railFooter but not its row left the theme toggle stranded as an unlabelled icon beneath the decision, where it read as a control belonging to the decision. The whole attribution row is hidden on mobile now, toggle included. The rest is deletion. `forceMobile` and its duplicated .forceMobile block existed only for the preview's ?mobile=1 param, which no longer exists — 41 lines of CSS that shadowed the media query it copied. The .banner, .fields, and .field rules styled the preview's warning banner and its mock per-field checkboxes, neither of which the real page renders. Checked and NOT changed: the disabled Allow button. It looked full-strength in the dark capture, but sampling the pixels shows rgb(109,72,57) against the enabled copper — the shared .pdpp-btn:disabled opacity is doing its job in both themes, and overriding a vendored component to fix a misread would have been the wrong repair. 23 console tests still pass; tsc clean. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
… grant
The date controls held state and the submission dropped it. An owner who
narrowed "top artists" to 2025 pressed Allow and got a grant covering
every year — the screen appeared to offer a narrowing the grant never
recorded. Reported as gap 6 of the prior report; this closes it.
Red run first, both tests, before any fix:
✖ accepting a consent challenge carries the owner's per-stream date
range onto the issued grant
✔ accepting with no date range leaves the issued grant unbounded in time
ℹ tests 79 pass 78 fail 1
AssertionError: the issued grant MUST record the range the owner
chose, not drop it
actual: null expected: true
The control case passing is what makes the red run meaningful: the
harness reads the persisted grant correctly, and only the ranged path
fails.
The fix is a translation, not a new path. buildChallengeApprovalBody now
emits the same flat narrow_since_/narrow_until_ keys the form POST has
always used, via the existing scopeSinceInputName helpers, so the
console's dates go through parseSubmittedStreamScopes and
resolveStreamScopeSelection — validated against the manifest's own
consent_time_field, rejected when the stream declares no time axis —
rather than a parallel route that could diverge from the form's. Nothing
in the grant engine changed.
Three deliberate boundaries:
- A range keyed to a stream the owner unchecked is dropped, matching
resolveSubmittedStreamScopes's own posture for the form: an unchecked
stream grants nothing and its leftover date is noise.
- An empty range is omitted rather than sent, so the server is never
asked to record "no bound" as though it were a choice.
- The range is NOT folded into decision_digest. That digest binds the
client, the access mode, and which streams were approved — terms the
AS re-resolves independently. The range instead goes through the
manifest-checked narrowing that can legitimately normalize it, and
`until` is exclusive (spec-core.md:758-759), so a digest over the raw
value would bind a number the AS is entitled to change.
Green run:
✔ accepting a consent challenge carries the owner's per-stream date
range onto the issued grant
✔ accepting with no date range leaves the issued grant unbounded in time
ℹ tests 79 pass 79 fail 0
The new tests read the persisted grant through getGrantPackageAccess —
the record the /mcp read path consults — not an owner-facing summary, so
they assert what enforcement will actually honor. 28 console tests, tsc
clean on both sides.
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
… one Both found by inspecting the four range captures (light/dark x desktop/mobile) with a date range actually set, which is the surface the previous captures could not show — darshana shoots a static page and these controls only exist once the owner checks a stream. At 390px "tim@gmail.com" ran into "1 of 3 data types" on the Spotify row. The handle is identifying-at-a-glance rather than read-in-full, so it truncates and the count holds its column. That needed min-width:0 on the label too, which meant moving its layout out of an inline style into CSS — a flex child cannot shrink below its content unless every ancestor allows it. The mobile bar said "1 streams". Now it says "1 stream". Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Record that the original hosted image build succeeds after reclaiming disk space, with build and runtime proof. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Signed-off-by: tnunamak <tnunamak@gmail.com> Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Store consent challenges in the shared database so restart and replica changes retain the owner-bound request through its TTL. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Let the checked-details text use the identity block measure at desktop and mobile widths. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Guard direct new-tab client policy and terms links, including omission when metadata carries neither URI. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Move trust explanations to accessible chip hints and show only the client-published policy and terms line. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Keep the client identity, trust hint, and published links together above the consent choices. Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Keep account labels visible on narrow screens, add field bulk actions, and show described field names with raw-name hints. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Link trust triggers to stable tooltip ids and normalize repeated account prefixes across punctuation and case. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Show a muted explanation for streams without a declared time field. Assisted-by: AI Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI Signed-off-by: tnunamak <tnunamak@gmail.com>
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com> Assisted-by: AI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Before this branch, an owner — the person whose personal data is being shared, who approves or refuses each request for it — could use the consent screen to say "share only my Spotify saved tracks from 2025, and only these eight fields" and still end up with a grant covering every stream, every field, and every year. A grant is the server-side record of what an app was permitted to read, and it is what the system consults on every later data request; a stream is one named table of data inside a connected account (Spotify exposes
saved_tracksandtop_artistsas separate streams), and a source is the connected account itself, holding one or more streams. The screen rendered date pickers and field lists, but the submission dropped them, so the app on the other side received far more personal data than the owner agreed to hand over — and the audit record showed the wide grant as if the owner had chosen it. That is the concrete risk this branch closes: a consent surface that appears to narrow access without actually narrowing it.What the consent surface is, and where it now lives
DataConnect — this repository, a desktop and web client for the Personal Data Portability Protocol (PDPP, the open specification this project implements; the requirements quoted below come from its core specification document,
spec-core.md, which lives in a separate repository,PDP-Connect/pdpp, alongside this one) — bundles two services. The authorization server (AS) is the component that third-party applications talk to over OAuth 2.0, the standard flow in which an app sends a user to a server they already trust, that server asks the user to approve, and the app receives a token rather than the user's password: the AS receives the/oauth/authorizerequest, validates it, mints access tokens, and writes the grant record described above. In this repository the AS's code lives underreference-implementation/, so "the AS", "the reference implementation", and "the reference server" all name the same service in this description; where the distinction matters it is called out. The console is the separate owner-facing web application, built with the React framework Next.js and living underapps/console/, where the owner manages their connected sources and their grants. Until now the AS also rendered the consent screen itself, as server-generated HTML — a second, weaker copy of the console's design system, maintained separately from the app the owner actually uses.This branch moves the consent screen into the console as a real page (
/consent) and leaves the protocol on the AS. The mechanism is a challenge handoff — the same shape used by Ory Hydra, a widely deployed open-source OAuth server that likewise keeps the protocol to itself and delegates the human-facing login and consent screens to a separate application it does not trust. When an authorize request arrives, the AS parks the request parameters server-side under a challenge — an opaque, single-use, randomly generated id (cc_<random>) that stands in for the parked request and means nothing on its own — and redirects the browser to/consent?challenge=cc_…. The console fetches a JSON render model for that challenge — the client's identity and trust status, the owner's sources and streams, the declared fields, the purpose and retention sentences, the expiry options — displays it, and posts the owner's decision back to the AS.The reason for the indirection is that the browser never carries
client_id,redirect_uri, or the PKCE code challenge (the cryptographic value that binds this authorization to the client's later token request) between the two hops. Those live only in the AS's challenge store, so a browser that tampers with the URL cannot redirect an approval to a different app. On the accept path, ids the render model did not publish resolve to nothing and are dropped, which means a forgedsource_idin the submission can only narrow the resulting grant, never widen it.The challenge itself is stored durably in the same database the rest of the server uses, keyed to the signed-in owner and marked consumed the moment a decision is recorded. An earlier revision of this work kept it in an in-memory map, which meant a restart lost pending decisions and a deployment running more than one replica could route the approval to a process that had never heard of the challenge. Persisting it removes both problems and makes single-use enforcement a database fact rather than a per-process one.
Critically, the approval and refusal paths are not a second implementation. Both run the AS's existing approval handler through a wrapper that captures the redirect rather than emitting it, so every validation, the grant creation, and the audit trail are the same code the old HTML form POST ran. The old route the HTML form posted to,
POST /oauth/authorize/mcp-package, still exists and still works. ("MCP" in that path and in the test filenames below is the Model Context Protocol, the interface through which an AI assistant reads granted data from this server; it names the surface these grants are issued for, not a second consent mechanism.)Field narrowing, date ranges, and expiry
With the page in place, three owner controls are now wired end to end rather than displayed and discarded.
Fields. Selecting a stream exposes the fields declared for it by the connector manifest — a JSON declaration shipped with each connector (the adapter that pulls data out of a given service) that names the streams it offers, the fields in each, which of those fields are required, and which timestamp field a date filter may use. Fields the manifest marks required are checked and disabled — the owner cannot uncheck something the stream cannot function without. Optional fields start checked, and unchecking them updates a live summary such as
8 of 10 fields— the totals differ per stream, so a different stream shows a different denominator. The console sends the selected set, and the AS translates it into the same field inputs the old form used, so the existing validation re-reads the manifest, restores required fields, and persists the resolved list on the grant.Date ranges. A stream whose manifest declares a time field (for example Spotify's saved tracks declares
saved_at, the moment the track was saved) offers a since/until range. The submission goes through the same manifest-checked narrowing the form used, which rejects a range on a stream that has no such timestamp to filter on, and normalizes the bound:sinceis inclusive anduntilis exclusive per the PDPP specification, so "through 2025-12-31" is recorded as ending at2026-01-01T00:00:00.000Z. Recording it as midnight on the 31st would silently cut the owner's last chosen day out of the grant.Expiry. Grant expiry is an owner choice with a bounded 90-day default, and the screen has offered a free-form date picker since an earlier round of owner feedback. The server-side resolver understood only the three fixed option ids (
90d,1y,never), so every date an owner typed came back as "Choose how long this access should last." — telling them to choose a duration they had just chosen. A bareYYYY-MM-DDnow resolves ahead of that fixed vocabulary. It is bounded to the end of the chosen day, and capped at five years so that a mistyped2226for2026cannot become a two-century grant that looks, on the confirmation screen, exactly like a correct one.Field selections and date ranges are deliberately outside
decision_digest, the hash the console computes over what it displayed so the AS can independently recompute it and refuse an approval that does not match the terms shown. The digest binds the client, the access mode (whether the app gets a one-time read or ongoing access until the owner revokes it), and which streams were approved — terms the AS re-resolves identically. Narrowing choices instead pass through manifest validation that may legitimately normalize them (the exclusive-untilrule above), so hashing the raw submitted value would bind a number the AS is entitled to change. The digest is computed by the console, never by the server: a server-minted digest would only ever be compared against the server's own recomputation and would bind nothing.Client identity and trust
The PDPP specification requires that a consent surface keep four things visually distinct — terms the protocol actually enforces, structured policy declarations such as the stated purpose and retention period, data descriptions authored by the connector manifest, and claims the client asserts about itself — and never flatten them into one undifferentiated block. It also requires that a client with no positive trust signal be treated as unverified, and that
logo_uri(an image URL the requesting app supplies about itself) never be fetched and rendered directly, because doing so would let any unvetted app make the server issue a request to a URL of its choosing and paint arbitrary imagery onto the screen where the owner decides.The screen honors this. Trust has three states and untrusted is the default, so a client that proved nothing cannot fall through into a badge claiming otherwise:
verifiedmeans an operator of this deployment registered the app by hand,domainmeans the AS fetched a client identity metadata document — a JSON file of the app's own display details — from the HTTPS origin named in the app's own client id, and confirmed the document claims that same identity back, and anything else is unverified, shown with a monogram — the app's initials drawn in a plain tile — generated from its display name instead of any image it supplied. A domain-verified client's logo is fetched by the AS itself, through a cache that already carries two guards: it refuses URLs pointing at internal network addresses, so a hostile app cannot use the server as a proxy to probe machines behind the firewall (a server-side request forgery attack), and it accepts only image content types, so the fetched bytes cannot be script or HTML. The cached image is then served from the console's own origin at/oauth/consent-client-logos/:keywith theX-Content-Type-Options: nosniffheader, which stops a browser from second-guessing that content type and executing the bytes as something else. The browser never sees the remote URL the app declared. Privacy-policy and terms-of-service links from the resolved metadata render as a secondary row, with an explicit fallback line when the client published neither.What was verified, and how
The primary evidence is a journey oracle — a Playwright browser-automation script that drives the whole owner journey through the real rendered page against a live deployment, reads the owner password only from an environment variable, and never writes that password, a PKCE verifier, an authorization code, or a bearer token into the JSON receipt it emits. It is not a unit test suite; it clicks the actual screen and then checks the server-side consequences. The oracle script is not part of this diff. It lives as
scripts/consent-journey-oracle.tson a separate branch,consent/journey-oracle-0903, cut from a commit partway along this one and proposed separately; a reviewer looking for it in the changed files here will not find it, and the evidence below therefore cannot be reproduced from this branch alone.Run at this branch's head, the oracle passes all 20 steps against the live production deployment at
https://pdpp.vivid.fish(2026-09-03T21:25Z). The steps that bear on the risk above, and what each one actually establishes:field-narrowing— unchecking two optional fields moved the on-screen summary to11 of 13 fields, proving the controls are bound to real state rather than decorative; thegrant-recordstep below is what proves the narrowing survives to the server.field-select-none— pressing "select none" left 2 fields still checked, proving the manifest's required fields cannot be unchecked away by a bulk action, andfield-select-allrestored all 13.per-stream-date-range— setting a since/until range changed that stream's own visible summary line, proving the date controls hold per-stream state; a separate server-side test (below) is what proves the range reaches the stored grant.grant-record— the grant package (the stored record of one approval, which may cover several streams from one source) was read back from the server after approval and contains the source the owner selected and the expiry they chose — that is, the owner's decision survived the round trip into durable storage, which is exactly what failed before.resource-server— the newly issued token was used against the resource server, the component that serves the actual personal data and enforces the grant on each read. Data the grant covered came back; a control the grant did not cover (sharing Amazon order items) was refused. This proves enforcement reads the narrowed grant rather than waving through anything with a valid token.revoke— revoking the grant in the console made the already-issued token stop working, proving revocation takes effect on existing tokens and not merely on future ones.cancel— declining the request returnederror=access_deniedto the app, proving a refusal is reported to the client rather than leaving it hanging.A separate
claims-auditstep is a guard against exactly the defect this branch fixes. Its "claims" are not the client's self-asserted claims from the specification section above; here the word means a promise the screen itself makes to the owner — a phrase likeAll 13 fields · Changethat advertises a capability. The step scans the rendered page for such phrases and fails the run when one has no journey step exercising it, which is precisely how a control that looks live but does nothing gets caught. At head all 8 such on-screen promises map to steps. This is the check that caught the field-narrowing gap in the first place — an earlier local run failed with "field Change control exposes fewer than two selectable fields" while the screen was already promisingAll N fields · Change.Underneath the oracle, the AS's own OAuth test suite (
reference-implementation/test/hosted-mcp-oauth.test.ts) grew from 72 passing tests when this work began to 80, and the console's consent tests to 38. Two of the added server tests were written to fail first, and each was paired with a control case that had to keep passing while the defect existed — that pairing is what proves the test harness was genuinely reading the stored grant rather than reading nothing at all: the case with a date range failed withactual: null, meaning the stored grant carried no time bound whatsoever, while the control case with no range passed. Both read the grant back throughgetGrantPackageAccess, the server function that answers "what does this grant permit", and the same one consulted on every incoming data request, so they assert what enforcement will actually honor rather than what an owner-facing summary page happens to display.The screen was also inspected visually in light and dark themes at desktop and 390px mobile widths, which is how three layout defects were found and fixed: the last source row sitting underneath the decision bar — the Allow/Cancel strip pinned to the bottom of the screen on mobile — with no way to scroll it clear, an account handle colliding with the stream count on a narrow row, and a theme toggle stranded as an unlabelled icon that read as part of the decision.
What is NOT verified
access_mode— the field carrying the one-time-versus-ongoing choice defined earlier — and defaults it tocontinuous, the ongoing arm; the screen submits that default without offering the choice. The old HTML picker had a radio button for it, so this is a capability the move to the console lost.CONSOLE-BRAND-ICONS-REPORT.md, plusDC-IMAGE-BUILD-REPORT.mdandDC-IMAGE-SUPERVISOR-REPORT.md, which record how the DataConnect container image was built and checked during this work. They are development notes, not documentation meant to ship, and a reviewer may reasonably ask for them to be deleted or moved before merge.Also in this branch
Connector brand icons — the small service logos beside each source row — are now served from the AS at
/connector-index.json, an endpoint that projects the icon declarations of the connector manifests bundled with this build, with a dark-theme variant chosen when the page is in dark mode and a monogram fallback when an index entry is missing or its image fails to render. Two of the logo images that arrive with the bundled connectors omit the XML namespace declaration an SVG needs to be treated as an image at all, which makes the browser report the image as zero pixels wide; the component treats that as a failure and falls back to the monogram rather than showing a broken image. All icons come from the console's own origin rather than being loaded from third-party URLs.Separately, the trust badge's explanation opens as a hover tooltip on desktop and as a tap-to-open panel on touch screens. That touch panel is built on a shared popover component, and the consent screen had been overriding its accessibility role to
tooltip. The component now setsrole="dialog"itself and no longer accepts an override, because a screen reader announces a dialog as something the user opened and can dismiss, whereas a tooltip is announced as a passive hint — and on a touch screen there is no hover, so the panel really is a dialog.Assisted-by: AI