Skip to content

Bring the Veiller miniapp back to parity with the Even app (XERK-237) - #140

Merged
xerhab merged 7 commits into
mainfrom
XERK-237
Aug 9, 2026
Merged

Bring the Veiller miniapp back to parity with the Even app (XERK-237)#140
xerhab merged 7 commits into
mainfrom
XERK-237

Conversation

@xerhab

@xerhab xerhab commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes XERK-237.

The ticket asked me to deploy the Veiller and Even apps against their emulators, step
through every feature, and verify Veiller looks and feels exactly like Even — noting that
Even works as it should and Veiller does not. It doesn't, and this brings it back.

How I compared them

Both in their real simulators, side by side, not by reading code:

  • Even — its phone page runs in a plain browser ("phone-page-only mode"), so I drove
    the real SessionPage / PhoneHistory / nav modules with fixtures matching Veiller's
    and screenshotted every state in Chromium.
  • Veiller — the Veiller monorepo's miniapp-simulator (from a fresh origin/main
    worktree) running the built bundle, via sim/walkthrough.ts (the lens) and
    sim/phone-tour.ts (the phone page in Chromium).

Pixel-diffed at 420x900. Before this change the login screens alone diverged across most
of the page; after it, dark is a byte-identical PNG and light differs by 10 pixels at
max channel delta 1 (antialiasing). The remaining diff on the session screen is the
tour's click leaving :hover on Stop and the live-dot's animation phase.

What had drifted

The phone page was a loose reinterpretation of the web UI's Lumen system, not a port.

  • An invented palette (--accent: #3fdc97 vs Even's #3FD9C9, its own --panel/
    --panel-2) and no light theme at all — the page was dark-only on a light phone.
  • No spacing, type, radius or shadow tokens.
  • Inter and Space Grotesk were named in the font stack but never shipped. The WebView
    loads from file:// with no network, so every heading, the wordmark and the card titles
    silently fell back to the system sans.
  • No wordmark dot; sim instead of Signed in as sim; Sign out instead of Log out;
    no .content card; Start/Stop crammed into the title row; text-only bottom tabs
    where Even has mic/clock icons and an active-tab indicator.
  • The song card rendered below the cue card — inverted.
  • Login errors appeared inline instead of on the toast.
  • History cues expanded inline instead of opening the detail popup.
  • .cue-inline-caret, .cue-inline-title and .session-translation-text had no CSS
    rules at all
    and rendered unstyled.
  • A Veiller-only "Web app" button Even has no equivalent of.

Three features were written off as unportable. Two of them weren't.

  • History audio. The api accepts the bearer token as ?token= on
    /conversations/{id}/audio — which is exactly how Even's own <audio src> and download
    link reach it. No cross-origin fetch is involved. Restored: the background mints the URL,
    the page plays it, and "Download audio.wav" goes through session.system.download.
  • Light/dark. The host reports its scheme (session.colorScheme); nothing read it.
    Now forwarded on a tenir:color-scheme channel and stamped on <html data-theme>, with
    prefers-color-scheme still the fallback.
  • Long cue bodies were clipped to the box's four rows, so anything past them was
    simply unreadable. Even hands the overflow to a host-scrolled container (XERK-133); the
    scene API is replace-the-frame with no scrollable container, so the body is now paged
    on swipes
    , with a in place of the host's scroll bar.

Differences that remain, and why

All three are host-forced and now documented in veiller/README.md rather than buried in
a source comment:

Even Here Why
A long cue body scrolls in a host-scrolled container The app pages it on swipes, with a marker session.display.render is replace-the-frame; there is no scrollable container
prefers-color-scheme picks the palette The host's scheme is forwarded and stamped on <html data-theme> The WebView's media query doesn't track the phone's setting
An idle double-tap exits the app Does nothing The miniapp host has no self-exit API

One removal to flag: the Veiller-only "Web app" header button (it opened the server's
web UI via system.openUrl). Even has no such control, so it read as a divergence — but
it was working functionality, so say the word and I'll put it back.

Security

