Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
the real
SessionPage/PhoneHistory/ nav modules with fixtures matching Veiller'sand screenshotted every state in Chromium.
miniapp-simulator(from a freshorigin/mainworktree) running the built bundle, via
sim/walkthrough.ts(the lens) andsim/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
:hoveron 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.
--accent: #3fdc97vs Even's#3FD9C9, its own--panel/--panel-2) and no light theme at all — the page was dark-only on a light phone.loads from
file://with no network, so every heading, the wordmark and the card titlessilently fell back to the system sans.
siminstead ofSigned in as sim;Sign outinstead ofLog out;no
.contentcard; Start/Stop crammed into the title row; text-only bottom tabswhere Even has mic/clock icons and an active-tab indicator.
.cue-inline-caret,.cue-inline-titleand.session-translation-texthad no CSSrules at all and rendered unstyled.
Three features were written off as unportable. Two of them weren't.
?token=on/conversations/{id}/audio— which is exactly how Even's own<audio src>and downloadlink 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.session.colorScheme); nothing read it.Now forwarded on a
tenir:color-schemechannel and stamped on<html data-theme>, withprefers-color-schemestill the fallback.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.mdrather than buried ina source comment:
▾markersession.display.renderis replace-the-frame; there is no scrollable containerprefers-color-schemepicks the palette<html data-theme>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 — butit 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:downloadtakes a conversation id, not a URL. The host's download sheet doesnot scheme-filter the way
openUrldoes, so a page-supplied URL was arbitrarynetwork/file egress with the token attached. An allow-list looked equivalent and wasn't.
configureApiis a module-levelsingleton 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/menow take an explicit base; nothing shared moves until the server accepts.In
packages/client-core— shared byeven/,web/andmobile/: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 field —
mobile/'s Setup andSettings 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/logincarried the bearer token. A login proves nothing; it now sends onlythe credentials.
x-renewed-tokenwas adopted before checking whether the request was authenticated, sothe 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 thataccepted
/auth/loginthen rejected/auth/medestroyed 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
baseUrlthroughclient-coretheway the miniapp now does. None of
web/,mobile/oreven/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 makesno external requests.
Verification
Adversarial QA (the repo's
qaagent) ran six rounds against this branch, and earnedthem. 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.okguard caused in the XERK-168 sliding renewal (proved againstthe real api, not a stub). It also talked me out of deferring the
client-corefixes.Every finding is fixed and re-checked by mutation.
bun test— 145 pass, on the CI-pinned Bun 1.2.22 as well as 1.3.14 (I ran thewhole gate under the pinned version, including a clean
--frozen-lockfileinstall, sincethe font bundling is new).
sim/walkthrough.ts— 21 lens steps, no findings (new: paging a long cue to its lastword and back).
sim/phone-tour.ts— 13 phone steps in Chromium, no findings (new: the audio playeractually loading the clip's metadata and reaching the host's download sheet; a real
miniapp_color_scheme_changepushed at the host and followed through todata-theme).neutering the colour-scheme handler, deadening the download button, dropping the
signedIngate, unclamping the page offset, removing the path encoding, re-pointing theapi base mid-login, re-attaching the auth header to
/auth/login, dropping the tokenrestore, regressing the renewal guard back to
res.ok, and breaking a font path.client-core143,even292,mobile163,web115; all workspace typechecks andbuilds. Everything outside
veiller/andpackages/client-core/is untouched.New guards so this can't drift again quietly:
src/ui/css.test.tspins the tokens, bothpalettes, the bundled faces and the structural markers; a new
tsconfig.check.jsonbringstest and
sim/code into the typecheck gate (they were typechecked by nothing, which hadbeen hiding several latent errors in
sim/fake-server.ts).Known, deliberately not fixed: the cue-detail popup has no
max-height(markup and CSSbyte-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.mdnow says so.