Glasses multi-user account-linking — scoping
Status: DESIGN (not built). The prerequisite for ANY Mentra-store ambition. Scoped 2026-07-17 when the hardware arrived. Companion decision + framing: product_tasks "Mentra store distribution — gate on multi-user linking".
The problem
The glasses AppServer (apps/glasses) is single-user. Every session uses
one static HIVEJOURNAL_TOKEN (even the durable device token, migration 511) —
so the app calls HJ endpoints as ONE wearer. That's correct for the owner's own
device, but a store app is installed by strangers, each of whom must act as
their own HiveJournal account. Ship single-user to the store and a stranger
either can't auth or — worse — shares the owner's account and cloned voice.
MentraOS hands us a stable userId per session:
onSession(session, sessionId, mentraUserId). Multi-user = map that
mentraUserId → an HJ account, and call HJ as that account for the session.
Trust model
Do NOT put a user token on the device. Instead, two credentials:
- App-level secret (
GLASSES_APP_SECRET, env on the AppServer + HJ) — proves "this is the JQ glasses app." Never a user's credential. Verified constant-time, exactly like the partner-provisioningX-Partner-Key(this is the same app-acts-for-user shape as PR #1332 — reuse itspartner-grants-corehashing/compare helpers). - A per-user link, owner-consented — the wearer explicitly binds their Mentra device to their HJ account. The app never learns a user's password or long-lived token; it gets a short-lived, per-session token minted by HJ only for already-linked users.
Neither alone is enough: the app secret can look up only linked users; an
unlinked mentraUserId resolves to nothing.
Data model
glasses_device_links(new):id,mentra_user_id(unique, nullable until claimed),user_id(the HJ account),code(short claim code, hashed),code_expires_at,linked_at,revoked_at,label. One row per linked device;revoked_atcuts a device off instantly.- Reuse
glasses_tokens(migration 511) OR issue a short-lived signed session token on resolve — see "Per-session resolution".
Linking flow (owner-initiated, code-based)
- On
/dashboard/glasses, "Link a new device" → HJ generates a short code (e.g.472-916), stores its hash + theuser_idinglasses_device_linkswith a ~10-min expiry. - The wearer says to the glasses: "link my glasses, code four seven two nine
one six." The app parses the digits (a
parseLinkCodepure helper, likeparseAddItem) →POST /api/glasses/link { mentra_user, code }(app-secret authed) → HJ matches a live code, stampsmentra_user_id,linked_at. Now the binding exists.- Alt UX to evaluate on-device: enter the code in the MentraOS phone app's per-app settings if the SDK exposes a config surface — cleaner than voice-entering digits. Voice is the zero-dependency floor.
- Repeat per wearer/device. Unlink = revoke the row from the dashboard.
Per-session resolution
On each onSession(..., mentraUserId) the app calls
POST /api/glasses/resolve { mentra_user } (app-secret authed):
- Linked → HJ returns a short-lived per-user session token (signed,
~24h,
authenticateUseraccepts it — same prefix-routed path as the device token, but time-boxed and issued per session). The app cachesmentraUserId → tokenfor the session and uses it for all HJ calls. - Not linked → HJ returns
{ linked: false }; the app speaks: "Say 'link my glasses' with the code from your dashboard to get started."
This keeps long-lived secrets off the device: the app holds only its app secret
- ephemeral per-user session tokens it can always re-fetch.
Glasses-side changes (apps/glasses)
GLASSES_APP_SECRETenv; aresolveWearer(mentraUser)client + a small per-session token cache (replaces the singleHIVEJOURNAL_TOKENfor the multi-user build; keep the static token as a dev/single-user fallback).parseLinkCode(pure, unit-tested) + alinkintent ("link my glasses …").authHeaders()becomes per-session (uses the resolved token), not module-global.
Phases
- Phase 0 (done): single-user durable device token (#1338).
- Phase 1 — DONE (migration 513):
glasses_device_links+ owner CRUD/api/glasses/links+ app-secret-authed/api/glasses/link(claim) &/api/glasses/resolve(→hjs_session token) + the dashboard "Link someone else's glasses" code UI + the glasses runtime —onSessionresolves the wearer whenGLASSES_APP_SECRETis set, per-session token viaAsyncLocalStorage(no cross-wearer clobber; single-user unchanged when unset),parseLinkCommand+ link intent ("link my glasses <code>"). NOT YET validated on-device — needs a 2nd wearer to confirm wearer-A ≠ wearer-B and that ALS context propagates through the Mentra event handlers on real hardware. TheAsyncLocalStorage-through-events assumption is the one thing to verify first. - Phase 2 (store-readiness): first-run onboarding (unlinked → guided link), per-device unlink UI, rate-limit + lock the link/resolve endpoints, and the walker-economy-first store framing (the one slice useful to a stranger with no HJ history — see the store task).
Open questions
- Code entry UX: voice digits vs a MentraOS phone-app config field (SDK capability unverified). Voice is the floor; phone-config is nicer if available.
- Session-token lifetime vs re-resolve cost (24h + re-fetch on 401 is a sane default).
- Reuse vs fork the partner-provisioning primitives (#1332): the
app-secret + hashed-token + scope machinery is nearly identical; prefer
sharing
partner-grants-coreover duplicating.
Not in scope here
Billing/quota per linked wearer (each wearer already bills their own HJ TTS
quota via sayInUserVoice's user_id context — no change needed). Full OAuth
redirect (the code-link is simpler and sufficient; OAuth can come later without
breaking this).