Restoring history audio necessarily puts a token-bearing URL in the WebView — as it does
upstream. Pulling that thread led somewhere bigger than this ticket, so it is worth
reading before the rest of the diff.

In the miniapp:

  • tenir:download takes a conversation id, not a URL. The host's download sheet does
    not scheme-filter the way openUrl does, so a page-supplied URL was arbitrary
    network/file egress with the token attached. An allow-list looked equivalent and wasn't.
  • The login attempt no longer touches shared state. configureApi is a module-level
    singleton every handler reads, and pointing it at a candidate server before awaiting a
    call to that server left a window — as wide as that server cared to keep the request
    open — in which other handlers minted token-bearing URLs against it. request/login/
    me now take an explicit base; nothing shared moves until the server accepts.
  • The conversation id is path-encoded and must be non-empty.

In packages/client-core — shared by even/, web/ and mobile/:

Two of the same flaws were already there, and this is the part that matters most. Tenir is
self-hosted, so the server address is a user-typed fieldmobile/'s Setup and
Settings screens put it directly in front of this code, with no WebView to compromise
first. A user who mistypes an address, or is phished into one, was handing over their live
bearer token from the login form:

  • POST /auth/login carried the bearer token. A login proves nothing; it now sends only
    the credentials.
  • x-renewed-token was adopted before checking whether the request was authenticated, so
    the named server could hand back a replacement the device stored — leaving the real
    server 401ing after a login that failed.
  • login() is two round-trips and stored the new token between them, so a server that
    accepted /auth/login then rejected /auth/me destroyed the token the user already had.

No exported signature changed. I first proposed deferring these; the QA gate pushed back
that the shared clients are more exposed than the miniapp, and it was right.

Still deliberately deferred: threading an explicit baseUrl through client-core the
way the miniapp now does. None of web/, mobile/ or even/ mutate the base mid-attempt,
so there it is a hygiene refactor across three shipped front ends rather than a fix —
its own ticket.

The miniapp page has no HTML-injection sink (no innerHTML/eval/remote script) and makes
no external requests.

Verification

