diff --git a/ROADMAP.md b/ROADMAP.md index 02134ba..e4abdde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,15 +57,53 @@ their own copy — kept in sync; the meta `theme-color` stays a literal `#0a0e1a Real but not release-blocking for self-host. The P0/P1 findings from the same eval were fixed 2026-06-15 (see git log + `EVALUATION.md`). -- **Operator-internal strings leak to cloud users at the SSE error boundary** (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub convention in `AGENTS.md`. `server/index.js:3432,3003,4246` + `providers/relay.js:135-144`. (Sharp edge: `index.js:3419` *detects* these strings, then forwards them anyway.) +- **Operator-internal strings leak to cloud users at the SSE error boundary** (Parakeet/Gemma/CUDA/LAN IPs) — no scrub exists, violating the scrub convention in `AGENTS.md`. `server/index.js:3432,3003,4246` + `providers/relay.js:134-143` (now centralized in `handleRelayResponse`, with a copy in `pingBalance` at `:834` — the 2026-06-19 dedup makes the relay-side scrub a single-point fix). (Sharp edge: `index.js:3419` *detects* these strings, then forwards them anyway.) - **Credit over-spend TOCTOU on licensed installs** — N parallel requests pass the `total>0` check before any blind `debitOne` lands. Make check+debit atomic (reserve up front, refund on failure). `index.js:2497-2550` vs `:3158,:4197`. - **Multi-mode tenant can spend the operator's server Gemini key** via `transcriptionProvider:"gemini"` + empty key (bypasses relay metering) — `providers/index.js:104-114`. Refuse the operator-key fallback for non-admin tenants. - **`GET /api/history` parses every full session file** (transcript+summary, MB each) just to list ~8 metadata fields — cache them into `_meta.json` on save. `history.js:418-437`. - **Dependency CVEs** — nodemailer 6.10.1 (high; low practical reach here), ws/qs/express/protobufjs (moderate). `npm audit fix` (nodemailer is a major bump). -- **No tests on the riskiest files** (`/api/process` gating, `relay.js`, `tenant-auth.js`, billing) — the real summarize→save→debit path can't run end-to-end without a key/credits. Add an integration test as the regression net. +- **No tests on the riskiest files** (`/api/process` gating, `tenant-auth.js`, billing; `relay.js`'s response handling + operator helpers now have unit coverage via `test/relay.test.js`, but its transcribe/summarize/analyze paths still don't) — the real summarize→save→debit path can't run end-to-end without a key/credits. Add an integration test as the regression net. - **Smaller hardening:** unsanitized IDs persisted to `_meta.json` (array-form library import + `PUT /api/history/move`) — no file-path escape (read-time `safeFilename` guards the load), but sanitize at write too; `PUT /api/history/meta` accepts arbitrary JSON shapes with no schema; `index.js` is 4351 lines mixing routing/pipeline/yt-dlp/SSE. - **Doc drift (high-value):** AGENTS.md credit-gate order omits the "paid cloud user" bypass state (`:77` vs `index.js:2464-2472`); operator-facing `startos-registry/.../INSTRUCTIONS.md` + `assets/ABOUT.md` are stale Gemini-first (relay is the default provider). +## Refactoring backlog (from the 2026-06-19 refactor-scout survey) + +The two low-risk wins from the same survey are already DONE (relay.js response-parsing ++ operator-call dedup — extracted `handleRelayResponse` / `operatorPost` / `operatorGet`, +with a new `server/test/relay.test.js` covering the tts error-path control flow, envelope +parsing, and the operator throw-vs-null split; **158 tests pass**, +14). The rest are +deferred behind a **missing test net** — `server/index.js` +(4,376 lines) is the god file (router + pipeline + subscription engine + podcast RSS), and +its riskiest logic has no integration coverage, so these extractions need a characterization +test FIRST. Listed by descending value; the highest-value one is the subscription engine. + +- **Extract the subscription engine.** Move the subscription-discovery helpers + (`isPodcastFeedUrl`, `isAppleShowUrl`, `resolveAppleShowToFeed`, `parsePodcastRSS`, + `fetchDatesFromRSS`, `getChannelId`, `fetchChannelName`, `listChannelVideosFast`, + `fetchUploadDates`; `index.js:~891-1008`) plus `checkScopeSubscriptions` / + `_checkSubscriptionsInner` (`index.js:~1421`, ~800 lines total) into + `server/subscription-engine.js`. None depend on the Express `app` — they're trivially + testable in isolation but currently unreachable by any test. Write characterization tests + against fixture XML + mock yt-dlp output first. +- **Extract the `/api/process` pipeline.** The handler is a single ~1,800-line function + (`index.js:~2484-4281`): credit gate, provider resolution, URL resolution, relay + unified-pipeline branch, captions fast-path, relay-URL fast-path, audio-download-with-retry, + chunked-transcription, analysis-with-fallback, dispatch. Split into `server/pipeline.js`, + one step at a time. RISK: the many early-return paths with `releaseFreeSlot()` + `res.end()` + make blind extraction dangerous — write a characterization test (mock transcriber + + analyzer, ≥3 paths: relay mode, captions fast-path, chunked fallback) BEFORE touching it. +- **Extract `sweepAndRefreshTrial(req, res)` middleware.** A ~35-line block (sweep unapplied + purchases for `req.user`/`req.trial`, re-read the trial row, update `req.trial`) is + copy-pasted between `/api/account/whoami` (`index.js:~280-420`) and `/api/relay/status` + (`index.js:~561-675`), differing only by log prefix. MED risk (auth/billing path; moving it + changes when `req.trial` mutates relative to other middleware) — write an integration test + for both endpoints first. +- **Move the pure transcript coalescers to `util.js`.** `coalesceTranscriptEntries` + + `coalesceForAnalysis` (`index.js:~2167-2270`) are pure functions (no I/O, no state) buried + in the god file and currently untested, with non-trivial logic (median-duration guard, + bucket sizing, `indexMap`). Low-risk move; add unit tests alongside per the repo's + test-with-the-change convention. + ## Deferred hardening & cleanup (P3, from the 2026-06-14 full-eval — `EVALUATION.md`) Low-severity; batch when convenient. None block release. (P0/P1 work queue and P2 known debt live in `AGENTS.md` → Current state.) diff --git a/server/providers/relay.js b/server/providers/relay.js index dec8a57..b9751ae 100644 --- a/server/providers/relay.js +++ b/server/providers/relay.js @@ -111,19 +111,18 @@ export function createRelayProvider({ // Common error-handling wrapper. The relay's contract is that ANY // response (success or failure) carries the standard envelope so - // Recap can keep its balance display accurate even on errors. We - // try to parse error bodies to harvest that. - // GET wrapper mirroring postRelay's envelope-aware error handling. - // Used by the transcribe-url poll loop to fetch job status. - async function getRelay({ path, headers, signal }) { - let res; - try { - res = await fetch(`${base}${path}`, { method: "GET", headers, signal }); - } catch (err) { - recordRelayError(err?.message || String(err), creditKey); - throw err; - } - const text = await res.text(); + // Recap can keep its balance display accurate even on errors — we + // always try to parse the body, harvest credit/tier state from it, and + // on a non-OK status throw an Error carrying the parsed envelope + + // status code. `label` is the call name baked into the thrown message + // (e.g. "Relay GET /relay/jobs/x"). recordRelayError fires only when + // there's NO envelope to parse — a clean 4xx from the relay isn't an + // "unreachable" error, and updateRelayState already cleared lastError + // for it. (pingBalance deliberately keeps its own copy: it records an + // error even on a parsed envelope, a different contract — don't fold it + // in here.) Synchronous: returns the parsed envelope or throws — keep it + // that way so call sites can `return` it without awaiting. + function handleRelayResponse({ res, text, label }) { let parsed = null; try { parsed = text ? JSON.parse(text) : null; @@ -137,7 +136,7 @@ export function createRelayProvider({ parsed?.message || text?.slice(0, 300) || `HTTP ${res.status}`; - const err = new Error(`Relay GET ${path} ${res.status}: ${msg}`); + const err = new Error(`${label} ${res.status}: ${msg}`); err.status = res.status; err.envelope = parsed; if (!parsed) recordRelayError(msg, creditKey); @@ -146,6 +145,20 @@ export function createRelayProvider({ return parsed; } + // GET wrapper mirroring postRelay's envelope-aware error handling. + // Used by the transcribe-url poll loop to fetch job status. + async function getRelay({ path, headers, signal }) { + let res; + try { + res = await fetch(`${base}${path}`, { method: "GET", headers, signal }); + } catch (err) { + recordRelayError(err?.message || String(err), creditKey); + throw err; + } + const text = await res.text(); + return handleRelayResponse({ res, text, label: `Relay GET ${path}` }); + } + async function postRelay({ path, body, headers, signal }) { let res; try { @@ -160,26 +173,7 @@ export function createRelayProvider({ throw err; } const text = await res.text(); - let parsed = null; - try { - parsed = text ? JSON.parse(text) : null; - } catch {} - if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) { - updateRelayState(parsed, creditKey); - } - if (!res.ok) { - const msg = - parsed?.error || - parsed?.message || - text?.slice(0, 300) || - `HTTP ${res.status}`; - const err = new Error(`Relay ${path} ${res.status}: ${msg}`); - err.status = res.status; - err.envelope = parsed; - if (!parsed) recordRelayError(msg, creditKey); - throw err; - } - return parsed; + return handleRelayResponse({ res, text, label: `Relay ${path}` }); } return { @@ -941,21 +935,11 @@ export function createRelayProvider({ } if (!res.ok) { // Errors carry the standard JSON envelope — harvest balance + msg. + // Success returns binary, so tts can't fully share postRelay; only + // the error path delegates. handleRelayResponse always throws when + // !res.ok; `return` makes that explicit and mirrors getRelay/postRelay. const errText = await res.text().catch(() => ""); - let parsed = null; - try { - parsed = errText ? JSON.parse(errText) : null; - } catch {} - if (parsed && (typeof parsed.credits_remaining === "number" || parsed.tier)) { - updateRelayState(parsed, creditKey); - } - const msg = - parsed?.error || parsed?.message || errText?.slice(0, 300) || `HTTP ${res.status}`; - const err = new Error(`Relay /relay/tts ${res.status}: ${msg}`); - err.status = res.status; - err.envelope = parsed; - if (!parsed) recordRelayError(msg, creditKey); - throw err; + return handleRelayResponse({ res, text: errText, label: "Relay /relay/tts" }); } const audio = Buffer.from(await res.arrayBuffer()); // Success path: credit state lives in headers. "unlimited" (Max) → @@ -988,37 +972,51 @@ export function createRelayProvider({ // The relay is the source of truth for cloud Pro/Max tiers. The operator // grant flow calls these server-to-server, authenticated by the shared // operator key — no per-user license involved. -export async function setRelayUserTier({ userId, tier, expiresAt = null, timeoutMs = 10000 }) { +// +// Two contracts, deliberately different — don't collapse them into one: +// • operatorPost — writes/creates (tier grant, invoice, order): THROWS +// on missing config or a non-OK status, so the operator action that +// triggered it surfaces the failure instead of silently no-op'ing. +// • operatorGet — reads (current tier, plans, expiring subs): returns +// null on ANY misconfig/failure, so the caller falls back to a sane +// default rather than breaking. +async function operatorPost({ path, body, label, timeoutMs }) { const base = getRelayBaseURL(); const operatorKey = getRelayOperatorKey(); if (!base) throw new Error("relay base URL not configured"); if (!operatorKey) { throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)"); } - const res = await fetch(`${base.replace(/\/$/, "")}/relay/user-tier`, { + const res = await fetch(`${base.replace(/\/$/, "")}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey }, - body: JSON.stringify({ user_id: userId, tier, expires_at: expiresAt || undefined }), + body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const data = await res.json().catch(() => ({})); if (!res.ok) { - const err = new Error(data?.error || `relay user-tier ${res.status}`); + const err = new Error(data?.error || `${label} ${res.status}`); err.status = res.status; throw err; } return data; } -export async function getRelayUserTier({ userId, timeoutMs = 8000 }) { +async function operatorGet({ path, searchParams = null, timeoutMs }) { const base = getRelayBaseURL(); const operatorKey = getRelayOperatorKey(); if (!base || !operatorKey) return null; try { - const res = await fetch( - `${base.replace(/\/$/, "")}/relay/user-tier/${encodeURIComponent(userId)}`, - { headers: { "X-Recap-Operator-Key": operatorKey }, signal: AbortSignal.timeout(timeoutMs) } - ); + const url = new URL(`${base.replace(/\/$/, "")}${path}`); + if (searchParams) { + for (const [k, v] of Object.entries(searchParams)) { + url.searchParams.set(k, String(v)); + } + } + const res = await fetch(url, { + headers: { "X-Recap-Operator-Key": operatorKey }, + signal: AbortSignal.timeout(timeoutMs), + }); if (!res.ok) return null; return await res.json(); } catch { @@ -1026,6 +1024,22 @@ export async function getRelayUserTier({ userId, timeoutMs = 8000 }) { } } +export async function setRelayUserTier({ userId, tier, expiresAt = null, timeoutMs = 10000 }) { + return operatorPost({ + path: "/relay/user-tier", + body: { user_id: userId, tier, expires_at: expiresAt || undefined }, + label: "relay user-tier", + timeoutMs, + }); +} + +export async function getRelayUserTier({ userId, timeoutMs = 8000 }) { + return operatorGet({ + path: `/relay/user-tier/${encodeURIComponent(userId)}`, + timeoutMs, + }); +} + // Ask the relay to create a BTCPay invoice for a prepaid Pro/Max period for // `userId`. Operator-key authed (server-to-server). Returns // { invoice_id, checkout_url, sats, tier, period_days } or throws. @@ -1035,25 +1049,12 @@ export async function createRelayTierInvoice({ returnUrl = null, timeoutMs = 12000, }) { - const base = getRelayBaseURL(); - const operatorKey = getRelayOperatorKey(); - if (!base) throw new Error("relay base URL not configured"); - if (!operatorKey) { - throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)"); - } - const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-invoice`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey }, - body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }), - signal: AbortSignal.timeout(timeoutMs), + return operatorPost({ + path: "/relay/tier-invoice", + body: { user_id: userId, tier, return_url: returnUrl || undefined }, + label: "relay tier-invoice", + timeoutMs, }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - const err = new Error(data?.error || `relay tier-invoice ${res.status}`); - err.status = res.status; - throw err; - } - return data; } // Ask the relay to create a Zaprite (card) hosted-checkout order for a @@ -1066,25 +1067,12 @@ export async function createRelayZapriteOrder({ returnUrl = null, timeoutMs = 12000, }) { - const base = getRelayBaseURL(); - const operatorKey = getRelayOperatorKey(); - if (!base) throw new Error("relay base URL not configured"); - if (!operatorKey) { - throw new Error("operator key not configured (set RECAP_RELAY_OPERATOR_KEY)"); - } - const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-zaprite-order`, { - method: "POST", - headers: { "Content-Type": "application/json", "X-Recap-Operator-Key": operatorKey }, - body: JSON.stringify({ user_id: userId, tier, return_url: returnUrl || undefined }), - signal: AbortSignal.timeout(timeoutMs), + return operatorPost({ + path: "/relay/tier-zaprite-order", + body: { user_id: userId, tier, return_url: returnUrl || undefined }, + label: "relay tier-zaprite-order", + timeoutMs, }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - const err = new Error(data?.error || `relay tier-zaprite-order ${res.status}`); - err.status = res.status; - throw err; - } - return data; } // Read the buyable subscription plans + sats prices from the relay (the @@ -1092,19 +1080,7 @@ export async function createRelayZapriteOrder({ // { period_days, plans: [{tier, sats}] } or null when the relay is // unreachable / unconfigured (caller falls back to a sane default). export async function getRelayTierPlans({ timeoutMs = 8000 } = {}) { - const base = getRelayBaseURL(); - const operatorKey = getRelayOperatorKey(); - if (!base || !operatorKey) return null; - try { - const res = await fetch(`${base.replace(/\/$/, "")}/relay/tier-plans`, { - headers: { "X-Recap-Operator-Key": operatorKey }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } + return operatorGet({ path: "/relay/tier-plans", timeoutMs }); } // List cloud users whose prepaid Pro/Max period expires within @@ -1118,22 +1094,11 @@ export async function getRelayExpiringSubscriptions({ lapsedDays = 3, timeoutMs = 10000, } = {}) { - const base = getRelayBaseURL(); - const operatorKey = getRelayOperatorKey(); - if (!base || !operatorKey) return null; - try { - const url = new URL(`${base.replace(/\/$/, "")}/relay/expiring-subscriptions`); - url.searchParams.set("within_days", String(withinDays)); - url.searchParams.set("lapsed_days", String(lapsedDays)); - const res = await fetch(url, { - headers: { "X-Recap-Operator-Key": operatorKey }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!res.ok) return null; - return await res.json(); - } catch { - return null; - } + return operatorGet({ + path: "/relay/expiring-subscriptions", + searchParams: { within_days: withinDays, lapsed_days: lapsedDays }, + timeoutMs, + }); } // Streams a file off disk into a Blob with the given MIME type for diff --git a/server/test/relay.test.js b/server/test/relay.test.js new file mode 100644 index 0000000..29ef6b0 --- /dev/null +++ b/server/test/relay.test.js @@ -0,0 +1,243 @@ +// Unit tests for the relay provider's shared response handling and the +// operator-call helpers introduced by the dedup refactor +// (handleRelayResponse / operatorPost / operatorGet). The relay is +// network-dependent, so we stub global.fetch. These nail: +// - the tts error-path control flow, which must THROW, not fall through +// to the binary arrayBuffer read (regression for the missing-return fix); +// - handleRelayResponse's envelope parsing + the recordRelayError-only- +// when-no-envelope rule; +// - the deliberate throw-vs-null split between operatorPost (writes) and +// operatorGet (reads), including the missing-config branch. + +import { test, describe, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + createRelayProvider, + setRelayUserTier, + getRelayUserTier, + createRelayTierInvoice, + getRelayTierPlans, + getRelayExpiringSubscriptions, +} from "../providers/relay.js"; +import { getRelayState, resetRelayState } from "../relay-state.js"; +import { getRelayOperatorKey } from "../relay-default.js"; + +const BASE = "https://relay.recaps.cc"; + +// Pin an operator key so the operator-helper tests exercise the configured +// path. node --test isolates each test file in its own process, so this +// doesn't leak into other suites. +process.env.RECAP_RELAY_OPERATOR_KEY = "test-op-key"; + +const realFetch = global.fetch; +let fetchCalls = []; + +// Install a stub that records calls and returns (or runs) the given response. +function stubFetch(response) { + fetchCalls = []; + global.fetch = async (url, opts) => { + fetchCalls.push({ url: String(url), opts }); + return typeof response === "function" ? response(url, opts) : response; + }; +} + +afterEach(() => { + global.fetch = realFetch; + resetRelayState(); +}); + +// Minimal Response-like stubs covering the surfaces the provider touches. +function jsonRes({ ok = true, status = 200, body = {} }) { + return { ok, status, text: async () => JSON.stringify(body), json: async () => body }; +} +function textRes({ ok = false, status = 502, body = "Bad Gateway" }) { + return { + ok, + status, + text: async () => body, + json: async () => { + throw new SyntaxError("not json"); + }, + }; +} +function binRes({ headers = {}, bytes = [1, 2, 3] }) { + return { + ok: true, + status: 200, + headers: new Headers(headers), + arrayBuffer: async () => new Uint8Array(bytes).buffer, + }; +} + +const provider = () => createRelayProvider({ baseURL: BASE, installId: "test-install" }); +// A never-aborting signal so tts skips its 90s AbortSignal.timeout fallback. +const noTimeout = () => new AbortController().signal; + +describe("relay tts: error path throws (regression for the missing-return fix)", () => { + test("throws on a non-OK response instead of falling through to the binary read", async () => { + stubFetch(jsonRes({ ok: false, status: 500, body: { error: "kokoro busy" } })); + await assert.rejects( + () => provider().tts({ text: "hi", voice: "bm_george", signal: noTimeout() }), + (err) => { + assert.equal(err.status, 500); + assert.equal(err.message, "Relay /relay/tts 500: kokoro busy"); + assert.deepEqual(err.envelope, { error: "kokoro busy" }); + return true; + } + ); + }); + + test("harvests the credit envelope on a parsed error, records NO relay error", async () => { + stubFetch(jsonRes({ ok: false, status: 402, body: { error: "out of credits", credits_remaining: 0 } })); + await assert.rejects(() => provider().tts({ text: "x", voice: "v", signal: noTimeout() })); + const state = getRelayState("inst:test-install"); + assert.equal(state.creditsRemaining, 0); // updateRelayState ran off the envelope + assert.equal(state.lastError, null); // recordRelayError did NOT (envelope present) + }); + + test("records a relay error when the error body is not JSON", async () => { + stubFetch(textRes({ ok: false, status: 502, body: "Bad Gateway" })); + await assert.rejects( + () => provider().tts({ text: "x", voice: "v", signal: noTimeout() }), + (err) => { + assert.equal(err.status, 502); + assert.equal(err.message, "Relay /relay/tts 502: Bad Gateway"); + return true; + } + ); + assert.equal(getRelayState("inst:test-install").lastError, "Bad Gateway"); + }); + + test("success path returns binary audio and never enters the error branch", async () => { + stubFetch( + binRes({ + headers: { + "Content-Type": "audio/mpeg", + "X-Recap-Credits-Remaining": "7", + "X-Recap-Tier": "pro", + "X-Recap-Credit-Charged": "1", + "X-Recap-Audio-Duration": "12.5", + }, + bytes: [10, 20, 30], + }) + ); + const out = await provider().tts({ text: "hi", voice: "bm_george", signal: noTimeout() }); + assert.ok(Buffer.isBuffer(out.audio)); + assert.equal(out.audio.length, 3); + assert.equal(out.contentType, "audio/mpeg"); + assert.equal(out.creditCharged, 1); + assert.equal(out.durationSeconds, 12.5); + const state = getRelayState("inst:test-install"); + assert.equal(state.creditsRemaining, 7); + assert.equal(state.tier, "pro"); + }); +}); + +describe("operatorPost (writes — throw on failure)", () => { + test("setRelayUserTier POSTs the right URL/headers/body and returns data on OK", async () => { + stubFetch(jsonRes({ ok: true, body: { ok: true, tier: "max" } })); + const data = await setRelayUserTier({ userId: "u1", tier: "max", expiresAt: "2026-09-01T00:00:00Z" }); + assert.deepEqual(data, { ok: true, tier: "max" }); + assert.equal(fetchCalls.length, 1); + const { url, opts } = fetchCalls[0]; + assert.equal(url, `${BASE}/relay/user-tier`); + assert.equal(opts.method, "POST"); + assert.equal(opts.headers["Content-Type"], "application/json"); + assert.equal(opts.headers["X-Recap-Operator-Key"], "test-op-key"); + assert.deepEqual(JSON.parse(opts.body), { + user_id: "u1", + tier: "max", + expires_at: "2026-09-01T00:00:00Z", + }); + }); + + test("omits undefined body keys (expires_at) via JSON.stringify", async () => { + stubFetch(jsonRes({ ok: true, body: {} })); + await setRelayUserTier({ userId: "u1", tier: "pro" }); // expiresAt defaults null → undefined + assert.deepEqual(JSON.parse(fetchCalls[0].opts.body), { user_id: "u1", tier: "pro" }); + }); + + test("throws with .status and the relay's error message on a non-OK response", async () => { + stubFetch(jsonRes({ ok: false, status: 409, body: { error: "already granted" } })); + await assert.rejects( + () => setRelayUserTier({ userId: "u1", tier: "max" }), + (err) => { + assert.equal(err.status, 409); + assert.equal(err.message, "already granted"); + return true; + } + ); + }); + + test("falls back to '