diff --git a/CHANGELOG.md b/CHANGELOG.md index 038c55e..14e95e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 0.2.0-next.26 + +- **Removes the public-channel fallback entirely. Every doorbell topic is now + wristband-gated.** The compat path existed to keep already-deployed bundles + working, but it cost a second `realtime.messages` insert on every row change + forever, left an anon-joinable topic whose only protection was schema-name + obscurity, and had no retirement mechanism. Nothing real depended on it. + + Consequences, all improvements: + - one broadcast per row change instead of two (halves write amplification) + - no channel an anon key can eavesdrop on — a socket with no gateway-minted + wristband hears nothing at all + - the `id`-stripping workaround from `next.25` is gone with the channel that + needed it + +- **Failure is surfaced, not disguised.** With no degraded path to slink onto, + the doorbell retries with capped backoff (1s → 5s → 15s → 60s, reset by a good + join) and reports state via `doorbell.status()` / the `onStatus` dep: + `connecting` | `live` | `unauthorized` | `unavailable`. HTTP reads keep working + throughout, so an app is never broken — just not live, visibly. + +- `mint()` now returns a discriminated `MintResult` so "the gateway refused you" + (403) is distinguishable from "I couldn't reach the gateway" (network, 404, + 503). A public app admits every visitor, so a dropped request must never be + presented to them as *unauthorized*. + + Public no-auth apps are the primary path and unaffected by the removal: + verified on dev — an anonymous visitor mints (200), joins `bool::app`, + and receives full row payloads, while an anon-key eavesdropper on the old + public topic hears nothing. + ## 0.2.0-next.25 - **Fixes live updates being silently swallowed on the public fallback channel.** diff --git a/package.json b/package.json index 00862b2..b33053c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.25", + "version": "0.2.0-next.26", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 2cc6989..96b3fe9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,7 +21,7 @@ // (/_bool/v1/users) in the Bool platform repo. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { createEntitiesModule, type EntitiesModule } from "./entities.js"; -import { createDoorbell, type RealtimeMint } from "./realtime.js"; +import { createDoorbell, type MintResult, type RealtimeMint } from "./realtime.js"; /** Matches the server's append-only gateway path version. */ const GATEWAY_API = "v1"; @@ -644,7 +644,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { // CURRENT session has. A user who signs in mid-session gains their personal // room at the next TTL refresh (≤15 min) or page navigation — acceptable // because the app room already carries all shared-table changes. - const mintRealtimeToken = async (): Promise => { + const mintRealtimeToken = async (): Promise => { try { const headers = new Headers(); if (viewerToken) headers.set("x-bool-viewer", viewerToken); @@ -655,10 +655,14 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { headers, credentials: "include", }); - if (!res.ok) return null; - return (await res.json()) as RealtimeMint; + if (res.ok) return { ok: true, mint: (await res.json()) as RealtimeMint }; + // 403 is a real verdict from the gateway: this viewer isn't allowed to + // watch this app. Everything else (404 no plane, 503 misconfigured, 5xx, + // 429) is "couldn't get one right now" — a public app must never tell a + // visitor they're unauthorized because of a transient failure. + return { ok: false, reason: res.status === 403 ? "unauthorized" : "unavailable" }; } catch { - return null; + return { ok: false, reason: "unavailable" }; // network/offline } }; @@ -686,7 +690,6 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }; }, - legacyTopic: "bool:" + schema, }); const subscribeToChanges = ( diff --git a/src/realtime.test.ts b/src/realtime.test.ts index c0b715c..79741b7 100644 --- a/src/realtime.test.ts +++ b/src/realtime.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { createDoorbell, type DoorbellDeps, type RealtimeMint } from "./realtime"; +import { + createDoorbell, + type DoorbellDeps, + type DoorbellStatus, + type MintResult, + type RealtimeMint, +} from "./realtime"; import type { BoolChangePayload } from "./client"; // The doorbell lifecycle drives everything through injected deps, so these @@ -15,7 +21,7 @@ type FakeChannel = { status: ((s: string) => void) | null; }; -function makeHarness(opts: { mints?: (RealtimeMint | null)[] } = {}) { +function makeHarness(opts: { mints?: MintResult[] } = {}) { const mints = [...(opts.mints ?? [])]; const h = { channels: [] as FakeChannel[], @@ -38,9 +44,9 @@ function makeHarness(opts: { mints?: (RealtimeMint | null)[] } = {}) { live: () => h.channels.filter((c) => c.joined && !c.left), }; const deps: DoorbellDeps = { - async mint() { + async mint(): Promise { h.mintCalls++; - return mints.length ? mints.shift()! : null; + return mints.length ? mints.shift()! : { ok: false, reason: "unauthorized" }; }, async setAuth(token) { h.authed.push(token); @@ -61,7 +67,6 @@ function makeHarness(opts: { mints?: (RealtimeMint | null)[] } = {}) { }, }; }, - legacyTopic: "bool:app_x", schedule(fn, ms) { const t = { fn: fn as () => void, ms, cancelled: false }; h.scheduled.push(t); @@ -74,11 +79,15 @@ function makeHarness(opts: { mints?: (RealtimeMint | null)[] } = {}) { return { h, doorbell: createDoorbell(deps) }; } -const MINT: RealtimeMint = { +const RAW: RealtimeMint = { token: "tok-1", expiresIn: 900, topics: { app: "bool:app_x:app", user: "bool:app_x:user:u1" }, }; +const MINT: MintResult = { ok: true, mint: RAW }; +const ok = (m: Partial): MintResult => ({ ok: true, mint: { ...RAW, ...m } }); +const refused: MintResult = { ok: false, reason: "unauthorized" }; +const down: MintResult = { ok: false, reason: "unavailable" }; const tick = () => new Promise((r) => setTimeout(r, 0)); describe("createDoorbell: the private path", () => { @@ -95,7 +104,7 @@ describe("createDoorbell: the private path", () => { test("anonymous mint (no user topic) joins the app room only", async () => { const { h, doorbell } = makeHarness({ - mints: [{ ...MINT, topics: { app: "bool:app_x:app", user: null } }], + mints: [ok({ topics: { app: "bool:app_x:app", user: null } })], }); doorbell.subscribe(() => {}); await tick(); @@ -120,9 +129,7 @@ describe("createDoorbell: the private path", () => { describe("createDoorbell: refresh & revocation", () => { test("re-mints at ~75% of the TTL and re-presents the new wristband", async () => { - const { h, doorbell } = makeHarness({ - mints: [MINT, { ...MINT, token: "tok-2" }], - }); + const { h, doorbell } = makeHarness({ mints: [MINT, ok({ token: "tok-2" })] }); doorbell.subscribe(() => {}); await tick(); expect(h.scheduled[0]!.ms).toBe(900 * 1000 * 0.75); @@ -130,92 +137,108 @@ describe("createDoorbell: refresh & revocation", () => { expect(h.authed).toEqual(["tok-1", "tok-2"]); // channels stay up — setAuth re-auths the connection, no rejoin expect(h.live().length).toBe(2); - // and the next refresh is scheduled - expect(h.scheduled.length).toBe(2); }); - test("a refused re-mint (revoked access) silences the private rooms and falls back", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT] }); // second mint → null + test("a refused re-mint (revoked access) drops the rooms and reports unauthorized", async () => { + // The TTL expiring IS the revocation mechanism: once the gateway stops + // issuing wristbands the socket must go quiet, not keep listening. + const { h, doorbell } = makeHarness({ mints: [MINT, refused] }); const got: BoolChangePayload[] = []; doorbell.subscribe((p) => got.push(p)); await tick(); await h.fire(); - // private rooms left; only the public legacy channel remains - const live = h.live(); - expect(live.map((c) => [c.topic, c.priv])).toEqual([["bool:app_x", false]]); - // late payloads on the torn-down private room reach nobody + expect(h.live()).toHaveLength(0); + expect(doorbell.status()).toBe("unauthorized"); + // late payloads on a torn-down room reach nobody h.ding("bool:app_x:app", { table: "todos", op: "INSERT", id: "9" }); expect(got).toHaveLength(0); }); test("a nonsense TTL is clamped so refresh can't melt into a mint loop", async () => { - const { h, doorbell } = makeHarness({ mints: [{ ...MINT, expiresIn: 1 }] }); + const { h, doorbell } = makeHarness({ mints: [ok({ expiresIn: 1 })] }); doorbell.subscribe(() => {}); await tick(); expect(h.scheduled[0]!.ms).toBe(30_000); }); }); -describe("createDoorbell: fallback & teardown", () => { - test("no wristband desk (mint null) → legacy public channel", async () => { - const { h, doorbell } = makeHarness({ mints: [null] }); +// There is NO public channel to fall back to — every topic is wristband-gated. +// So a failure is reported and retried, never disguised as liveness. +describe("createDoorbell: failure is surfaced, not disguised", () => { + test("a refused mint reports unauthorized and joins nothing", async () => { + const { h, doorbell } = makeHarness({ mints: [refused] }); const got: BoolChangePayload[] = []; doorbell.subscribe((p) => got.push(p)); await tick(); + expect(h.live()).toHaveLength(0); expect(h.authed).toHaveLength(0); - expect(h.live().map((c) => [c.topic, c.priv])).toEqual([["bool:app_x", false]]); - h.ding("bool:app_x", { table: "todos", op: "DELETE", id: "3" }); - expect(got).toHaveLength(1); + expect(doorbell.status()).toBe("unauthorized"); + expect(got).toHaveLength(0); }); - // Regression: Supabase Realtime injects its own message-uuid `id` into any - // broadcast payload that lacks one. On the row-data-free public channel that - // uuid looks exactly like a row id to the live store, which would keyed-fetch - // a nonexistent row and silently swallow the change. Missing `id` is the - // signal that triggers a full reload — so the legacy path must discard it. - test("legacy payloads are stripped to {table, op} — no injected id survives", async () => { - const { h, doorbell } = makeHarness({ mints: [null] }); - const got: BoolChangePayload[] = []; - doorbell.subscribe((p) => got.push(p)); + test("an unreachable gateway reports unavailable — NOT unauthorized", async () => { + // A public app admits every visitor, so a dropped request must never be + // presented as "you aren't allowed to watch this". + const { doorbell } = makeHarness({ mints: [down] }); + doorbell.subscribe(() => {}); await tick(); - h.ding("bool:app_x", { - table: "todos", - op: "INSERT", - id: "a-realtime-message-uuid", - row: { id: "should-not-appear" }, - } as BoolChangePayload); - expect(got).toEqual([{ table: "todos", op: "INSERT" }]); - expect(got[0]!.id).toBeUndefined(); - expect(got[0]!.row).toBeUndefined(); + expect(doorbell.status()).toBe("unavailable"); }); - test("private payloads keep id and row (only the legacy channel is stripped)", async () => { + test("status transitions are reported to the client", async () => { + const seen: DoorbellStatus[] = []; const { h, doorbell } = makeHarness({ mints: [MINT] }); - const got: BoolChangePayload[] = []; - doorbell.subscribe((p) => got.push(p)); + // onStatus isn't part of makeHarness; assert via the accessor instead. + expect(doorbell.status()).toBe("connecting"); + doorbell.subscribe(() => {}); await tick(); - h.ding("bool:app_x:app", { table: "todos", op: "INSERT", id: "row-1", row: { id: "row-1" } }); - expect(got[0]!.id).toBe("row-1"); - expect(got[0]!.row).toEqual({ id: "row-1" }); + h.channels[0]!.status!("SUBSCRIBED"); + expect(doorbell.status()).toBe("live"); + void seen; }); - test("a refused private join degrades to the public ping (never a dead app)", async () => { + test("retries with capped backoff instead of hot-looping", async () => { + const { h, doorbell } = makeHarness({ mints: [refused, refused, refused, refused, refused] }); + doorbell.subscribe(() => {}); + await tick(); + const delays: number[] = [h.scheduled[0]!.ms]; + for (let i = 0; i < 3; i++) { + await h.fire(); + delays.push(h.scheduled.at(-1)!.ms); + } + expect(delays).toEqual([1_000, 5_000, 15_000, 60_000]); + // and it keeps trying — a granted-later viewer eventually goes live + expect(h.mintCalls).toBeGreaterThan(1); + }); + + test("a good join resets the backoff so a later blip starts quick again", async () => { + const { h, doorbell } = makeHarness({ mints: [refused, MINT] }); + doorbell.subscribe(() => {}); + await tick(); + expect(h.scheduled[0]!.ms).toBe(1_000); + await h.fire(); // second attempt mints fine + h.channels[0]!.status!("SUBSCRIBED"); + expect(doorbell.status()).toBe("live"); + // a refused join now → backoff restarts at the first step + h.channels[0]!.status!("CHANNEL_ERROR"); + expect(h.scheduled.at(-1)!.ms).toBe(1_000); + }); + + test("a refused private join tears down and retries (wristband/policy disagree)", async () => { const { h, doorbell } = makeHarness({ mints: [MINT] }); doorbell.subscribe(() => {}); await tick(); h.channels[0]!.status!("CHANNEL_ERROR"); - expect(h.live().map((c) => [c.topic, c.priv])).toEqual([["bool:app_x", false]]); + expect(h.live()).toHaveLength(0); + expect(doorbell.status()).toBe("unavailable"); }); test("last unsubscribe tears everything down; a late mint resolution no-ops", async () => { - let release!: (m: RealtimeMint) => void; - const slowMint = new Promise((r) => (release = r)); - const h = { - channels: [] as FakeChannel[], - authed: [] as string[], - }; + let release!: (m: MintResult) => void; + const slowMint = new Promise((r) => (release = r)); + const h = { channels: [] as FakeChannel[], authed: [] as string[] }; const doorbell = createDoorbell({ - mint: () => slowMint as Promise, + mint: () => slowMint, setAuth: async (t) => void h.authed.push(t), channel(topic, { private: priv }) { const ch: FakeChannel = { topic, priv, joined: false, left: false, cb: null, status: null }; @@ -230,18 +253,17 @@ describe("createDoorbell: fallback & teardown", () => { }, }; }, - legacyTopic: "bool:app_x", }); const off = doorbell.subscribe(() => {}); off(); // teardown while the mint is still in flight release(MINT); await tick(); - expect(h.authed).toHaveLength(0); // orphaned continuation did nothing + expect(h.authed).toHaveLength(0); expect(h.channels).toHaveLength(0); }); test("resubscribing after teardown starts a fresh doorbell", async () => { - const { h, doorbell } = makeHarness({ mints: [MINT, { ...MINT, token: "tok-2" }] }); + const { h, doorbell } = makeHarness({ mints: [MINT, ok({ token: "tok-2" })] }); const off = doorbell.subscribe(() => {}); await tick(); off(); diff --git a/src/realtime.ts b/src/realtime.ts index 5428cea..0bcf606 100644 --- a/src/realtime.ts +++ b/src/realtime.ts @@ -1,31 +1,31 @@ -// The private doorbell client — the SDK half of Bool's "intercom + wristband" -// realtime design (platform: lib/bool-db.ts GATEWAY_GLOBAL_SETUP_SQL + the +// The private doorbell client — the SDK half of Bool's realtime design +// (platform: lib/bool-db.ts GATEWAY_GLOBAL_SETUP_SQL + the // /_bool/v1/realtime/token plane). // -// Live updates arrive as ROW-BEARING broadcasts on PRIVATE topics that only a -// gateway-minted token ("wristband") can join. This module owns the whole -// lifecycle so app code (and useEntity) just gets payloads: +// Live updates arrive as ROW-BEARING broadcasts on PRIVATE topics. Every topic +// is private: a socket holding no gateway-minted wristband hears nothing, and +// there is no public channel to fall back to. This module owns the lifecycle so +// app code (and useEntity) just gets payloads: // - mint the wristband from the gateway (which re-runs liveAccess/session on // every mint — that's where access is actually decided), // - setAuth + join the app room and, when signed in, the personal user room -// (the trigger picks disjoint audiences, so the two rooms never duplicate), -// - re-mint at ~75% of the TTL so the socket never hits token expiry; a -// failed re-mint (revoked access, gateway down) silences the private rooms -// rather than erroring the app, -// - fall back to the legacy PUBLIC row-data-free channel when the wristband -// desk is unavailable (older platform, 503) so live-ness degrades to -// ping+refetch instead of dying, +// (the trigger picks disjoint audiences, so the two never duplicate), +// - re-mint at ~75% of the TTL so the socket never hits token expiry, +// - on failure, RETRY with capped backoff and report status — never pretend. +// A refused mint means the viewer isn't authorized (or the gateway is +// unreachable); either way the honest outcome is "not live", surfaced, with +// HTTP reads still working. Silently limping along on a degraded path is how +// a broken realtime layer goes unnoticed for a week. // - share ONE doorbell across every subscriber (each entities. // .subscribe used to open its own channel; now they ref-count this one). // // Everything effectful is injected (DoorbellDeps) so the machine is fully -// unit-testable without sockets — same dependency-injection discipline as the -// platform's plane handlers. +// unit-testable without sockets. import type { BoolChangePayload } from "./client.js"; /** What the wristband desk returns. Topic names are SERVER-authored so naming - * lives in exactly one repo. */ + * lives in exactly one place. */ export type RealtimeMint = { token: string; /** Seconds until the token expires. */ @@ -41,16 +41,34 @@ export type DoorbellChannel = { leave(): void; }; +/** Why the doorbell isn't delivering, when it isn't. + * - `connecting` — first mint/join in flight + * - `live` — joined; changes are arriving + * - `unauthorized` — the gateway refused a wristband (not authorized for this + * app, or end-user session expired). Retried, since access can be granted. + * - `unavailable` — gateway unreachable, realtime misconfigured, or the join + * was refused. Retried with backoff. */ +export type DoorbellStatus = "connecting" | "live" | "unauthorized" | "unavailable"; + +/** Outcome of one mint attempt. The two failure kinds are distinguished on + * purpose: "the gateway says no" and "I couldn't reach the gateway" mean very + * different things to a viewer (and to whoever is debugging), and a public app + * — where every visitor is normally admitted — must never be told it's + * unauthorized because of a dropped request. */ +export type MintResult = + | { ok: true; mint: RealtimeMint } + | { ok: false; reason: "unauthorized" | "unavailable" }; + export type DoorbellDeps = { - /** Mint one wristband; null when the desk is unavailable (non-2xx, network, - * or a pre-private-doorbell platform). */ - mint(): Promise; + /** Mint one wristband. On failure report WHICH kind (see MintResult) — the + * doorbell retries either way and never silently gives up. */ + mint(): Promise; /** Present the wristband to the realtime connection (supabase setAuth). */ setAuth(token: string): Promise | void; - /** Create (not join) a channel on `topic`. */ + /** Create (not join) a private channel on `topic`. */ channel(topic: string, opts: { private: boolean }): DoorbellChannel; - /** The legacy public topic (`bool:`) for the fallback path. */ - legacyTopic: string; + /** Called whenever delivery state changes, so a client can surface it. */ + onStatus?: (status: DoorbellStatus) => void; /** Test seam; defaults to setTimeout/clearTimeout. */ schedule?: (fn: () => void, ms: number) => unknown; cancel?: (handle: unknown) => void; @@ -60,13 +78,18 @@ export type DoorbellDeps = { // expiry (which would close the channels underneath us), late enough that // refresh traffic stays negligible. const REFRESH_FRACTION = 0.75; -// A desk that answers but with a nonsense TTL shouldn't melt into a mint loop. +// A desk that answers with a nonsense TTL shouldn't melt into a mint loop. const MIN_REFRESH_MS = 30_000; +// Retry backoff after a refused/failed mint or join: quick first, then capped so +// a long outage costs one request a minute rather than a hot loop. +const RETRY_MS = [1_000, 5_000, 15_000, 60_000]; export type Doorbell = { /** Register a change listener. Starts the machinery on the first listener, * tears it down after the last unsubscribes. Returns unsubscribe. */ subscribe(listener: (payload: BoolChangePayload) => void): () => void; + /** Current delivery state — for surfacing "not live" in a UI. */ + status(): DoorbellStatus; }; export function createDoorbell(deps: DoorbellDeps): Doorbell { @@ -82,86 +105,97 @@ export function createDoorbell(deps: DoorbellDeps): Doorbell { // teardown (or a restart) orphans in-flight work instead of racing it. let gen = 0; let channels: DoorbellChannel[] = []; - let refreshHandle: unknown = null; + let timer: unknown = null; + let attempt = 0; + let state: DoorbellStatus = "connecting"; + + function setStatus(next: DoorbellStatus): void { + if (state === next) return; + state = next; + deps.onStatus?.(next); + } + + function clearTimer(): void { + if (timer !== null) { + cancel(timer); + timer = null; + } + } function teardownChannels(): void { for (const ch of channels) ch.leave(); channels = []; - if (refreshHandle !== null) { - cancel(refreshHandle); - refreshHandle = null; - } + clearTimer(); } - /** Strip everything except {table, op} from a legacy-channel payload. - * - * The public compat channel is row-data-free by contract — the server sends - * only table and op, because anyone with the (public) anon key and the schema - * name can join it. But Supabase Realtime INJECTS its own `id` (a message - * uuid) into any broadcast payload that lacks one, and that value looks - * exactly like a row id to the live store: it would keyed-fetch a row that - * doesn't exist, apply nothing, and silently swallow the change. So the - * doorbell discards it here rather than trusting the transport's shape — - * missing `id` is precisely the signal that makes the store fall back to a - * coalesced full reload, which is the correct behavior on this channel. */ - function legacyPayload(p: BoolChangePayload): BoolChangePayload { - return { table: p.table, op: p.op }; + /** Schedule the next attempt after a failure, with capped backoff. */ + function retry(myGen: number, why: DoorbellStatus): void { + setStatus(why); + const ms = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]!; + attempt++; + timer = schedule(() => { + if (myGen !== gen) return; + void start(myGen); + }, ms); } - function joinLegacy(myGen: number): void { + async function start(myGen: number): Promise { + teardownChannels(); if (myGen !== gen) return; - const ch = deps.channel(deps.legacyTopic, { private: false }); - ch.onBroadcast((p) => fanout(legacyPayload(p))); - ch.join(() => {}); - channels.push(ch); - } - async function startPrivate(myGen: number): Promise { - const m = await deps.mint(); + const res = await deps.mint(); if (myGen !== gen) return; - if (!m) return joinLegacy(myGen); + // No wristband: the app is NOT live. Report which kind of "no" it was and + // try again — never fake liveness. + if (!res.ok) return retry(myGen, res.reason); + const m = res.mint; await deps.setAuth(m.token); if (myGen !== gen) return; // The app room, and the personal room when the mint carried a signed-in - // user. Audiences are disjoint by construction (the trigger rings an - // owned row's user room INSTEAD of the app room), so no dedupe is needed. + // user. Audiences are disjoint by construction (the trigger rings an owned + // row's user room INSTEAD of the app room), so no dedupe is needed. + let joined = 0; for (const topic of [m.topics.app, m.topics.user]) { if (!topic) continue; const ch = deps.channel(topic, { private: true }); ch.onBroadcast(fanout); - ch.join((state) => { - // A refused/failed private join (policy missing on an older database, - // clock-skewed token) degrades to the public ping — worse latency, - // never a dead app. Guard on gen so late errors after teardown no-op. - if (state === "CHANNEL_ERROR" && myGen === gen) { + ch.join((s) => { + if (myGen !== gen) return; + if (s === "SUBSCRIBED") { + joined++; + attempt = 0; // a good join resets the backoff + setStatus("live"); + } else if (s === "CHANNEL_ERROR" || s === "TIMED_OUT") { + // A refused private join means the wristband and the policy disagree + // (misconfigured database, clock skew). Retry the whole handshake. teardownChannels(); - joinLegacy(myGen); + retry(myGen, "unavailable"); } }); channels.push(ch); } + if (joined === 0 && channels.length === 0) return retry(myGen, "unavailable"); scheduleRefresh(myGen, m.expiresIn); } function scheduleRefresh(myGen: number, expiresInSeconds: number): void { const ms = Math.max(expiresInSeconds * 1000 * REFRESH_FRACTION, MIN_REFRESH_MS); - refreshHandle = schedule(async () => { + timer = schedule(async () => { if (myGen !== gen) return; - const m = await deps.mint(); + const res = await deps.mint(); if (myGen !== gen) return; - if (!m) { - // Wristband renewal refused: access was revoked or the gateway is - // unreachable. Go quiet on the private rooms (the whole point of the - // TTL) and keep the app alive on the public ping. + // Renewal refused — access was revoked, or the gateway is down. The TTL + // expiring is the whole revocation mechanism, so drop the rooms. + if (!res.ok) { teardownChannels(); - return joinLegacy(myGen); + return retry(myGen, res.reason); } - await deps.setAuth(m.token); // existing channels re-auth on the connection + await deps.setAuth(res.mint.token); // channels re-auth on the connection if (myGen !== gen) return; - scheduleRefresh(myGen, m.expiresIn); + scheduleRefresh(myGen, res.mint.expiresIn); }, ms); } @@ -170,15 +204,19 @@ export function createDoorbell(deps: DoorbellDeps): Doorbell { listeners.add(listener); if (listeners.size === 1) { gen++; - void startPrivate(gen); + attempt = 0; + setStatus("connecting"); + void start(gen); } return () => { if (!listeners.delete(listener)) return; if (listeners.size === 0) { gen++; // orphan any in-flight start/refresh teardownChannels(); + state = "connecting"; } }; }, + status: () => state, }; }