Adversarial QA (the repo's qa agent) ran six rounds against this branch, and earned
them. It caught two test escapes I'd introduced — a theme assertion that still passed with
the channel completely dead, and a download assertion that passed with a dead button —
then the allow-list weakness, then the in-flight window my "fix" for it left open, then a
regression my own res.ok guard caused in the XERK-168 sliding renewal (proved against
the real api, not a stub). It also talked me out of deferring the client-core fixes.
Every finding is fixed and re-checked by mutation.

  • bun test145 pass, on the CI-pinned Bun 1.2.22 as well as 1.3.14 (I ran the
    whole gate under the pinned version, including a clean --frozen-lockfile install, since
    the font bundling is new).
  • sim/walkthrough.ts — 21 lens steps, no findings (new: paging a long cue to its last
    word and back).
  • sim/phone-tour.ts — 13 phone steps in Chromium, no findings (new: the audio player
    actually loading the clip's metadata and reaching the host's download sheet; a real
    miniapp_color_scheme_change pushed at the host and followed through to data-theme).
  • Mutation-checked, 15 mutations, 15 caught — including every security invariant:
    neutering the colour-scheme handler, deadening the download button, dropping the
    signedIn gate, unclamping the page offset, removing the path encoding, re-pointing the
    api base mid-login, re-attaching the auth header to /auth/login, dropping the token
    restore, regressing the renewal guard back to res.ok, and breaking a font path.
  • client-core 143, even 292, mobile 163, web 115; all workspace typechecks and
    builds. Everything outside veiller/ and packages/client-core/ is untouched.

New guards so this can't drift again quietly: src/ui/css.test.ts pins the tokens, both
palettes, the bundled faces and the structural markers; a new tsconfig.check.json brings
test and sim/ code into the typecheck gate (they were typechecked by nothing, which had
been hiding several latent errors in sim/fake-server.ts).

Known, deliberately not fixed: the cue-detail popup has no max-height (markup and CSS
byte-identical to Even's, and unreachable while the api clips cue bodies to 240 chars —
overflow starts past ~800; a fix belongs on both front ends at once), and light-mode
accent-on-surface contrast is 3.92:1, inherited unchanged from the shared Lumen palette.

Not verified: real G2 glasses and the real Veiller phone app — no device available, so
all glasses evidence is the Veiller SDK simulator and all phone evidence is headless
Chromium. The sims are not in CI (they need a Veiller checkout); qa.md now says so.

xerhab added 7 commits August 9, 2026 09:57
`even/` is the reference implementation and the Veiller miniapp is meant to be
the same product on a different host. Running both against their simulators and
comparing them screen by screen, the phone page had drifted a long way: the
stylesheet was a loose reinterpretation of the web UI's Lumen system rather than
a port of it, and several features were dropped as "not portable" when the host
in fact supports them.

Phone page — now a faithful port of `even/index.html`:

  - Stylesheet ported wholesale: the real Lumen token set (spacing, type scale,
    radius, shadow, semantic colours) instead of an invented palette, and the
    LIGHT palette, which was missing entirely — the page was dark-only on a
    light phone.
  - Inter and Space Grotesk are bundled (latin, only the weights the design
    system uses, ~100 KB). The faces were named but never shipped, so the
    WebView — which loads from file:// with no network — silently fell back to
    the system sans for every heading and the wordmark.
  - Structure and copy realigned: the wordmark and its accent dot, the "Log in"
    heading, `.field`-wrapped inputs, the `Signed in as <user>` / `Log out` bar,
    the `.content` card, Start/Stop on its own row, and the bottom navigation's
    mic/clock icons with the active-tab indicator (it was text-only tabs).
  - The song card renders ABOVE the cue card, as upstream; they were inverted.
  - Login errors surface on the toast, not an inline paragraph.
  - History cues open the detail popup instead of expanding inline.
  - `.cue-inline-caret`, `.cue-inline-title` and `.session-translation-text`
    had no rules at all and rendered unstyled.
  - The Veiller-only "Web app" button is gone (with its now-dead open-url
    channel): upstream has no such control.

Features restored, each with the host API that makes it possible:

  - History audio. `/conversations/{id}/audio` accepts the bearer token as a
    query param — which is exactly how upstream's `<audio src>` and download
    link reach it — so the background mints the URL and the page plays it. No
    cross-origin fetch is involved. "Save audio.wav" hands the same URL to
    `session.system.download`.
  - Light/dark. The host reports its scheme (`session.colorScheme`); the
    background forwards it and the page stamps `<html data-theme>`. The
    stylesheet keys off both that and `prefers-color-scheme`.
  - Long cue bodies. Upstream gives the overflow to a host-scrolled container
    (XERK-133); the scene API is replace-the-frame with no scrollable
    container, so the body is paged on swipes and the title row carries a "▾"
    in place of the host's scroll bar. Every word is reachable again — before,
    anything past the box's four rows was simply unreadable.

Two differences remain, both forced by the host and now documented in
`veiller/README.md` rather than buried: that cue paging, and the idle
double-tap app exit (the miniapp host has no self-exit API).

Verification: `src/ui/css.test.ts` now pins the tokens, both palettes, the
bundled faces and the structural markers that drifted; `hud.test.ts` covers
body paging and clamping; controller tests cover paging on swipe, the audio-URL
and download RPCs, and the colour-scheme broadcast. `sim/walkthrough.ts` gains a
long-cue paging step and `sim/phone-tour.ts` gains audio-player and theme steps
(the fake server now serves the audio route with query-param auth, as the real
api does). 137 bun tests pass; both sims report no findings.
The gate returned PARTIAL. Fixes, in its order of severity:

  - "Download audio.wav" reported a failure over a successful save. The real
    host answers `{success}` and the miniapp simulator answers `{ok}`; the
    handler read only the former, so the one harness that can drive the button
    disagreed with the device. It now accepts either shape — and the phone tour
    actually CLICKS the button, which it never did, so the toast is asserted
    rather than assumed.

  - Hardened the two new RPCs. They widened what the WebView can reach: the
    token-bearing clip URL and, through `tenir:download`, the host's download
    sheet — which (unlike `openUrl`) does not scheme-filter, so an unchecked URL
    was arbitrary network/file egress carrying the wearer's token. `download`
    now refuses anything that is not the api's own URL, compared on a path
    boundary so a look-alike host can't pass on a shared prefix; the audio URL
    path-encodes the conversation id so a "../"-shaped one can't steer it.
    Neither was exploitable — the page has no HTML injection sink and makes no
    external requests — but the page had no business naming those targets.

  - Copy parity: the button says "Download audio.wav", as upstream does. It was
    the only text left that differed between the two front ends.

  - The theme sim step tested the stylesheet while claiming to test the wiring:
    it flipped `data-theme` itself, so it still passed with the scheme hard-wired
    to dark. It now asserts the host's scheme ARRIVES (the simulator injects no
    `window.MentraOS`, so a `data-theme` can only have come down the channel),
    and separately that each palette renders — including a contrast check, since
    a token defined only in the dark block would leave light unreadable. The
    background half stays covered by the unit test; the simulator hardcodes dark
    and exposes no way to push a change.

  - The boot seed read only `window.MentraOS`, which the real host aliases from
    `window.Veiller` but the simulator does not inject — so the first-paint path
    was dead in every harness. It reads both now.

  - `showAudio`'s stale-reply guard tore the player down instead of returning,
    so a late reply for a conversation the viewer had left would have taken out
    the one they had moved to.

  - Docs: `qa.md`'s step count, and a note that the sims are NOT in CI and must
    be run by hand — all the integration evidence for this area is manual.
    `veiller/README.md` now records the removed "Web app" button too.

