Files
1260996051 KV-optional: run WITH and WITHOUT external Hanzo KV (#4)
* KV-optional: in-process backend when KV_URL unset

Make dataroom run WITH and WITHOUT external Hanzo KV, uniform with commerce
infra/kv.go. KV_URL unset -> in-process MemoryKV (single-replica correct: cache,
rate-limit, tus upload locks, export/download job stores, digest queues all work
with no external datastore). KV_URL set -> external Hanzo KV over ioredis
(multi-replica HA). Malformed KV_URL fails CLOSED (throws) -- never a silent
in-process fallback.

- lib/kv/url.ts: resolveKvUrl -- fail-closed parse, kv:// brand scheme -> redis://
  wire scheme, password redaction in errors.
- lib/kv/memory-kv.ts: in-process ioredis-compatible store (strings+TTL, zset,
  set, hash, list, pipeline) -- the exact surface dataroom uses.
- lib/kv/select.ts: the one backend selector (mirrors NewKVClient/FromURL).
- lib/redis.ts: use createKvClient() for redis + lockerRedisClient; drop the
  redis://localhost:6379 silent default (the WITHOUT-KV break) and vestigial
  REDIS_URL; Upstash-compat shims unchanged.
- Tests (node --test, dep-free): 25 pass proving selection + in-process
  correctness incl NX lock mutual-exclusion, TTL eviction, zset ordering,
  pipeline shape, fail-closed URL, cross-instance isolation (multi-replica
  split-brain contract).

* kv: warn once when falling back to in-process (non-silent split-brain)

Emit a one-time startup warning when KV_URL is unset so the WITHOUT-KV mode is
never silent — a multi-replica deploy with KV_URL unset splits sessions,
rate-limit windows and tus locks across replicas (single-replica only).

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-07 15:47:14 -07:00

63 lines
2.2 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { resolveKvUrl } from "./url.ts";
test("unset / empty / whitespace ⇒ null (in-process backend)", () => {
assert.equal(resolveKvUrl(undefined), null);
assert.equal(resolveKvUrl(null), null);
assert.equal(resolveKvUrl(""), null);
assert.equal(resolveKvUrl(" "), null);
});
test("kv:// brand scheme normalizes to redis:// wire scheme", () => {
assert.equal(
resolveKvUrl("kv://hanzo-kv.hanzo.svc:6379"),
"redis://hanzo-kv.hanzo.svc:6379",
);
assert.equal(
resolveKvUrl("kv://:secret@hanzo-kv:6379"),
"redis://:secret@hanzo-kv:6379",
);
});
test("kvs:// (TLS brand scheme) normalizes to rediss://", () => {
assert.equal(resolveKvUrl("kvs://hanzo-kv:6380"), "rediss://hanzo-kv:6380");
});
test("raw redis:// / rediss:// DSN is accepted verbatim (commerce parity)", () => {
assert.equal(
resolveKvUrl("redis://:pw@hanzo-kv:6379/0"),
"redis://:pw@hanzo-kv:6379/0",
);
assert.equal(resolveKvUrl("rediss://hanzo-kv:6380"), "rediss://hanzo-kv:6380");
});
test("FAIL CLOSED: unparseable URL throws (never silent in-process fallback)", () => {
assert.throws(() => resolveKvUrl("::: not a url :::"), /malformed/);
});
test("FAIL CLOSED: non-redis scheme throws", () => {
// A bare host:port parses as scheme 'localhost:' — must be rejected, not
// silently connected to a phantom localhost.
assert.throws(() => resolveKvUrl("localhost:6379"), /unsupported scheme|malformed/);
assert.throws(() => resolveKvUrl("http://hanzo-kv:6379"), /unsupported scheme/);
assert.throws(() => resolveKvUrl("valkey://hanzo-kv:6379"), /unsupported scheme/);
});
test("FAIL CLOSED: missing host throws", () => {
assert.throws(() => resolveKvUrl("redis://"), /missing a host/);
});
test("error message REDACTS the password (no credential leak in logs)", () => {
let msg = "";
try {
resolveKvUrl("http://:supersecretpw@hanzo-kv:6379");
} catch (e) {
msg = (e as Error).message;
}
assert.ok(msg.length > 0, "expected a throw");
assert.ok(!msg.includes("supersecretpw"), "password must not appear in error");
assert.ok(msg.includes("<redacted>"), "expected redaction marker");
});