product

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:

  1. 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-provisioning X-Partner-Key (this is the same app-acts-for-user shape as PR #1332 — reuse its partner-grants-core hashing/compare helpers).
  2. 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_at cuts 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)

  1. On /dashboard/glasses, "Link a new device" → HJ generates a short code (e.g. 472-916), stores its hash + the user_id in glasses_device_links with a ~10-min expiry.
  2. The wearer says to the glasses: "link my glasses, code four seven two nine one six." The app parses the digits (a parseLinkCode pure helper, like parseAddItem) → POST /api/glasses/link { mentra_user, code } (app-secret authed) → HJ matches a live code, stamps mentra_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.
  3. 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, authenticateUser accepts it — same prefix-routed path as the device token, but time-boxed and issued per session). The app caches mentraUserId → token for 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_SECRET env; a resolveWearer(mentraUser) client + a small per-session token cache (replaces the single HIVEJOURNAL_TOKEN for the multi-user build; keep the static token as a dev/single-user fallback).
  • parseLinkCode (pure, unit-tested) + a link intent ("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 runtimeonSession resolves the wearer when GLASSES_APP_SECRET is set, per-session token via AsyncLocalStorage (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. The AsyncLocalStorage-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-core over 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).

GLASSES MULTIUSER AUTH — Docs | HiveJournal