Two gate observations left as-is, both deliberate: the cue-detail popup has no
max-height (byte-identical markup and CSS to `even/`, and unreachable while the
api clips cue bodies to 240 chars — fixing it belongs on both front ends at
once), and accent-on-surface contrast is 3.92:1 in light mode, inherited
unchanged from the shared Lumen palette.

139 bun tests pass on the pinned CI Bun (1.2.22); both sims report no findings.
…ERK-237)

The gate came back FAIL, and it was right on the important one: my previous
round's two "fixes" cancelled each other out.

  - The theme sim step justified itself with "the simulator injects no
    `window.MentraOS`, so a `data-theme` can only have come down the channel" —
    which the SAME commit made false, by teaching the page to also read
    `window.Veiller.colorScheme`, which the simulator does inject. Both sources
    yield "dark", so the step passed with the channel completely dead. It now
    pushes a REAL `miniapp_color_scheme_change` envelope at the host and
    asserts the page follows it light→dark→light, so the whole path runs:
    host → session.colorScheme → onColorSchemeChange → broadcast → data-theme.
    (`host.push` is private in the simulator's types but a plain method; it is
    declared in sim-path.ts with a note, since `emit` sends a
    subscription-gated envelope the SDK never matches on.)

  - The download step only asserted that no failure toast appeared, so a
    completely dead button passed. It now asserts the request actually reached
    the host, and that the host was handed the api's own clip URL.

  - `tenir:download` no longer takes a URL at all — it takes the conversation
    id and mints the URL itself. The allow-list I added looked equivalent but
    was not: `tenir:login` re-points the api base even when the login FAILS, so
    the page could move the base and then satisfy the check. Not naming the
    target is the only version that can't be walked around.

  - The `signedIn` gate on download was covered by nothing; it is now.

  - `showAudio`'s catch path had the same staleness bug I had just fixed on the
    success path — a rejected reply for a conversation the viewer had left tore
    down the one they were on.

  - Test and harness code was typechecked by NOTHING: `tsconfig.json` excludes
    tests and never covered `sim/`. A new `tsconfig.check.json` covers the whole
    tree and `typecheck` runs both, which turned up several latent errors in
    `sim/fake-server.ts` (`ServerSocket` no longer exists, an optional port, a
    Uint8Array body) and untyped mocks in `ws.test.ts`. All fixed.

  - Stale "Save audio.wav" comments now match the shipped label.

