Skip to content

feat(console): real consent flow with field narrowing, time limits, and grant binding - #57

Draft
tnunamak wants to merge 56 commits into
mainfrom
consent/real-console-flow-0902
Draft

feat(console): real consent flow with field narrowing, time limits, and grant binding#57
tnunamak wants to merge 56 commits into
mainfrom
consent/real-console-flow-0902

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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_tracks and top_artists as 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/authorize request, validates it, mints access tokens, and writes the grant record described above. In this repository the AS's code lives under reference-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 under apps/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 forged source_id in 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: since is inclusive and until is exclusive per the PDPP specification, so "through 2025-12-31" is recorded as ending at 2026-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 bare YYYY-MM-DD now resolves ahead of that fixed vocabulary. It is bounded to the end of the chosen day, and capped at five years so that a mistyped 2226 for 2026 cannot 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-until rule 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: verified means an operator of this deployment registered the app by hand, domain means 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/:key with the X-Content-Type-Options: nosniff header, 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.ts on 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 to 11 of 13 fields, proving the controls are bound to real state rather than decorative; the grant-record step 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, and field-select-all restored 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 returned error=access_denied to the app, proving a refusal is reported to the client rather than leaving it hanging.

A separate claims-audit step 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 like All 13 fields · Change that 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 promising All 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 with actual: null, meaning the stored grant carried no time bound whatsoever, while the control case with no range passed. Both read the grant back through getGrantPackageAccess, 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

  • CSRF protection is wired but untested. Both mutating routes call the cross-site-request-forgery guard, but the shared test server fixture runs with owner authentication disabled, and the guard short-circuits when authentication is off. No test proves it rejects a token-less POST. Closing this needs a password-enabled fixture.
  • The "tampered source_id" test proves less than its name suggests. It shows that an unresolvable id mints nothing (empty selection, 400). It does not cover the stronger case — one valid source plus one forged id, asserting the minted grant covers only the valid one.
  • The access mode is not owner-selectable here. The render model publishes the supported values of access_mode — the field carrying the one-time-versus-ongoing choice defined earlier — and defaults it to continuous, 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.
  • Source kind is not carried in the model. The old HTML picker showed a badge distinguishing a source reached through one of this project's connectors from one served natively by the provider's own API; the render model carries no such field, so the new screen cannot show that distinction.
  • Owner login and device pages remain server-rendered by the AS, as scoped. Only the consent screen moved.
  • Pre-existing console suite failures, not observed green anywhere. This work was done in a git worktree — a second checkout of the repository on disk, sharing one history, used so several changes could proceed in parallel. A group of console tests resolve shared packages by walking up to directories that sit beside a normal clone, and those neighbours are not beside a worktree, so those tests fail on path resolution before they test anything. The identical failures were measured at the branch point, so this branch neither introduces nor fixes them. The honest consequence is that nobody has seen the full console suite pass here; CI running against a normal clone is what would establish that, and it has not run yet on this branch.
  • Three working-note files are committed at the repository root and probably should not be: CONSOLE-BRAND-ICONS-REPORT.md, plus DC-IMAGE-BUILD-REPORT.md and DC-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 sets role="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

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant