@muhkoo/connect 0.10.10-alpha.0 → 0.10.12-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.
@@ -4537,8 +4537,12 @@ class AuthClient {
4537
4537
  * Read a factor record for `username` (returns an indistinguishable decoy if
4538
4538
  * absent). Gated types (email) require the recover `verifyToken`.
4539
4539
  */
4540
- async vaultRead(username, factorType, verifyToken) {
4541
- const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken }));
4540
+ async vaultRead(username, factorType, verifyToken,
4541
+ /** Passkeys are per-origin: naming the caller's host returns THAT origin's
4542
+ * passkey instead of whichever was enrolled first (which WebAuthn would
4543
+ * refuse). Ignored for other factor types. */
4544
+ rpId) {
4545
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId }));
4542
4546
  return await this.parse("vaultRead", res);
4543
4547
  }
4544
4548
  /**
@@ -5018,6 +5022,29 @@ function fromBase64(b64) {
5018
5022
  * seed) while moving credential entry onto the Muhkoo origin. The envelope is
5019
5023
  * versioned so a future per-app-key handoff (v2) can swap in without a protocol
5020
5024
  * change.
5025
+ *
5026
+ * ## v2 — device pairing (TV / no-redirect handoff)
5027
+ *
5028
+ * v1's K_t rides in the redirect URL *fragment*, which works only because the
5029
+ * receiving app is a browser page that can read `location.hash`. A TV app has
5030
+ * no redirect to receive: it polls. So v2 keeps the *entire* v1 shape — the
5031
+ * server relays a sealed envelope it cannot read — and replaces only the key
5032
+ * agreement:
5033
+ *
5034
+ * - the TV generates an ephemeral P-256 ECDH keypair in memory and publishes
5035
+ * the public half ({@link generateDevicePairingKeypair});
5036
+ * - the hosted page (the component that already holds the unlocked seed and
5037
+ * already owns sealing) seals *to that public key* with a fresh ephemeral
5038
+ * keypair of its own ({@link sealSeedToDevice}) — ECDH → HKDF-SHA256 →
5039
+ * AES-256-GCM;
5040
+ * - the TV recovers the seed with its private key
5041
+ * ({@link unsealSeedFromDevice}).
5042
+ *
5043
+ * The server stores and relays the v2 envelope byte-for-byte, exactly as it
5044
+ * does for v1, and can read neither. The one thing ECDH cannot supply is proof
5045
+ * that the public key the hosted page sealed to is *the TV's* rather than an
5046
+ * attacker's — that is what {@link pairingVerificationCode} is for, and it is
5047
+ * the only MITM defence in the flow (see the entropy note on that function).
5021
5048
  */
5022
5049
  const TE = new TextEncoder();
5023
5050
  const TD = new TextDecoder();
@@ -5043,6 +5070,236 @@ async function unsealSeed(sealedKeys, fragmentKey) {
5043
5070
  const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
5044
5071
  return new Uint8Array(seed);
5045
5072
  }
5073
+ // ---- v2: ECDH device pairing ----------------------------------------------
5074
+ //
5075
+ // Deliberately a SEPARATE unseal entry point rather than one `unsealSeed` that
5076
+ // dispatches on `envelope.v`. The two versions do not just differ in envelope
5077
+ // shape — their *second argument* is a different kind of thing entirely (v1: a
5078
+ // base64url string plucked out of a URL fragment; v2: a non-extractable
5079
+ // `CryptoKey`). Overloading one function on a `string | CryptoKey` union would
5080
+ // buy nothing at the call sites (each caller statically knows which flow it is
5081
+ // in) while adding a narrowing branch to the v1 path, which is live in
5082
+ // production hosted auth. Each function still validates `v` and rejects the
5083
+ // other version's envelope, so a mis-routed envelope fails loudly rather than
5084
+ // being interpreted under the wrong scheme.
5085
+ /** P-256 curve parameters for the pairing ECDH. */
5086
+ const PAIRING_CURVE = { name: "ECDH", namedCurve: "P-256" };
5087
+ /**
5088
+ * HKDF `info` for the v2 seal. Versioned and domain-separated: v1 derives no
5089
+ * key at all (K_t is raw random), so no v1 key can ever collide with this, and
5090
+ * a future v3 must pick a new label rather than reuse this one.
5091
+ */
5092
+ const HKDF_INFO_V2 = "muhkoo/hosted-handoff/v2/device-seal";
5093
+ /** Domain separator for {@link pairingVerificationCode} — distinct from the seal KDF. */
5094
+ const PAIRING_CODE_DOMAIN = "muhkoo/hosted-handoff/v2/pairing-code";
5095
+ /**
5096
+ * Alphabet for the pairing verification code: 30 symbols, every confusable pair
5097
+ * removed in BOTH directions — no `0`/`O`, no `1`/`I`/`L`, and no `U` (which is
5098
+ * read as `V` on the low-resolution, far-viewed type a TV renders). Because the
5099
+ * user compares this string across two screens by eye, unambiguity beats the
5100
+ * convenience of a power-of-two alphabet; the derivation below rejection-samples
5101
+ * to avoid the modulo bias that costs us.
5102
+ */
5103
+ const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
5104
+ /** Length of the pairing verification code, in characters. 30^8 ≈ 39.3 bits. */
5105
+ const PAIRING_CODE_LENGTH = 8;
5106
+ /**
5107
+ * PBKDF2 iterations for the verification code. This is NOT protecting a
5108
+ * password — it is a deliberate work factor on an attacker precomputing codes.
5109
+ * See {@link pairingVerificationCode}.
5110
+ */
5111
+ const PAIRING_CODE_ITERATIONS = 200_000;
5112
+ /**
5113
+ * (Device side) Generate the ephemeral P-256 ECDH keypair for one pairing
5114
+ * attempt. Call this once per attempt and throw it away when the attempt ends —
5115
+ * reusing a keypair across attempts would let an old, observed ciphertext be
5116
+ * decrypted by whoever later obtains the key.
5117
+ */
5118
+ async function generateDevicePairingKeypair() {
5119
+ const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
5120
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
5121
+ return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
5122
+ }
5123
+ /**
5124
+ * (Hosted-page side) Seal the master seed *to a device's* pairing public key.
5125
+ *
5126
+ * A fresh ephemeral keypair is generated per call and its public half is
5127
+ * carried in the envelope (`epk`), so sealing the same seed to the same device
5128
+ * twice yields unrelated ciphertext — and the sealer retains nothing that could
5129
+ * later reopen the envelope.
5130
+ *
5131
+ * Returns the v2 `sealedKeys` string, which slots into exactly the same server
5132
+ * field as v1: relayed verbatim, never read.
5133
+ */
5134
+ async function sealSeedToDevice(seed, devicePublicKeyB64) {
5135
+ const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
5136
+ const devicePub = await importPairingPublicKey(devicePubRaw);
5137
+ const ephemeral = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
5138
+ const epkRaw = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
5139
+ const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
5140
+ const iv = crypto.getRandomValues(new Uint8Array(12));
5141
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
5142
+ const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
5143
+ return toBase64(TE.encode(JSON.stringify(envelope)));
5144
+ }
5145
+ /**
5146
+ * (Device side) Recover the master seed from a v2 envelope using the ephemeral
5147
+ * private key from {@link generateDevicePairingKeypair}.
5148
+ *
5149
+ * Throws on any tamper: a substituted `epk` yields a different shared secret
5150
+ * (or fails point validation outright), and any edit to `iv`/`ct` fails the
5151
+ * AES-GCM tag. There is no path that returns partially-authenticated bytes.
5152
+ *
5153
+ * `devicePublicKeyB64` is the caller's OWN public half (from
5154
+ * {@link generateDevicePairingKeypair}). It is required, not optional, because
5155
+ * it is mixed into the KDF — see {@link deriveDeviceSealKey} for why binding the
5156
+ * recipient matters. A device always holds both halves, so this costs nothing.
5157
+ */
5158
+ async function unsealSeedFromDevice(sealedKeys, privateKey, devicePublicKeyB64) {
5159
+ const envelope = JSON.parse(TD.decode(fromBase64Loose(sealedKeys)));
5160
+ if (envelope.v !== 2)
5161
+ throw new Error(`Unsupported device-handoff envelope version ${envelope.v}`);
5162
+ const devicePubRaw = decodePairingPublicKey(devicePublicKeyB64);
5163
+ const epkRaw = decodePairingPublicKey(envelope.epk);
5164
+ const epk = await importPairingPublicKey(epkRaw);
5165
+ const key = await deriveDeviceSealKey(privateKey, epk, epkRaw, devicePubRaw, ["decrypt"]);
5166
+ const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64Loose(envelope.iv) }, key, fromBase64Loose(envelope.ct));
5167
+ return new Uint8Array(seed);
5168
+ }
5169
+ /**
5170
+ * Short code derived deterministically from the device's pairing public key,
5171
+ * displayed on BOTH the TV and the approving browser so the human can confirm
5172
+ * they match before approving.
5173
+ *
5174
+ * ## What this is worth (be honest about it)
5175
+ *
5176
+ * ~39.3 bits (30^8), rendered in an unambiguous 30-symbol alphabet and grouped
5177
+ * as `XXXX-XXXX` for comparison. Alongside the recipient-key binding in
5178
+ * {@link deriveDeviceSealKey}, this is what stops a substituted device key: the
5179
+ * KDF rejects a *negated* key, and this code is what a human uses to reject a
5180
+ * wholly *attacker-generated* one, which the crypto cannot detect on its own —
5181
+ * ECDH proves nothing about WHOSE public key it is.
5182
+ *
5183
+ * Two attacks, and what each actually costs:
5184
+ *
5185
+ * 1. **Online grind.** Generate keypairs until one's code equals the honest
5186
+ * TV's. ~2^39 trials. Naively that reads as "keygen + hash", but a real
5187
+ * attacker never generates keys: walking `P_{i+1} = P_i + G` while tracking
5188
+ * `d_i = d_0 + i` makes each trial one point-add plus one derivation. With
5189
+ * a plain hash that is well under a core-day — which is why this uses
5190
+ * PBKDF2-SHA256 at {@link PAIRING_CODE_ITERATIONS} iterations instead. The
5191
+ * work factor applies per trial, pushing the same grind to the order of
5192
+ * 10^5 core-days. The target is also only learned when pairing starts, so
5193
+ * the grind is online against the pairing TTL (keep it minutes).
5194
+ * 2. **Precomputed table.** The reason this is not a plain hash. A table over
5195
+ * candidate keys is computed once and reused forever; with SHA-256 that is
5196
+ * cheap enough to be the preferred attack. The iteration count multiplies
5197
+ * the table's build cost by the same 2 * 10^5.
5198
+ *
5199
+ * Neither is a proof of security, and an adversary with ASIC-scale hardware
5200
+ * changes these numbers. If the margin is ever judged insufficient, raise
5201
+ * {@link PAIRING_CODE_LENGTH} — 10 characters buys ~49 bits for two more
5202
+ * characters of human comparison, and is display-only: it does not touch the
5203
+ * envelope or the wire format.
5204
+ *
5205
+ * The cost is paid once per pairing, on two idle devices, so ~200k iterations
5206
+ * is imperceptible here in a way it would not be on a hot path.
5207
+ */
5208
+ async function pairingVerificationCode(devicePublicKeyB64) {
5209
+ const pubRaw = decodePairingPublicKey(devicePublicKeyB64);
5210
+ const material = await crypto.subtle.importKey("raw", pubRaw, { name: "PBKDF2" }, false, [
5211
+ "deriveBits",
5212
+ ]);
5213
+ // Enough bytes that rejection sampling (below) effectively never runs dry:
5214
+ // each byte is rejected with p = 16/256, so 32 bytes yields 8 symbols with
5215
+ // overwhelming probability.
5216
+ const bits = new Uint8Array(await crypto.subtle.deriveBits({
5217
+ name: "PBKDF2",
5218
+ hash: "SHA-256",
5219
+ salt: TE.encode(PAIRING_CODE_DOMAIN),
5220
+ iterations: PAIRING_CODE_ITERATIONS,
5221
+ }, material, 256));
5222
+ // Rejection-sample so all 30 symbols are equally likely. 256 is not a
5223
+ // multiple of 30, so a plain modulo would over-weight the first 16 symbols.
5224
+ const n = PAIRING_CODE_ALPHABET.length;
5225
+ const limit = Math.floor(256 / n) * n; // 240
5226
+ let out = "";
5227
+ for (let i = 0; i < bits.length && out.length < PAIRING_CODE_LENGTH; i++) {
5228
+ if (bits[i] >= limit)
5229
+ continue;
5230
+ out += PAIRING_CODE_ALPHABET[bits[i] % n];
5231
+ }
5232
+ if (out.length < PAIRING_CODE_LENGTH) {
5233
+ // Astronomically unlikely (p < 2^-40); fail loudly rather than return a
5234
+ // short code that two screens would still "agree" on.
5235
+ throw new Error("Pairing code derivation ran out of entropy.");
5236
+ }
5237
+ // Grouped for human comparison — the format the spec and both UIs display.
5238
+ return out.slice(0, 4) + "-" + out.slice(4);
5239
+ }
5240
+ // ---- v2 internals ----------------------------------------------------------
5241
+ /**
5242
+ * ECDH → HKDF-SHA256 → AES-256-GCM key.
5243
+ *
5244
+ * The raw ECDH output is never used as a key directly; it is extracted+expanded
5245
+ * with BOTH public keys mixed in:
5246
+ *
5247
+ * - the sender's ephemeral key as HKDF **salt**, and
5248
+ * - the recipient's device key appended to the HKDF **info**.
5249
+ *
5250
+ * Binding the recipient is not decoration. P-256 ECDH returns only the shared
5251
+ * point's x-coordinate, and negating a public key — `(x, y)` → `(x, p−y)`, which
5252
+ * is still on the curve and which ANYONE can compute from the published key —
5253
+ * yields the SAME x. So without the recipient in the KDF, a seal addressed to
5254
+ * the negated key still decrypts under the honest private key, even though that
5255
+ * negated key displays a DIFFERENT {@link pairingVerificationCode}. That would
5256
+ * leave the human code as the only thing rejecting a substituted recipient.
5257
+ * Mixing `devicePubRaw` in makes the substitution fail in the crypto layer,
5258
+ * where it belongs.
5259
+ *
5260
+ * (The salt is load-bearing for the same reason — it is what makes a swapped
5261
+ * `epk` derive a different key. Do not "simplify" either input away.)
5262
+ */
5263
+ async function deriveDeviceSealKey(priv, pub, epkRaw, devicePubRaw, usages) {
5264
+ const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256));
5265
+ const prk = await crypto.subtle.importKey("raw", shared, { name: "HKDF" }, false, ["deriveBits"]);
5266
+ const label = TE.encode(HKDF_INFO_V2);
5267
+ const info = new Uint8Array(label.length + devicePubRaw.length);
5268
+ info.set(label, 0);
5269
+ info.set(devicePubRaw, label.length);
5270
+ const okm = await crypto.subtle.deriveBits({
5271
+ name: "HKDF",
5272
+ hash: "SHA-256",
5273
+ salt: epkRaw,
5274
+ info: info,
5275
+ }, prk, 256);
5276
+ return crypto.subtle.importKey("raw", okm, { name: "AES-GCM" }, false, usages);
5277
+ }
5278
+ /** Decode + shape-check a raw SEC1 uncompressed P-256 public key. */
5279
+ function decodePairingPublicKey(b64) {
5280
+ if (typeof b64 !== "string" || b64.length === 0) {
5281
+ throw new Error("Pairing public key must be a non-empty base64 string.");
5282
+ }
5283
+ const raw = fromBase64Loose(b64);
5284
+ if (raw.length !== 65 || raw[0] !== 0x04) {
5285
+ throw new Error("Pairing public key must be a 65-byte uncompressed P-256 point.");
5286
+ }
5287
+ return raw;
5288
+ }
5289
+ /** Import a raw P-256 point for ECDH. Rejects points that are not on the curve. */
5290
+ function importPairingPublicKey(raw) {
5291
+ return crypto.subtle.importKey("raw", raw, PAIRING_CURVE, false, []);
5292
+ }
5293
+ /**
5294
+ * Base64 decode that also accepts the URL-safe alphabet and missing padding —
5295
+ * pairing public keys may travel through query strings, QR payloads, or JSON
5296
+ * depending on the client, and rejecting a key over `-` vs `+` would be a
5297
+ * miserable failure mode to debug.
5298
+ */
5299
+ function fromBase64Loose(s) {
5300
+ const norm = s.replace(/-/g, "+").replace(/_/g, "/");
5301
+ return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
5302
+ }
5046
5303
  /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