Mutation-checked, the three that escaped last round: neutering the page's
colour-scheme handler, deadening the download button, and dropping the signedIn
gate all now fail the suite or the tour.

Left deliberately: the cue-popup max-height (byte-identical to `even/`, and the
api clips cue bodies to 240 chars — the gate measured overflow starting past
~800, and a fix belongs on both front ends at once) and light-mode accent
contrast at 3.92:1 (inherited unchanged from the shared Lumen palette).

139 tests pass under the CI-pinned Bun 1.2.22; both sims report no findings.
Round 3 of the QA gate: everything else it raised is closed, but it was right
that D3 was not — I had removed the URL argument from `tenir:download` without
touching what actually made an allow-list unsafe.

`handleLogin` re-pointed `configureApi` BEFORE validating, and a failed login
left it there while the session stayed signed in. So the page never needed to
name a URL: it named a HOST one call earlier, and the background then dutifully
minted a token-bearing URL against it. The gate drove it end to end — a failing
`tenir:login` at an attacker host, then a plain `tenir:download {id}`, and the
host's download sheet was handed the attacker's URL carrying the real token.

The candidate base is still applied for the attempt — the login has to reach the
server the wearer typed — but the previous one is restored when it fails, and the
URL is persisted only once the server has actually accepted us (so a typo, or a
hostile URL, is never what the next boot comes back to). Mutation-checked: drop
the restore and the new test fails.

Also from round 3:

  - `tenir:audio-url` / `tenir:download` reject an id that isn't a non-empty
    string, instead of addressing `/conversations//audio`.
  - Step 11's URL assertion no longer regexes for `?token=`: the simulator
    truncates a traced payload at 120 chars, so a longer host or a realistic JWT
    would have failed it spuriously. It asserts the right CONVERSATION reached
    the host; the token is asserted on the `<audio src>` in the same step and
    pinned exactly in the unit test.
  - `index.html`'s player comment still described the design this branch
    replaced ("hands the same URL to the host's download sheet").
  - Dead `setDownloadOk` harness hook removed; `setDownloadReply` covers it.
  - `tsconfig.check.json` now spells out that the two typecheck passes must stay
    in order — tests pull `src/` in with Bun's globals in scope, so the narrow
    pass is what keeps a `Bun.*` call out of shipped browser code.

The gate's remaining notes are accepted as-is and recorded: `host.push` is
private-in-TS-only and fails loud if upstream ever changes it (a broken step
throws and exits non-zero, it cannot silently pass), and the token crossing into
the WebView is inherent to the audio feature — as it is upstream — with no
HTML-injection sink on the page and no external requests.

140 tests pass under the CI-pinned Bun 1.2.22; both sims report no findings.
Round 4 of the QA gate. The sequential re-point attack is dead and every
legitimate flow survives — but restoring the base after a failure was a
ROLLBACK, not isolation, and the gate found the two holes that leaves.

**The window.** `configureApi` is a module-level singleton every handler reads,
and the attempt moved it before awaiting a call to the server the page named —
so that server decided how long the window lasted. The gate fired a login at an
attacker host that stalled 3s, and inside the window `tenir:audio-url` and
`tenir:download` both handed out the attacker's host carrying the wearer's real
token. `request()`/`login()`/`me()` now take an explicit base, so the attempt
carries its own and NOTHING shared moves until the server has accepted us.

**The token.** "A failed login leaves things exactly where they were" was not
true: `request()` attached the bearer token to every call including
`POST /auth/login`, and adopted `x-renewed-token` before checking `res.ok`. So
the named server received the wearer's live token and could hand back a
replacement the device stored — leaving the REAL server 401ing after a login
that failed. A login proves nothing, so it no longer sends the token at all, and
a renewal is only adopted from a response the server accepted.

That second one is the root of this whole class: kill the token egress and the
residual value of a re-point largely goes with it.

All three are mutation-checked — re-mutate the base, the header, or the renewal
and a test fails. The in-flight case has its own test, with a fetch stub that can
hold a response open (the `wait` hook) so an attacker-paced server is
reproducible rather than argued about.

