cautpartener / client / src / core / app-mailbox-auth.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
/**
 * ONE app-scoped mailbox bearer for the whole app (docs/PROTOCOL.md §6).
 *
 * The node's open mint (`GET <credsUrl>?ttl=86400`, no `room` param — verified
 * against the live node 2026-08-22) returns a token valid for EVERY room.
 * Under open mint a per-room token adds no security — the unguessable room id
 * is the access guard — so per-room minting only multiplied requests (each
 * mint bills a small fee and rate-limits). This class satisfies the exact
 * interface lib/mailbox.ts MailboxClient consumes (get() / invalidate(); the
 * client re-mints once on 401/403 via invalidate()+get()) and is shared by
 * every room, probe and public-plane transport via appMailboxAuth().
 *
 * It EXTENDS the frozen lib's MailboxAuth purely to satisfy its nominal type
 * (ES #-private fields make the class non-structural); every method is
 * overridden and no base state is used.
 *
 * Caching: memory + localStorage (nsKey — all tabs share one token). The
 * stored expiry is the ACTUAL `expires_at` the node returned (fallback: its
 * `ttl`), never the requested ttl. Refresh starts ~5 min before expiry; if
 * the refresh mint fails but the old token has not actually expired yet, the
 * old token is served (offline tolerance — a failed mint must not kill a
 * still-valid bearer). A hard mint failure rejects, which MailboxClient
 * degrades to its typed "net" results exactly as with the lib auth.
 */

import { nsKey } from "../config";
import { MailboxAuth } from "../lib/mailbox";

const MINT_TTL_SEC = 86_400; // ask for the max (24 h); the node may clamp
const REFRESH_MARGIN_MS = 300_000; // renew 5 min early
const LS_KEY = nsKey("mbx-app-token");

interface MintResponse {
  token: string;
  ttl?: number;
  expires_at?: number; // unix seconds — the node's actual expiry
}

interface StoredToken {
  token: string;
  exp: number; // ms epoch, actual expiry (NOT minus the refresh margin)
}

export class AppMailboxAuth extends MailboxAuth {
  #credsUrl: string;
  #mem: StoredToken | null = null;
  #inflight: Promise<string> | null = null;

  constructor(credsUrl: string) {
    super(credsUrl, ""); // base state unused — see module docstring
    this.#credsUrl = credsUrl;
  }

  override get(): Promise<string> {
    const t = this.#load();
    if (t && Date.now() < t.exp - REFRESH_MARGIN_MS) return Promise.resolve(t.token);
    this.#inflight ??= this.#mint(t).finally(() => (this.#inflight = null));
    return this.#inflight;
  }

  /** Drop the cached token (server said 401/403) so the next get() re-mints. */
  override invalidate(): void {
    this.#mem = null;
    try {
      localStorage.removeItem(LS_KEY);
    } catch {}
  }

  /** Memory first, then localStorage (another tab may have refreshed). */
  #load(): StoredToken | null {
    const now = Date.now();
    if (this.#mem && now < this.#mem.exp - REFRESH_MARGIN_MS) return this.#mem;
    try {
      const raw = localStorage.getItem(LS_KEY);
      if (raw) {
        const t = JSON.parse(raw) as StoredToken;
        if (typeof t?.token === "string" && typeof t?.exp === "number" && now < t.exp) {
          this.#mem = t;
          return t;
        }
      }
    } catch {}
    return this.#mem && now < this.#mem.exp ? this.#mem : null;
  }

  /** Mint app-wide (NO room param); on failure fall back to a stale-but-unexpired token. */
  async #mint(stale: StoredToken | null): Promise<string> {
    try {
      const url = `${this.#credsUrl}?ttl=${MINT_TTL_SEC}`;
      const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
      if (!res.ok) throw new Error(`mailbox mint ${res.status}`);
      const c = (await res.json()) as MintResponse;
      if (!c.token) throw new Error("mailbox mint: no token");
      // the ACTUAL expiry the node granted — never assume the requested ttl
      const exp =
        typeof c.expires_at === "number" && c.expires_at > 0
          ? c.expires_at * 1000
          : Date.now() + (c.ttl ?? 0) * 1000;
      const t: StoredToken = { token: c.token, exp };
      this.#mem = t;
      try {
        localStorage.setItem(LS_KEY, JSON.stringify(t));
      } catch {} // Safari private mode / quota — memory cache still serves this tab
      return c.token;
    } catch (err) {
      if (stale && Date.now() < stale.exp) return stale.token; // offline grace
      throw err;
    }
  }
}

// ---- the single shared instance ---------------------------------------------

let shared: AppMailboxAuth | null = null;
let sharedUrl = "";

/**
 * The app-wide auth. One instance per credsUrl (the descriptor can rotate the
 * endpoint at boot — a changed URL invalidates the old instance, and the
 * localStorage token is dropped so a token from the old endpoint never leaks
 * to the new one).
 */
export function appMailboxAuth(credsUrl: string): AppMailboxAuth {
  if (!shared || sharedUrl !== credsUrl) {
    if (shared) shared.invalidate();
    shared = new AppMailboxAuth(credsUrl);
    sharedUrl = credsUrl;
  }
  return shared;
}

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git