@muhkoo/connect 0.10.12-alpha.0 → 0.10.14-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/connect.d.ts CHANGED
@@ -2482,7 +2482,7 @@ declare class SessionState {
2482
2482
 
2483
2483
  /**
2484
2484
  * `client.auth.hosted` — centralized hosted auth (auth.muhkoo.dev).
2485
- * AUTH_HOSTED_PLAN.md.
2485
+ * AUTH_HOSTED_PLAN.md; TV device pairing per `muhkoo/auth/docs/tv-device-pairing-spec.md`.
2486
2486
  *
2487
2487
  * Two sides share this class:
2488
2488
  * - **Developer apps** call {@link login} (redirect to the hosted page) and
@@ -2498,6 +2498,22 @@ declare class SessionState {
2498
2498
  * page → (user authenticates) → POST /api/auth/grant → redirect_uri?code&state#k=<K_t>
2499
2499
  * app → POST /api/auth/token {code, code_verifier} → {sessionToken, sealedKeys}
2500
2500
  * → unseal sealedKeys with K_t → master seed → session ready
2501
+ *
2502
+ * ## Device pairing (v2 — TVs and other keyboard-less devices)
2503
+ *
2504
+ * The redirect flow above is unchanged and remains the fallback for anything
2505
+ * with a browser and a keyboard. A device with no redirect to receive instead
2506
+ * displays a code and **polls**:
2507
+ *
2508
+ * TV → {@link startDevicePairing} → shows `userCode` + `verificationCode`
2509
+ * user → opens auth.muhkoo.dev/link, signs in, {@link lookupDevicePairing}
2510
+ * user → compares the verification code on both screens,
2511
+ * {@link approveDevicePairing} seals the seed TO the TV's public key
2512
+ * TV → {@link waitForDevicePairing} → unseal → own ZK session → persisted
2513
+ *
2514
+ * Same invariant as v1: the accelerator relays a sealed envelope it cannot read.
2515
+ * Only the key agreement changes (ECDH to a key the TV generated, instead of a
2516
+ * one-time key delivered in a URL fragment a TV can't receive).
2501
2517
  */
2502
2518
 
2503
2519
  interface HostedAuthDeps {
@@ -2505,10 +2521,171 @@ interface HostedAuthDeps {
2505
2521
  session: SessionState;
2506
2522
  /** Base URL of the hosted auth SPA (e.g. https://auth.muhkoo.dev). */
2507
2523
  authBaseUrl: string;
2524
+ /**
2525
+ * Absolute URL of the accelerator (e.g. https://api.muhkoo.dev) — the origin
2526
+ * the `/api/auth/device*` endpoints live on.
2527
+ *
2528
+ * Optional for backwards compatibility: `AuthNamespace` does not pass it
2529
+ * today, so it is resolved from the {@link AuthClient} at call time (see
2530
+ * `apiBase`). Pass it explicitly whenever you construct `HostedAuth` yourself.
2531
+ */
2532
+ apiBaseUrl?: string;
2533
+ /**
2534
+ * `preimagePoK` circuit assets, used when a paired device mints its OWN ZK
2535
+ * session. Defaults to {@link defaultCircuitUrls} anchored at `apiBaseUrl` —
2536
+ * which matches `Client`'s own default. Supply it if the app overrode
2537
+ * `ClientOptions.circuits`.
2538
+ */
2539
+ circuits?: CircuitUrls;
2540
+ /** Custom fetch — defaults to `globalThis.fetch`. */
2541
+ fetch?: typeof fetch;
2542
+ /** Sleep used by the poll loop. Injectable so tests don't burn wall-clock. */
2543
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
2544
+ }
2545
+ /** One in-flight pairing attempt. Hold it; pass it back into poll/wait/cancel. */
2546
+ interface DevicePairingSession {
2547
+ /** Opaque; carry it back into poll/wait/cancel. Never render it. */
2548
+ deviceCode: string;
2549
+ /** Show this, large. Formatted `"XXXX-XXXX"`. */
2550
+ userCode: string;
2551
+ /**
2552
+ * Show this too, and tell the user it must match the browser. Computed
2553
+ * LOCALLY from this device's own public key — the server's copy is never
2554
+ * trusted (that binding is the only MITM defence in the flow).
2555
+ */
2556
+ verificationCode: string;
2557
+ verificationUri: string;
2558
+ /** Same URI with the code pre-filled — render as a QR code. */
2559
+ verificationUriComplete: string;
2560
+ /** Absolute epoch-ms. */
2561
+ expiresAt: number;
2562
+ /** Server-recommended seconds between polls; may grow via `slow_down`. */
2563
+ interval: number;
2564
+ /** Ephemeral private key for this attempt. In memory only, never persisted. */
2565
+ privateKey: CryptoKey;
2566
+ /**
2567
+ * This attempt's own public half, base64 raw SEC1.
2568
+ *
2569
+ * NOT in the spec's §7 listing, but REQUIRED: `unsealSeedFromDevice` takes
2570
+ * the recipient key as a third argument because it is mixed into the KDF to
2571
+ * defeat a point-negation substitution. The shipped crypto is authoritative;
2572
+ * carrying the public half here costs nothing (a device always holds both).
2573
+ */
2574
+ devicePublicKeyB64: string;
2575
+ }
2576
+ /** The result of one poll. Discriminated on `status`. */
2577
+ type DevicePairingPoll = {
2578
+ status: "pending";
2579
+ interval: number;
2580
+ expiresIn: number;
2581
+ } | {
2582
+ status: "slow_down";
2583
+ interval: number;
2584
+ retryAfter: number;
2585
+ } | {
2586
+ status: "approved";
2587
+ user: AuthUser;
2588
+ } | {
2589
+ status: "denied";
2590
+ } | {
2591
+ status: "expired";
2592
+ };
2593
+ interface StartDevicePairingOptions {
2594
+ appId: string;
2595
+ /** Human label shown to the approver. Default: a UA-derived guess. */
2596
+ label?: string;
2508
2597
  }
2509
- interface AuthUser$1 {
2510
- username: string;
2511
- commitment: string;
2598
+ interface WaitForDevicePairingOptions {
2599
+ signal?: AbortSignal;
2600
+ /** Called before each sleep — drive a countdown / "waiting…" UI. */
2601
+ onTick?: (remainingMs: number, interval: number) => void;
2602
+ }
2603
+ /** A pending pairing, resolved by the approver from the typed `user_code`. */
2604
+ interface DevicePairingRequest {
2605
+ userCode: string;
2606
+ devicePublicKeyB64: string;
2607
+ /** Recomputed locally by the SDK; compare with what the TV shows. */
2608
+ verificationCode: string;
2609
+ deviceFingerprint: string;
2610
+ deviceLabel: string;
2611
+ appId: string;
2612
+ appLabel: string;
2613
+ isKnownDevice: boolean;
2614
+ requiresReauth: boolean;
2615
+ expiresAt: number;
2616
+ }
2617
+ /** A device the account has paired, as listed on the security page. */
2618
+ interface PairedDevice {
2619
+ id: string;
2620
+ label: string;
2621
+ appId: string;
2622
+ pairedAt: number;
2623
+ lastSeenAt: number;
2624
+ activeSession: boolean;
2625
+ }
2626
+ /**
2627
+ * Why a pairing ended.
2628
+ *
2629
+ * `denied` / `expired` / `aborted` / `rate_limited` are the four the spec names
2630
+ * for {@link HostedAuth.waitForDevicePairing}. The rest are additive and exist
2631
+ * because the flow genuinely produces them:
2632
+ * - `network` — the reference loop in spec §9.2 throws it after 5 consecutive
2633
+ * transport failures;
2634
+ * - `commitment_mismatch` — spec §10.8's MANDATORY client check failed (the
2635
+ * seed does not derive the commitment the server sent). This is what turns
2636
+ * §10.2's substitution attack from silent into loud;
2637
+ * - `account_mismatch` — spec §10.2 detection 2: this device was previously
2638
+ * paired to a DIFFERENT account. Refuse rather than silently adopt;
2639
+ * - `reauth_required` — see {@link ReauthRequiredError};
2640
+ * - `invalid_user_code` — client-side canonicalization rejected the input;
2641
+ * - `error` — anything else; read `.code` for the server's machine code.
2642
+ */
2643
+ type DevicePairingErrorReason = "denied" | "expired" | "aborted" | "rate_limited" | "network" | "commitment_mismatch" | "account_mismatch" | "reauth_required" | "invalid_user_code" | "error";
2644
+ /**
2645
+ * Any terminal failure of the pairing flow.
2646
+ *
2647
+ * `.reason` drives the device's UI state machine; `.code` carries the server's
2648
+ * machine error code verbatim (spec §6.7) for the cases the reason collapses —
2649
+ * e.g. `consent_required`, `too_many_devices`, `key_mismatch` all arrive as
2650
+ * `reason: "error"` with a distinct `.code`.
2651
+ */
2652
+ declare class DevicePairingError extends Error {
2653
+ readonly reason: DevicePairingErrorReason;
2654
+ /** Machine code from the error envelope (`{error, message}`), when there was one. */
2655
+ readonly code?: string;
2656
+ readonly status?: number;
2657
+ /** Seconds, from `Retry-After`, when the server sent one. */
2658
+ readonly retryAfter?: number;
2659
+ /** Any extra fields the error envelope carried (e.g. `version`). */
2660
+ readonly details?: Record<string, unknown>;
2661
+ constructor(reason: DevicePairingErrorReason, opts?: {
2662
+ message?: string;
2663
+ code?: string;
2664
+ status?: number;
2665
+ retryAfter?: number;
2666
+ details?: Record<string, unknown>;
2667
+ cause?: unknown;
2668
+ });
2669
+ }
2670
+ /**
2671
+ * Approving a **new** device needs a recently authenticated approver (spec
2672
+ * §10.7, `FRESH_AUTH_MAX_AGE_MS`). The hosted page catches this, prompts for a
2673
+ * password / passkey / Google factor, and retries the approval.
2674
+ *
2675
+ * Server-enforced, not a UI convention — `requiresReauth` on
2676
+ * {@link DevicePairingRequest} is only the hint.
2677
+ */
2678
+ declare class ReauthRequiredError extends DevicePairingError {
2679
+ /** How long ago the approver's session authenticated, in seconds. */
2680
+ readonly authAgeSeconds?: number;
2681
+ /** The freshness window the server enforces, in seconds. */
2682
+ readonly maxAgeSeconds?: number;
2683
+ constructor(opts?: {
2684
+ message?: string;
2685
+ authAgeSeconds?: number;
2686
+ maxAgeSeconds?: number;
2687
+ status?: number;
2688
+ });
2512
2689
  }
2513
2690
  declare class HostedAuth {
2514
2691
  private readonly deps;
@@ -2541,7 +2718,7 @@ declare class HostedAuth {
2541
2718
  * code (+ PKCE verifier) for the session, unseal the seed from the fragment,
2542
2719
  * establish the session, and scrub the URL. Returns the signed-in user.
2543
2720
  */
2544
- handleCallback(): Promise<AuthUser$1>;
2721
+ handleCallback(): Promise<AuthUser>;
2545
2722
  /**
2546
2723
  * (Hosted-page side) Finish an /authorize request after the user has
2547
2724
  * authenticated here: seal the in-memory seed, mint the grant, and return
@@ -2554,12 +2731,190 @@ declare class HostedAuth {
2554
2731
  codeChallenge: string;
2555
2732
  state: string;
2556
2733
  }): Promise<string>;
2734
+ /**
2735
+ * (Device side) Begin pairing. Generates the ephemeral ECDH keypair, ensures
2736
+ * a persistent device identity key exists, signs a possession proof over it,
2737
+ * and calls `POST /api/auth/device/code`.
2738
+ *
2739
+ * Display `userCode` (large, monospace) **and** `verificationCode`, then call
2740
+ * {@link waitForDevicePairing}. The verification code returned here is
2741
+ * computed locally from this device's own public key — the server also
2742
+ * returns a copy, which is ignored on purpose: trusting it would forfeit the
2743
+ * entire MITM binding (spec §6.1, §10.1).
2744
+ */
2745
+ startDevicePairing(opts: StartDevicePairingOptions): Promise<DevicePairingSession>;
2746
+ /**
2747
+ * (Device side) One poll of `POST /api/auth/device/token`.
2748
+ *
2749
+ * On `"approved"` this does the whole tail of the flow before returning: it
2750
+ * unseals the envelope with the ephemeral private key, derives the identity,
2751
+ * **verifies the commitment matches** (spec §10.8 — mandatory, and the check
2752
+ * that makes a substituted seed loud rather than silent), mints the device's
2753
+ * OWN long-lived ZK session, registers the device via `PUT /api/auth/devices`,
2754
+ * and persists the identity for cold starts.
2755
+ *
2756
+ * Prefer {@link waitForDevicePairing}; use this only for a custom loop. A
2757
+ * transport failure REJECTS (the caller decides whether to retry); protocol
2758
+ * outcomes resolve as a status.
2759
+ */
2760
+ pollDevicePairing(session: DevicePairingSession): Promise<DevicePairingPoll>;
2761
+ /**
2762
+ * (Device side) Poll to completion with the interval + `slow_down` backoff of
2763
+ * spec §9.
2764
+ *
2765
+ * The server's `interval` is authoritative: on `slow_down` the client moves to
2766
+ * `max(serverInterval, own + 5, retryAfter)` so the two can't drift into a
2767
+ * permanent slow_down loop. Sleeps carry ±10 % jitter so a rack of devices
2768
+ * restarted together doesn't synchronise, and the loop stops at `expiresAt`
2769
+ * without waiting for the server to say so.
2770
+ *
2771
+ * Resolves with the signed-in user, or rejects with {@link DevicePairingError}.
2772
+ */
2773
+ waitForDevicePairing(session: DevicePairingSession, opts?: WaitForDevicePairingOptions): Promise<AuthUser>;
2774
+ /**
2775
+ * (Device side) Abandon an attempt. Marks the session dead so an in-flight
2776
+ * {@link waitForDevicePairing} rejects with `aborted` on its next tick, and
2777
+ * clears the device code.
2778
+ *
2779
+ * The ephemeral `CryptoKey` cannot be zeroed from JS — dropping the last
2780
+ * reference to `session` is what makes it unrecoverable, so don't keep the
2781
+ * object around. Deliberately does NOT notify the server: the pairing is
2782
+ * single-use and expires on its own, and an unauthenticated "cancel" endpoint
2783
+ * would just be another way to grief someone else's pending code.
2784
+ */
2785
+ cancelDevicePairing(session: DevicePairingSession): void;
2786
+ /**
2787
+ * (Device side) Cold-start path. Reads the persisted device identity,
2788
+ * re-derives the ZK identity from the stored seed, mints a fresh session, and
2789
+ * touches `PUT /api/auth/devices`.
2790
+ *
2791
+ * Returns `null` when nothing is persisted, when the blob doesn't derive its
2792
+ * own pinned commitment (corrupt/tampered storage), or when the account no
2793
+ * longer accepts the identity — call {@link startDevicePairing} then.
2794
+ */
2795
+ resumeDeviceSession(): Promise<AuthUser | null>;
2796
+ /** (Device side) Wipe the persisted seed, the device identity key, and the session. */
2797
+ forgetDeviceSession(): Promise<void>;
2798
+ /** (Device side) Whether a persisted device identity exists. Cheap: no crypto, no network. */
2799
+ hasDeviceSession(): Promise<boolean>;
2800
+ /**
2801
+ * (Hosted page) Resolve a typed `user_code` into the pending request.
2802
+ * Session-authed.
2803
+ *
2804
+ * `verificationCode` is recomputed here from the returned device key — the
2805
+ * server's copy is discarded. Both screens must show independently derived
2806
+ * values or the comparison proves nothing.
2807
+ */
2808
+ lookupDevicePairing(userCode: string): Promise<DevicePairingRequest>;
2809
+ /**
2810
+ * (Hosted page) Seal the in-memory seed to the device's public key and post
2811
+ * the ciphertext. Requires an unlocked seed — the user just authenticated
2812
+ * here — exactly like {@link completeAuthorize}. The server stores and relays
2813
+ * the envelope verbatim and can't read it.
2814
+ *
2815
+ * Show the user the verification code and the device label FIRST, and only
2816
+ * call this on an explicit tap. Throws {@link ReauthRequiredError} when the
2817
+ * account's last authentication is too old for a new device; the page should
2818
+ * prompt for a factor and retry. A stale legal agreement arrives as
2819
+ * `DevicePairingError` with `.code === "consent_required"` and
2820
+ * `.details.version`.
2821
+ */
2822
+ approveDevicePairing(req: DevicePairingRequest, opts?: {
2823
+ label?: string;
2824
+ }): Promise<{
2825
+ deviceId: string;
2826
+ }>;
2827
+ /**
2828
+ * (Hosted page) Refuse a pairing. No re-auth and no consent gate — declining
2829
+ * must never be harder than accepting. Leaves a tombstone so the device is
2830
+ * told "declined" rather than an indistinguishable "expired".
2831
+ */
2832
+ denyDevicePairing(userCode: string): Promise<void>;
2833
+ /**
2834
+ * The account's paired devices. Session-authed.
2835
+ *
2836
+ * UI copy note (spec §10.6): removing a device forces a full re-approval next
2837
+ * time, but be careful what you promise. It does NOT sign the device out —
2838
+ * the server returns `session_revoked: false`, and that session runs until it
2839
+ * expires — and it cannot take back the master seed the device already holds.
2840
+ * Users assume "remove" means "locked out immediately"; it does not.
2841
+ */
2842
+ listPairedDevices(): Promise<PairedDevice[]>;
2843
+ /**
2844
+ * Un-pair a device. Session-authed.
2845
+ *
2846
+ * It stops being a "known" device, so pairing it again needs full approval
2847
+ * with a fresh factor. It does NOT end that device's current session — the
2848
+ * server reports `session_revoked: false` — and it cannot take back the
2849
+ * master seed the device already holds. Do not write UI copy promising an
2850
+ * immediate sign-out; users will assume it and be wrong.
2851
+ */
2852
+ revokePairedDevice(id: string): Promise<void>;
2853
+ /**
2854
+ * The whole tail of an approved pairing: unseal → derive → **verify** →
2855
+ * authenticate → register → persist. Split out of `pollDevicePairing` so the
2856
+ * ordering (and the two refusals) stay readable.
2857
+ */
2858
+ private adoptSealedSeed;
2859
+ /**
2860
+ * Run the standard ZK challenge → Groth16 proof → authenticate round-trip for
2861
+ * an identity derived from a seed the caller already holds, and store the
2862
+ * resulting session.
2863
+ *
2864
+ * Identical to `ZkAuth.login()`'s tail (`proveAndStore`) — same challenge,
2865
+ * same circuit, same signature — so the commitment is byte-identical to
2866
+ * `zk.login(username, password)` by construction. No new endpoint, no circuit
2867
+ * change, no alternative derivation.
2868
+ *
2869
+ * (Spec §7 puts this on `AuthNamespace` as `loginWithSeed`; see the report —
2870
+ * it lives here because `AuthNamespace.ts` was out of scope for this change,
2871
+ * and `proveAndStore` is private.)
2872
+ */
2873
+ private mintSession;
2874
+ /**
2875
+ * `PUT /api/auth/devices` — the device checks in after each self-login so the
2876
+ * security page can show "last seen" and revoke the right session. Never
2877
+ * creates a record (only approve does), so a 404 here is expected on a device
2878
+ * whose pairing was revoked. Best-effort by design.
2879
+ */
2880
+ private touchPairedDevice;
2881
+ /** Reject a session that {@link cancelDevicePairing} has retired. */
2882
+ private assertLive;
2883
+ private sleep;
2884
+ /**
2885
+ * One round-trip to a `/api/auth/device*` endpoint. Never throws on a non-2xx
2886
+ * — the poll path has to inspect 401/403/429 itself — so callers that don't
2887
+ * want that follow up with {@link throwForStatus}.
2888
+ */
2889
+ private deviceApi;
2890
+ /** Turn a non-2xx device-endpoint response into the right typed error. */
2891
+ private throwForStatus;
2892
+ /**
2893
+ * Accelerator origin for the device endpoints.
2894
+ *
2895
+ * Prefers the explicit dep. Falls back to the {@link AuthClient}'s own base
2896
+ * URL, because `AuthNamespace` builds `HostedAuth` without one today and a
2897
+ * hard-coded default would silently point a staging or local build at
2898
+ * production. Last resort: map the auth SPA host (`auth.…` → `api.…`).
2899
+ */
2900
+ private apiBase;
2901
+ private circuits;
2557
2902
  private location;
2558
2903
  private store;
2559
2904
  private take;
2560
2905
  /** Drop `code`, `state`, and the `#k` fragment from the address bar. */
2561
2906
  private scrubUrl;
2562
2907
  }
2908
+ /**
2909
+ * Canonicalize a typed `user_code`: uppercase, drop whitespace and `-`.
2910
+ *
2911
+ * We deliberately do NOT guess (`O → 0`, `I → 1`, …). Both members of every
2912
+ * confusable pair are excluded from the alphabet, so a typed `O` is a genuine
2913
+ * misread — telling the user is more useful than silently resolving it.
2914
+ */
2915
+ declare function canonicalUserCode(input: string): string;
2916
+ /** Group a canonical code for display and for the wire: `"K7QD3MXR"` → `"K7QD-3MXR"`. */
2917
+ declare function formatUserCode(canonical: string): string;
2563
2918
 
2564
2919
  /**
2565
2920
  * `client.auth` — user authentication, exposed today through the `zk`
@@ -2867,6 +3222,7 @@ declare class AuthNamespace {
2867
3222
  readonly hosted: HostedAuth;
2868
3223
  constructor(deps: ZkAuthDeps & {
2869
3224
  authBaseUrl: string;
3225
+ apiBaseUrl?: string;
2870
3226
  });
2871
3227
  /** Convenience pass-through: the currently signed-in user, or `null`. */
2872
3228
  get user(): AuthUser | null;
@@ -5164,6 +5520,155 @@ declare class Client {
5164
5520
  recoverSession(): Promise<boolean>;
5165
5521
  }
5166
5522
 
5523
+ /**
5524
+ * `deviceStore` — at-rest persistence for a paired device (the TV).
5525
+ *
5526
+ * A device that has completed {@link HostedAuth.startDevicePairing} holds the
5527
+ * account's **master seed**. Android WebView has no WebAuthn, so the passkey
5528
+ * wrapping used elsewhere isn't available, and re-pairing on every cold start
5529
+ * is not acceptable UX for a set-top box. So the seed is persisted, encrypted:
5530
+ *
5531
+ * | Item | Where | Form |
5532
+ * | ------------------------ | ------------------------------------ | ---- |
5533
+ * | wrapping key | IndexedDB (`muhkoo.device.v1`) | `CryptoKey`, AES-GCM-256, **non-extractable** |
5534
+ * | device identity keypair | IndexedDB (`muhkoo.device.v1`) | `CryptoKeyPair`, ECDSA P-256, private half **non-extractable** |
5535
+ * | sealed identity blob | localStorage `muhkoo.tv.identity.v1` | `{"v":1,"iv":…,"ct":…}` over `JSON{username, commitment, seed(b64), pairedAt}` |
5536
+ *
5537
+ * The split is deliberate — ciphertext in one backend, key in another, and the
5538
+ * key non-extractable so JS can *use* it but not export it.
5539
+ *
5540
+ * ## Be honest about what this is worth
5541
+ *
5542
+ * **This is obfuscation, not protection.** It raises the cost of a casual
5543
+ * `localStorage` dump to "nothing useful", and that is all:
5544
+ *
5545
+ * - **XSS on the device origin** cannot *exfiltrate* the wrapping key (it is
5546
+ * non-extractable) but can absolutely call `decrypt` and read the seed. The
5547
+ * only mitigation is that there is no third-party script in the shell —
5548
+ * enforce a strict CSP with no `unsafe-inline`, no CDN, no remote fonts.
5549
+ * - **ADB / root / a backup extraction / a forensic image recovers the seed.**
5550
+ * The IndexedDB LevelDB files hold the AES key bytes in the clear; WebCrypto
5551
+ * "non-extractable" is a JS-API boundary, not an OS one. **A stolen device is
5552
+ * a stolen identity.**
5553
+ *
5554
+ * That is not an implementation shortcut, it is the shape of the problem: any
5555
+ * wrapping key usable without user interaction lives somewhere equally readable
5556
+ * on a keyboard-less device with no hardware key store exposed to WebView.
5557
+ * Reduce the blast radius with `android:allowBackup="false"`,
5558
+ * `android:debuggable="false"`, a strict CSP, and by storing *only* the 32-byte
5559
+ * seed + username + commitment (never a session token, never a recovery phrase).
5560
+ *
5561
+ * ## Documented upgrade path — no protocol change
5562
+ *
5563
+ * If the native shell exposes a Keystore bridge:
5564
+ *
5565
+ * ```java
5566
+ * @JavascriptInterface public String wrap(String b64plain); // AES-GCM, StrongBox when present
5567
+ * @JavascriptInterface public String unwrap(String b64cipher);
5568
+ * ```
5569
+ *
5570
+ * this module prefers `globalThis.MuhkooKeystore` automatically and falls back
5571
+ * to the WebCrypto scheme otherwise. The Keystore key is hardware-backed and
5572
+ * non-exportable at the OS level, so a copied filesystem image alone becomes
5573
+ * useless. Nothing on the wire, in the endpoint contracts, or in the SDK surface
5574
+ * changes — only the two functions below do, internally.
5575
+ *
5576
+ * ## Degradation
5577
+ *
5578
+ * Nothing here throws at import time. On a runtime with neither backend (SSR,
5579
+ * Workers, Node) the module still loads: {@link loadDeviceIdentity} returns
5580
+ * `null`, {@link persistDeviceIdentity} throws a typed
5581
+ * {@link DeviceStoreUnavailableError} the caller can swallow, and
5582
+ * {@link deviceIdentityKey} falls back to an **in-memory** keypair for the
5583
+ * lifetime of the process (so pairing still works; the device is just never
5584
+ * recognised as "known" on a later run).
5585
+ */
5586
+ /** localStorage key holding the AES-GCM-sealed identity blob. */
5587
+ declare const DEVICE_IDENTITY_STORAGE_KEY = "muhkoo.tv.identity.v1";
5588
+ /** IndexedDB database holding the non-extractable keys. */
5589
+ declare const DEVICE_KEY_DB_NAME = "muhkoo.device.v1";
5590
+ /** What a paired device persists. The seed **is** the identity — treat it as such. */
5591
+ interface PersistedDeviceIdentity {
5592
+ username: string;
5593
+ /** Decimal-string Poseidon commitment. Pinned: a later pairing that yields a
5594
+ * different commitment for this device must be refused, not adopted. */
5595
+ commitment: string;
5596
+ /** The 32-byte master seed. */
5597
+ seed: Uint8Array;
5598
+ pairedAt: number;
5599
+ }
5600
+ /** Thrown when persistence is impossible (no IndexedDB and/or no localStorage). */
5601
+ declare class DeviceStoreUnavailableError extends Error {
5602
+ constructor(what: string);
5603
+ }
5604
+ /** Key/value store for live `CryptoKey` objects (IndexedDB in the browser). */
5605
+ interface DeviceKeyVault {
5606
+ get(id: string): Promise<unknown | null>;
5607
+ put(id: string, value: unknown): Promise<void>;
5608
+ /** Delete the entire store — used by `clearDeviceIdentity`. */
5609
+ destroy(): Promise<void>;
5610
+ }
5611
+ /** String store for the sealed blob (localStorage in the browser). */
5612
+ interface DeviceBlobStore {
5613
+ get(key: string): string | null;
5614
+ set(key: string, value: string): void;
5615
+ remove(key: string): void;
5616
+ }
5617
+ /** The native Keystore bridge, when the shell provides one (see module docs). */
5618
+ interface MuhkooKeystoreBridge {
5619
+ wrap(b64plain: string): string | Promise<string>;
5620
+ unwrap(b64cipher: string): string | Promise<string>;
5621
+ }
5622
+ /**
5623
+ * Point the store at explicit backends (or `null` to force "unavailable"), and
5624
+ * reset the memoized device keypair. Testing / embedding seam — pass `{}` to
5625
+ * restore the default browser backends.
5626
+ */
5627
+ declare function configureDeviceStore(opts?: {
5628
+ keys?: DeviceKeyVault | null;
5629
+ blobs?: DeviceBlobStore | null;
5630
+ keystore?: MuhkooKeystoreBridge | null;
5631
+ }): void;
5632
+ /**
5633
+ * True when {@link deviceIdentityKey} had to fall back to an in-memory keypair
5634
+ * because no key vault was available. Such a device can pair, but will never be
5635
+ * recognised as "known" afterwards (so approval always needs a fresh factor).
5636
+ */
5637
+ declare function deviceIdentityIsEphemeral(): boolean;
5638
+ /**
5639
+ * The device's **persistent** ECDSA P-256 identity keypair, created on first
5640
+ * call. This is not a browser fingerprint — it is a key, and every
5641
+ * `POST /api/auth/device/code` proves possession of it. Without that proof,
5642
+ * "known device" would be an unauthenticated string anyone could claim in order
5643
+ * to skip the approver's re-authentication.
5644
+ *
5645
+ * The private half is non-extractable and never leaves IndexedDB as bytes.
5646
+ */
5647
+ declare function deviceIdentityKey(): Promise<CryptoKeyPair>;
5648
+ /** `base64url(SHA-256(raw SEC1 device identity public key))` — 43 chars. */
5649
+ declare function deviceFingerprint(): Promise<string>;
5650
+ /** Whether a sealed identity blob exists. Cheap: no crypto, no IndexedDB, no network. */
5651
+ declare function hasDeviceIdentity(): Promise<boolean>;
5652
+ /**
5653
+ * Seal and store the identity. Throws {@link DeviceStoreUnavailableError} when
5654
+ * there is nowhere to put it — callers should treat that as "this device won't
5655
+ * resume", not as a pairing failure.
5656
+ */
5657
+ declare function persistDeviceIdentity(v: PersistedDeviceIdentity): Promise<void>;
5658
+ /**
5659
+ * Recover the persisted identity, or `null` when there is none / it can't be
5660
+ * opened (wiped IndexedDB, a different browser profile, a corrupted blob).
5661
+ * Never throws — a cold start with unreadable storage must fall back to a fresh
5662
+ * pairing, not to an error screen.
5663
+ */
5664
+ declare function loadDeviceIdentity(): Promise<PersistedDeviceIdentity | null>;
5665
+ /**
5666
+ * Wipe everything: the sealed blob **and** the whole key database — so the
5667
+ * wrapping key never outlives the ciphertext it opens, and the device identity
5668
+ * key is regenerated (a re-pair is then correctly treated as a new device).
5669
+ */
5670
+ declare function clearDeviceIdentity(): Promise<void>;
5671
+
5167
5672
  /**
5168
5673
  * Passkey factor (M1.1) — WebAuthn with the **PRF extension**.
5169
5674
  *
@@ -5210,6 +5715,23 @@ declare function defaultRpId(): string;
5210
5715
  * sign-in (and offer to enroll a passkey for this origin) instead.
5211
5716
  */
5212
5717
  declare function rpIdUsableForOrigin(rpId: string | undefined | null, host?: string): boolean;
5718
+ /**
5719
+ * Would {@link AuthNamespace.loginWithPasskey} accept a factor recorded with
5720
+ * this `rpId`, from `host`?
5721
+ *
5722
+ * This is deliberately NOT the same as {@link rpIdUsableForOrigin}. That answers
5723
+ * a raw WebAuthn question and is `false` for a missing rpId. But a factor with
5724
+ * NO recorded rpId predates per-origin enrolment, and `loginWithPasskey`
5725
+ * resolves it as `params.rpId ?? host` — so it IS usable, from anywhere.
5726
+ *
5727
+ * Callers deciding "should I even try a passkey here?" must use this one.
5728
+ * Reaching for `rpIdUsableForOrigin` instead silently excludes every legacy
5729
+ * passkey holder, which has locked real users out of production twice.
5730
+ *
5731
+ * The `?? host` fallback applies to nullish only, matching `loginWithPasskey`:
5732
+ * an empty-string rpId stays unusable rather than resolving to the host.
5733
+ */
5734
+ declare function passkeyUsableFromOrigin(rpId: string | undefined | null, host?: string): boolean;
5213
5735
  /** Thrown when the account's passkey belongs to a different origin. Callers
5214
5736
  * should treat this as "no passkey here" and fall back to another factor. */
5215
5737
  declare class PasskeyOriginError extends Error {
@@ -6137,5 +6659,5 @@ declare class KeyringClient implements KeyringTransport {
6137
6659
  fetchMetadata(): Promise<SpaceMetadata | null>;
6138
6660
  }
6139
6661
 
6140
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
6141
- export type { ACL, ACLEntry, AccessTokenInfo, AccessTokensNamespaceDeps, AgentConfig, AgentCreateInput, AgentDbToolMode, AgentProvisioned, AgentScopeOpts, AgentSkill, AgentToolsConfig, AgentTrigger, AgentTriggerType, AgentUpdateInput, AgentsNamespaceDeps, AttachedPeerNetwork, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, BlockStore, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, CachedSpaceMessage, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, ConnectivityOptions, ConnectivityState, CreateAccessTokenInput, CreatedAccessToken, DbQueryPage, DehydratedKeypair, DehydratedKeys, DevicePairingKeypair, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EngineHandle, EngineHost, EphemeralEvent, EpochKeyProvider, FileManifest, FileNamespaceDeps, FileStat, FileStorageOptions, FunctionCaps, FunctionConfig, FunctionDeployInput, FunctionInvokeOptions, FunctionScopeOpts, FunctionTrigger, FunctionTriggerType, FunctionTriggers, FunctionUpdateInput, FunctionsNamespaceDeps, Groth16Proof, Hasher, HlcParts, HlcState, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, KvEntry, KvNamespaceDeps, LoginOptions, LwwMap, LwwRegister, MessageBody, MessageDeletedEvent, MessageEntry, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOp, MessageOptions, MessageSubscription, MuhkooAgentMeta, MuhkooAgentToolsConfig, MuhkooAppDescriptor, MuhkooDBAccess, MuhkooDBMeta, MuhkooFunctionMeta, MuhkooMessageEvent, MuhkooSpaceMeta, OAuthProvider, OfflineEntry, OfflineManagerDeps, OfflineStore, OutSink, PacketCipher, PacketHeaders, PacketOptions, Participant, PasskeyEnrollment, PeerBlockSource, PeerExchangeOptions, PeerId, PeerNetworkOptions, PeerTransport, Permission, PermissionCheckRequest, PermissionCheckResult, PersonalSpaceClientOptions, Pkce, PublicKeyJWK, QueueEntry, RatchetState, ReceiveResult, RegisterParams, Replayer, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, Scope, SealedHandoff, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardByteCache, ShardClientOptions, SharedSpaceClientOptions, SignalEnvelope, SignalKind, SignalingSpace, SnapshotNamespaceDeps, SnarkProof, SpaceChannelLike, SpaceCursor, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, SpaceOfflineAdapter, StorageChangeEvent, StoreName, StoredSession, Subscription, UserIdentity, VaultFactor, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WebRtcTransportOptions, WrappedPayload, WriteFileInput, WriteFileOptions, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };
6662
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReauthRequiredError, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
6663
+ export type { ACL, ACLEntry, AccessTokenInfo, AccessTokensNamespaceDeps, AgentConfig, AgentCreateInput, AgentDbToolMode, AgentProvisioned, AgentScopeOpts, AgentSkill, AgentToolsConfig, AgentTrigger, AgentTriggerType, AgentUpdateInput, AgentsNamespaceDeps, AttachedPeerNetwork, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, BlockStore, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, CachedSpaceMessage, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, ConnectivityOptions, ConnectivityState, CreateAccessTokenInput, CreatedAccessToken, DbQueryPage, DehydratedKeypair, DehydratedKeys, DeviceBlobStore, DeviceKeyVault, DevicePairingErrorReason, DevicePairingKeypair, DevicePairingPoll, DevicePairingRequest, DevicePairingSession, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EngineHandle, EngineHost, EphemeralEvent, EpochKeyProvider, FileManifest, FileNamespaceDeps, FileStat, FileStorageOptions, FunctionCaps, FunctionConfig, FunctionDeployInput, FunctionInvokeOptions, FunctionScopeOpts, FunctionTrigger, FunctionTriggerType, FunctionTriggers, FunctionUpdateInput, FunctionsNamespaceDeps, Groth16Proof, Hasher, HlcParts, HlcState, HostedAuthDeps, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, KvEntry, KvNamespaceDeps, LoginOptions, LwwMap, LwwRegister, MessageBody, MessageDeletedEvent, MessageEntry, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOp, MessageOptions, MessageSubscription, MuhkooAgentMeta, MuhkooAgentToolsConfig, MuhkooAppDescriptor, MuhkooDBAccess, MuhkooDBMeta, MuhkooFunctionMeta, MuhkooKeystoreBridge, MuhkooMessageEvent, MuhkooSpaceMeta, OAuthProvider, OfflineEntry, OfflineManagerDeps, OfflineStore, OutSink, PacketCipher, PacketHeaders, PacketOptions, PairedDevice, Participant, PasskeyEnrollment, PeerBlockSource, PeerExchangeOptions, PeerId, PeerNetworkOptions, PeerTransport, Permission, PermissionCheckRequest, PermissionCheckResult, PersistedDeviceIdentity, PersonalSpaceClientOptions, Pkce, PublicKeyJWK, QueueEntry, RatchetState, ReceiveResult, RegisterParams, Replayer, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, Scope, SealedHandoff, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardByteCache, ShardClientOptions, SharedSpaceClientOptions, SignalEnvelope, SignalKind, SignalingSpace, SnapshotNamespaceDeps, SnarkProof, SpaceChannelLike, SpaceCursor, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, SpaceOfflineAdapter, StartDevicePairingOptions, StorageChangeEvent, StoreName, StoredSession, Subscription, UserIdentity, VaultFactor, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WaitForDevicePairingOptions, WebRtcTransportOptions, WrappedPayload, WriteFileInput, WriteFileOptions, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };