Every Redis key and channel Pad uses was flat — pad:events:, pad:event_seq,
pad:watchevents*, pad:session:* — so two Pad installations pointed at one
Redis endpoint cross-feed each other's notifications and merge each other's
session-presence registries. Different logical DB numbers do not help:
Redis pub/sub is not namespaced by DB at all.
The exposure is narrow but real. Delivery is filtered per caller on user
id, and user ids are per-installation UUIDs, so cross-feed needs the same
id in both installations — a CLONED database, such as a staging
environment restored from a production dump. For that case it is a genuine
cross-tenant leak: foreign sessions listed in the picker, and a private
push deliverable across installations.
Fixed the way internal/watchevents' existing ruling demanded: not by one
package growing a prefix the others lack, but through internal/redisns —
one value parsed in cmd/pad/cmd_server.go and passed into all three
constructors. The three cannot drift because there is nothing to drift
from, and the operator rule is stateable in one sentence for every
keyspace.
PAD_REDIS_NAMESPACE defaults to empty, which reproduces the historical
names byte for byte, so an existing deployment keeps addressing its own
replay buffers, counters and presence entries across the upgrade. Tests
assert both directions per keyspace — present under the namespace AND
absent under the historical names — because an implementation that wrote
both would still cross-feed while passing a one-directional test.
Namespaces are validated at startup, and a colon is rejected specifically:
it is Pad's own separator, so namespace "a:events" would build
pad:a:events:<ws> and collide with installation "a"'s channel —
reintroducing the cross-feed through the mechanism meant to fix it.
Names are built through a function rather than assembled from a literal at
each site, and redisns' doc says why: "pad:" also begins Pad's OAuth SCOPE
values (pad:read / pad:write / pad:admin) in four files, so a grep-driven
prefix sweep would break authorization.
Not included, deliberately: hash tags for Redis Cluster. BUG-2724's trail
recommended shipping them alongside on cost-sharing grounds; that premise
is falsified by publishScript, which spans four keys in one EVAL and fails
CROSSSLOT exactly as presence's MGET does. There is no cheap half, and no
cluster client here to exercise tagged keys against, so they would ship
untested by construction. Cluster stays documented as unsupported and the
future unit is named on the trail.
Renaming is a CUTOVER for the buses (the seq and epoch keys carry
Last-Event-ID meaning, so connected clients resync) and free for presence
(90s TTL). Both stated in docs/deployment.md and at the constructors.
Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
Two coupled defects in the push path, fixed as one unit because 2699's honest-acceptance signature is the substrate 2698's fix reports through.
BUG-2699 — Bus.Publish reports acceptance. The endpoint returned 200 pushed:true
for a publish that was dropped, because Publish returned nothing and swallowed
every failure. An error is two outcomes and they are kept apart: ErrBusClosed
proves nothing was published (503 unavailable, safe to resend), while any other
error means UNCONFIRMED — go-redis retries a command whose reply was lost, which
is why the publish script already carries a dedupe token — and gets 502
push_unconfirmed, deliberately off the web client's safe-to-resend list.
MemoryBus was the worse case, not the exempt one: neither implementation checked
`closed`, and the in-process one dropped silently with no log at all. Seven
production call sites, not the six the item named; the six best-effort producers
discard through one named helper, and an AST-based test fails when a new
producer publishes directly.
BUG-2698 — RedisSessionPresence. A session-targeted push was resolved against
the answering replica's presence registry, and the handler skips the publish
when the target is absent, so a POST landing on A for a session held on B
dropped the instruction and answered delivered_sessions:0. Fixed at the REGISTRY
rather than the gate: a shared registry makes the snapshot right, which makes
the picker complete and restores the gate's original premise, so the existing
skip becomes correct for the reason it was written. Entry and index are written
atomically under a TTL renewed by a goroutine that lives exactly as long as the
connection; a crashed process stops renewing and Redis clears it. Staleness is
unchanged and now stated in full: ~30s for a dropped client, ~90s for a dead
instance.
delivered_sessions becomes nullable — null means published-but-uncountable,
never zero — documented as three states at every consumer.
35 Codex review rounds. Notable: a per-user registry cap was added and then
removed after three consecutive rounds found defects inside it and a fourth was
asked whether it belonged in this PR at all; a context bound was documented,
disproved by its own test (go-redis does not apply a command context to
connection establishment — 5.0s measured against a 150ms ctx), and rewritten to
say what is true. Every fix was mutation-checked; one instrument was deleted for
passing on broken code and one for not asserting its own premise.
Filed rather than folded in: BUG-2724 (Redis keyspace namespacing + Cluster),
BUG-2725 (delivered_sessions is an estimate with error in both directions),
BUG-2726 (no concurrent-connection limit on the watch stream), BUG-2727 (Redis
absent from readiness/metrics; silent subscriber loss), BUG-2728 (epoch-reset
resume lead).
Gates: build · make lint 0 issues · go test ./... (25 pkgs) · svelte-check 0
errors · vitest 1738 passed · CI 7/7 including Go (PostgreSQL) and Nix.
* fix(docker): bind to 127.0.0.1 + require POSTGRES_PASSWORD (TASK-661)
A fresh Docker install previously published 7777 on 0.0.0.0 with a
hardcoded pad:pad Postgres credential. The bootstrap endpoint is
reachable until the first admin is created, so this combination lets
anyone who can route to the host claim the instance — and with M5's
X-Forwarded-For spoof (fixed in TASK-660) chained with the loopback
bootstrap check, it became a full takeover.
Changes:
- docker-compose.yml: publish "127.0.0.1:7777:7777" by default, with a
PAD_BIND_ADDR override for operators who intentionally want LAN
access. Require POSTGRES_PASSWORD via ${VAR:?err} so docker compose
refuses to start when it's unset — can't silently inherit a weak
default credential.
- docker-compose.prod.yml: drop the "change-me-in-production"
placeholder; require the same env var as the base file.
- .env.example: new file documenting POSTGRES_PASSWORD (required),
PAD_BIND_ADDR, REDIS_PASSWORD, PAD_CLOUD_SECRET, PAD_ENCRYPTION_KEY,
PAD_TRUSTED_PROXIES with generation instructions.
- README.md: add a Docker Compose section covering the .env workflow
and the loopback-default → LAN override.
Parent: PLAN-643 (OSS Security Hardening).
* fix(docker): use libpq keyword=value DSN to avoid URI-encoding the Postgres password per Codex P1
Passwords produced by 'openssl rand -base64' often include '/', '+', or ':'
which are reserved in URI userinfo. Injecting them into postgres://user:PASS@...
breaks sql.Open. Switch PAD_DATABASE_URL to the libpq keyword=value form
(host=... password=... dbname=...) where the password is parsed as a single
token regardless of special characters.
Also teach pgDbnameFromURL to parse both DSN shapes so 'pad db backup/restore'
still shows the correct database name in its confirmation prompt.
- Make SSE limit checks atomic with subscription via SubscribeIfAllowed to
prevent TOCTOU races where concurrent requests bypass connection caps
- Replace fmt.Sprintf JSON assembly with json.Marshal (auditMeta helper) in
all audit log call sites to prevent silent JSONB insert failures on
PostgreSQL when metadata contains special characters
- Pass database credentials via PGDATABASE env var instead of pg_dump/psql
command-line args to avoid leaking passwords in ps/proc output
- Fix audit-log query builder to rebind placeholders once after all filters
are appended, preventing duplicate $1 placeholders on PostgreSQL
- Replace per-workspace SSE GaugeVec with a single Gauge to avoid unbounded
Prometheus label cardinality in multi-tenant deployments
- Fix prod Docker Compose: override PAD_REDIS_URL with password and add
authenticated Redis healthcheck when REDIS_PASSWORD is set