5047
5304
  async function generatePkce() {
5048
5305
  const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
@@ -5068,7 +5325,7 @@ function base64UrlDecode(s) {
5068
5325
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
5069
5326
  * `package.json` `version`.
5070
5327
  */
5071
- const VERSION = "0.10.10-alpha.0";
5328
+ const VERSION = "0.10.12-alpha.0";
5072
5329
 
5073
5330
  /**
5074
5331
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -5129,6 +5386,8 @@ class HttpClient {
5129
5386
  /** Origin of {@link baseUrl} — credentials are only attached same-origin. */
5130
5387
  baseOrigin;
5131
5388
  apiKey;
5389
+ /** Scoped machine access token — takes precedence over {@link apiKey} on `X-Muhkoo-Key`. */
5390
+ accessToken;
5132
5391
  getSessionToken;
5133
5392
  onUnauthorized;
5134
5393
  fetchFn;
@@ -5143,6 +5402,7 @@ class HttpClient {
5143
5402
  this.baseOrigin = "";
5144
5403
  }
5145
5404
  this.apiKey = opts.apiKey ?? null;
5405
+ this.accessToken = opts.accessToken ?? null;
5146
5406
  this.getSessionToken = opts.getSessionToken ?? (() => null);
5147
5407
  this.onUnauthorized = opts.onUnauthorized ?? null;
5148
5408
  const f = opts.fetch ?? globalThis.fetch;
@@ -5163,8 +5423,11 @@ class HttpClient {
5163
5423
  // server-reflected path that escapes `baseUrl`) must NOT receive the app
5164
5424
  // key or session token, or it becomes a token-exfiltration vector.
5165
5425
  if (this.isSameOrigin(input)) {
5166
- if (this.apiKey)
5167
- headers.set("X-Muhkoo-Key", this.apiKey);
5426
+ // Access token and app key resolve through the same accelerator path
5427
+ // and ride the same header; the access token takes precedence.
5428
+ const key = this.accessToken ?? this.apiKey;
5429
+ if (key)
5430
+ headers.set("X-Muhkoo-Key", key);
5168
5431
  const token = this.getSessionToken();
5169
5432
  if (token)
5170
5433
  headers.set("X-Muhkoo-Session", token);
@@ -5578,6 +5841,35 @@ async function passkeyPrfCapable() {
5578
5841
  function defaultRpId() {
5579
5842
  return typeof location !== "undefined" ? location.hostname : "";
5580
5843
  }
5844
+ /**
5845
+ * Can a passkey enrolled under `rpId` be used from the CURRENT origin?
5846
+ *
5847
+ * WebAuthn only accepts an RP id that is the origin's own host or a registrable
5848
+ * *parent* of it — so a passkey enrolled on `app.example.com` is unusable on
5849
+ * `app.example.net`, and one enrolled on a full host is unusable from a sibling
5850
+ * host. An app served on BOTH a custom domain and the platform host therefore
5851
+ * needs one passkey per origin.
5852
+ *
5853
+ * Call this BEFORE `navigator.credentials.get()`: passing a foreign rpId throws
5854
+ * a raw "The requested RPID did not match the origin or related origins", which
5855
+ * is a dead end for the user. Checking first lets the caller fall back to normal
5856
+ * sign-in (and offer to enroll a passkey for this origin) instead.
5857
+ */
5858
+ function rpIdUsableForOrigin(rpId, host) {
5859
+ const h = host ?? (typeof location !== "undefined" ? location.hostname : "");
5860
+ if (!rpId || !h)
5861
+ return false;
5862
+ return h === rpId || h.endsWith(`.${rpId}`);
5863
+ }
5864
+ /** Thrown when the account's passkey belongs to a different origin. Callers
5865
+ * should treat this as "no passkey here" and fall back to another factor. */
5866
+ class PasskeyOriginError extends Error {
5867
+ code = "passkey_wrong_origin";
5868
+ constructor(message = "No passkey is enrolled for this site. Sign in another way, then add one for this device.") {
5869
+ super(message);
5870
+ this.name = "PasskeyOriginError";
5871
+ }
5872
+ }
5581
5873
  /**
5582
5874
  * Create a passkey with PRF and return its credential id, a fresh PRF salt, and
5583
5875
  * the PRF output for that salt. Throws if the authenticator can't do PRF.
@@ -6271,12 +6563,22 @@ class ZkAuth {
6271
6563
  * its PRF, unwraps the master seed → identity → session.
6272
6564
  */
6273
6565
  async loginWithPasskey(username, opts = {}) {
6274
- const { factor } = await this.deps.auth.vaultRead(username, "passkey");
6566
+ // Ask for THIS origin's passkey — they're enrolled per-origin, so the
6567
+ // account's first passkey may belong to a different host entirely.
6568
+ const host = defaultRpId();
6569
+ const { factor } = await this.deps.auth.vaultRead(username, "passkey", undefined, host);
6275
6570
  const params = factor?.params;
6276
6571
  if (!factor?.wrap || !factor?.iv || !params?.credentialId || !params?.prfSalt) {
6277
6572
  throw new Error("No passkey is set up for this account.");
6278
6573
  }
6279
- const prfOutput = await evaluatePasskeyPrf(params.rpId ?? defaultRpId(), fromBase64(params.credentialId), fromBase64(params.prfSalt));
6574
+ const rpId = params.rpId ?? host;
6575
+ // Refuse a foreign rpId up front: WebAuthn would throw a raw "requested
6576
+ // RPID did not match the origin", which reads as a hard failure to the
6577
+ // user. A typed error lets the app fall back to another factor and offer
6578
+ // to enroll a passkey for this origin.
6579
+ if (!rpIdUsableForOrigin(rpId, host))
6580
+ throw new PasskeyOriginError();
6581
+ const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
6280
6582
  const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
6281
6583
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
6282
6584
  const identity = await deriveIdentityFromSeed(seed);
@@ -6296,6 +6598,27 @@ class ZkAuth {
6296
6598
  const { factors } = await this.deps.auth.vaultFactors(token);
6297
6599
  return factors;
6298
6600
  }
6601
+ /**
6602
+ * Is a passkey enrolled that can unlock from THIS origin?
6603
+ *
6604
+ * Passkeys are origin-bound, so an app served on both a custom domain and the
6605
+ * platform host needs one per host. Use this to decide whether to offer
6606
+ * "Unlock with passkey" or go straight to sign-in — prompting with another
6607
+ * origin's passkey fails with a raw WebAuthn error. Requires a session
6608
+ * (metadata read); returns false when signed out or unsupported.
6609
+ */
6610
+ async hasPasskeyForThisOrigin() {
6611
+ if (!passkeySupported())
6612
+ return false;
6613
+ try {
6614
+ const host = defaultRpId();
6615
+ const factors = await this.listFactors();
6616
+ return factors.some((f) => f.type === "passkey" && rpIdUsableForOrigin(f.rpId, host));
6617
+ }
6618
+ catch {
6619
+ return false;
6620
+ }
6621
+ }
6299
6622
  /** Remove a login method by id (can't remove your only one). Session-authed. */
6300
6623
  async removeFactor(id) {
6301
6624
  const token = this.deps.session.token;
@@ -10109,6 +10432,59 @@ class FunctionsNamespace {
10109
10432
  }
10110
10433
  }
10111
10434
 
10435
+ /**
10436
+ * `client.accessTokens` — manage an app's **access tokens**: scoped, expiring,
10437
+ * non-ZK machine-to-machine credentials (`mk_<env>_at_…`).
10438
+ *
10439
+ * const { plaintext } = await client.accessTokens.create(appId, {
10440
+ * scopes: ["db:read", "db:write"], env: "live", expiresInDays: 30,
10441
+ * label: "ci-runner",
10442
+ * });
10443
+ * // hand `plaintext` to the machine caller as its ClientOptions.accessToken
10444
+ *
10445
+ * These are MANAGEMENT calls — creating/listing/revoking tokens is authorized by
10446
+ * the developer session (same as the `agents`/`functions` namespaces). The token
10447
+ * itself resolves through the SAME accelerator path as an app key (the
10448
+ * `X-Muhkoo-Key` header), but is scoped to a fixed vocabulary of permissions and
10449
+ * expires. The plaintext is returned ONCE, on create — store it then; it can't
10450
+ * be read back.
10451
+ */
10452
+ /** All valid {@link Scope}s, in canonical order. */
10453
+ const ACCESS_TOKEN_SCOPES = [
10454
+ "db:read",
10455
+ "db:write",
10456
+ "kv:read",
10457
+ "kv:write",
10458
+ "storage:read",
10459
+ "storage:write",
10460
+ "messages:read",
10461
+ "messages:write",
10462
+ "functions:invoke",
10463
+ "ai:infer",
10464
+ ];
10465
+ class AccessTokensNamespace {
10466
+ deps;
10467
+ constructor(deps) {
10468
+ this.deps = deps;
10469
+ }
10470
+ /** List the app's access tokens (metadata only — never the plaintext). */
10471
+ async list(appId) {
10472
+ const res = await this.deps.http.get(this.path(appId, ""));
10473
+ return res.tokens ?? [];
10474
+ }
10475
+ /** Mint an access token (owner/editor only). The plaintext is returned once. */
10476
+ async create(appId, input) {
10477
+ return this.deps.http.post(this.path(appId, ""), input);
10478
+ }
10479
+ /** Revoke an access token by its `keyId`. */
10480
+ async revoke(appId, keyId) {
10481
+ return this.deps.http.del(this.path(appId, `/${encodeURIComponent(keyId)}`));
10482
+ }
10483
+ path(appId, suffix) {
10484
+ return `/api/apps/${encodeURIComponent(appId)}/access-tokens${suffix}`;
10485
+ }
10486
+ }
10487
+
10112
10488
  /**
10113
10489
  * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
10114
10490
  * keypair (the connect `KeyStore` entry keyed by the member id / username).
@@ -11356,6 +11732,8 @@ class Client {
11356
11732
  agents;
11357
11733
  /** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
11358
11734
  functions;
11735
+ /** Access tokens — `client.accessTokens.create(appId, …)`, etc. */
11736
+ accessTokens;
11359
11737
  /** Offline cache + sync — `client.offline.status`, `client.offline.snapshot`, etc. */
11360
11738
  offline;
11361
11739
  session;
@@ -11409,6 +11787,7 @@ class Client {
11409
11787
  this.http = new HttpClient({
11410
11788
  baseUrl: this.baseUrl,
11411
11789
  apiKey: options.apiKey,
11790
+ accessToken: options.accessToken,
11412
11791
  getSessionToken: () => this.session.token,
11413
11792
  fetch: reportingFetch,
11414
11793
  // Self-heal stale sessions: on a 401, try a silent re-auth (only
@@ -11485,6 +11864,7 @@ class Client {
11485
11864
  });
11486
11865
  this.agents = new AgentsNamespace({ http: this.http });
11487
11866
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
11867
+ this.accessTokens = new AccessTokensNamespace({ http: this.http });
11488
11868
  // Replay deferred shard uploads on reconnect. The bytes live in the
11489
11869
  // Cache API keyed by hash; re-PUT them through a (defer-free) shard
11490
11870
  // client so a failure surfaces as transient and the entry stays queued.
@@ -11634,6 +12014,7 @@ function MuhkooDB(meta) {
11634
12014
  table: meta.table ?? String(propertyKey),
11635
12015
  access: meta.access ?? "read",
11636
12016
  description: meta.description,
12017
+ backend: meta.backend ?? false,
11637
12018
  });
11638
12019
  });
11639
12020
  }
@@ -11673,9 +12054,12 @@ function ejectAgentPrompt(appClass) {
11673
12054
  for (const s of d.spaces)
11674
12055
  lines.push(`- ${s.name}: ${s.description}`);
11675
12056
  }
11676
- if (d.tables.length) {
12057
+ // Backend-only tables are deliberately withheld — the agent should never be
12058
+ // told about server-managed / internal state it can't (and shouldn't) touch.
12059
+ const promptTables = d.tables.filter((t) => !t.backend);
12060
+ if (promptTables.length) {
11677
12061
  lines.push("", "App database tables:");
11678
- for (const t of d.tables) {
12062
+ for (const t of promptTables) {
11679
12063
  const verbs = t.access === "write"
11680
12064
  ? "read & write — db_query/db_get/db_insert/db_update/db_delete"
11681
12065
  : "read-only — db_query/db_get";
@@ -11693,7 +12077,7 @@ function ejectAgentPrompt(appClass) {
11693
12077
  // How to respond — the load-bearing behavioral contract. Without this, models
11694
12078
  // (especially gpt-oss) tend to run tool calls and then end their turn with no
11695
12079
  // user-facing message, so the user sees the work happen but gets no reply.
11696
- const hasTools = d.tables.length > 0 || d.functions.length > 0;
12080
+ const hasTools = promptTables.length > 0 || d.functions.length > 0;
11697
12081
  lines.push("", "How to respond:");
11698
12082
  if (hasTools) {
11699
12083
  lines.push("- When you need information or need to make a change, call your tools immediately — don't ask the user to do it, and don't describe or name the tools to them.", "- After your tool calls return, you MUST send one final reply to the channel, in plain language, that answers the request using the results.");
@@ -11903,5 +12287,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
11903
12287
  default: loadRsWasm
11904
12288
  });
11905
12289
 
11906
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, 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, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, 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, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
12290
+ 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 };
11907
12291
  //# sourceMappingURL=index.js.map