From 40e65860f0ab0394653618797d38297b2f39bfcc Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Tue, 29 Sep 2026 11:20:25 +0100 Subject: [PATCH] feat(backend): enhance distributed concurrency control and locking for SEP-0001 Stellar Info Generator - Add sep0001-toml-coordinator: per-process single-flight, Redis SET NX PX lock with owner tokens and compare-and-delete release, a shared TOML store with SHA-256 integrity checks, and generation fencing so a regeneration that races an invalidation cannot publish stale content - Fail open to direct generation when Redis is unavailable, errors, or is the no-op fallback client - Route: strict UUID validation for merchant_id, lower-case normalisation, strong ETag / If-None-Match (304) - Invalidate stellar.toml on merchant branding updates (route and service) - Document design, security notes and SEP1_TOML_* configuration - Add coordinator and route tests (41 new tests) Closes #1460 Co-Authored-By: Claude Opus 5.5 --- backend/.env.example | 6 + backend/src/lib/sep0001-toml-coordinator.js | 454 +++++++++++++++++ .../src/lib/sep0001-toml-coordinator.test.js | 458 ++++++++++++++++++ backend/src/routes/merchants.js | 4 + backend/src/routes/sep0001.js | 160 +++--- backend/src/routes/sep0001.test.js | 113 +++++ backend/src/services/merchantService.js | 4 + docs/SEP0001_CONCURRENCY_CONTROL.md | 111 +++++ 8 files changed, 1241 insertions(+), 69 deletions(-) create mode 100644 backend/src/lib/sep0001-toml-coordinator.js create mode 100644 backend/src/lib/sep0001-toml-coordinator.test.js create mode 100644 backend/src/routes/sep0001.test.js create mode 100644 docs/SEP0001_CONCURRENCY_CONTROL.md diff --git a/backend/.env.example b/backend/.env.example index 6295ebb9..8ec12fde 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,6 +39,12 @@ SEP10_VERIFY_RATE_LIMIT_MAX=10 # Max live SEP-10 nonces kept for replay protection (entries expire with their challenge) SEP10_NONCE_CACHE_MAX=10000 +# SEP-0001 stellar.toml distributed coordination (optional overrides, see docs/SEP0001_CONCURRENCY_CONTROL.md) +SEP1_TOML_CACHE_TTL_MS=300000 +SEP1_TOML_LOCK_TTL_MS=10000 +SEP1_TOML_LOCK_WAIT_MS=3000 +SEP1_TOML_LOCK_POLL_MS=50 + # SEP-12 KYC rate limiting (optional overrides) SEP12_RATE_LIMIT_WINDOW_MS=900000 SEP12_RATE_LIMIT_MAX=50 diff --git a/backend/src/lib/sep0001-toml-coordinator.js b/backend/src/lib/sep0001-toml-coordinator.js new file mode 100644 index 00000000..637abbed --- /dev/null +++ b/backend/src/lib/sep0001-toml-coordinator.js @@ -0,0 +1,454 @@ +/** + * sep0001-toml-coordinator.js + * + * Distributed concurrency control for the SEP-0001 Stellar Info Generator + * (issue #1460). + * + * Problem + * ------- + * GET /.well-known/stellar.toml is public and unauthenticated. Every request + * previously queried Supabase and regenerated the TOML. Wallets, anchors and + * crawlers fetch this file aggressively, so a burst for one merchant (or a + * cold start behind a load balancer) fans out into N identical database reads + * across N API instances. There was also no invalidation: a cached copy could + * not be cleared when a merchant changed the branding that feeds the file. + * + * Solution + * -------- + * 1. In-process single-flight — concurrent requests for the same merchant on + * one instance share a single in-flight generation. + * 2. Shared store — `sep1:{}:toml` holds the rendered TOML plus a + * SHA-256 digest so any instance can serve a peer's result. + * 3. Distributed lock — `sep1:{}:lock` (SET NX PX + random owner token) + * elects one instance to regenerate. Release is a compare-and-delete Lua + * script, so a holder whose lease already expired can never delete a + * lock now owned by another instance. + * 4. Generation fencing — `sep1:{}:gen` is a counter bumped atomically + * with the entry delete on invalidation. The leader records the + * generation BEFORE reading the merchant and publishes its result with a + * Lua compare-and-set, so a regeneration that raced a branding update can + * never overwrite the fresher state with stale content. + * 5. Followers poll the shared store. If the leader fails or crashes (lock + * released or expired with no entry written) the next poll takes the + * lock itself. After waitTimeoutMs a follower generates directly. + * + * All keys for one merchant share a Redis Cluster hash tag (`{}`) so the + * multi-key Lua scripts stay on a single slot. + * + * Failure policy: FAIL OPEN. stellar.toml is public, read-only data, so any + * Redis failure (or the project's no-op fallback client) degrades to direct + * generation instead of failing the request. Coordination is an optimization; + * correctness never depends on Redis being reachable. + * + * Security: merchant ids are validated before they become part of a Redis + * key, and every shared entry is validated (version, merchant binding, size, + * digest, required SEP-0001 fields) before it is served. A corrupted or + * tampered entry counts as a miss, never as content. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { connectRedisClient } from "./redis.js"; +import { logger } from "./logger.js"; +import { generateStellarToml, validateStellarToml } from "./sep0001-generator.js"; + +export const ENTRY_VERSION = 1; +export const MAX_TOML_BYTES = 64 * 1024; +const KEY_PREFIX = "sep1"; +const MERCHANT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +// Generation counters outlive every entry and lock by a wide margin so a +// counter can never expire (and reset) while a leader still holds a lease. +const GENERATION_TTL_MS = 24 * 60 * 60 * 1000; + +/** Compare-and-delete: only the token holder may release the lock. */ +export const RELEASE_LOCK_SCRIPT = + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; + +/** + * Publish an entry only if the generation observed before generation is still + * current. KEYS: entry, gen. ARGV: observedGen, payload, ttlMs. + */ +export const WRITE_IF_GENERATION_SCRIPT = + "local g = redis.call('get', KEYS[2]) or '0' " + + "if g == ARGV[1] then redis.call('set', KEYS[1], ARGV[2], 'PX', ARGV[3]) return 1 end " + + "return 0"; + +/** + * Atomically bump the generation and drop the entry. + * KEYS: entry, gen. ARGV: genTtlMs. + */ +export const INVALIDATE_SCRIPT = + "local g = redis.call('incr', KEYS[2]) " + + "redis.call('pexpire', KEYS[2], ARGV[1]) " + + "redis.call('del', KEYS[1]) " + + "return g"; + +function readIntEnv(env, name, fallback, min, max) { + const raw = Number.parseInt(String(env[name] ?? ""), 10); + if (!Number.isFinite(raw) || raw <= 0) return fallback; + return Math.min(Math.max(raw, min), max); +} + +/** + * Resolve coordinator timing from the environment, clamped to safe bounds. + */ +export function resolveCoordinatorConfig(env = process.env) { + return { + cacheTtlMs: readIntEnv(env, "SEP1_TOML_CACHE_TTL_MS", 300_000, 1_000, 3_600_000), + lockTtlMs: readIntEnv(env, "SEP1_TOML_LOCK_TTL_MS", 10_000, 1_000, 60_000), + waitTimeoutMs: readIntEnv(env, "SEP1_TOML_LOCK_WAIT_MS", 3_000, 0, 30_000), + pollIntervalMs: readIntEnv(env, "SEP1_TOML_LOCK_POLL_MS", 50, 10, 1_000), + }; +} + +export function isValidMerchantId(merchantId) { + return typeof merchantId === "string" && MERCHANT_ID_PATTERN.test(merchantId); +} + +/** + * Build the Redis keys for one merchant. Throws on ids that are not safe to + * embed in a key (separators, braces, whitespace, oversized input). + */ +export function buildTomlKeys(merchantId) { + if (!isValidMerchantId(merchantId)) { + throw new TypeError("Invalid merchant id for SEP-0001 cache key"); + } + const tag = `${KEY_PREFIX}:{${merchantId}}`; + return { + entry: `${tag}:toml`, + gen: `${tag}:gen`, + lock: `${tag}:lock`, + }; +} + +export function digestToml(toml) { + return createHash("sha256").update(toml, "utf8").digest("hex"); +} + +/** + * Parse and validate a shared entry. Returns `{ toml, digest }` or null. + */ +export function parseSharedEntry(raw, merchantId, expectedGen) { + if (raw === null || raw === undefined) return null; + const text = String(raw); + if (text.length > MAX_TOML_BYTES * 2) return null; + + let entry; + try { + entry = JSON.parse(text); + } catch { + return null; + } + + if ( + !entry || + typeof entry !== "object" || + entry.v !== ENTRY_VERSION || + entry.merchantId !== merchantId || + typeof entry.toml !== "string" || + typeof entry.digest !== "string" || + String(entry.gen) !== String(expectedGen) || + Buffer.byteLength(entry.toml, "utf8") > MAX_TOML_BYTES || + digestToml(entry.toml) !== entry.digest || + !validateStellarToml(entry.toml) + ) { + return null; + } + + return { toml: entry.toml, digest: entry.digest }; +} + +const defaultSleep = (ms) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); + +async function resolveRedis(getRedis) { + try { + const client = await getRedis(); + // connectRedisClient() returns a no-op client (isOpen:false) when Redis is + // unreachable. Its SET reports success for everyone, so treat it as "no + // distributed backend" instead of a lock that every caller wins. + if (!client || client.isOpen === false || typeof client.sendCommand !== "function") { + return null; + } + return client; + } catch (err) { + logger.warn({ err: err?.message }, "SEP-0001 coordinator: Redis unavailable, generating directly"); + return null; + } +} + +/** + * Create a coordinator bound to a merchant loader. + * + * @param {object} opts + * @param {(merchantId: string) => Promise} opts.loadMerchant + * Fetches the merchant row; resolves null when it does not exist. + * @param {() => Promise} [opts.getRedis] + * @param {(merchant: object) => string} [opts.render] + * @param {(ms: number) => Promise} [opts.sleep] + * @param {() => number} [opts.now] + * @param {object} [opts.config] Overrides for resolveCoordinatorConfig() + */ +export function createStellarTomlCoordinator({ + loadMerchant, + getRedis = connectRedisClient, + render = generateStellarToml, + sleep = defaultSleep, + now = Date.now, + config = {}, +} = {}) { + if (typeof loadMerchant !== "function") { + throw new TypeError("createStellarTomlCoordinator requires a loadMerchant function"); + } + + const settings = { ...resolveCoordinatorConfig(), ...config }; + /** merchantId -> { localGen, promise } */ + const inflight = new Map(); + /** merchantId -> local invalidation counter (orders single-flight joins) */ + const localGenerations = new Map(); + + async function renderFresh(merchantId) { + const merchant = await loadMerchant(merchantId); + if (!merchant) return null; + + const toml = render(merchant); + if (!validateStellarToml(toml)) { + const err = new Error("Failed to generate valid stellar.toml"); + err.status = 500; + throw err; + } + if (Buffer.byteLength(toml, "utf8") > MAX_TOML_BYTES) { + const err = new Error("Generated stellar.toml exceeds size limit"); + err.status = 500; + throw err; + } + return { toml, digest: digestToml(toml) }; + } + + async function readShared(client, keys, merchantId) { + const [rawEntry, rawGen] = await client.sendCommand(["MGET", keys.entry, keys.gen]); + const gen = rawGen === null || rawGen === undefined ? "0" : String(rawGen); + if (rawEntry === null || rawEntry === undefined) { + return { gen, hit: null }; + } + const hit = parseSharedEntry(rawEntry, merchantId, gen); + if (!hit) { + logger.warn({ merchantId }, "SEP-0001 coordinator: ignoring invalid or stale shared entry"); + } + return { gen, hit }; + } + + async function lead(client, keys, merchantId, observedGen, token) { + try { + const result = await renderFresh(merchantId); + if (result) { + const payload = JSON.stringify({ + v: ENTRY_VERSION, + merchantId, + gen: observedGen, + digest: result.digest, + toml: result.toml, + generatedAt: now(), + }); + try { + const written = await client.sendCommand([ + "EVAL", + WRITE_IF_GENERATION_SCRIPT, + "2", + keys.entry, + keys.gen, + observedGen, + payload, + String(settings.cacheTtlMs), + ]); + if (Number(written) !== 1) { + logger.info( + { merchantId }, + "SEP-0001 coordinator: generation changed during regeneration; result not published", + ); + } + } catch (err) { + logger.warn({ err: err?.message, merchantId }, "SEP-0001 coordinator: shared write failed"); + } + } + return result ? { ...result, source: "leader" } : null; + } finally { + try { + const released = await client.sendCommand(["EVAL", RELEASE_LOCK_SCRIPT, "1", keys.lock, token]); + if (Number(released) !== 1) { + logger.warn( + { merchantId, lockTtlMs: settings.lockTtlMs }, + "SEP-0001 coordinator: lock expired before release; regeneration outlived lock TTL", + ); + } + } catch (err) { + // The lease still expires via PX, so a failed release is not fatal. + logger.error({ err: err?.message, merchantId }, "SEP-0001 coordinator: lock release failed"); + } + } + } + + async function coordinate(merchantId) { + const keys = buildTomlKeys(merchantId); + const client = await resolveRedis(getRedis); + if (!client) { + const result = await renderFresh(merchantId); + return result ? { ...result, source: "direct" } : null; + } + + const deadline = now() + settings.waitTimeoutMs; + let leadership = null; + try { + for (;;) { + const { gen, hit } = await readShared(client, keys, merchantId); + if (hit) return { ...hit, source: "shared" }; + + const token = randomUUID(); + const acquired = await client.sendCommand([ + "SET", + keys.lock, + token, + "NX", + "PX", + String(settings.lockTtlMs), + ]); + if (acquired === "OK") { + leadership = { gen, token }; + break; + } + + if (now() >= deadline) { + logger.warn( + { merchantId, waitTimeoutMs: settings.waitTimeoutMs }, + "SEP-0001 coordinator: lock wait timed out, generating directly", + ); + break; + } + await sleep(settings.pollIntervalMs); + } + } catch (err) { + logger.warn( + { err: err?.message, merchantId }, + "SEP-0001 coordinator: Redis coordination failed, generating directly", + ); + } + + // Outside the try: loader/render errors from the leader must propagate, + // not trigger a second (direct) generation. + if (leadership) { + return lead(client, keys, merchantId, leadership.gen, leadership.token); + } + + const result = await renderFresh(merchantId); + return result ? { ...result, source: "direct" } : null; + } + + /** + * Resolve the stellar.toml for a merchant. + * + * @param {string} merchantId + * @returns {Promise} + * null when the merchant does not exist. + */ + function getStellarToml(merchantId) { + if (!isValidMerchantId(merchantId)) { + return Promise.reject(new TypeError("Invalid merchant id")); + } + + const localGen = localGenerations.get(merchantId) ?? 0; + const existing = inflight.get(merchantId); + if (existing && existing.localGen === localGen) { + return existing.promise; + } + + const promise = coordinate(merchantId).finally(() => { + if (inflight.get(merchantId)?.promise === promise) { + inflight.delete(merchantId); + } + }); + inflight.set(merchantId, { localGen, promise }); + return promise; + } + + /** + * Invalidate the cached stellar.toml for a merchant on every instance. + * Never throws: a failed invalidation is logged and the entry ages out via + * its TTL. + * + * @returns {Promise} whether the shared store was invalidated + */ + async function invalidate(merchantId) { + if (!isValidMerchantId(merchantId)) return false; + + // Requests that start after this point must not join a flight that began + // before the underlying data changed. + localGenerations.set(merchantId, (localGenerations.get(merchantId) ?? 0) + 1); + + const client = await resolveRedis(getRedis); + if (!client) return false; + const keys = buildTomlKeys(merchantId); + try { + await client.sendCommand([ + "EVAL", + INVALIDATE_SCRIPT, + "2", + keys.entry, + keys.gen, + String(GENERATION_TTL_MS), + ]); + return true; + } catch (err) { + logger.error({ err: err?.message, merchantId }, "SEP-0001 coordinator: invalidation failed"); + return false; + } + } + + return { getStellarToml, invalidate, settings }; +} + +let defaultCoordinator; + +/** + * Loader backed by Supabase; excludes soft-deleted merchants. + */ +export async function loadMerchantFromSupabase(merchantId) { + const { supabase } = await import("./supabase.js"); + const { data, error } = await supabase + .from("merchants") + .select("id, business_name, email, notification_email, recipient, branding_config") + .eq("id", merchantId) + .is("deleted_at", null) + .maybeSingle(); + + if (error) { + const err = new Error("Failed to fetch merchant"); + err.status = 500; + err.cause = error; + throw err; + } + return data ?? null; +} + +/** Process-wide coordinator used by the SEP-0001 route. */ +export function getStellarTomlCoordinator() { + if (!defaultCoordinator) { + defaultCoordinator = createStellarTomlCoordinator({ loadMerchant: loadMerchantFromSupabase }); + } + return defaultCoordinator; +} + +/** + * Invalidate a merchant's stellar.toml across all instances. Call after any + * write to a field that feeds the generator (business_name, email, + * notification_email, recipient, branding_config, deleted_at). + */ +export function invalidateStellarToml(merchantId) { + return getStellarTomlCoordinator().invalidate(merchantId); +} + +/** Test helper: drop the process-wide coordinator. */ +export function resetStellarTomlCoordinatorForTests() { + defaultCoordinator = undefined; +} diff --git a/backend/src/lib/sep0001-toml-coordinator.test.js b/backend/src/lib/sep0001-toml-coordinator.test.js new file mode 100644 index 00000000..14290878 --- /dev/null +++ b/backend/src/lib/sep0001-toml-coordinator.test.js @@ -0,0 +1,458 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { + ENTRY_VERSION, + INVALIDATE_SCRIPT, + MAX_TOML_BYTES, + RELEASE_LOCK_SCRIPT, + WRITE_IF_GENERATION_SCRIPT, + buildTomlKeys, + createStellarTomlCoordinator, + digestToml, + isValidMerchantId, + parseSharedEntry, + resolveCoordinatorConfig, +} from "./sep0001-toml-coordinator.js"; +import { logger } from "./logger.js"; + +const MERCHANT_ID = "3f0c6a8e-6b1d-4c4e-9a53-1f3d2b7c9e10"; +const MERCHANT = { + id: MERCHANT_ID, + business_name: "Coordinated Merchant", + email: "merchant@example.com", + notification_email: "support@example.com", + recipient: "GBUQWP3BOUZX34ULNQG23RQ6F4YUSXHTQSXUSMIQSTBE2BRUY4DQAT2B", + branding_config: { homepage: "https://example.com" }, +}; + +/** + * In-memory Redis shared between "instances". Implements exactly the commands + * and Lua scripts the coordinator issues, with PX expiry on a manual clock. + */ +function createFakeRedis(clock) { + const store = new Map(); // key -> { value, expiresAt } + const calls = []; + + const read = (key) => { + const e = store.get(key); + if (!e) return null; + if (e.expiresAt !== null && e.expiresAt <= clock.now) { + store.delete(key); + return null; + } + return e.value; + }; + const write = (key, value, px) => { + store.set(key, { value: String(value), expiresAt: px ? clock.now + Number(px) : null }); + }; + + const client = { + isOpen: true, + store, + calls, + read, + write, + async sendCommand(args) { + calls.push(args); + const [cmd, ...rest] = args; + switch (cmd) { + case "GET": + return read(rest[0]); + case "MGET": + return rest.map(read); + case "DEL": + return rest.reduce((n, k) => n + (store.delete(k) ? 1 : 0), 0); + case "SET": { + const [key, value, ...opts] = rest; + const nx = opts.includes("NX"); + const pxIdx = opts.indexOf("PX"); + const px = pxIdx >= 0 ? opts[pxIdx + 1] : null; + if (nx && read(key) !== null) return null; + write(key, value, px); + return "OK"; + } + case "EVAL": { + const [script, numKeys, ...tail] = rest; + const keys = tail.slice(0, Number(numKeys)); + const argv = tail.slice(Number(numKeys)); + if (script === RELEASE_LOCK_SCRIPT) { + if (read(keys[0]) === argv[0]) { + store.delete(keys[0]); + return 1; + } + return 0; + } + if (script === WRITE_IF_GENERATION_SCRIPT) { + const g = read(keys[1]) ?? "0"; + if (g === argv[0]) { + write(keys[0], argv[1], argv[2]); + return 1; + } + return 0; + } + if (script === INVALIDATE_SCRIPT) { + const g = Number(read(keys[1]) ?? "0") + 1; + write(keys[1], g, argv[0]); + store.delete(keys[0]); + return g; + } + throw new Error("unknown script"); + } + default: + throw new Error(`unsupported command ${cmd}`); + } + }, + }; + return client; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeInstance(redis, clock, overrides = {}) { + return createStellarTomlCoordinator({ + getRedis: async () => redis, + now: () => clock.now, + sleep: async (ms) => { + clock.now += ms; + await Promise.resolve(); + }, + config: { cacheTtlMs: 60_000, lockTtlMs: 5_000, waitTimeoutMs: 1_000, pollIntervalMs: 50 }, + ...overrides, + }); +} + +describe("sep0001-toml-coordinator — helpers", () => { + it("validates merchant ids before they reach a Redis key", () => { + expect(isValidMerchantId(MERCHANT_ID)).toBe(true); + for (const bad of ["", "a:b", "a}b", "a{b", "a b", "x".repeat(129), null, 42, "*"]) { + expect(isValidMerchantId(bad)).toBe(false); + expect(() => buildTomlKeys(bad)).toThrow(TypeError); + } + }); + + it("puts all merchant keys on one cluster hash slot", () => { + const keys = buildTomlKeys(MERCHANT_ID); + for (const key of Object.values(keys)) { + expect(key).toContain(`{${MERCHANT_ID}}`); + } + expect(new Set(Object.values(keys)).size).toBe(3); + }); + + it("clamps configuration from the environment", () => { + expect(resolveCoordinatorConfig({})).toEqual({ + cacheTtlMs: 300_000, + lockTtlMs: 10_000, + waitTimeoutMs: 3_000, + pollIntervalMs: 50, + }); + const cfg = resolveCoordinatorConfig({ + SEP1_TOML_CACHE_TTL_MS: "1", + SEP1_TOML_LOCK_TTL_MS: "999999", + SEP1_TOML_LOCK_WAIT_MS: "nope", + SEP1_TOML_LOCK_POLL_MS: "-5", + }); + expect(cfg).toEqual({ cacheTtlMs: 1_000, lockTtlMs: 60_000, waitTimeoutMs: 3_000, pollIntervalMs: 50 }); + }); + + it("requires a loader", () => { + expect(() => createStellarTomlCoordinator({})).toThrow(TypeError); + }); + + describe("parseSharedEntry", () => { + const toml = 'NETWORK_PASSPHRASE = "x"\nTRANSFER_SERVER = "y"'; + const good = { v: ENTRY_VERSION, merchantId: MERCHANT_ID, gen: "0", digest: digestToml(toml), toml }; + + it("accepts a well-formed entry", () => { + expect(parseSharedEntry(JSON.stringify(good), MERCHANT_ID, "0")).toEqual({ + toml, + digest: good.digest, + }); + }); + + it.each([ + ["missing", null], + ["not json", "{nope"], + ["wrong version", JSON.stringify({ ...good, v: 99 })], + ["other merchant", JSON.stringify({ ...good, merchantId: "someone-else" })], + ["stale generation", JSON.stringify({ ...good, gen: "7" })], + ["tampered content", JSON.stringify({ ...good, toml: `${toml}\nSIGNING_KEY = "evil"` })], + ["missing required fields", JSON.stringify({ ...good, toml: "x", digest: digestToml("x") })], + [ + "oversized", + JSON.stringify({ + ...good, + toml: `${toml}${"a".repeat(MAX_TOML_BYTES)}`, + digest: digestToml(`${toml}${"a".repeat(MAX_TOML_BYTES)}`), + }), + ], + ])("rejects %s entries", (_label, raw) => { + expect(parseSharedEntry(raw, MERCHANT_ID, "0")).toBeNull(); + }); + }); +}); + +describe("sep0001-toml-coordinator — coordination", () => { + let clock; + let redis; + + beforeEach(() => { + vi.clearAllMocks(); + clock = { now: 1_000_000 }; + redis = createFakeRedis(clock); + }); + + it("coalesces concurrent requests on one instance into a single load", async () => { + const gate = deferred(); + const loadMerchant = vi.fn(() => gate.promise); + const coord = makeInstance(redis, clock, { loadMerchant }); + + const pending = Array.from({ length: 25 }, () => coord.getStellarToml(MERCHANT_ID)); + await Promise.resolve(); + gate.resolve(MERCHANT); + const results = await Promise.all(pending); + + expect(loadMerchant).toHaveBeenCalledTimes(1); + expect(new Set(results.map((r) => r.digest)).size).toBe(1); + expect(results[0].toml).toContain('name = "Coordinated Merchant"'); + }); + + it("elects a single leader across instances; followers reuse the shared entry", async () => { + const gate = deferred(); + const loadA = vi.fn(() => gate.promise); + const loadB = vi.fn(async () => MERCHANT); + const a = makeInstance(redis, clock, { loadMerchant: loadA }); + const b = makeInstance(redis, clock, { + loadMerchant: loadB, + // Follower polls yield to the event loop so the leader can finish. + sleep: async () => { + gate.resolve(MERCHANT); + await new Promise((r) => setImmediate(r)); + }, + }); + + const leaderP = a.getStellarToml(MERCHANT_ID); + await new Promise((r) => setImmediate(r)); + const followerP = b.getStellarToml(MERCHANT_ID); + const [leader, follower] = await Promise.all([leaderP, followerP]); + + expect(leader.source).toBe("leader"); + expect(follower.source).toBe("shared"); + expect(follower.digest).toBe(leader.digest); + expect(loadA).toHaveBeenCalledTimes(1); + expect(loadB).not.toHaveBeenCalled(); + // Lock is released once the leader is done. + expect(redis.read(buildTomlKeys(MERCHANT_ID).lock)).toBeNull(); + }); + + it("serves later requests from the shared store until the TTL lapses", async () => { + const loadMerchant = vi.fn(async () => MERCHANT); + const coord = makeInstance(redis, clock, { loadMerchant }); + + expect((await coord.getStellarToml(MERCHANT_ID)).source).toBe("leader"); + expect((await coord.getStellarToml(MERCHANT_ID)).source).toBe("shared"); + expect(loadMerchant).toHaveBeenCalledTimes(1); + + clock.now += 60_001; + expect((await coord.getStellarToml(MERCHANT_ID)).source).toBe("leader"); + expect(loadMerchant).toHaveBeenCalledTimes(2); + }); + + it("invalidation forces regeneration on every instance", async () => { + let name = "Before"; + const loadMerchant = vi.fn(async () => ({ ...MERCHANT, business_name: name })); + const a = makeInstance(redis, clock, { loadMerchant }); + const b = makeInstance(redis, clock, { loadMerchant }); + + expect((await a.getStellarToml(MERCHANT_ID)).toml).toContain('"Before"'); + expect((await b.getStellarToml(MERCHANT_ID)).source).toBe("shared"); + + name = "After"; + await expect(a.invalidate(MERCHANT_ID)).resolves.toBe(true); + + const fromB = await b.getStellarToml(MERCHANT_ID); + expect(fromB.source).toBe("leader"); + expect(fromB.toml).toContain('"After"'); + }); + + it("fences out a stale regeneration that raced an invalidation", async () => { + const gate = deferred(); + const loadMerchant = vi + .fn() + .mockImplementationOnce(() => gate.promise) // slow read of OLD data + .mockImplementation(async () => ({ ...MERCHANT, business_name: "Fresh" })); + const a = makeInstance(redis, clock, { loadMerchant }); + const b = makeInstance(redis, clock, { loadMerchant }); + + const stale = a.getStellarToml(MERCHANT_ID); + await new Promise((r) => setImmediate(r)); + + // Branding update lands while the leader is still reading. + await b.invalidate(MERCHANT_ID); + gate.resolve({ ...MERCHANT, business_name: "Stale" }); + expect((await stale).toml).toContain('"Stale"'); + + // The stale result must NOT have been published. + expect(redis.read(buildTomlKeys(MERCHANT_ID).entry)).toBeNull(); + expect(logger.info).toHaveBeenCalledWith( + { merchantId: MERCHANT_ID }, + expect.stringContaining("generation changed"), + ); + + const next = await b.getStellarToml(MERCHANT_ID); + expect(next.toml).toContain('"Fresh"'); + }); + + it("does not let requests issued after a local invalidation join an older flight", async () => { + const gate = deferred(); + const loadMerchant = vi + .fn() + .mockImplementationOnce(() => gate.promise) + .mockImplementation(async () => ({ ...MERCHANT, business_name: "New" })); + const coord = makeInstance(redis, clock, { + loadMerchant, + getRedis: async () => null, // isolate local single-flight behaviour + }); + + const first = coord.getStellarToml(MERCHANT_ID); + await coord.invalidate(MERCHANT_ID); + const second = coord.getStellarToml(MERCHANT_ID); + gate.resolve({ ...MERCHANT, business_name: "Old" }); + + expect((await first).toml).toContain('"Old"'); + expect((await second).toml).toContain('"New"'); + expect(loadMerchant).toHaveBeenCalledTimes(2); + }); + + it("takes over when the leader's lock expires without a result", async () => { + const keys = buildTomlKeys(MERCHANT_ID); + // A crashed peer left a lock behind that expires in 200ms. + redis.write(keys.lock, "crashed-peer", 200); + const loadMerchant = vi.fn(async () => MERCHANT); + const coord = makeInstance(redis, clock, { loadMerchant }); + + const result = await coord.getStellarToml(MERCHANT_ID); + expect(result.source).toBe("leader"); + expect(loadMerchant).toHaveBeenCalledTimes(1); + }); + + it("never releases a lock it no longer owns", async () => { + const keys = buildTomlKeys(MERCHANT_ID); + const coord = makeInstance(redis, clock, { + loadMerchant: async () => { + // Lease expires mid-generation and another instance takes the lock. + clock.now += 5_001; + redis.write(keys.lock, "other-owner", 5_000); + return MERCHANT; + }, + }); + + await coord.getStellarToml(MERCHANT_ID); + expect(redis.read(keys.lock)).toBe("other-owner"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ merchantId: MERCHANT_ID }), + expect.stringContaining("lock expired before release"), + ); + }); + + it("generates directly when the lock wait times out", async () => { + const keys = buildTomlKeys(MERCHANT_ID); + redis.write(keys.lock, "slow-peer", 60_000); + const loadMerchant = vi.fn(async () => MERCHANT); + const coord = makeInstance(redis, clock, { loadMerchant }); + + const result = await coord.getStellarToml(MERCHANT_ID); + expect(result.source).toBe("direct"); + expect(loadMerchant).toHaveBeenCalledTimes(1); + expect(redis.read(keys.lock)).toBe("slow-peer"); + }); + + it("ignores a tampered shared entry and regenerates", async () => { + const keys = buildTomlKeys(MERCHANT_ID); + const toml = 'NETWORK_PASSPHRASE = "x"\nTRANSFER_SERVER = "https://evil.example"'; + redis.write( + keys.entry, + JSON.stringify({ v: ENTRY_VERSION, merchantId: MERCHANT_ID, gen: "0", digest: "0".repeat(64), toml }), + 60_000, + ); + const coord = makeInstance(redis, clock, { loadMerchant: async () => MERCHANT }); + + const result = await coord.getStellarToml(MERCHANT_ID); + expect(result.source).toBe("leader"); + expect(result.toml).not.toContain("evil.example"); + }); + + it("fails open when Redis is unavailable or returns the no-op client", async () => { + const loadMerchant = vi.fn(async () => MERCHANT); + for (const getRedis of [ + async () => { + throw new Error("ECONNREFUSED"); + }, + async () => ({ isOpen: false, sendCommand: async () => null }), + ]) { + const coord = makeInstance(redis, clock, { loadMerchant, getRedis }); + expect((await coord.getStellarToml(MERCHANT_ID)).source).toBe("direct"); + await expect(coord.invalidate(MERCHANT_ID)).resolves.toBe(false); + } + }); + + it("fails open when a Redis command throws mid-coordination", async () => { + const broken = { isOpen: true, sendCommand: vi.fn().mockRejectedValue(new Error("READONLY")) }; + const coord = makeInstance(redis, clock, { + loadMerchant: async () => MERCHANT, + getRedis: async () => broken, + }); + expect((await coord.getStellarToml(MERCHANT_ID)).source).toBe("direct"); + await expect(coord.invalidate(MERCHANT_ID)).resolves.toBe(false); + }); + + it("returns null for unknown merchants and caches nothing", async () => { + const coord = makeInstance(redis, clock, { loadMerchant: async () => null }); + await expect(coord.getStellarToml(MERCHANT_ID)).resolves.toBeNull(); + const keys = buildTomlKeys(MERCHANT_ID); + expect(redis.read(keys.entry)).toBeNull(); + expect(redis.read(keys.lock)).toBeNull(); + }); + + it("propagates loader errors once and still releases the lock", async () => { + const loadMerchant = vi.fn(async () => { + const err = new Error("db down"); + err.status = 500; + throw err; + }); + const coord = makeInstance(redis, clock, { loadMerchant }); + + await expect(coord.getStellarToml(MERCHANT_ID)).rejects.toThrow("db down"); + expect(loadMerchant).toHaveBeenCalledTimes(1); + expect(redis.read(buildTomlKeys(MERCHANT_ID).lock)).toBeNull(); + }); + + it("rejects generated content that fails SEP-0001 validation", async () => { + const coord = makeInstance(redis, clock, { + loadMerchant: async () => MERCHANT, + render: () => "garbage", + }); + await expect(coord.getStellarToml(MERCHANT_ID)).rejects.toMatchObject({ status: 500 }); + expect(redis.read(buildTomlKeys(MERCHANT_ID).entry)).toBeNull(); + }); + + it("rejects invalid merchant ids without touching Redis or the database", async () => { + const loadMerchant = vi.fn(); + const coord = makeInstance(redis, clock, { loadMerchant }); + await expect(coord.getStellarToml("bad:id")).rejects.toThrow(TypeError); + await expect(coord.invalidate("bad:id")).resolves.toBe(false); + expect(loadMerchant).not.toHaveBeenCalled(); + expect(redis.calls).toHaveLength(0); + }); +}); diff --git a/backend/src/routes/merchants.js b/backend/src/routes/merchants.js index 0b5f8dc6..8b9734da 100644 --- a/backend/src/routes/merchants.js +++ b/backend/src/routes/merchants.js @@ -23,6 +23,7 @@ import { setApiKeyExpirySchema, } from "../lib/merchant-payload-validation.js"; import { renderReceiptEmail } from "../lib/email-templates.js"; +import { invalidateStellarToml } from "../lib/sep0001-toml-coordinator.js"; import { createWebhookDomainVerificationState, readWebhookDomainVerification, @@ -361,6 +362,9 @@ function createMerchantsRouter({ throw error; } + // Branding feeds the SEP-0001 [ORG] section; drop cached copies everywhere. + await invalidateStellarToml(req.merchant.id); + res.json({ branding_config: data.branding_config }); } catch (err) { next(err); diff --git a/backend/src/routes/sep0001.js b/backend/src/routes/sep0001.js index c1275b60..16ce717f 100644 --- a/backend/src/routes/sep0001.js +++ b/backend/src/routes/sep0001.js @@ -1,83 +1,105 @@ import express from "express"; -import { supabase } from "../lib/supabase.js"; -import { generateStellarToml, validateStellarToml } from "../lib/sep0001-generator.js"; +import { logger } from "../lib/logger.js"; +import { getStellarTomlCoordinator } from "../lib/sep0001-toml-coordinator.js"; -const router = express.Router(); +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function etagMatches(header, etag) { + if (!header) return false; + return header + .split(",") + .map((value) => value.trim().replace(/^W\//, "")) + .some((value) => value === "*" || value === etag); +} /** - * @swagger - * /.well-known/stellar.toml: - * get: - * summary: Get SEP-0001 stellar.toml for merchant - * tags: [SEP-0001] - * parameters: - * - in: query - * name: merchant_id - * schema: - * type: string - * format: uuid - * description: Merchant ID (optional, uses authenticated merchant if not provided) - * responses: - * 200: - * description: SEP-0001 stellar.toml content - * content: - * text/plain: - * schema: - * type: string - * 404: - * description: Merchant not found - * 500: - * description: Failed to generate stellar.toml + * Build the SEP-0001 router. + * + * @param {object} [options] + * @param {() => { getStellarToml: (merchantId: string) => Promise }} [options.getCoordinator] */ -router.get("/.well-known/stellar.toml", async (req, res, next) => { - try { - let merchantId = req.query.merchant_id; - - // If no merchant_id provided, use authenticated merchant - if (!merchantId && req.merchant) { - merchantId = req.merchant.id; - } +export function createSep0001Router({ getCoordinator = getStellarTomlCoordinator } = {}) { + const router = express.Router(); - if (!merchantId) { - return res.status(400).json({ error: "merchant_id required" }); - } + /** + * @swagger + * /.well-known/stellar.toml: + * get: + * summary: Get SEP-0001 stellar.toml for merchant + * description: > + * Generation is coordinated across API instances (single-flight plus a + * Redis lock and shared cache) so bursts of requests for one merchant + * cause at most one database read. Responses carry a strong ETag and + * honour If-None-Match. + * tags: [SEP-0001] + * parameters: + * - in: query + * name: merchant_id + * schema: + * type: string + * format: uuid + * description: Merchant ID (optional, uses authenticated merchant if not provided) + * responses: + * 200: + * description: SEP-0001 stellar.toml content + * content: + * text/plain: + * schema: + * type: string + * 304: + * description: Not modified (If-None-Match matched the current ETag) + * 400: + * description: merchant_id missing or not a UUID + * 404: + * description: Merchant not found + * 500: + * description: Failed to generate stellar.toml + */ + router.get("/.well-known/stellar.toml", async (req, res, next) => { + try { + let merchantId = req.query.merchant_id; - // Fetch merchant data - const { data: merchant, error } = await supabase - .from("merchants") - .select("id, business_name, email, notification_email, recipient, branding_config") - .eq("id", merchantId) - .is("deleted_at", null) - .maybeSingle(); + // If no merchant_id provided, use authenticated merchant + if (!merchantId && req.merchant) { + merchantId = req.merchant.id; + } - if (error) { - console.error("Failed to fetch merchant:", error); - return res.status(500).json({ error: "Failed to fetch merchant" }); - } + if (!merchantId) { + return res.status(400).json({ error: "merchant_id required" }); + } - if (!merchant) { - return res.status(404).json({ error: "Merchant not found" }); - } + if (typeof merchantId !== "string" || !UUID_PATTERN.test(merchantId)) { + return res.status(400).json({ error: "merchant_id must be a valid UUID" }); + } + + const result = await getCoordinator().getStellarToml(merchantId.toLowerCase()); + + if (!result) { + return res.status(404).json({ error: "Merchant not found" }); + } + + const etag = `"${result.digest}"`; + res.set({ + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + ETag: etag, + }); - // Generate stellar.toml - const tomlContent = generateStellarToml(merchant); + if (etagMatches(req.get("If-None-Match"), etag)) { + return res.status(304).end(); + } - // Validate generated content - if (!validateStellarToml(tomlContent)) { - console.error("Generated invalid stellar.toml for merchant:", merchantId); - return res.status(500).json({ error: "Failed to generate valid stellar.toml" }); + res.send(result.toml); + } catch (err) { + logger.error({ err: err?.message }, "Error generating stellar.toml"); + if (err?.status === 500) { + return res.status(500).json({ error: err.message }); + } + next(err); } + }); - // Return as text/plain with proper caching headers - res.set({ - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "public, max-age=3600", // Cache for 1 hour - }); - res.send(tomlContent); - } catch (err) { - console.error("Error generating stellar.toml:", err); - next(err); - } -}); + return router; +} -export default router; +export default createSep0001Router(); diff --git a/backend/src/routes/sep0001.test.js b/backend/src/routes/sep0001.test.js new file mode 100644 index 00000000..847c619f --- /dev/null +++ b/backend/src/routes/sep0001.test.js @@ -0,0 +1,113 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { createSep0001Router } from "./sep0001.js"; + +const MERCHANT_ID = "3f0c6a8e-6b1d-4c4e-9a53-1f3d2b7c9e10"; +const RESULT = { + toml: 'NETWORK_PASSPHRASE = "x"\nTRANSFER_SERVER = "y"', + digest: "a".repeat(64), + source: "shared", +}; + +function createApp(getStellarToml, merchant) { + const app = express(); + if (merchant) { + app.use((req, _res, next) => { + req.merchant = merchant; + next(); + }); + } + app.use(createSep0001Router({ getCoordinator: () => ({ getStellarToml }) })); + app.use((err, _req, res, _next) => res.status(err.status || 500).json({ error: err.message })); + return app; +} + +describe("GET /.well-known/stellar.toml", () => { + let getStellarToml; + + beforeEach(() => { + getStellarToml = vi.fn(async () => RESULT); + }); + + it("returns the TOML with caching headers and a strong ETag", async () => { + const res = await request(createApp(getStellarToml)).get( + `/.well-known/stellar.toml?merchant_id=${MERCHANT_ID}`, + ); + expect(res.status).toBe(200); + expect(res.text).toBe(RESULT.toml); + expect(res.headers["content-type"]).toMatch(/text\/plain/); + expect(res.headers["cache-control"]).toBe("public, max-age=3600"); + expect(res.headers.etag).toBe(`"${RESULT.digest}"`); + expect(getStellarToml).toHaveBeenCalledWith(MERCHANT_ID); + }); + + it("normalizes merchant_id casing so one merchant maps to one cache key", async () => { + await request(createApp(getStellarToml)).get( + `/.well-known/stellar.toml?merchant_id=${MERCHANT_ID.toUpperCase()}`, + ); + expect(getStellarToml).toHaveBeenCalledWith(MERCHANT_ID); + }); + + it("answers 304 when If-None-Match matches", async () => { + const res = await request(createApp(getStellarToml)) + .get(`/.well-known/stellar.toml?merchant_id=${MERCHANT_ID}`) + .set("If-None-Match", `W/"other", "${RESULT.digest}"`); + expect(res.status).toBe(304); + expect(res.text).toBe(""); + }); + + it("falls back to the authenticated merchant", async () => { + const res = await request(createApp(getStellarToml, { id: MERCHANT_ID })).get( + "/.well-known/stellar.toml", + ); + expect(res.status).toBe(200); + expect(getStellarToml).toHaveBeenCalledWith(MERCHANT_ID); + }); + + it("requires merchant_id", async () => { + const res = await request(createApp(getStellarToml)).get("/.well-known/stellar.toml"); + expect(res.status).toBe(400); + expect(getStellarToml).not.toHaveBeenCalled(); + }); + + it.each(["not-a-uuid", "sep1:{x}:lock", "*", `${MERCHANT_ID}x`])( + "rejects malformed merchant_id %s before any lookup", + async (bad) => { + const res = await request(createApp(getStellarToml)) + .get("/.well-known/stellar.toml") + .query({ merchant_id: bad }); + expect(res.status).toBe(400); + expect(getStellarToml).not.toHaveBeenCalled(); + }, + ); + + it("rejects array-valued merchant_id (parameter pollution)", async () => { + const res = await request(createApp(getStellarToml)).get( + `/.well-known/stellar.toml?merchant_id=${MERCHANT_ID}&merchant_id=${MERCHANT_ID}`, + ); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown merchants", async () => { + getStellarToml.mockResolvedValue(null); + const res = await request(createApp(getStellarToml)).get( + `/.well-known/stellar.toml?merchant_id=${MERCHANT_ID}`, + ); + expect(res.status).toBe(404); + }); + + it("returns 500 when generation fails", async () => { + getStellarToml.mockRejectedValue(Object.assign(new Error("Failed to fetch merchant"), { status: 500 })); + const res = await request(createApp(getStellarToml)).get( + `/.well-known/stellar.toml?merchant_id=${MERCHANT_ID}`, + ); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Failed to fetch merchant"); + }); +}); diff --git a/backend/src/services/merchantService.js b/backend/src/services/merchantService.js index 1fcd0310..d870761c 100644 --- a/backend/src/services/merchantService.js +++ b/backend/src/services/merchantService.js @@ -7,6 +7,7 @@ import { normalizeApiKeyExpiry, } from "../lib/merchant-payload-validation.js"; import { sendWebhook } from "../lib/webhooks.js"; +import { invalidateStellarToml } from "../lib/sep0001-toml-coordinator.js"; import { getPayloadForVersion } from "../webhooks/resolver.js"; const DEFAULT_WEBHOOK_SECRET_ROTATION_GRACE_HOURS = 24; @@ -270,6 +271,9 @@ export const merchantService = { throw error; } + // Branding feeds the SEP-0001 [ORG] section; drop cached copies everywhere. + await invalidateStellarToml(merchantId); + return { branding_config: data.branding_config }; }, diff --git a/docs/SEP0001_CONCURRENCY_CONTROL.md b/docs/SEP0001_CONCURRENCY_CONTROL.md new file mode 100644 index 00000000..90b24dae --- /dev/null +++ b/docs/SEP0001_CONCURRENCY_CONTROL.md @@ -0,0 +1,111 @@ +# SEP-0001 stellar.toml — Distributed Concurrency Control + +Issue: #1460 + +`GET /.well-known/stellar.toml` is public, unauthenticated, and fetched often by +wallets, anchors and crawlers. Before this change, every request read the +merchant row from Supabase and regenerated the TOML, on every API instance. +There was also no way to clear a cached copy when a merchant changed their +branding. + +Implementation: `backend/src/lib/sep0001-toml-coordinator.js`. + +## Layers + +| Layer | Scope | Purpose | +|-------|-------|---------| +| Single-flight | per process | Concurrent requests for one merchant share one in-flight generation. | +| Shared store `sep1:{id}:toml` | cluster | Rendered TOML, SHA-256 digest and generation, stored with a TTL. | +| Lock `sep1:{id}:lock` | cluster | `SET NX PX` with a random owner token. One instance regenerates; the others poll the shared store. | +| Generation `sep1:{id}:gen` | cluster | Fencing counter. Invalidation increments it and deletes the entry in one atomic step. | + +All keys for a merchant share the `{id}` hash tag, so the multi-key Lua scripts +work on Redis Cluster. + +## Request flow + +1. `MGET entry gen`. If the entry is valid and its generation equals `gen`, the + instance serves it (`source: shared`). +2. Otherwise it tries `SET lock NX PX lockTtl`. + - **Leader:** loads the merchant, renders and validates the TOML, then + publishes it with `WRITE_IF_GENERATION_SCRIPT`. The write succeeds only + if `gen` still equals the value observed in step 1. The leader then + releases the lock with a compare-and-delete script. + - **Follower:** sleeps `pollIntervalMs` and goes back to step 1. If the + leader crashes, its lock expires and the next poll takes over. +3. If `waitTimeoutMs` passes, or any Redis command fails, the instance + generates the TOML directly (`source: direct`). + +## Invalidation + +`invalidateStellarToml(merchantId)` runs after `PUT /api/merchant-branding` and +`merchantService.updateMerchantBranding`. It: + +- bumps a local counter, so later requests on this instance don't join a + flight that started before the write, and +- runs `INVALIDATE_SCRIPT` (`INCR gen`, `PEXPIRE gen`, `DEL entry`) atomically. + +Fencing covers this race: leader L reads the old row, the merchant updates +their branding (gen 0 → 1), and then L tries to publish. L's conditional write +sees gen 1, which differs from the 0 it observed, so it discards the stale +result. The next request regenerates from fresh data. + +Call `invalidateStellarToml` after any future write to `business_name`, +`email`, `notification_email`, `recipient`, `branding_config` or `deleted_at`. + +## HTTP behaviour + +- `merchant_id` must be a UUID. Anything else, including repeated query + parameters, gets a 400 before Redis or the database is touched. The id is + lower-cased so each merchant maps to a single cache key. +- Responses include a strong `ETag` (the SHA-256 of the body). A matching + `If-None-Match` gets a `304`. +- Unchanged: `Cache-Control: public, max-age=3600`, 404 for unknown or + soft-deleted merchants. + +## Security notes + +- **Key injection:** merchant ids are checked twice, as a UUID at the route and + against `^[A-Za-z0-9_-]{1,128}$` in the coordinator, before they become part + of a key. `:`, `{`, `}`, `*` and whitespace are rejected. +- **Cache poisoning:** a shared entry is served only if all of these hold: + - the entry version matches + - it is bound to the same merchant id + - its generation is current + - it is at most 64 KiB + - its SHA-256 digest matches the body + - it passes SEP-0001 field validation + + Anything else counts as a miss and is regenerated. +- **Lock safety:** each lock has a random owner token, and release is + compare-and-delete. A leader whose lease expired can never delete a lock + that another instance now holds. +- **Fail open:** the data is public and read-only, so Redis outages, and the + project's no-op Redis fallback client, degrade to direct generation. They + never produce errors or a lock that every caller wins. +- **No negative caching:** unknown merchants are not cached, so a newly created + merchant becomes visible immediately. +- **Error hygiene:** database errors are logged server-side. Clients receive a + generic `Failed to fetch merchant` message. + +## Configuration + +| Variable | Default | Bounds | +|----------|---------|--------| +| `SEP1_TOML_CACHE_TTL_MS` | 300000 | 1000 – 3600000 | +| `SEP1_TOML_LOCK_TTL_MS` | 10000 | 1000 – 60000 | +| `SEP1_TOML_LOCK_WAIT_MS` | 3000 | 0 – 30000 | +| `SEP1_TOML_LOCK_POLL_MS` | 50 | 10 – 1000 | + +## Tests + +``` +npx vitest run src/lib/sep0001 src/routes/sep0001 +``` + +- `src/lib/sep0001-toml-coordinator.test.js`: single-flight, cross-instance + leader election, TTL expiry, invalidation, stale-write fencing, crashed-leader + takeover, lock ownership, wait timeout, tampered entries, Redis failure modes, + loader errors, and invalid ids. +- `src/routes/sep0001.test.js`: headers, ETag and 304, UUID validation, + parameter pollution, 404 and 500 responses.