NOTE FOR REVIEW: `packages/client-core/src/api.ts` — shared by even/, web/ and
mobile/ — has both flaws verbatim (`authHeader()` on `/auth/login`, renewal
adopted before the `res.ok` check). Pre-existing, not introduced here, and out of
this ticket's scope, so I have NOT touched it: the fix is the same few lines but
it changes three shipped clients and pulls their gates into a parity PR. Worth
its own ticket.

143 tests pass under the CI-pinned Bun 1.2.22; both sims report no findings.
…ying the token (XERK-237)

Round 5 of the QA gate. The isolation and token-egress fixes hold — it re-ran
the race, the re-point and every legitimate flow, and confirmed the `wait` hook
can't wedge the suite. Two things left.

**A half-successful login still destroyed the token.** `login()` is two
round-trips and stored the new token between them, so a server that accepted
`/auth/login` and then rejected `/auth/me` left the wearer holding a token their
real server 401s on — from an attempt that reports failure. The new token is now
provisional until `me()` confirms it, and the previous one goes back if it
doesn't. (No egress: the credential leak was already fixed, so the named server
only ever sees its own token.)

**client-core: I was wrong to defer it.** I had flagged the identical flaws in
`packages/client-core/src/api.ts` as a follow-up because it is shared by even/,
web/ and mobile/ and widens this PR's gates. The gate pushed back, and it is
right: Tenir is self-hosted, so the server address is a USER-TYPED field —
`mobile/src/screens/Setup.tsx` and `Settings.tsx` put it directly in front of
this code. There is no WebView to compromise first. A user who mistypes an
address, or is phished into one, hands over their live bearer token from the
login form. That exposure is larger than veiller's, not smaller, and shipping a
commit that publicly describes the attack while leaving the more-exposed client
unfixed is not a trade worth making. The repo's own parity rule says as much.

So the two behaviour fixes ship here, for all four front ends:
  - `/auth/login` no longer carries the bearer token. A login proves nothing; it
    only ever needed the credentials.
  - `x-renewed-token` is adopted only from a response the server accepted.
  - (plus the same provisional-token ordering.)

What I have NOT done, and still think belongs in its own ticket: threading an
explicit `baseUrl` through client-core the way veiller now does. That is a real
refactor across three shipped front ends, and none of them currently mutate the
base mid-attempt the way the miniapp's login did.

Mutation-checked on both sides: re-attach the header, drop the `res.ok` guard,
or drop the token restore, and a test fails — five mutations, five caught.

Green: veiller 144 (pinned Bun 1.2.22) + both sims no findings; client-core 142,
even 292, mobile 163, web 115; all workspace typechecks and builds.
…eeding (XERK-237)

Round 6 of the QA gate: no blockers left, but it caught that my own `res.ok`
guard quietly regressed XERK-168 — and proved it against the real api rather
than a stub.

`api/src/api/main.py`'s renewal middleware runs after the route with NO status
check, so an aged-but-valid token is renewed on authenticated 404s and 422s too.
Booted with a 4s TTL, the gate measured renewals on 404 for
`/conversations/{id}`, `/conversations/{id}/audio` and DELETE — all of which the
`res.ok` gate threw away. The sliding renewal is what keeps a device signed in
until it explicitly signs out, so dropping those makes it expire sooner than it
should.

The right question was never "did this response succeed" but "did this request
present a token". `auth: false` on `/auth/login` already means a login can never
legitimately receive a renewal, so gating on that closes the hole exactly while
leaving every authenticated response — 2xx or not — able to renew.

Both copies, with a test each for a renewal on an authenticated 404; regress
either back to `res.ok` and that test fails.

Green: veiller 145 (pinned Bun 1.2.22) + both sims no findings; client-core 143,
even 292, mobile 163, web 115; all workspace typechecks and builds.
@xerhab
xerhab merged commit bb988cf into main Aug 9, 2026
7 checks passed
@xerhab
xerhab deleted the XERK-237 branch August 9, 2026 19:31
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