45 Commits

Author SHA1 Message Date
egutierrez 4dea99a524 chore: auto-commit (1 archivos)
- build/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:18 +02:00
egutierrez 07978fc697 Merge branch 'issue/room-history-endpoint'
Server owns the JetStream stream of persisted rooms + GET /rooms/{id}/history so
clients without JetStream (uniweb) can read the backlog over plain HTTP.
2026-06-14 19:47:05 +02:00
egutierrez bf47511f2a docs(unibus): bump to 0.16.0; document server stream ownership + history endpoint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:46:56 +02:00
egutierrez 73fd89f0b9 feat(membershipd): open JetStream for the embedded node + wire it into the server
The control plane previously opened a privileged JetStream client only when
clustered or running --store kv (needJS). It now also opens one for a standalone
single-node embedded deployment (openJS = needJS || embedded), because the
embedded NATS always ships JetStream and the server needs it to own persisted
rooms' durable streams (ensure on create + serve GET /rooms/{id}/history). An
external NATS without a cluster/KV feature is unchanged (no JetStream; history
degrades to empty).

The internal service identity is generated under the same broadened condition so
the in-process JetStream connection authenticates under enforce. After NewServer
the js context is wired via SetJetStream with the control-plane KV replication
factor, so a persisted room's history is as available as its metadata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:46:56 +02:00
egutierrez e71063b16e feat(membership): server owns persisted rooms' stream + GET /rooms/{id}/history
The durable JetStream stream of a persisted (ModeMatrix) room was created only
by the Go client's first publish/subscribe. A client that speaks only core NATS
(the browser client uniweb, which has no JetStream) therefore never created it,
so its messages were captured nowhere and lost on reload. Move stream ownership
to the control plane and expose the backlog over plain HTTP.

- handleCreateRoom ensures the room's stream (idempotent CreateOrUpdateStream)
  BEFORE writing the room row, so the subject is captured from the first message
  whoever publishes it. Done before the store write so a stream failure leaves no
  orphan room. Skipped when no JetStream is wired (room still works, no history).
- New member-only GET /rooms/{id}/history?limit=N (default 200, hard cap 1000):
  reads the stream server-side via the modern jetstream API (Stream.Info +
  GetMsg by sequence, no consumer) and returns the last N frames oldest->newest
  as {"messages":[<base64-std of the marshaled frame>]}. The server never
  decrypts — it relays the E2E ciphertext bytes the stream already holds.
  Existence is checked first (404), then membership (403); enforce rejects an
  unsigned request with 401 before the handler runs.
- Lazy backfill: the history endpoint ensures the stream of a pre-existing
  persisted room, so it starts capturing from now on. Messages sent before the
  stream existed were never captured and are unrecoverable.
- The stream config (streamConfigForRoom) mirrors pkg/client/persist.go
  byte-for-byte plus Replicas (matched to the control-plane KV replication). It
  is copied rather than imported because pkg/client imports pkg/membership and
  the reverse would be an import cycle; the source of truth is documented in a
  comment.
- Server gains SetJetStream(js, replicas) to wire the privileged JetStream
  context and the room-stream replication factor.

Tests (history_test.go): golden (3 frames round-trip in order, decodable),
core-NATS capture (the central fix), handleCreateRoom creates the stream, limit,
empty room ([] not null), 401 unsigned, 403 non-member, 404 missing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:46:45 +02:00
egutierrez 3fdbb54353 Merge branch 'quick/directory-route-path'
fix: directory route registered as /directory (Caddy strips /api) — was 404 in prod
2026-06-14 16:05:06 +02:00
egutierrez 8c3ddaa294 fix(membership): register directory route as /directory, not /api/directory
Caddy strips /api via `handle_path /api/*` before forwarding to membershipd,
so the SPA's GET /api/directory arrives as GET /directory. The route was
registered with the /api prefix, so the stripped request hit no route and
returned 404 in production: the directory never resolved and uniweb fell back
to short ids. Every other control-plane route is registered without the prefix;
this aligns directory with them.

The unit test passed despite the bug because it requested /api/directory, the
same wrong path as the registration. Corrected the request paths to /directory
so the test now exercises the real production path (verified: reverting the
registration to /api/directory now makes TestDirectoryGolden fail with 404).

Bump 0.15.0 -> 0.15.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:05:00 +02:00
egutierrez e48b092135 Merge branch 'issue/names-bot-provisioning'
Integra GET /api/directory (endpoint->handle resolution) y el provisioning
one-command de bots (membershipd bot add).
2026-06-14 15:39:55 +02:00
egutierrez 0b39e86ed6 docs(unibus): bump to 0.15.0; document directory + bot provisioning
Add the 'Directorio de nombres (endpoint -> handle)' and 'Provisioning de bots /
unibots' sections with an end-to-end snippet, and a capability growth log entry
for v0.15.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:32:00 +02:00
egutierrez 669bad52af feat(membershipd): one-command bot provisioning (bot add)
Add `membershipd bot add --handle <name> --out <path> [--role] [--store]` to
provision a bus identity for an automated process in a single step: mint a fresh
Ed25519+X25519 identity (cs.GenerateIdentity, the same derivation worker/chat
use), register its signing key in the allowlist, and write the credentials to a
0600 file. The file is the canonical identity format read by client.LoadIdentity,
so a worker/clientcheck binary pointed at --out connects as the new user with no
extra conversion. Shares the sqlite/kv store plumbing with `user add`.

New exported pkg/client.WriteNewIdentity writes an identity in that format but
refuses to overwrite an existing file (never silently clobber private keys).

provisionBot ordering guarantees no half-provisioned bot: refuse an existing
--out before touching the store, register (an already-registered key is a clear
error, not a panic), then write credentials. Tests cover the golden path
(register + 0600 file + LoadIdentity round-trip), default role, the
already-registered error path (no file written), and the out-exists error path
(no orphan user).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:30:17 +02:00
egutierrez 2ba40701b2 feat(membership): add GET /api/directory for endpoint->handle resolution
Authenticated bus users (member or admin) can now map a sender's endpoint id
back to a readable handle. The endpoint is derived server-side from each user's
sign_pub with frame.EndpointID (base64url(sha256(signPub)), unpadded), matching
the bus's own construction byte-for-byte. Only active users are listed; under
enforce the existing auth middleware rejects an unauthenticated caller with 401.

Tests cover the golden path (two users -> 200 with handles + endpoints), the
auth contract (unsigned -> 401), revoked-user exclusion, and endpoint parity
against the cross-language vector from cmd/busvectors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:27:38 +02:00
egutierrez 363aa97def Merge branch 'quick/proxy-ready' 2026-06-14 13:49:24 +02:00
egutierrez e3f40913bc chore(deploy): version the same-origin Caddy config for uniweb
Capture the reverse-proxy vhost that fronts the browser-native uniweb
client on magnus (chat-<hash>.organic-machine.com): the SPA at /, the
signed control plane under /api (prefix stripped so request signatures
verify), and the NATS-over-WebSocket data plane under /nats. One origin
means no CORS and keeps the cluster node IPs hidden behind the proxy.

Self-contained fragment (includes the shared security_headers snippet) so
it validates with `caddy validate` on its own; the other vhosts on magnus
carry basic-auth secrets and are intentionally left out of git. Documents
the matching membershipd flags this config requires (--cors-origins with
the same-origin host, --trusted-proxies naming the Caddy node).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:43:15 +02:00
egutierrez 0b96c114b6 feat(membership): trust reverse-proxy forwarded client IP for rate limit
The per-IP rate limiter keys on the transport RemoteAddr. Behind the
same-origin Caddy proxy that fronts the control plane, every request
arrives with the proxy's single IP, which collapses the limiter into one
bucket shared by the whole world — a flood from one client throttles all
of them.

Add an opt-in `--trusted-proxies` flag (comma-separated IPs/CIDRs). When
the immediate peer is one of the named proxies, clientIP now believes its
X-Forwarded-For (read right-to-left, skipping trusted hops) or X-Real-IP
and keys on the real client. A direct, non-trusted peer's forwarding
headers are ignored entirely, so this opens no quota-fanning hole: an
attacker connecting straight to the public :8470 cannot spoof a key. The
zero value (no flag) preserves the prior RemoteAddr-only behavior exactly.

Covered by ratelimit_proxy_test.go: trusted vs untrusted peers, XFF
right-to-left precedence, client-prepended forgery, X-Real-IP fallback,
and rejection of malformed proxy entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:29:57 +02:00
egutierrez 294905984c fix(membership): allow X-Unibus-* auth headers in CORS preflight
A browser signs every control-plane request with X-Unibus-Pub/Ts/Nonce/Sig
(busauth.signedHeaders). The CORS Allow-Headers only listed Content-Type and
Authorization, so the browser's preflight rejected the real request and the SPA
failed with 'Failed to fetch' on the first authenticated call (listRooms). Add the
four X-Unibus-* headers to Access-Control-Allow-Headers.

This was invisible to the Node smoke (fetch in Node does no CORS preflight); only a
real browser surfaced it. Verified live: enmanuel logs into uniweb against the
cluster and lists rooms. Regression test asserts the header is present.
2026-06-14 12:12:20 +02:00
egutierrez feb917fc6a feat(cluster): deploy browser WebSocket + CORS to the 3-node cluster
Roll the --ws-port + --cors-origins flags (issue uniweb/0001) out to the unibus
cluster so the browser-native uniweb client can reach the data plane (nats.ws)
and the control plane (CORS) on every node. The WS reuses the data-plane TLS
(wss://) and the same origin allowlist.

Per-node WS port override (WS_PORT_<NAME>): magnus runs unibus_admin on
127.0.0.1:8480, so the bus WS binds 8485 there to avoid a crash-loop; homer and
datardos keep 8480. deploy-cluster.sh also gains DEPLOY_ONLY=<name> for rolling
one node at a time. Rolled out and verified 2026-06-13: all three nodes healthy,
WS reachable, CORS 204, cluster quorum (R3) intact throughout.
2026-06-13 23:23:52 +02:00
egutierrez c0216de766 feat(membershipd): --ws-port wires the embedded NATS WebSocket listener
Phase 0 left the WebsocketConfig field unwired; add --ws-port so membershipd can
actually expose the browser data-plane transport. It reuses the data-plane TLS
(wss:// when TLS is on, ws:// for a loopback dev stack) and the same
--cors-origins allowlist that gates the control plane, so one flag pair opens
both planes to the browser-native uniweb client (issue uniweb/0001).
2026-06-13 23:05:33 +02:00
egutierrez 0088fb946b feat(busvectors): add nkey + signed control-request vectors
Extend the cross-language vectors with the NATS user nkey derived from the
Ed25519 public key, and a signed control-plane request (CanonicalRequest +
Ed25519 signature). These let the TypeScript busauth port verify it authenticates
on both planes exactly like the Go client (issue uniweb/0001, Phase 1).
2026-06-13 22:49:20 +02:00
egutierrez e058b324f4 Merge branch 'quick/0001-ws-cors-prep' 2026-06-13 22:21:51 +02:00
egutierrez a5086ecd18 chore: bump unibus to 0.14.0 (browser-native client prep, Phase 0) 2026-06-13 22:21:51 +02:00
egutierrez 8a51c5cc1f feat(busvectors): deterministic cross-language test vectors
Add cmd/busvectors, a generator that emits stable JSON test vectors for the bus
protocol and its E2E crypto (endpoint id, Ed25519 sign, ChaCha20-Poly1305 AEAD
with a fixed nonce, sealed-box of a room key, and canonical Frame wire bytes +
SigningBytes). It uses the same registry crypto (functions/cybersecurity) the
bus uses, so the vectors are the contract the TypeScript port must match
byte-for-byte (issue uniweb/0001, Phase 0).

Regenerate with: go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json
2026-06-13 22:21:32 +02:00
egutierrez ec8d34aaa1 feat(membership): opt-in CORS allowlist for the browser-native client
Add Server.AllowedOrigins and an applyCORS step at the top of ServeHTTP so a
browser SPA (uniweb) can call the control plane cross-origin: an allow-listed
Origin gets the Access-Control-Allow-* headers, and a preflight (OPTIONS) is
answered 204 before the rate limiter and auth ever run. A disallowed or missing
origin gets no headers (preflight 403), so the browser blocks the request.

Wire it through membershipd's --cors-origins flag (comma list, reusing
splitRoutes as a generic parser). Empty allowlist = CORS off, no headers
emitted, behavior identical to before: native Go/Kotlin clients send no Origin
and are unaffected. Opt-in per deployment (issue uniweb/0001, Phase 0).

Tests: preflight allow/deny, header on the real response, CORS-off default, and
no-Origin native client unaffected.
2026-06-13 22:17:44 +02:00
egutierrez 36f4ba0eaf feat(embeddednats): optional WebSocket listener for browser clients
Add WebsocketConfig to ServerConfig so the embedded nats-server can expose an
additional WebSocket port (nats.ws) alongside the TCP data plane. This lets a
browser SPA speak the NATS protocol directly, the way native TCP peers (Go,
Kotlin/android) already do — the first enabler for uniweb becoming a
browser-native client with no Go gateway (issue uniweb/0001, Phase 0).

The client authenticator applies to WebSocket connections too, so this adds a
transport, not a trust bypass. Plain ws:// is used only without TLS (loopback
dev); a certificate yields wss://. An empty AllowedOrigins enforces same-origin.
Nil WebsocketConfig keeps the server TCP-only, so existing single-node and
cluster deployments are unchanged.

Tests: WebSocket listener opens and completes the upgrade handshake (101); no
listener opens when WebsocketConfig is nil.
2026-06-13 22:11:39 +02:00
egutierrez 9661a5ce1f refactor: split web frontend + gateway out to uniweb app (bump 0.13.0)
The SPA (web/) and the web gateway (cmd/webgw) move to a dedicated app
projects/message_bus/apps/uniweb (its own Gitea sub-repo). unibus is now
strictly the bus plane: membership/keys, the client library and demo peers.
uniweb consumes unibus as a Go module via replace => ../unibus.

No capability lost; same SPA and gateway, in their own service folder.
go build/vet/test green after extraction.
2026-06-13 21:21:08 +02:00
egutierrez fadee1a7d0 Merge branch 'integrate/web-join-0.12.0' 2026-06-13 21:17:37 +02:00
egutierrez 9567ff4db9 chore: bump unibus to 0.12.0 (web wallet + per-user gateway) 2026-06-13 21:17:37 +02:00
egutierrez 9e01060931 Merge branch 'master' into integrate/web-join-0.12.0 2026-06-13 21:16:25 +02:00
egutierrez 4994ea1483 feat(web): wallet join/recover/login (BIP39 seed identity)
Add the device-local wallet onboarding to the SPA. The user's identity
is derived deterministically from a 12-word BIP39 mnemonic and lives on
the device; the browser never signs, never talks NATS, and never sends
the seed to the server.

Wallet layer (web/src/wallet/):
- derive.ts: deterministic identity from a mnemonic. seed = BIP39 seed,
  then HKDF-SHA256 domain-separated into an Ed25519 signing key
  (info "unibus-sign-v1") and an X25519 key-exchange key (info
  "unibus-kex-v1"). The same mnemonic always yields the same sign_pub,
  which is what makes recovery possible without admin intervention. The
  four halves match cs.Identity on the Go side exactly.
- bip39.ts: thin wrappers over @scure/bip39 (generate, validate,
  normalize) so the checksum logic stays in the audited library.
- crypto.ts: at-rest encryption of the private key with WebCrypto only
  (PBKDF2-SHA256 210k iters -> AES-256-GCM). The password never leaves
  the device and only protects the local key copy.
- store.ts: IndexedDB persistence of the encrypted identity (private key
  encrypted; public halves + handle in the clear for display).
- account.ts: saveAndOpen / unlockAndOpen / localIdentity compose the
  primitives with the gateway session API.

Screens:
- Welcome: choose invite link or recover-with-seed on an empty device.
- Join: generate seed, show it once behind an acknowledge gate, confirm
  3 random words, set a local password, register the PUBLIC key with the
  bus via the invite token, then open the session.
- Recover: paste the 12 words, validate, show the reconstructed sign_pub,
  set a new local password, open the session. No register (the identity
  is already in the allowlist).
- WalletLogin: unlock the device's stored identity with the password.
- AuthShell: shared card/header for all pre-chat screens.
- App.tsx: route between join / welcome / login / recover / chat based on
  the invite link, a live gateway session, and any stored identity.

api.ts/types.ts: add register() and session() against the gateway
contract; vite dev server on :5183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:50 +02:00
egutierrez 7d93d550d1 feat(webgw): per-user wallet sessions + invite register
Add the gateway backend for the wallet onboarding flow so each browser
session carries its OWN bus identity instead of sharing the single
operator client.

- POST /api/session (session.go): the browser hands its full wallet
  keypair (unlocked from the local encrypted key, over TLS) and the
  gateway spins up a dedicated bus client that acts AS that user. The
  private key lives only in process memory for the life of the session
  and is dropped on logout/shutdown. identityFromHex enforces the exact
  key sizes (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32) that
  match cs.Identity on the Go side.
- POST /api/register (register.go): unauthenticated onboarding gated by
  a one-shot invite token. Validates the two PUBLIC key halves, then
  either consumes a configured --mock-tokens invite (local testing) or
  proxies to the bus POST /register (--register-url, bus >= 0.12.0). The
  handle/role come from the invite, never from the client.
- server.go: sessions move from a token->time map to a sessionStore of
  per-user *session records; auth() now resolves the session and passes
  its gateway to each handler. The legacy operator passphrase login
  (POST /api/login) is kept, bound to the shared operator gateway.
- main.go: build a busTemplate config that wallet sessions clone with
  their own Identity; wire --register-url / --mock-tokens.
- webgw_test.go: identity-size validation, hex-key validation, mock
  token parsing, and single-use register (201 then 409) using a fixed
  browser-derived wallet vector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:33 +02:00
egutierrez f31580deec Merge quick/nats-monitor-flag: UNIBUS_NATS_MONITOR loopback monitoring decoupled from debug log (bump 0.11.0) 2026-06-07 21:18:59 +02:00
agent 5ea8fa1c20 feat(web): wire the SPA to the live bus via the gateway (drop mock)
Replace the mock data source with a real data layer that talks to the webgw
gateway over REST + SSE. The UI components keep their look and props; only
where the data comes from changed.

- src/api.ts: the single repository layer. fetch wrappers (same-origin cookie)
  for login/logout/me and rooms list/create/join/send, plus streamRoom() which
  opens an EventSource and yields each decrypted message. Wire->UI mappers
  (roomFromWire, messageFromWire).
- src/types.ts: add the gateway wire shapes (MeInfo, RoomWire, MsgWire) next to
  the existing UI types.
- App.tsx: probe /api/me on mount to resume an existing session; otherwise show
  Login. Logout calls the gateway.
- Login.tsx: the password field now unlocks the gateway session (operator
  passphrase); shows a basic error and a loading state. Wallet-per-browser is
  phase 2.
- ChatShell.tsx: load rooms from /api/rooms with loading / empty / error states;
  same Flex layout.
- ChatPanel.tsx: stream messages over SSE for the active room (dedup by id),
  composer sends through the gateway; no optimistic insert (the peer's own echo
  returns over SSE with the real frame id).
- vite.config.ts: dev proxy /api (REST + SSE) -> the gateway on :8481.

mock.ts is left untouched (no longer imported) to avoid churn with the parallel
styling work on master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:14:19 +02:00
agent fb8a03cf0c feat(webgw): web gateway peer (REST + SSE) for the chat SPA
Add cmd/webgw: a single Go binary that holds the operator's bus identity,
connects to the bus as a real authenticated peer (pkg/client), and exposes a
small REST + SSE API the browser consumes. The browser never signs, never
speaks NATS, and never sees a private key.

Endpoints (all under /api, gated by a session cookie except login):
  POST /api/login            unlock a session with the operator passphrase
  POST /api/logout
  GET  /api/me               operator identity the gateway acts as
  GET  /api/rooms            ListMyRooms
  POST /api/rooms            CreateRoom (default policy: encrypted+persisted+signed)
  POST /api/rooms/{id}/join  Join (fetch room key)
  POST /api/rooms/{id}/send  Publish (sealed + signed by the peer)
  GET  /api/rooms/{id}/stream  SSE of decrypted frames (history then live)

Design notes:
- One fan-out hub per room: a single bus subscription is multiplexed to N SSE
  clients, avoiding the per-(room,endpoint) durable-consumer contention that
  multiple Subscribe calls would cause.
- Posture seam mirrors unibus_admin/clientcheck: empty --ca = plaintext dev,
  non-empty = TLS+nkey on both planes; RefreshSession after a membership change
  only under the secured (ACL) posture.
- Identity loaded from `pass` or a 0600 file, held only in memory.
- Session auth: passphrase compared in constant time; opaque HttpOnly cookie
  so EventSource (which cannot set headers) can authenticate the stream.

TRUST MODEL: room content stays end-to-end encrypted on the bus. The gateway
reads plaintext only because it acts AS the operator's client — a legitimate
member of each room holding the room key. The per-browser wallet (WebCrypto)
that moves decryption into the browser is phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:14:08 +02:00
Egutierrez 1c9325104c feat(embeddednats): UNIBUS_NATS_MONITOR flag decoupled from debug log
Add a dedicated UNIBUS_NATS_MONITOR=1 toggle that opens the embedded
nats-server monitoring HTTP endpoint (127.0.0.1:8222, loopback only) so a
local metrics scraper can read /varz, /connz and /jsz for server-level
metrics (msgs/s, connections, KV bucket msgs, RAFT leader per stream,
restarts).

Previously the monitoring endpoint was only reachable via UNIBUS_NATS_DEBUG=1,
which is coupled to the verbose nats-server debug log: enabling the endpoint
also wrote routes/RAFT/room subjects to journald in clear, which regresses the
hardened posture (issue 0007). The two concerns are now decoupled.

The toggle computation is extracted to a pure function
natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor): MONITOR=1
opens the endpoint while keeping the log quiet (NoLog true / Debug false). The
inverse coupling is preserved for backward compatibility (DEBUG still implies
MONITOR). The 127.0.0.1 bind stays hardcoded — the monitoring endpoint has no
auth and must never be reachable from the network.

Deploy wiring versioned: additive systemd drop-in
membershipd-cluster.service.d/nats-monitor.conf (Environment=UNIBUS_NATS_MONITOR=1)
plus a "NATS server metrics" section in the cluster README with the rolling
activation runbook (magnus -> homer -> datardos) gated on R3 reconvergence
(followers 2/2) between nodes.

Tests: pure decoupling table (monitor on => log NOT debug; debug => monitor;
default closed) + a real embedded server with MONITOR=1 asserting /varz answers
200 on loopback:8222, and a server without the flag with the endpoint closed.
100% additive: behavior is identical without the flag. Bump app.md 0.10.0 ->
0.11.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:57:46 +02:00
egutierrez b4f3118e85 Merge quick/users-http-admin: HTTP admin-only users API + client methods (report 0014) 2026-06-07 20:46:44 +02:00
egutierrez e9053169da Merge quick/0011-deploy-gaps: live user-add --store kv + clientcheck E2E + runbook fixes (report 0012) 2026-06-07 20:46:44 +02:00
Egutierrez b983e43090 docs(0007): spec encryption-at-rest del control plane (JetStream/SQLite en disco) 2026-06-07 20:34:35 +02:00
egutierrez b379730225 docs(app): document users HTTP admin model, bump 0.10.0
Add a gotcha describing the unified-storage model (the server writes
users to the same store/KV as rooms), the admin-only HTTP surface, and
the CLI-seeds-admin-#0 bootstrap. Bump the version 0.9.0 -> 0.10.0 and
add the capability growth log entry for the new HTTP admin users API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:32:05 +02:00
egutierrez 450ca01baf feat(membership,client): HTTP admin-only users API
Close the last control-plane asymmetry: rooms had a signed HTTP surface
but users were only manageable via the local CLI or direct store access.
Add admin-only HTTP endpoints, symmetric with rooms, executed against the
same privileged store the server already serves (SQLite single-node, the
replicated JetStream KV in cluster) — no new KV connection, no internal
identity, so the admin panel can manage the allowlist by signing as an
admin instead of needing --db / direct KV access.

Endpoints (all behind requireAdmin, on top of the existing
signature+nonce+TLS+enforce middleware):
  - GET  /users                    list the full allowlist (incl. revoked)
  - POST /users                    add {sign_pub, handle, role}
  - POST /users/{signpub}/revoke   revoke (status flip, no hard delete)

requireAdmin is default-deny with no dev relaxation: it allows a request
only when the authenticated signer is confirmed by the store as an active
admin; any other case (no signer, non-admin, revoked, store error) is 403,
fail-closed. The request context now also carries the signer's sign_pub
hex, because the endpoint id is a one-way hash of the key and cannot be
reversed to look the signer up in the allowlist.

Validation/idempotency mirror the CLL: sign_pub must be 64-hex, role must
be admin|member (empty defaults to member), re-adding an existing key is a
409 that leaves the row untouched. The hex check is unified into
membership.ValidateSignPubHex, reused by the CLI and the handlers.

pkg/client gains ListUsers/AddUser/RevokeUser (flat UserInfo type) signed
via doJSON, so the panel plugs in directly.

Tests: non-admin -> 403 on all three endpoints; admin add->list->revoke
roundtrip; validation (400 hex, 400 role, 409 re-add, row untouched); plus
a client test against an embedded membershipd under enforce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:31:57 +02:00
egutierrez e1a7402ff1 chore: bump unibus to 0.9.0 (live user-add + clientcheck)
New capability membershipd user add --store kv against a live cluster plus
cmd/clientcheck end-to-end verification (issue 0011 gaps, report 0012). Adds
the capability growth log entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez ce72131ddf docs(cluster): correct runbook + wire --internal-id-file into deploy
Corrections learned from the real 0011 deploy:
- Bring up: the "start magnus alone and verify healthz" order deadlocks — a
  lone node of a 3-node cluster has no meta-group quorum and never serves
  healthz until a second node joins. Document a quorum-forming start and that
  a node never self-serves.
- Replication: R1 is an unusable SPOF (all six control-plane buckets on one
  node) and the cold start only converges with the three cold-start fixes;
  go straight to R3 once the cluster forms.
- Add a "user add --store kv" section: the live user-add path that replaces
  stop-seed-restart, with its security model and idempotency/HA/no-delete
  semantics.
- Topology: real IPs, ROUTE_NETWORK=public (no WireGuard mesh exists).
- Chaos test: mark the data-plane client + failover proofs as validated (0012).

Deploy machinery now emits the persisted internal identity: the unit gains
--internal-id-file ${INTERNAL_ID_FILE} and deploy-cluster.sh writes
INTERNAL_ID_FILE into each node's cluster.env, so a fresh deploy enables the
live user-add path on every node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez 3aa5a2c9a9 feat(clientcheck): end-to-end client verification (E2E room + failover)
The 0011 chaos test validated only the control plane (healthz + leader
failover + KV readable with 2/3); it never connected an authenticated bus
client to the data plane. cmd/clientcheck is a reusable verification tool: it
connects with a real identity (nkey + TLS on both planes, multi-node seed
lists), creates an ephemeral E2E room (encrypted + signed, no durable stream),
and either publishes N messages and asserts all come back decrypted (golden)
or publishes a counter for a duration while logging the attached node (loop),
so stopping a node mid-run shows the client fail over to a survivor and keep
receiving with quorum 2/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez 02c2004ebd feat(membershipd): user add/list/revoke --store kv against a live cluster
Closes the most valuable 0011 deploy gap: adding users to the running
cluster's replicated allowlist with no stop-seed-restart. Under enforce the
per-subject ACL confines every bus user to its own rooms, so no ordinary
identity may write the control-plane KV buckets; the only identity the
authenticator grants full JetStream permissions is membershipd's internal
service identity.

- main.go: --internal-id-file persists that identity (load-or-create, 0600)
  instead of a fresh ephemeral key, so the same nkey is available out of
  process. Empty keeps the ephemeral default (single-node/dev unchanged).
- users_kv.go: connectKVStore loads the persisted identity, presents its
  nkey (recognized as internal -> full perms), opens the KV store and
  writes. Defaults assume an on-node loopback invocation; a remote target
  without --ca is refused (allowlist must not travel cleartext, audit N6).
  Prints KV_UNIBUS_users replication (followers_current) after a write.
- users_cli.go: --store kv on add/list/revoke. Re-adding a key is an explicit
  ErrUserExists (no silent overwrite / role flip); revoke is a status flip.
- pkg/client: LoadIdentity (load-only) extracted from LoadOrCreateIdentity,
  preserving its "corrupt file is an error, not silently regenerated" guard.
- kv_useradd_test.go: golden write under enforce, idempotency, unreachable
  endpoint, and remote-without-CA refusal against an embedded node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:38 +02:00
egutierrez ff580ac031 Merge quick/cluster-coldstart-fixes: 3-node cluster cold-start fixes + real topology 2026-06-07 18:56:28 +02:00
egutierrez 9fbff79df4 chore(deploy): fill cluster nodes.env with the real 3-node topology
Set magnus's public IP (135.125.201.30) and switch ROUTE_NETWORK to "public":
the three nodes have no WireGuard mesh (homer/datardos do not even have wg
installed), so server-to-server routes go over the public IPs, still protected
by the separate cluster route CA (mutual TLS). KV_REPLICAS is raised to 3 now
that the cluster runs at R3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:28 +02:00
egutierrez 33746d9962 fix(cluster): make the JetStream control-plane survive a cold multi-node start
Bringing up the 3-node cluster from clean stores never converged: every node
looped on `open KV bucket "UNIBUS_rooms" (replicas=1): context deadline exceeded`.
Three independent defects in the clustered bootstrap path, none of which surface
on a single node (where JetStream is ready instantly), caused it:

1. embeddednats: route connection pooling (nats-server 2.10 default pool of 3)
   churned with "duplicate route"/"client closed" reconnects on the small cluster,
   interrupting the meta-group RAFT heartbeats and forcing perpetual leader
   re-elections. Set Cluster.PoolSize = -1 (single route per peer).

2. embeddednats: the cluster nodes are Docker hosts, so NATS advertised the docker
   bridge IPs (172.x / 10.0.x) to peers, which then tried to dial those private,
   mutually-unreachable addresses. Set Cluster.NoAdvertise = true so only the
   explicit public-IP routes are used. Also added a UNIBUS_NATS_DEBUG env toggle
   (off by default) that enables the embedded server's logger and loopback
   monitoring port for debugging the route/meta layer.

3. membership.OpenJetStream: a KV op is a NATS request/reply; on a cold cluster the
   op was published once, before the node had contact with the meta leader, so the
   request was dropped and the single long-context call just blocked until timeout.
   Retry each bucket op with short per-attempt contexts until it succeeds or an
   overall bootstrap budget (120s) is exhausted, so it lands once the meta settles.

With these the cluster forms cleanly, creates the KV buckets, scales R1->R3 in
place, and survives loss of one node (quorum 2/3). Verified on magnus+homer+datardos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:28 +02:00
31213 changed files with 2035147 additions and 4295 deletions
+3 -3
View File
@@ -12,9 +12,9 @@ worker.id
/membershipd
/worker
/chat
/webgw
*.exe
registry.db
# local workspace (no committear: replace absoluto al registry)
go.work
go.work.sum
# Local session infra (machine-specific absolute paths; never distributed).
.mcp.json
-15
View File
@@ -1,15 +0,0 @@
# Android / Gradle build artifacts
*.iml
.gradle/
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
# binding gomobile regenerable (38MB): ver mobile/gen_aar.sh
/app/libs/*.aar
/app/libs/*-sources.jar
-75
View File
@@ -1,75 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "com.unibus.app"
compileSdk = 34
defaultConfig {
applicationId = "com.unibus.app"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// The unibus.aar ships native libgojni.so for these ABIs. Limit the APK
// to the desktop/emulator + phone ABIs we actually target.
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
// Compose compiler matching Kotlin 1.9.24.
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// gomobile binding over pkg/client (real end-to-end crypto on device).
implementation(files("libs/unibus.aar"))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
-12
View File
@@ -1,12 +0,0 @@
# libs/
`unibus.aar` (binding gomobile sobre `pkg/client`, ~38 MB con `libgojni.so` para
4 ABIs) vive aquí pero **no se versiona** — es un artefacto de build reproducible.
Regenéralo con:
```bash
../../mobile/gen_aar.sh
```
(desde la raíz del repo: `./mobile/gen_aar.sh`). Requiere Go + gomobile + Android NDK.
-4
View File
@@ -1,4 +0,0 @@
# gomobile binding: keep the generated Go<->Java bridge classes intact so the
# JNI layer can find them by name at runtime.
-keep class go.** { *; }
-keep class com.unibus.core.mobile.** { *; }
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The bus is reached over the network (NATS data plane + control plane). -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="unibus"
android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.Unibus"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Unibus">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,88 +0,0 @@
package com.unibus.app
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.unibus.app.data.Message
import com.unibus.app.data.MockUnibusRepository
import com.unibus.app.data.Room
import com.unibus.app.data.UnibusRepository
import com.unibus.app.data.User
import kotlinx.coroutines.launch
/**
* Estado de la app. Orquesta el [UnibusRepository] (mock por defecto) y expone
* estado observable a Compose. Cambiar el repo por [com.unibus.app.data.BindingUnibusRepository]
* conecta la UI al bus real sin tocar las pantallas.
*/
class AppViewModel(
private val repo: UnibusRepository,
) : ViewModel() {
// Constructor no-arg para que androidx `viewModel()` lo instancie por
// reflexión. Por defecto usa el repositorio mock (iteración de diseño).
constructor() : this(MockUnibusRepository())
var user by mutableStateOf<User?>(null)
private set
var rooms by mutableStateOf<List<Room>>(emptyList())
private set
var activeRoomId by mutableStateOf<String?>(null)
private set
var messages by mutableStateOf<List<Message>>(emptyList())
private set
var connecting by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set
val activeRoom: Room?
get() = rooms.firstOrNull { it.id == activeRoomId }
fun connect(handle: String, password: String) {
if (connecting) return
connecting = true
error = null
viewModelScope.launch {
repo.connect(handle, password)
.onSuccess {
user = it
rooms = repo.listRooms()
}
.onFailure { error = it.message ?: "No se pudo conectar" }
connecting = false
}
}
fun openRoom(id: String) {
activeRoomId = id
messages = repo.messagesOf(id)
repo.subscribe(id) { incoming ->
if (activeRoomId == id) messages = messages + incoming
}
}
fun closeRoom() {
activeRoomId = null
messages = emptyList()
}
fun send(text: String) {
val rid = activeRoomId ?: return
val body = text.trim()
if (body.isEmpty()) return
viewModelScope.launch {
repo.send(rid, body).onSuccess { messages = messages + it }
}
}
fun logout() {
repo.close()
user = null
rooms = emptyList()
activeRoomId = null
messages = emptyList()
}
}
@@ -1,63 +0,0 @@
package com.unibus.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.unibus.app.ui.ChatScreen
import com.unibus.app.ui.LoginScreen
import com.unibus.app.ui.RoomListScreen
import com.unibus.app.ui.theme.LocalUnibusColors
import com.unibus.app.ui.theme.UnibusColors
import com.unibus.app.ui.theme.UnibusTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
UnibusTheme {
CompositionLocalProvider(LocalUnibusColors provides UnibusColors()) {
UnibusApp()
}
}
}
}
}
/**
* Navegación por estado (sin librería de routing — KISS): el usuario fluye
* Login → lista de rooms → chat, igual que la web pero en una sola columna.
*/
@Composable
private fun UnibusApp(vm: AppViewModel = viewModel()) {
val user = vm.user
val activeRoom = vm.activeRoom
when {
user == null -> LoginScreen(
connecting = vm.connecting,
error = vm.error,
onLogin = { handle, password -> vm.connect(handle, password) },
)
activeRoom == null -> RoomListScreen(
user = user,
rooms = vm.rooms,
onSelect = { vm.openRoom(it) },
onLogout = { vm.logout() },
)
else -> {
BackHandler { vm.closeRoom() }
ChatScreen(
room = activeRoom,
messages = vm.messages,
onSend = { vm.send(it) },
onBack = { vm.closeRoom() },
)
}
}
}
@@ -1,157 +0,0 @@
package com.unibus.app.data
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.unibus.core.mobile.FrameListener
import com.unibus.core.mobile.Mobile
import com.unibus.core.mobile.Session
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
/**
* Implementación real sobre el binding gomobile (pkg/client): cifrado de extremo
* a extremo EN el dispositivo, igual que cualquier otro peer del bus. Comparte
* interfaz con [MockUnibusRepository], así que la UI no cambia al enchufarla.
*
* Estado: cableado completo y compilable contra unibus.aar. La iteración 1 de la
* app corre sobre el mock para iterar el diseño; para activar el bus real basta
* con instanciar este repo en [com.unibus.app.MainActivity] pasando las URLs del
* bus y (si el bus exige TLS+auth) el ca.crt en assets.
*
* Contrato de membresía (issue 0006e): tras CreateRoom / Join / Invite hay que
* llamar [refresh] ANTES de subscribe/publish en esa room, o un bus seguro
* deniega el subject. refresh() además tira las suscripciones: re-suscribir luego.
*/
class BindingUnibusRepository(
context: Context,
private val natsURL: String,
private val ctrlURL: String,
) : UnibusRepository {
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private var session: Session? = null
private var user: User? = null
@Serializable
private data class RoomDTO(
val room_id: String,
val subject: String,
val epoch: Int = 0,
val encrypted: Boolean = false,
val role: String = "",
)
/** Ruta sandbox de la identidad de larga duración (claves privadas). */
private fun identityPath(): String =
File(appContext.filesDir, "identity.key").absolutePath
/**
* Copia ca.crt de assets a un fichero local y devuelve su ruta; "" si no hay
* (bus de desarrollo en texto plano). El binding pinea TLS a este CA cuando
* la ruta no está vacía.
*/
private fun caPathOrEmpty(): String {
return try {
val out = File(appContext.filesDir, "ca.crt")
appContext.assets.open("ca.crt").use { input ->
out.outputStream().use { input.copyTo(it) }
}
out.absolutePath
} catch (_: Exception) {
""
}
}
override suspend fun connect(handle: String, password: String): Result<User> =
withContext(Dispatchers.IO) {
try {
// La identidad se persiste cifrada en el sandbox; password la
// desbloquea en una iteración futura (hoy LoadOrCreateIdentity la
// crea/lee directamente). handle es la etiqueta visible local.
Mobile.generateIdentity(identityPath())
val s = Mobile.newSession(identityPath(), natsURL, ctrlURL, caPathOrEmpty())
session = s
val u = User(id = s.endpointID(), handle = handle)
user = u
Result.success(u)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun listRooms(): List<Room> = withContext(Dispatchers.IO) {
val s = session ?: return@withContext emptyList()
val raw = runCatching { s.listRoomsJSON() }.getOrDefault("[]")
val dtos = runCatching { json.decodeFromString<List<RoomDTO>>(raw) }.getOrDefault(emptyList())
dtos.map {
Room(
id = it.room_id,
name = it.subject,
encrypted = it.encrypted,
lastMessage = "",
lastTs = System.currentTimeMillis(),
unread = 0,
messages = emptyList(),
)
}
}
override fun messagesOf(roomId: String): List<Message> = emptyList()
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
val s = session ?: return
val myId = user?.id
// FrameListener.onFrame llega en una goroutine de NATS: saltamos al hilo
// principal antes de tocar estado de Compose.
val listener = object : FrameListener {
override fun onFrame(rid: String, sender: String, msgID: String, text: String) {
val msg = Message(
id = msgID,
sender = sender,
body = text,
ts = System.currentTimeMillis(),
mine = sender == myId,
)
mainHandler.post { onMessage(msg) }
}
}
runCatching { s.subscribe(roomId, listener) }
}
override suspend fun send(roomId: String, text: String): Result<Message> =
withContext(Dispatchers.IO) {
val s = session ?: return@withContext Result.failure(IllegalStateException("sin sesión"))
try {
s.publish(roomId, text)
Result.success(
Message(
id = "local-${System.currentTimeMillis()}",
sender = user?.id ?: "yo",
body = text,
ts = System.currentTimeMillis(),
mine = true,
),
)
} catch (e: Exception) {
Result.failure(e)
}
}
/** Reaplica permisos tras un cambio de membresía. Re-suscribir después. */
suspend fun refresh(): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { session?.refreshSession(); Unit }
}
override fun close() {
runCatching { session?.close() }
session = null
user = null
}
}
@@ -1,59 +0,0 @@
package com.unibus.app.data
// Datos de muestra para iterar el diseño sin el bus conectado (espejo de mock.ts).
private const val NOW = 1749300000000L
private fun m(n: Int): Long = NOW - n * 60_000L
val MOCK_ROOMS: List<Room> = listOf(
Room(
id = "general",
name = "general",
encrypted = true,
lastMessage = "¿Lo desplegamos hoy?",
lastTs = m(2),
unread = 3,
messages = listOf(
Message("1", "ana", "Buenas, ¿cómo va el cluster?", m(40)),
Message("2", "lucas", "Los 3 nodos en R3, quorum verde", m(38), mine = true),
Message("3", "ana", "Brutal. ¿Y el frontend?", m(30)),
Message("4", "leo", "Primera iteración lista, estilo Element", m(6)),
Message("5", "ana", "¿Lo desplegamos hoy?", m(2)),
),
),
Room(
id = "board",
name = "board · privado",
encrypted = true,
lastMessage = "Os paso el acta cifrada",
lastTs = m(95),
unread = 0,
messages = listOf(
Message("1", "ceo", "Reunión a las 18:00", m(120)),
Message("2", "lucas", "Anotado", m(96), mine = true),
Message("3", "ceo", "Os paso el acta cifrada", m(95)),
),
),
Room(
id = "bots",
name = "bots",
encrypted = false,
lastMessage = "echo: ping",
lastTs = m(210),
unread = 0,
messages = listOf(
Message("1", "lucas", "!ping", m(212), mine = true),
Message("2", "echobot", "echo: ping", m(210)),
),
),
Room(
id = "infra",
name = "infra",
encrypted = true,
lastMessage = "magnus + homer + datardos OK",
lastTs = m(330),
unread = 1,
messages = listOf(
Message("1", "leo", "magnus + homer + datardos OK", m(330)),
),
),
)
@@ -1,30 +0,0 @@
package com.unibus.app.data
/**
* Modelos de dominio de la UI. En la iteración 1 se llenan con datos mock; más
* adelante vendrán del binding gomobile (pkg/client) a través de
* [UnibusRepository]. Reflejan los tipos de la app web (types.ts).
*/
data class User(
val id: String,
val handle: String,
)
data class Message(
val id: String,
val sender: String, // handle
val body: String,
val ts: Long, // epoch ms
val mine: Boolean = false,
)
data class Room(
val id: String,
val name: String,
val encrypted: Boolean,
val lastMessage: String,
val lastTs: Long,
val unread: Int,
val messages: List<Message>,
)
@@ -1,74 +0,0 @@
package com.unibus.app.data
/**
* Capa de repositorio que aísla la UI de la fuente de datos. La iteración 1 usa
* [MockUnibusRepository] (en memoria) para iterar el diseño. Cuando se enchufe
* el bus real, [BindingUnibusRepository] (en BindingRepository.kt) implementa
* esta misma interfaz sobre el binding gomobile (pkg/client), sin tocar la UI.
*/
interface UnibusRepository {
/** Desbloquea/crea la identidad y conecta al bus. Devuelve el usuario logueado. */
suspend fun connect(handle: String, password: String): Result<User>
/** Rooms a las que pertenece el peer. */
suspend fun listRooms(): List<Room>
/** Mensajes históricos conocidos de una room (mock: los del propio Room). */
fun messagesOf(roomId: String): List<Message>
/**
* Suscribe a una room. [onMessage] se invoca por cada mensaje entrante.
* Las implementaciones que vienen del bus DEBEN entregar [onMessage] en el
* hilo principal (el binding lo recibe en una goroutine de NATS).
*/
fun subscribe(roomId: String, onMessage: (Message) -> Unit)
/** Publica texto en la room. */
suspend fun send(roomId: String, text: String): Result<Message>
/** Cierra la sesión. */
fun close()
}
/**
* Implementación en memoria: arranca con [MOCK_ROOMS] y acumula los mensajes que
* el usuario envía. No toca red ni binding — sirve para construir y revisar la UI.
*/
class MockUnibusRepository : UnibusRepository {
private var user: User? = null
private val sent = mutableMapOf<String, MutableList<Message>>()
override suspend fun connect(handle: String, password: String): Result<User> {
val u = User(id = handle, handle = handle)
user = u
return Result.success(u)
}
override suspend fun listRooms(): List<Room> = MOCK_ROOMS
override fun messagesOf(roomId: String): List<Message> {
val base = MOCK_ROOMS.firstOrNull { it.id == roomId }?.messages.orEmpty()
return base + (sent[roomId].orEmpty())
}
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
// El mock no recibe tráfico entrante; el eco lo gestiona la UI al enviar.
}
override suspend fun send(roomId: String, text: String): Result<Message> {
val handle = user?.handle ?: "yo"
val msg = Message(
id = "local-${System.currentTimeMillis()}",
sender = handle,
body = text,
ts = System.currentTimeMillis(),
mine = true,
)
sent.getOrPut(roomId) { mutableListOf() }.add(msg)
return Result.success(msg)
}
override fun close() {
user = null
}
}
@@ -1,203 +0,0 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.data.Message
import com.unibus.app.data.Room
import com.unibus.app.ui.theme.Brand3
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun ChatScreen(
room: Room,
messages: List<Message>,
onSend: (String) -> Unit,
onBack: () -> Unit,
) {
val colors = LocalUnibusColors.current
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size, room.id) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.chatBg),
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Atrás", tint = Color.White)
}
InitialsAvatar(room.name, size = 38.dp, rounded = true, accent = true)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
room.name,
fontWeight = FontWeight(650),
fontSize = 16.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
contentDescription = null,
tint = colors.dimmed,
modifier = Modifier
.padding(start = 6.dp)
.size(14.dp),
)
}
Text(
if (room.encrypted) "cifrada · E2E" else "abierta · cleartext",
color = colors.dimmed,
fontSize = 11.sp,
)
}
IconButton(onClick = { /* opciones de room (futuro) */ }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Opciones", tint = colors.dimmed)
}
}
HorizontalDivider(color = colors.divider)
// Mensajes
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(messages, key = { it.id }) { msg -> MessageRow(msg) }
}
HorizontalDivider(color = colors.divider)
// Composer
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { /* adjuntar (futuro) */ }) {
Icon(Icons.Filled.AttachFile, contentDescription = "Adjuntar", tint = colors.dimmed)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Mensaje a ${room.name}") },
singleLine = true,
shape = CircleShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = colors.field,
unfocusedContainerColor = colors.field,
),
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send),
keyboardActions = KeyboardActions(onSend = {
if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" }
}),
)
Box(
modifier = Modifier
.padding(start = 6.dp)
.size(46.dp)
.clip(CircleShape)
.background(if (draft.trim().isEmpty()) colors.field else colors.brand),
contentAlignment = Alignment.Center,
) {
IconButton(
onClick = { if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" } },
enabled = draft.trim().isNotEmpty(),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Enviar", tint = Color.White)
}
}
}
}
}
@Composable
private fun MessageRow(msg: Message) {
val colors = LocalUnibusColors.current
Row(verticalAlignment = Alignment.Top) {
InitialsAvatar(msg.sender, size = 36.dp, rounded = false, accent = msg.mine)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
msg.sender,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = if (msg.mine) Brand3 else Color.White,
)
Text(
timeShort(msg.ts),
color = colors.dimmed,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
msg.body,
fontSize = 14.sp,
color = com.unibus.app.ui.theme.OnSurface,
modifier = Modifier.padding(top = 1.dp),
)
}
}
}
@@ -1,48 +0,0 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.ui.theme.Brand5
/**
* Avatar con iniciales, equivalente al <Avatar> de la web. [rounded] = esquinas
* (rooms/chat header) vs círculo (usuarios). [accent] colorea el de marca.
*/
@Composable
fun InitialsAvatar(
text: String,
size: Dp = 42.dp,
rounded: Boolean = true,
accent: Boolean = false,
modifier: Modifier = Modifier,
) {
val shape = if (rounded) RoundedCornerShape((size.value * 0.28f).dp) else CircleShape
val bg = if (accent) Brand5 else Color(0xFF3A3D44) // gris neutro tipo Avatar color="gray"
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(bg),
contentAlignment = Alignment.Center,
) {
Text(
text = initials(text),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = (size.value * 0.36f).sp,
)
}
}
@@ -1,154 +0,0 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.ui.theme.Brand4
import com.unibus.app.ui.theme.Dark7
import com.unibus.app.ui.theme.Dark9
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun LoginScreen(
connecting: Boolean,
error: String?,
onLogin: (handle: String, password: String) -> Unit,
) {
val colors = LocalUnibusColors.current
var handle by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val ready = handle.trim().isNotEmpty() && password.isNotEmpty() && !connecting
fun submit() {
if (ready) onLogin(handle.trim(), password)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Dark9),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.padding(24.dp)
.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Dark7),
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
// ThemeIcon "light brand" — círculo translúcido con candado.
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(Brand4.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
tint = Brand4,
modifier = Modifier.size(30.dp),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("unibus", fontSize = 26.sp, color = Brand4)
Text(
"Mensajería cifrada de extremo a extremo",
color = colors.dimmed,
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = handle,
onValueChange = { handle = it },
label = { Text("Identidad") },
placeholder = { Text("tu-handle") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
placeholder = { Text("••••••••") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
leadingIcon = { Icon(Icons.Filled.VpnKey, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Text(
"Desbloquea tu identidad cifrada en este dispositivo",
color = colors.dimmed,
fontSize = 12.sp,
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Text(error, color = androidx.compose.ui.graphics.Color(0xFFFF6B6B), fontSize = 13.sp)
}
Button(
onClick = { submit() },
enabled = ready,
modifier = Modifier.fillMaxWidth(),
) {
if (connecting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = androidx.compose.ui.graphics.Color.White,
)
} else {
Text("Conectar")
}
}
}
}
}
}
@@ -1,199 +0,0 @@
package com.unibus.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Badge
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.unibus.app.data.Room
import com.unibus.app.data.User
import com.unibus.app.ui.theme.LocalUnibusColors
@Composable
fun RoomListScreen(
user: User,
rooms: List<Room>,
onSelect: (String) -> Unit,
onLogout: () -> Unit,
) {
val colors = LocalUnibusColors.current
var query by remember { mutableStateOf("") }
val q = query.trim().lowercase()
val filtered = if (q.isEmpty()) rooms else rooms.filter {
it.name.lowercase().contains(q) || it.messages.any { m -> m.body.lowercase().contains(q) }
}
Column(
modifier = Modifier
.fillMaxSize()
.background(colors.sidebarBg),
) {
// Header: avatar + handle + menú
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
InitialsAvatar(user.handle, size = 36.dp, rounded = false, accent = true)
Text(
user.handle,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
)
var menuOpen by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "Menú", tint = colors.dimmed)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text("Desconectar") },
onClick = { menuOpen = false; onLogout() },
leadingIcon = {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp))
},
)
}
}
}
// Buscador
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Buscar rooms, usuarios, mensajes…") },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
)
HorizontalDivider(color = colors.divider)
if (filtered.isEmpty()) {
Text(
"Sin resultados",
color = colors.dimmed,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(6.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(filtered, key = { it.id }) { room ->
RoomItem(room = room, onClick = { onSelect(room.id) })
}
}
}
}
}
@Composable
private fun RoomItem(room: Room, onClick: () -> Unit) {
val colors = LocalUnibusColors.current
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.clickable(onClick = onClick)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
InitialsAvatar(room.name, size = 46.dp, rounded = true)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
contentDescription = if (room.encrypted) "cifrada" else "abierta",
tint = colors.dimmed,
modifier = Modifier.size(13.dp),
)
Text(
room.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 4.dp),
)
Text(timeShort(room.lastTs), color = colors.dimmed, fontSize = 11.sp)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 2.dp),
) {
Text(
room.lastMessage,
color = colors.dimmed,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (room.unread > 0) {
Badge(
containerColor = colors.brand,
contentColor = Color.White,
) { Text(room.unread.toString()) }
}
}
}
}
}
@@ -1,17 +0,0 @@
package com.unibus.app.ui
import java.util.Calendar
/** Iniciales (hasta 2 letras/dígitos) para los avatares, igual que la web. */
fun initials(s: String): String {
val cleaned = s.filter { it.isLetterOrDigit() }
return if (cleaned.isEmpty()) "?" else cleaned.take(2).uppercase()
}
/** Hora corta HH:mm a partir de epoch ms. */
fun timeShort(ts: Long): String {
val c = Calendar.getInstance().apply { timeInMillis = ts }
val h = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val min = c.get(Calendar.MINUTE).toString().padStart(2, '0')
return "$h:$min"
}
@@ -1,80 +0,0 @@
package com.unibus.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// --- Brand: índigo/violeta de unibus (mismos tonos que el tema Mantine de la web) ---
val Brand2 = Color(0xFFB5A3F5) // brand.2
val Brand3 = Color(0xFF8D70ED) // brand.3 — legible sobre fondo oscuro
val Brand4 = Color(0xFF6C47E6) // brand.4 — acento principal
val Brand5 = Color(0xFF5A2FE2) // brand.5 — filled
// --- Grises oscuros equivalentes a la escala dark.* de Mantine ---
val Dark9 = Color(0xFF101113) // fondo de la app (login)
val Dark8 = Color(0xFF141517) // sidebar / lista de rooms
val Dark7 = Color(0xFF1A1B1E) // panel de chat / superficie
val Dark6 = Color(0xFF25262B) // item activo / elevado
val Dark5 = Color(0xFF2C2E33) // campos de entrada
val Dark4 = Color(0xFF373A40) // bordes / divisores
val Dimmed = Color(0xFF909296) // texto secundario
val OnSurface = Color(0xFFE3E3E6) // texto principal
/**
* Tokens de color que Material 3 no expresa directamente y que la UI replica de
* la web (matices dark.6/7/8/9, color "dimmed", borde). Se exponen vía un
* CompositionLocal para que cualquier composable los lea sin prop-drilling.
*/
data class UnibusColors(
val appBg: Color = Dark9,
val sidebarBg: Color = Dark8,
val chatBg: Color = Dark7,
val activeItem: Color = Dark6,
val field: Color = Dark5,
val divider: Color = Dark4,
val dimmed: Color = Dimmed,
val brand: Color = Brand4,
)
val LocalUnibusColors = staticCompositionLocalOf { UnibusColors() }
private val UnibusDarkScheme = darkColorScheme(
primary = Brand4,
onPrimary = Color.White,
primaryContainer = Brand5,
onPrimaryContainer = Color.White,
secondary = Brand3,
background = Dark9,
onBackground = OnSurface,
surface = Dark7,
onSurface = OnSurface,
surfaceVariant = Dark6,
onSurfaceVariant = Dimmed,
outline = Dark4,
error = Color(0xFFFF6B6B),
)
private val UnibusTypography = Typography(
titleLarge = Typography().titleLarge.copy(fontWeight = FontWeight(650)),
titleMedium = Typography().titleMedium.copy(fontWeight = FontWeight(650)),
bodyMedium = Typography().bodyMedium.copy(fontSize = 14.sp),
labelLarge = Typography().labelLarge.copy(fontWeight = FontWeight.SemiBold),
)
@Composable
fun UnibusTheme(content: @Composable () -> Unit) {
// unibus es dark-first; ignoramos el modo del sistema a propósito.
@Suppress("UNUSED_EXPRESSION")
isSystemInDarkTheme()
MaterialTheme(
colorScheme = UnibusDarkScheme,
typography = UnibusTypography,
content = content,
)
}
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Material "lock" glyph, white, centered in the adaptive-icon safe zone.
24dp source scaled x3 (=72dp) and translated by 18 to center it. -->
<group
android:scaleX="3"
android:scaleY="3"
android:translateX="18"
android:translateY="18">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM9,6c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2H9V6z" />
</group>
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/unibus_brand" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/unibus_brand" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- dark.9 — app background -->
<color name="unibus_bg">#101113</color>
<!-- brand.5 — índigo/violeta accent, used as launcher icon background -->
<color name="unibus_brand">#5A2FE2</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">unibus</string>
</resources>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Compose-only host theme: no action bar, dark window background matching
the app's dark.9 surface so there is no white flash before Compose draws. -->
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/unibus_bg</item>
<item name="android:statusBarColor">@color/unibus_bg</item>
<item name="android:navigationBarColor">@color/unibus_bg</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
-5
View File
@@ -1,5 +0,0 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
}
-5
View File
@@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.caching=true
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-249
View File
@@ -1,249 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-92
View File
@@ -1,92 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-24
View File
@@ -1,24 +0,0 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "unibus"
include(":app")
+194 -1
View File
@@ -2,7 +2,7 @@
name: unibus
lang: go
domain: infra
version: 0.8.0
version: 0.16.0
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
tags: [service, messaging, nats, e2e]
uses_functions:
@@ -122,6 +122,21 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
las rutas GET de lectura. Confía en la red interna. Las rutas mutantes
(`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los
bytes canónicos de la request. Endurecer es fase posterior.
- **Gestión de usuarios: storage unificado, alta por dos vías.** El allowlist de
usuarios vive en el MISMO store que las rooms (`pkg/membership.Store`): SQLite en
single-node, JetStream KV replicado (`UNIBUS_users`) en cluster. El `Server` ya
tiene ese store privilegiado abierto (es quien sirve el KV en cada nodo), así que
expone `GET/POST /users` y `POST /users/{signpub}/revoke` como API HTTP admin-only,
simétrica con las rutas de rooms: el panel de administración firma como admin y el
server ejecuta la mutación contra el mismo store. El panel NO necesita `--db`, ni la
identidad interna, ni correr en un nodo del cluster; funciona idéntico en single-node
y cluster. La autorización es default-deny: solo un firmante que el store confirma como
`role == "admin"` activo pasa, cualquier otro recibe 403 (encima de la firma+nonce+TLS
ya existentes). La CLI `membershipd user add --store kv` sigue existiendo SOLO para
sembrar el admin #0 (bootstrap del huevo-gallina: sin un admin sembrado no hay quién
firme el primer `POST /users`); a partir de ahí toda la gestión es HTTP admin-only. El
alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin
sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable.
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
@@ -143,6 +158,62 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
`cybersecurity` del registry compila limpio con `CGO_ENABLED=0`. NO requiere
`fts5` ni `gcc`.
## Directorio de nombres (endpoint → handle)
Cada frame del bus lleva el **endpoint id** del remitente
(`base64url(sha256(signPub))`, sin padding — `frame.EndpointID`), no un nombre
legible. Para que un cliente muestre nombres en vez de hashes, el control-plane
expone la ruta del directorio. La SPA la llama como `GET /api/directory`, pero
Caddy hace `handle_path /api/*` y **stripea `/api`** antes de reenviar a
`membershipd`, así que el servidor la registra (como todas las rutas del
control-plane) SIN el prefijo: `GET /directory`:
- **Auth:** el mismo middleware de firma que el resto del control-plane
(cabeceras `X-Unibus-Pub/Ts/Nonce/Sig` sobre `CanonicalRequest`). NO es
admin-only: cualquier usuario activo del bus (member o admin) puede leerlo. En
modo `enforce`, una request sin firmar recibe 401 antes de llegar al handler.
- **Respuesta** `{ "members": [ { "sign_pub", "endpoint", "handle", "role" } ] }`,
solo usuarios `status=active`. El `endpoint` lo computa el servidor desde el
`sign_pub` con la misma derivación que el bus, así que casa byte a byte con el
sender id que el cliente ya tiene en cada mensaje.
- CORS: cubierto por la allowlist `--cors-origins` existente (mismas cabeceras
que el resto de rutas, sin caso especial).
## Provisioning de bots / unibots
Dar de alta una identidad para un proceso automatizado es **un solo comando**.
Antes había que derivar un keypair a mano y pasar el `sign_pub` a `user add`;
ahora `bot add` lo hace todo: mintea una identidad de bus fresca (Ed25519 +
X25519, la misma derivación `cs.GenerateIdentity` que usan `worker`/`chat`),
registra su `sign_pub` en el allowlist con `handle` y `role`, y escribe las
credenciales a un fichero 0600 que el proceso lee para conectar.
```bash
# 1. Provisionar el bot (store sqlite local; usa --store kv contra un cluster vivo).
membershipd bot add --handle notifier --out ./local_files/notifier.id
# provisioned bot "notifier" role=member
# sign_pub: 97d5a903...b1d4
# endpoint: HU85l2onjrK4EoTLoBfJVkGEXMw9LAjNEjPWiDS8YwM
# credentials: ./local_files/notifier.id (0600)
# 2. El proceso arranca como ese usuario leyendo el --out (formato canónico
# pkg/client.LoadIdentity, sin conversión): el worker demo lo consume directo.
worker --id-file ./local_files/notifier.id --nats-url nats://127.0.0.1:4250 \
--ctrl-url http://127.0.0.1:8470
# 3. (opcional) Verlo en el directorio / en user list.
membershipd user list
```
Las credenciales (`--out`) quedan en el fichero indicado, con permisos 0600. Es
el secreto del bot: contiene las claves privadas, trátalo como una clave SSH
(ver Gotcha "Identidad = secreto crítico"). `bot add` rehúsa sobrescribir un
`--out` existente, y registra al usuario ANTES de escribir el fichero, de modo
que un fallo nunca deja un bot a medias.
Flags: `--handle` y `--out` obligatorios; `--role admin|member` (default member);
`--store sqlite|kv` y el resto de flags de conexión idénticos a `user add`.
## Convención de subjects
```
@@ -154,6 +225,128 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
## Capability growth log
- v0.16.0 (2026-06-14) — feat: el server asegura el stream JetStream de las rooms
persist + `GET /rooms/{id}/history` para que clientes sin JetStream (uniweb) lean
el histórico. (1) `handleCreateRoom` crea (idempotente, `CreateOrUpdateStream`) el
stream durable `UNIBUS_<roomID>` de una room persist ANTES de responder, así su
subject se captura desde el minuto cero venga el mensaje de un cliente Go o de un
cliente browser que solo habla core NATS (antes el stream lo creaba solo el cliente
Go, así que los mensajes de uniweb se perdían). (2) Nuevo endpoint member-only
`GET /rooms/{id}/history?limit=N` (default 200, cap 1000): lee el stream
server-side y devuelve `{messages:[<base64-std del frame marshalado>]}` en orden
oldest→newest; el server jamás descifra (relay del ciphertext E2E). Backfill de
rooms persist existentes: lazy-ensure del stream en el endpoint (empiezan a
capturar desde ahora; los mensajes previos al stream no son recuperables). El
control plane abre ahora su propio contexto JetStream también en single-node
embebido. Todo aditivo; build/vet/test verdes.
- v0.15.1 (2026-06-14) — fix: la ruta del directorio se registraba con prefijo /api y Caddy lo stripeaba (404 en prod); corregida a /directory.
- v0.15.0 (2026-06-14) — nombres legibles + provisioning de bots de un comando.
(1) Nuevo `GET /api/directory` en el control-plane: cualquier usuario activo del
bus (member o admin), autenticado con la misma firma Ed25519 que el resto de
rutas, resuelve endpoint id → handle. Devuelve `{members:[{sign_pub, endpoint,
handle, role}]}` solo de usuarios activos; el endpoint lo deriva el servidor con
`frame.EndpointID`, casando byte a byte con el sender id de cada frame (paridad
verificada contra el vector de `cmd/busvectors`). (2) Nuevo `membershipd bot add
--handle <name> --out <path> [--role] [--store]`: mintea identidad, la registra en
el allowlist y escribe credenciales 0600 en formato `client.LoadIdentity`, de modo
que un proceso (worker/clientcheck) conecta como ese usuario sin pasos manuales.
Nuevo helper exportado `pkg/client.WriteNewIdentity` (no sobrescribe ficheros
existentes). Todo aditivo; build/vet/test verdes.
- v0.14.0 (2026-06-13) — prep para el cliente browser-nativo `uniweb` (issue
uniweb/0001, Fase 0), todo aditivo y opt-in: (1) el nats-server embebido puede
exponer un listener WebSocket (`WebsocketConfig`) para que un navegador hable el
protocolo NATS via `nats.ws`, igual que los peers TCP nativos; el authenticator
nkey aplica también al WebSocket. (2) El control-plane (`membershipd`) gana una
allowlist CORS opt-in (`--cors-origins`) para aceptar llamadas cross-origin del
navegador; vacía = CORS off, sin cambios para clientes nativos. (3) `cmd/busvectors`
genera vectores de test deterministas (endpoint id, firma Ed25519, AEAD
ChaCha20-Poly1305, sealed-box, wire del Frame) como contrato de paridad para el
port TypeScript. Peers Go/Kotlin existentes sin cambios; build/vet/test verdes.
- v0.13.0 (2026-06-13) — el frontend web se separa a su propia app `uniweb`
(`projects/message_bus/apps/uniweb`, sub-repo Gitea propio). unibus deja de
contener la SPA (`web/`) y el gateway web (`cmd/webgw`): ahora es estrictamente
el plano del bus (membresía/claves, librería cliente y peers demo). `uniweb`
consume unibus como módulo Go via `replace github.com/enmanuel/unibus =>
../unibus`, importando `pkg/{busauth,client,frame,room}`, y mantiene su propio
`replace fn-registry` para las primitivas de cybersecurity. Movimiento sin
pérdida de capacidad: la misma SPA y el mismo gateway, solo que en su carpeta
de servicio propia. unibus build/vet/test verdes tras la extracción.
- v0.12.0 (2026-06-13) — frontend web wallet por usuario integrado a master. La
SPA gana un onboarding criptográfico: cada usuario deriva su identidad de forma
determinista desde una mnemónica BIP39 de 12 palabras (esquema HKDF →
Ed25519/X25519), cifrada at-rest en el dispositivo con AES-256-GCM, con caminos
join (invitación) / login (passphrase local) / recover (re-derivación en
dispositivo nuevo, sin admin). El gateway `cmd/webgw` (REST + SSE) pasa de
identidad única de operador a sesiones wallet por usuario con registro por
token de invitación. Integra `quick/web-join` sobre el master 0.11.0
(auto-merge de `embeddednats.go` sin conflictos; Go build/vet/test y
`pnpm build` verdes).
- v0.11.0 (2026-06-07) — flag dedicado `UNIBUS_NATS_MONITOR` que abre el endpoint
de monitoring HTTP del nats-server embebido (`127.0.0.1:8222`, loopback only) de
forma DESACOPLADA del debug-log. Antes el monitoring solo se abría con
`UNIBUS_NATS_DEBUG=1`, que además encendía el log verboso del nats-server
(rutas/RAFT/subjects a journald en claro) — incompatible con el endurecimiento
del issue 0007. El cómputo de los toggles se extrae a una función pura
`natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor)`: `MONITOR=1`
abre el endpoint dejando el log en silencio (`NoLog` true / `Debug` false), y se
mantiene el acoplamiento inverso por compatibilidad (`DEBUG` sigue implicando
`MONITOR`). El bind loopback `127.0.0.1` queda hardcoded — el monitoring NUNCA es
público y no lleva auth; lo lee un scraper local que empuja a VictoriaMetrics
(dashboard `unibus-nats` en `fleet_monitoring`). Se versiona el cableado de
deploy: drop-in systemd aditivo `membershipd-cluster.service.d/nats-monitor.conf`
(`Environment=UNIBUS_NATS_MONITOR=1`) + sección "NATS server metrics" en el
README del cluster con el runbook de activación rolling (magnus→homer→datardos)
y gate de reconvergencia R3 (`followers 2/2`) entre nodos. Tests nuevos: tabla
pura del desacoplamiento (monitor on ⇒ log NO debug; debug ⇒ monitor; default
cerrado) + server real con `MONITOR=1` que confirma `/varz` 200 en loopback:8222
y server sin flag con el endpoint cerrado. Cambios 100% aditivos: sin el flag el
comportamiento es idéntico; build/test verdes.
- v0.10.0 (2026-06-07) — API HTTP admin-only de gestión de usuarios, cerrando la
última asimetría del control plane: las rooms tenían superficie HTTP firmada
(`POST /rooms`, etc.) pero los users solo se gestionaban por CLI local o acceso
directo al store. Se añaden `GET /users` (lista completa, incluidos revocados),
`POST /users` (alta `{sign_pub, handle, role}`: valida hex de 64 chars + role en
`{admin, member}`, 409 idempotente que no sobrescribe ni eleva rol) y
`POST /users/{signpub}/revoke` (flip de status, sin hard-delete). Los tres pasan por
un helper `requireAdmin` default-deny que confirma contra el store que el firmante
autenticado es un user `role == "admin"` activo (el endpoint id es un hash one-way de
la clave, así que el contexto lleva ahora también el `sign_pub` hex del firmante para
resolver `GetUser`); cualquier otro firmante recibe 403, encima de la firma+nonce+TLS+
enforce ya heredadas del middleware. NO se abre conexión KV nueva ni se usa la identidad
interna: el server escribe vía su `s.store` privilegiado, el MISMO que las rooms (SQLite
single-node, KV `UNIBUS_users` en cluster). `pkg/client` gana `ListUsers/AddUser/RevokeUser`
(tipo plano `UserInfo`) firmando como admin, así la pestaña Users del panel deja de
necesitar `--db`/acceso KV directo. La CLI `membershipd user add --store kv` queda SOLO
para sembrar el admin #0 (bootstrap). La validación de `sign_pub` se unifica en
`membership.ValidateSignPubHex`, reusada por la CLI y los handlers. Tests nuevos:
no-admin → 403 en los tres endpoints, roundtrip admin add→list→revoke, y validación
(hex inválido → 400, role inválido → 400, re-alta → 409), más un test de cliente contra
un membershipd embebido. Cambios 100% aditivos: el comportamiento single-node y de las
rutas de rooms no cambia; vet/build/test verdes.
- v0.9.0 (2026-06-07) — cierre de los gaps que el despliegue del cluster (report
0011) dejó abiertos (report 0012). (GAP A) Nueva capability `membershipd user
add|list|revoke --store kv`: alta/baja de usuarios contra el KV replicado del
cluster EN MARCHA, sin el procedimiento de parar-sembrar-rearrancar. Usa la
conexión interna privilegiada — el daemon persiste su identidad de servicio con
`--internal-id-file` (cada nodo genera/carga la suya, 0600 junto a las claves TLS)
y la CLI, ejecutada por loopback en un nodo, presenta esa nkey que el
autenticador reconoce con permisos plenos de JetStream; ninguna identidad de
usuario normal puede tocar los buckets `KV_UNIBUS_*` bajo la ACL por-subject. El
alta es idempotente (re-alta de la misma clave = `ErrUserExists` explícito, sin
sobrescribir ni elevar rol), commitea con quórum 2/3 (HA, imprime
`followers_current`) y rechaza un destino remoto sin `--ca` (igual que
`migrate-to-kv`). (GAP B) Nuevo `cmd/clientcheck`: verificación end-to-end real
con un cliente autenticado (identidad operator, nkey+TLS+https) que crea una room
E2E, publica y recibe descifrado contra el cluster vivo, incluido un nodo parado a
media transmisión donde el cliente hace failover a un superviviente y sigue
recibiendo con cero pérdida (quórum 2/3) — el plano de datos que el chaos test del
0011 nunca probó. (GAP C) Runbook `deploy/cluster/README.md` corregido: el orden
de arranque "magnus solo y verifica healthz" deadlockeaba (un nodo solo no tiene
quórum del meta-group y nunca sirve healthz); se documenta el arranque por quórum,
que R1 es un SPOF inservible (ir directo a R3) y la nueva vía de alta con el
cluster vivo. La plantilla de deploy (unit + `deploy-cluster.sh`) emite ya
`INTERNAL_ID_FILE` y el flag. Verificado contra los 3 VPS reales (magnus + homer +
datardos); posture enforce+ACL+TLS+R3 intacta.
- v0.8.0 (2026-06-07) — completar y endurecer el cluster (issue 0006, fases
0006a0006g) que cierra los bloqueantes de la auditoría dedicada del cluster
(report 0008) y cablea el control plane descentralizado que 0003 dejó a medias.
BIN
View File
Binary file not shown.
+281
View File
@@ -0,0 +1,281 @@
// Command busvectors emits deterministic cross-language test vectors for the bus
// protocol and its end-to-end crypto. The browser-native client (uniweb) ports the
// protocol to TypeScript; these vectors are the contract that proves the port is
// byte-for-byte compatible with this Go reference implementation (issue
// uniweb/0001, Phase 0).
//
// Every input is fixed (hardcoded key material and messages) so the output is
// stable across runs and can be committed as a golden file. The crypto primitives
// are the SAME registry functions the bus uses (functions/cybersecurity), so the
// vectors exercise the real path, not a test-only reimplementation.
//
// Coverage:
// - endpoint_id : EndpointID(signPub) = base64url(sha256(signPub))
// - sign : Ed25519 signature over a fixed message (deterministic)
// - aead : ChaCha20-Poly1305 seal with a FIXED nonce (deterministic, so
// the TS port must reproduce the same ciphertext AND open it)
// - keybox : sealed-box (X25519) of a room key for a recipient; the TS port
// must OPEN it (the ephemeral sender key is random, so only the
// open direction is a stable vector — the TS->Go seal direction
// is covered by the live E2E test in Phase 3)
// - frame : canonical JSON wire bytes of a Frame, and its SigningBytes
//
// Usage:
//
// go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
)
// Fixed key material. The bytes are arbitrary but stable: the point is a golden
// file, not secrecy (these are test vectors, never real identities).
var (
signSeed = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
kexPriv = mustHex("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f")
recipientKexPriv = mustHex("404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f")
aeadKey = mustHex("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f")
aeadNonce = mustHex("808182838485868788898a8b") // 12 bytes (ChaCha20-Poly1305 IETF)
roomKey = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf")
signMessage = []byte("unibus parity vector message")
aeadAAD = []byte("unibus-room-42")
aeadPlaintext = []byte("hello from the bus")
)
// vectors is the JSON document consumed by the TypeScript parity tests. Every field
// is hex except the frame wire bytes, which are base64 (the frame is JSON, so the
// TS side compares the exact UTF-8 bytes).
type vectors struct {
Note string `json:"note"`
Endpoint endpointVector `json:"endpoint_id"`
Nkey nkeyVector `json:"nkey"`
Sign signVector `json:"sign"`
AEAD aeadVector `json:"aead"`
KeyBox keyboxVector `json:"keybox"`
Frame frameVector `json:"frame"`
CtrlReq controlReqVector `json:"control_request"`
}
type endpointVector struct {
SignPubHex string `json:"sign_pub_hex"`
EndpointID string `json:"endpoint_id"` // base64url(sha256(sign_pub)), unpadded
}
type nkeyVector struct {
SignPubHex string `json:"sign_pub_hex"`
NkeyPublic string `json:"nkey_public"` // NATS user nkey ("U...") from the Ed25519 pubkey
}
type controlReqVector struct {
Method string `json:"method"`
Path string `json:"path"`
Ts string `json:"ts"`
Nonce string `json:"nonce"`
BodyHex string `json:"body_hex"` // raw request body (empty for GET)
CanonicalHex string `json:"canonical_hex"` // bytes that get signed
SigHex string `json:"sig_hex"` // Ed25519 over canonical, by the signer below
SignPrivHex string `json:"sign_priv_hex"`
}
type signVector struct {
SignPrivHex string `json:"sign_priv_hex"`
SignPubHex string `json:"sign_pub_hex"`
MessageHex string `json:"message_hex"`
SigHex string `json:"sig_hex"`
}
type aeadVector struct {
KeyHex string `json:"key_hex"`
NonceHex string `json:"nonce_hex"`
AADHex string `json:"aad_hex"`
PlaintextHex string `json:"plaintext_hex"`
CiphertextHex string `json:"ciphertext_hex"` // includes the 16-byte Poly1305 tag
}
type keyboxVector struct {
RecipientKexPubHex string `json:"recipient_kex_pub_hex"`
RecipientKexPrivHex string `json:"recipient_kex_priv_hex"`
SecretHex string `json:"secret_hex"`
SealedHex string `json:"sealed_hex"`
}
type frameVector struct {
// The source fields, so the TS side can build the same Frame and compare.
Type int `json:"type"`
Subject string `json:"subject"`
Sender string `json:"sender"`
MsgID string `json:"msg_id"`
Epoch int `json:"epoch"`
NonceHex string `json:"nonce_hex"`
PayloadHex string `json:"payload_hex"`
WireB64 string `json:"wire_b64"` // base64(Marshal()) — full frame incl. sig
SigningB64 string `json:"signing_bytes_b64"` // base64(SigningBytes()) — what gets signed
SigHex string `json:"sig_hex"` // Ed25519 over SigningBytes
}
func main() {
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "busvectors:", err)
os.Exit(1)
}
}
func run(out *os.File) error {
// Identity from the fixed seed: Go's ed25519 private key layout is seed||pub, the
// same 64-byte layout cs.Identity and the TS wallet use.
signPriv := ed25519.NewKeyFromSeed(signSeed)
signPub := signPriv.Public().(ed25519.PublicKey)
// X25519 public keys from the fixed private scalars (curve25519 clamps internally,
// matching @noble/curves x25519.getPublicKey).
kexPub, err := curve25519.X25519(kexPriv, curve25519.Basepoint)
if err != nil {
return fmt.Errorf("kex pub: %w", err)
}
recipientKexPub, err := curve25519.X25519(recipientKexPriv, curve25519.Basepoint)
if err != nil {
return fmt.Errorf("recipient kex pub: %w", err)
}
// AEAD with a FIXED nonce so the vector is deterministic. This is the same cipher
// (ChaCha20-Poly1305 IETF, 12-byte nonce) that cs.SealAEAD uses; we set the nonce
// explicitly only to make the vector reproducible. OpenAEAD verifies round-trip.
aead, err := chacha20poly1305.New(aeadKey)
if err != nil {
return fmt.Errorf("aead cipher: %w", err)
}
ciphertext := aead.Seal(nil, aeadNonce, aeadPlaintext, aeadAAD)
if _, err := cs.OpenAEAD(aeadKey, aeadNonce, ciphertext, aeadAAD); err != nil {
return fmt.Errorf("aead self-check: %w", err)
}
// Sealed box of the room key for the recipient. The sender's ephemeral key is
// random (anonymous sealed box), so SealedHex changes per run; the stable, useful
// assertion for the TS port is that OpenKeyBox recovers the secret, which we
// self-check here. The TS test opens SealedHex and compares to SecretHex.
sealed, err := cs.SealKeyBox(recipientKexPub, roomKey)
if err != nil {
return fmt.Errorf("seal keybox: %w", err)
}
if got, err := cs.OpenKeyBox(recipientKexPub, recipientKexPriv, sealed); err != nil || hex.EncodeToString(got) != hex.EncodeToString(roomKey) {
return fmt.Errorf("keybox self-check failed: %v", err)
}
// A representative encrypted-room frame, signed end-to-end.
f := frame.Frame{
Type: frame.PUB,
Subject: "room.parity",
Sender: frame.EndpointID(signPub),
MsgID: "01HZY0VECTORFIXEDULID0001",
Epoch: 1,
Nonce: aeadNonce,
Payload: ciphertext,
}
f.Sig = ed25519.Sign(signPriv, f.SigningBytes())
wire, err := f.Marshal()
if err != nil {
return fmt.Errorf("marshal frame: %w", err)
}
// NATS user nkey derived from the Ed25519 public key (the browser must produce
// the same "U..." string to authenticate on the data plane).
nkeyPub, err := busauth.NkeyPublicFromSignPub(signPub)
if err != nil {
return fmt.Errorf("nkey public: %w", err)
}
// A signed control-plane request vector: the browser signs CanonicalRequest the
// same way to authenticate every HTTP call to membershipd. A POST with a body
// exercises the sha256(body) term.
const ctrlMethod = "POST"
const ctrlPath = "/rooms"
const ctrlTs = "1700000000"
const ctrlNonce = "Zm9vYmFyMTIzNDU2Nzg5MA=="
ctrlBody := []byte(`{"subject":"room.parity"}`)
canonical := membership.CanonicalRequest(ctrlMethod, ctrlPath, ctrlTs, ctrlNonce, ctrlBody)
ctrlSig := ed25519.Sign(signPriv, canonical)
v := vectors{
Note: "Deterministic cross-language vectors for the unibus protocol. Generated by " +
"cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. " +
"sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.",
Endpoint: endpointVector{
SignPubHex: hex.EncodeToString(signPub),
EndpointID: frame.EndpointID(signPub),
},
Nkey: nkeyVector{
SignPubHex: hex.EncodeToString(signPub),
NkeyPublic: nkeyPub,
},
Sign: signVector{
SignPrivHex: hex.EncodeToString(signPriv),
SignPubHex: hex.EncodeToString(signPub),
MessageHex: hex.EncodeToString(signMessage),
SigHex: hex.EncodeToString(ed25519.Sign(signPriv, signMessage)),
},
AEAD: aeadVector{
KeyHex: hex.EncodeToString(aeadKey),
NonceHex: hex.EncodeToString(aeadNonce),
AADHex: hex.EncodeToString(aeadAAD),
PlaintextHex: hex.EncodeToString(aeadPlaintext),
CiphertextHex: hex.EncodeToString(ciphertext),
},
KeyBox: keyboxVector{
RecipientKexPubHex: hex.EncodeToString(recipientKexPub),
RecipientKexPrivHex: hex.EncodeToString(recipientKexPriv),
SecretHex: hex.EncodeToString(roomKey),
SealedHex: hex.EncodeToString(sealed),
},
Frame: frameVector{
Type: int(f.Type),
Subject: f.Subject,
Sender: f.Sender,
MsgID: f.MsgID,
Epoch: f.Epoch,
NonceHex: hex.EncodeToString(f.Nonce),
PayloadHex: hex.EncodeToString(f.Payload),
WireB64: base64.StdEncoding.EncodeToString(wire),
SigningB64: base64.StdEncoding.EncodeToString(f.SigningBytes()),
SigHex: hex.EncodeToString(f.Sig),
},
CtrlReq: controlReqVector{
Method: ctrlMethod,
Path: ctrlPath,
Ts: ctrlTs,
Nonce: ctrlNonce,
BodyHex: hex.EncodeToString(ctrlBody),
CanonicalHex: hex.EncodeToString(canonical),
SigHex: hex.EncodeToString(ctrlSig),
SignPrivHex: hex.EncodeToString(signPriv),
},
// kexPub is unused in a vector field today but derived above to validate the
// scalar; reference it so the intent is documented.
}
_ = kexPub
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(v)
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic("busvectors: bad fixed hex: " + s)
}
return b
}
+260
View File
@@ -0,0 +1,260 @@
// Command clientcheck is an end-to-end verification client for a live unibus
// cluster (issue 0011 GAP B). The 0011 chaos test validated only the control
// plane (healthz + meta/stream-leader failover + KV readable with 2/3); it never
// connected an authenticated bus client (nkey + TLS) to create a room and
// publish/subscribe through it, least of all across a node loss. clientcheck does
// exactly that with a real identity (the operator), so the data-plane end-to-end
// path — connect, create an E2E room, publish, receive decrypted — is exercised
// against the running cluster, including while a node is stopped.
//
// It is a reusable tool, not a throwaway script: point it at the cluster's CA,
// an identity file, and the NATS + control-plane seed lists.
//
// # golden: connect, create an E2E room, publish N, confirm N decrypted back
// clientcheck --ca ca.crt --identity-file operator.id \
// --nats-seeds nats://A:4250,nats://B:4250,nats://C:4250 \
// --ctrl-seeds https://A:8470,https://B:8470,https://C:8470 --messages 5
//
// # loop: publish a counter every interval for the duration, logging the node
// # it is attached to — stop a node mid-run (systemctl stop membershipd-cluster)
// # and watch it fail over to a survivor and keep receiving (quorum 2/3).
// clientcheck ... --mode loop --duration 45s --interval 1s
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
func main() {
var (
caPath = flag.String("ca", "", "bus CA cert pinning TLS on both planes (required for a secured cluster)")
idFile = flag.String("identity-file", "", "path to the client identity JSON (e.g. `pass show unibus/operator-identity` written 0600) (required)")
natsSeeds = flag.String("nats-seeds", "", "comma-separated NATS urls of the cluster nodes (required)")
ctrlSeeds = flag.String("ctrl-seeds", "", "comma-separated control-plane https urls of the cluster nodes (required)")
subject = flag.String("subject", "test.gapcheck", "test room subject PREFIX; a random token is appended so runs never collide with real rooms")
messages = flag.Int("messages", 5, "golden mode: number of messages to publish and expect back")
mode = flag.String("mode", "golden", "golden (publish N, verify N decrypted) | loop (publish a counter for --duration, for failover testing)")
duration = flag.Duration("duration", 30*time.Second, "loop mode: how long to keep publishing")
interval = flag.Duration("interval", 1*time.Second, "loop mode: delay between published messages")
)
flag.Parse()
if *idFile == "" || *natsSeeds == "" || *ctrlSeeds == "" {
log.Fatalf("clientcheck: --identity-file, --nats-seeds and --ctrl-seeds are required")
}
id, err := client.LoadIdentity(*idFile)
if err != nil {
log.Fatalf("clientcheck: load identity: %v", err)
}
natsList := splitCSV(*natsSeeds)
ctrlList := splitCSV(*ctrlSeeds)
if len(natsList) == 0 || len(ctrlList) == 0 {
log.Fatalf("clientcheck: empty --nats-seeds or --ctrl-seeds")
}
// Build the secure client options: nkey on the data plane, TLS pinned to the
// bus CA on both planes, and the FULL seed lists so nats.go fails over to a
// surviving node when the attached one dies (the failover this tool verifies).
opts := client.Options{
NatsServers: natsList[1:],
CtrlURLs: ctrlList[1:],
}
if *caPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(*caPath)
if err != nil {
log.Fatalf("clientcheck: load CA: %v", err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
for _, u := range ctrlList {
if !strings.HasPrefix(u, "https://") {
log.Fatalf("clientcheck: control URL %q must be https:// when --ca is set", u)
}
}
}
c, err := client.NewWithOptions(natsList[0], ctrlList[0], id, opts)
if err != nil {
log.Fatalf("clientcheck: connect: %v", err)
}
defer c.Close()
log.Printf("connected: endpoint=%s nats=%s", c.Endpoint().ID, c.ConnectedServer())
// Create an EPHEMERAL E2E room (encrypted + signed, NOT persisted): the test
// stays end-to-end encrypted (the cluster requires encryption on a public
// bind) while leaving no durable JetStream stream behind. The random subject
// token guarantees the room is unique and never a real room.
rnd := make([]byte, 8)
if _, err := rand.Read(rnd); err != nil {
log.Fatalf("clientcheck: random: %v", err)
}
subj := fmt.Sprintf("%s.%s", *subject, hex.EncodeToString(rnd))
policy := room.Policy{Encrypt: true, Persist: false, SignMsgs: true}
roomID, err := c.CreateRoom(subj, policy)
if err != nil {
log.Fatalf("clientcheck: create room: %v", err)
}
log.Printf("created E2E room: id=%s subject=%s (encrypt=%v sign=%v persist=%v)", roomID, subj, policy.Encrypt, policy.SignMsgs, policy.Persist)
// Under the per-subject ACL, NATS freezes permissions at connect time, so the
// just-created room's subject is not yet publishable/subscribable on the live
// connection. RefreshSession reconnects so the authenticator re-derives the
// ACL (now including this room) — the post-0006 contract every client follows
// after a membership change.
if err := c.RefreshSession(); err != nil {
log.Fatalf("clientcheck: refresh session: %v", err)
}
switch *mode {
case "golden":
runGolden(c, roomID, *messages)
case "loop":
runLoop(c, roomID, *duration, *interval)
default:
log.Fatalf("clientcheck: --mode must be golden or loop, got %q", *mode)
}
}
// runGolden subscribes, publishes n messages, and asserts all n come back
// decrypted. Exits non-zero if any are missing.
func runGolden(c *client.Client, roomID string, n int) {
var mu sync.Mutex
got := map[string]bool{}
sub, err := c.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) {
mu.Lock()
got[string(plaintext)] = true
mu.Unlock()
})
if err != nil {
log.Fatalf("clientcheck: subscribe: %v", err)
}
defer sub.Unsubscribe()
time.Sleep(300 * time.Millisecond) // let the subscription settle
want := make([]string, n)
for i := 0; i < n; i++ {
msg := fmt.Sprintf("gapcheck-e2e-%d", i)
want[i] = msg
if err := c.Publish(roomID, []byte(msg)); err != nil {
log.Fatalf("clientcheck: publish %d: %v", i, err)
}
}
log.Printf("published %d messages to %s; waiting for decrypted echoes...", n, roomID)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
have := len(got)
mu.Unlock()
if have >= n {
break
}
time.Sleep(100 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
missing := 0
for _, w := range want {
if !got[w] {
missing++
log.Printf(" MISSING: %q", w)
}
}
log.Printf("connected node at finish: %s", c.ConnectedServer())
if missing > 0 {
log.Fatalf("GOLDEN FAIL: %d/%d messages not received decrypted", missing, n)
}
log.Printf("GOLDEN OK: all %d messages received and decrypted end-to-end", n)
}
// runLoop publishes a numbered message every interval for the duration and logs
// the count received plus the node currently attached, so an operator stopping a
// cluster node mid-run sees the client fail over to a survivor and keep receiving
// (quorum 2/3). It is the live failover-with-a-connected-client test the 0011
// chaos run never performed.
func runLoop(c *client.Client, roomID string, duration, interval time.Duration) {
var mu sync.Mutex
received := 0
servers := map[string]int{} // node -> #ticks observed attached
sub, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) {
mu.Lock()
received++
mu.Unlock()
})
if err != nil {
log.Fatalf("clientcheck: subscribe: %v", err)
}
defer sub.Unsubscribe()
time.Sleep(300 * time.Millisecond)
log.Printf("loop: publishing every %s for %s — stop a node now to test failover", interval, duration)
end := time.Now().Add(duration)
sent := 0
for time.Now().Before(end) {
msg := fmt.Sprintf("gapcheck-loop-%d", sent)
err := c.Publish(roomID, []byte(msg))
sent++
mu.Lock()
recv := received
mu.Unlock()
node := c.ConnectedServer()
up := c.IsConnected()
if node != "" {
mu.Lock()
servers[node]++
mu.Unlock()
}
pubStatus := "ok"
if err != nil {
pubStatus = "ERR:" + err.Error()
}
log.Printf(" t=%2ds sent=%d recv=%d up=%v node=%s publish=%s",
sent, sent, recv, up, node, pubStatus)
time.Sleep(interval)
}
mu.Lock()
defer mu.Unlock()
log.Printf("loop done: sent=%d received=%d", sent, received)
nodes := make([]string, 0, len(servers))
for n := range servers {
nodes = append(nodes, n)
}
sort.Strings(nodes)
for _, n := range nodes {
log.Printf(" attached to %s for %d ticks", n, servers[n])
}
if len(servers) > 1 {
log.Printf("FAILOVER OBSERVED: client was attached to %d distinct nodes across the run", len(servers))
}
if received == 0 {
log.Fatalf("LOOP FAIL: received 0 messages")
}
log.Printf("LOOP OK: client kept receiving across the run (received=%d)", received)
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"encoding/hex"
"errors"
"flag"
"fmt"
"os"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
)
// runBotCLI implements `membershipd bot add ...`, one-command provisioning of a
// bus identity for an automated process. Where `user add` requires the operator
// to derive a keypair by hand and pass the public key, `bot add` mints the
// identity, registers its signing key in the allowlist, AND writes the bot's
// credentials to a 0600 file the process reads to connect — no manual key
// derivation, no second step. It shares the SQLite/KV store plumbing with the
// user CLI, so `--store kv` provisions against a live cluster the same way.
//
// Like the user CLI it never returns: it exits non-zero on error so it composes
// in shell scripts and systemd ExecStartPre hooks.
func runBotCLI(args []string) {
if len(args) == 0 {
botUsage()
os.Exit(2)
}
sub, rest := args[0], args[1:]
switch sub {
case "add":
botAdd(rest)
case "-h", "--help", "help":
botUsage()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "membershipd bot: unknown subcommand %q\n\n", sub)
botUsage()
os.Exit(2)
}
}
func botUsage() {
fmt.Fprint(os.Stderr, `usage: membershipd bot add [flags]
Provision a bus identity for an automated process (a "unibot") in one command:
mint a fresh Ed25519+X25519 identity, register its signing key in the allowlist,
and write the credentials to a 0600 file the process loads to connect.
required flags:
--handle <name> human-readable name for the bot (shown in the directory)
--out <path> where to write the bot credentials (refused if it exists)
optional flags:
--role <role> admin or member (default member)
--store <kind> sqlite (local DB, default) | kv (the live cluster's allowlist)
--db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
--store kv flags (defaults assume an on-node invocation):
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
--kv-replicas <n> KV replication factor, match the cluster (default 3)
examples:
membershipd bot add --handle notifier --out ./local_files/notifier.id
membershipd bot add --store kv --handle relay --role member --out /opt/unibus/secrets/relay.id
The --out file is the canonical identity format read by the worker/clientcheck
clients (pkg/client.LoadIdentity), so the provisioned bot connects with no extra
conversion: point the process at it (e.g. worker --id-file <path>) and it joins
the bus as this user.
`)
}
func botAdd(args []string) {
fs := flag.NewFlagSet("bot add", flag.ExitOnError)
handle := fs.String("handle", "", "human-readable bot name (required)")
role := fs.String("role", membership.RoleMember, "role: admin or member")
out := fs.String("out", "", "path to write the bot credentials, 0600 (required)")
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
_ = fs.Parse(args)
if *handle == "" || *out == "" {
fmt.Fprintln(os.Stderr, "membershipd bot add: --handle and --out are required")
os.Exit(2)
}
store, kv, closeStore := resolveStore("bot add", kf, *dbPath)
defer closeStore()
signPubHex, endpoint, err := provisionBot(store, *handle, *role, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "membershipd bot add: %v\n", err)
os.Exit(1)
}
fmt.Printf("provisioned bot %q role=%s\n", *handle, *role)
fmt.Printf(" sign_pub: %s\n", signPubHex)
fmt.Printf(" endpoint: %s\n", endpoint)
fmt.Printf(" credentials: %s (0600)\n", *out)
if kv != nil {
reportKVReplication(kv.js)
}
}
// provisionBot mints a fresh bus identity and provisions it. It is the generating
// half; provisionBotWithIdentity does the registration + persistence so a test can
// inject a known identity (e.g. to exercise the already-registered error path).
func provisionBot(store membership.Store, handle, role, out string) (signPubHex, endpoint string, err error) {
id, err := cs.GenerateIdentity()
if err != nil {
return "", "", fmt.Errorf("generate bot identity: %w", err)
}
return provisionBotWithIdentity(store, id, handle, role, out)
}
// provisionBotWithIdentity registers id's signing key under handle/role and writes
// id's credentials to out. It returns the lowercase-hex signing key and the
// derived endpoint id.
//
// Ordering is deliberate so a failure never leaves a half-provisioned bot:
// 1. refuse if out already exists, BEFORE the store is touched (no orphan user);
// 2. register the user — an already-registered key is a clear error, not a panic;
// 3. only then write the 0600 credentials file.
//
// A write failure after a successful register is reported with the registered key
// so the operator can revoke it; this is the one residual non-atomic seam (a
// local admin command, acceptable per KISS).
func provisionBotWithIdentity(store membership.Store, id cs.Identity, handle, role, out string) (signPubHex, endpoint string, err error) {
if handle == "" || out == "" {
return "", "", fmt.Errorf("handle and out are required")
}
if role == "" {
role = membership.RoleMember
}
if _, statErr := os.Stat(out); statErr == nil {
return "", "", fmt.Errorf("out file %q already exists; refusing to overwrite bot credentials", out)
} else if !os.IsNotExist(statErr) {
return "", "", fmt.Errorf("stat out %q: %w", out, statErr)
}
signPubHex = hex.EncodeToString(id.SignPub)
endpoint = frame.EndpointID(id.SignPub)
if err := store.AddUser(signPubHex, handle, role); err != nil {
if errors.Is(err, membership.ErrUserExists) {
return "", "", fmt.Errorf("sign_pub %s already registered; revoke it first to replace", signPubHex)
}
return "", "", fmt.Errorf("register bot user: %w", err)
}
if err := client.WriteNewIdentity(out, id); err != nil {
return "", "", fmt.Errorf("write bot credentials to %q (user %s WAS registered — revoke it to retry): %w", out, signPubHex, err)
}
return signPubHex, endpoint, nil
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"encoding/hex"
"os"
"path/filepath"
"testing"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
)
// openTestStore opens a fresh SQLite membership store in a temp dir.
func openTestStore(t *testing.T) membership.Store {
t.Helper()
store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// TestProvisionBotGolden is the happy path: provisioning a bot registers it in the
// allowlist with the right handle and role, AND writes a 0600 credentials file
// that LoadIdentity reconstructs into the same identity — so a worker/clientcheck
// binary pointed at the file connects as exactly this user with no extra step.
func TestProvisionBotGolden(t *testing.T) {
store := openTestStore(t)
out := filepath.Join(t.TempDir(), "notifier.id")
signPubHex, endpoint, err := provisionBot(store, "notifier", membership.RoleMember, out)
if err != nil {
t.Fatalf("provisionBot: %v", err)
}
// Registered in the allowlist with the right handle/role/status.
u, err := store.GetUser(signPubHex)
if err != nil {
t.Fatalf("get provisioned user: %v", err)
}
if u.Handle != "notifier" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
t.Fatalf("provisioned user row wrong: %+v", u)
}
// And it shows up in user list (the `user list` surface).
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
found := false
for _, x := range users {
if x.SignPub == signPubHex {
found = true
}
}
if !found {
t.Fatalf("provisioned bot missing from user list: %+v", users)
}
// Credentials file exists, is 0600, and round-trips through LoadIdentity to the
// same signing key + endpoint (no-friction contract).
info, err := os.Stat(out)
if err != nil {
t.Fatalf("stat out file: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("out file perms = %o, want 600", perm)
}
id, err := client.LoadIdentity(out)
if err != nil {
t.Fatalf("LoadIdentity(out): %v", err)
}
if got := hex.EncodeToString(id.SignPub); got != signPubHex {
t.Fatalf("loaded sign_pub %q != provisioned %q", got, signPubHex)
}
if got := frame.EndpointID(id.SignPub); got != endpoint {
t.Fatalf("loaded endpoint %q != reported %q", got, endpoint)
}
}
// TestProvisionBotDefaultRole: an empty role defaults to member.
func TestProvisionBotDefaultRole(t *testing.T) {
store := openTestStore(t)
out := filepath.Join(t.TempDir(), "bot.id")
signPubHex, _, err := provisionBot(store, "defrole", "", out)
if err != nil {
t.Fatalf("provisionBot: %v", err)
}
u, err := store.GetUser(signPubHex)
if err != nil {
t.Fatalf("get user: %v", err)
}
if u.Role != membership.RoleMember {
t.Fatalf("empty role should default to member, got %q", u.Role)
}
}
// TestProvisionBotSignPubAlreadyRegistered is the error path: provisioning an
// identity whose signing key is already in the allowlist fails with a clear error
// (not a panic) AND does not write a credentials file (no half-provisioned bot).
func TestProvisionBotSignPubAlreadyRegistered(t *testing.T) {
store := openTestStore(t)
// Pre-register a key, then try to provision a bot with that SAME identity.
id, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("generate identity: %v", err)
}
signPubHex := hex.EncodeToString(id.SignPub)
if err := store.AddUser(signPubHex, "preexisting", membership.RoleMember); err != nil {
t.Fatalf("pre-register: %v", err)
}
out := filepath.Join(t.TempDir(), "dup.id")
_, _, err = provisionBotWithIdentity(store, id, "dupbot", membership.RoleMember, out)
if err == nil {
t.Fatalf("provisioning an already-registered key should error")
}
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
t.Fatalf("credentials file must NOT be written on a duplicate-key failure (stat err = %v)", statErr)
}
}
// TestProvisionBotOutExists is the other error path: an existing --out file is
// refused BEFORE the store is mutated, so the run leaves no orphan user behind.
func TestProvisionBotOutExists(t *testing.T) {
store := openTestStore(t)
out := filepath.Join(t.TempDir(), "taken.id")
if err := os.WriteFile(out, []byte("preexisting credentials"), 0o600); err != nil {
t.Fatalf("seed out file: %v", err)
}
_, _, err := provisionBot(store, "clobber", membership.RoleMember, out)
if err == nil {
t.Fatalf("provisioning over an existing out file should error")
}
// The store must be untouched: no user was registered.
users, err := store.ListUsers()
if err != nil {
t.Fatalf("list users: %v", err)
}
if len(users) != 0 {
t.Fatalf("no user should be registered when out exists, got %+v", users)
}
}
+152
View File
@@ -0,0 +1,152 @@
package main
// Integration tests for issue 0011 GAP A: `membershipd user add --store kv`
// adds users to a RUNNING cluster's replicated allowlist via the privileged
// internal connection, instead of the stop-seed-restart procedure the 0011
// deploy required. These exercise the real connectKVStore path (load the
// persisted internal identity from a file, present its nkey, open the KV store,
// write the user) against an embedded enforce node, plus the idempotency and
// error semantics the DoD calls for. Multi-node replication and node-down quorum
// are validated against the live cluster (report 0012).
import (
"encoding/hex"
"errors"
"path/filepath"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership"
)
// startEnforceKVNode boots a single embedded enforce node whose authenticator
// recognizes internalPubHex as the privileged internal identity, bootstraps the
// KV control-plane store over the in-process internal connection, and publishes
// it into the holder — the exact sequence main.go performs for --store kv. It
// returns the client URL the CLI connects to.
func startEnforceKVNode(t *testing.T, internalID cs.Identity) string {
t.Helper()
holder := &storeHolder{}
auth := busauth.NewNkeyAuthenticatorACLInternal(
holder.IsAuthorized,
busauth.PermissionsFromSubjects(holder.subjectACL),
hex.EncodeToString(internalID.SignPub),
)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("start enforce node: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
intNC, js, err := connectInternalJS(ns, internalID, true)
if err != nil {
t.Fatalf("bootstrap internal connection: %v", err)
}
t.Cleanup(intNC.Close)
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
if err != nil {
t.Fatalf("bootstrap KV store: %v", err)
}
holder.set(kvStore)
return ns.ClientURL()
}
// TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI
// connection (real connectKVStore, loading the internal identity from a file and
// presenting its nkey) writes a user into the live KV allowlist, the user is
// authorized afterward, and re-adding the same key is an explicit ErrUserExists
// with no corruption (the unchanged row is still authorized).
func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600
if err != nil {
t.Fatalf("persist internal identity: %v", err)
}
url := startEnforceKVNode(t, internalID)
// Golden: connect as the privileged internal identity (loopback, no TLS) and
// add a new user, exactly as `user add --store kv` does.
kv, err := connectKVStore(url, idFile, "", 1)
if err != nil {
t.Fatalf("connectKVStore (privileged): %v", err)
}
defer kv.Close()
newUser, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("new user identity: %v", err)
}
pub := hex.EncodeToString(newUser.SignPub)
if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil {
t.Fatalf("add user to live KV: %v", err)
}
if !kv.store.IsAuthorized(pub) {
t.Fatalf("user added to KV must be authorized")
}
// Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists.
err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember)
if !errors.Is(err, membership.ErrUserExists) {
t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err)
}
// A different handle/role with the SAME key is also rejected — the row is not
// silently overwritten (no role flip).
if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) {
t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err)
}
u, err := kv.store.GetUser(pub)
if err != nil {
t.Fatalf("get user: %v", err)
}
if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
t.Fatalf("idempotent re-add corrupted the row: %+v", u)
}
}
// TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable
// internal identity file fails loudly (missing file, empty path) rather than
// silently connecting unprivileged.
func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) {
if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil {
t.Fatalf("empty --internal-id-file must be an error")
}
missing := filepath.Join(t.TempDir(), "nope.id")
if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil {
t.Fatalf("missing internal identity file must be an error")
}
}
// TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv
// at a dead endpoint yields a clear, handled error (no crash, no silent success).
func TestUserAddStoreKV_UnreachableKV(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
t.Fatalf("persist internal identity: %v", err)
}
// A loopback port with nothing listening: connect must fail fast and wrapped.
_, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1)
if err == nil {
t.Fatalf("connecting to a dead endpoint must error")
}
}
// TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca
// is refused so the allowlist write never travels in cleartext (audit 0008 N6,
// same guard as migrate-to-kv).
func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
t.Fatalf("persist internal identity: %v", err)
}
_, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1)
if err == nil {
t.Fatalf("remote target without --ca must be refused")
}
}
+96 -6
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -24,6 +25,7 @@ import (
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership"
)
@@ -45,6 +47,14 @@ func main() {
runMigrateCLI(os.Args[2:])
return
}
// `membershipd bot add ...` provisions a bus identity for an automated process
// in one command (mint identity + register + write 0600 credentials). It shares
// the same trusted-host model and store plumbing as the user CLI, so it is
// dispatched here before the server flag set parses os.Args.
if len(os.Args) > 1 && os.Args[1] == "bot" {
runBotCLI(os.Args[2:])
return
}
var (
bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers")
@@ -53,8 +63,11 @@ func main() {
dbPath = flag.String("db", "./local_files/unibus.db", "SQLite database path")
storeDir = flag.String("store-dir", "./local_files/blobs", "blob store directory")
natsPort = flag.Int("nats-port", 4250, "embedded NATS listen port (when --nats-url empty)")
wsPort = flag.Int("ws-port", 0, "WebSocket listen port for browser clients (nats.ws); 0 = disabled. Enables the browser-native uniweb client (issue uniweb/0001)")
natsStore = flag.String("nats-store", "./local_files/jetstream", "embedded JetStream store dir")
busAuth = flag.String("bus-auth", "off", "control-plane auth rollout: off|soft|enforce (feature flag bus-auth)")
corsOrigins = flag.String("cors-origins", "", "comma-separated CORS allowlist of browser origins permitted to call the control plane (e.g. http://localhost:5173,https://chat.example.com); empty = CORS off. Enables the browser-native uniweb client (issue uniweb/0001)")
trustedProxies = flag.String("trusted-proxies", "", "comma-separated IPs/CIDRs of reverse proxies whose X-Forwarded-For/X-Real-IP is trusted for the per-IP rate limit; empty = trust the direct connection only. Set to the same-origin proxy's address (e.g. the Caddy node) so the rate limit stays per-client behind the proxy")
tlsCert = flag.String("tls-cert", "", "PATH to the NATS server certificate (deploy/tls/server.crt); enables TLS on the embedded data plane")
tlsKey = flag.String("tls-key", "", "path to the NATS server private key (deploy/tls/server.key); required with --tls-cert")
// Cluster (issue 0003a): empty --cluster-name keeps the server standalone.
@@ -83,6 +96,17 @@ func main() {
// "kv" puts rooms/members/keys/users in replicated JetStream KV so any node
// in the cluster serves the same state.
storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)")
// Persisted internal service identity (issue 0011 gaps, GAP A): when set, the
// privileged internal identity used to manage JetStream is LOADED from this
// file (generated and persisted on first start) instead of being a fresh
// ephemeral key each boot. Persisting it is what lets `membershipd user add
// --store kv` write the replicated allowlist of a LIVE cluster: that CLI,
// run over loopback on a node, loads the SAME identity and presents the nkey
// this node's authenticator already grants full permissions. Empty keeps the
// ephemeral-per-process behavior (single-node/dev default, unchanged). The
// file holds a private key: it is written 0600 and belongs next to the node's
// TLS keys (deploy keeps it under secrets/, gitignored).
internalIDFile = flag.String("internal-id-file", "", "path to a persisted internal service identity (JSON); enables `membershipd user add --store kv` against the live cluster. Empty = ephemeral per-process identity (dev default)")
)
flag.Parse()
@@ -126,6 +150,16 @@ func main() {
decentralized := *storeBackend == "kv"
needJS := clustered || decentralized
enforce := authMode == membership.AuthEnforce
embedded := *natsURL == ""
// The control plane also needs a privileged JetStream client to OWN the durable
// per-room streams of persisted rooms (ensure the stream on room creation so the
// subject is captured from the first message — even from a JetStream-less browser
// client — and read it back for GET /rooms/{id}/history). The embedded NATS
// always ships JetStream, so open the client whenever we run embedded, even for a
// standalone SQLite node. For an EXTERNAL NATS we only reach for JetStream when a
// cluster/KV feature explicitly requires it (unchanged), so an operator-managed
// external deployment without those features behaves exactly as before.
openJS := needJS || embedded
// Internal service identity (issue 0006a): when the embedded data plane enforces
// auth, membershipd must still connect to its OWN server to manage JetStream.
@@ -135,10 +169,22 @@ func main() {
// the server is embedded), so a standalone or non-enforce node is unchanged.
var internalID cs.Identity
var internalPubHex string
if needJS && enforce && *natsURL == "" {
internalID, err = cs.GenerateIdentity()
if err != nil {
log.Fatalf("generate internal identity: %v", err)
if openJS && enforce && embedded {
if *internalIDFile != "" {
// Persisted identity: load it, generating + writing it (0600) on first
// start. A stable internal key is what `user add --store kv` presents to
// add users to a live cluster (GAP A); rotate it by deleting the file and
// restarting.
internalID, err = client.LoadOrCreateIdentity(*internalIDFile)
if err != nil {
log.Fatalf("load internal service identity %q: %v", *internalIDFile, err)
}
log.Printf("internal service identity: persisted (%s)", *internalIDFile)
} else {
internalID, err = cs.GenerateIdentity()
if err != nil {
log.Fatalf("generate internal identity: %v", err)
}
}
internalPubHex = hex.EncodeToString(internalID.SignPub)
}
@@ -243,6 +289,24 @@ func main() {
cfg.TLS = tlsCfg
log.Printf("NATS TLS: ON (%s)", *tlsCert)
}
if *wsPort > 0 {
// Expose a WebSocket listener so browser clients (uniweb via nats.ws) reach
// the data plane directly. It reuses the data-plane TLS (wss:// when TLS is
// on, ws:// for a loopback dev stack) and the same browser-origin allowlist
// as the control-plane CORS, so opening the data plane to the browser and
// opening the control plane to it are governed by one --cors-origins list.
scheme := "ws"
if cfg.TLS != nil {
scheme = "wss"
}
cfg.Websocket = &embeddednats.WebsocketConfig{
Host: *bind,
Port: *wsPort,
TLS: cfg.TLS,
AllowedOrigins: splitRoutes(*corsOrigins),
}
log.Printf("NATS WebSocket: ON (%s://%s:%d)", scheme, *bind, *wsPort)
}
ns, err = embeddednats.StartServer(cfg)
if err != nil {
log.Fatalf("start embedded nats: %v", err)
@@ -262,9 +326,9 @@ func main() {
// only client that can connect in this window (the holder still denies everyone
// else; the internal identity bypasses the store).
var js jetstream.JetStream
if needJS {
if openJS {
var internalNC *nats.Conn
if *natsURL == "" {
if embedded {
internalNC, js, err = connectInternalJS(ns, internalID, enforce)
} else {
internalNC, js, err = connectExternalJS(natsClientURL, *caFile)
@@ -286,6 +350,14 @@ func main() {
}
srv := membership.NewServer(store, blobs, authMode)
// Wire the privileged JetStream context so the control plane owns persisted
// rooms' durable streams (ensure on create + serve GET /rooms/{id}/history). The
// stream replication factor matches the control-plane KV replication so a room's
// history is as available as its metadata. js is nil only for an external NATS
// without a cluster/KV feature, where history degrades to empty (see openJS).
if js != nil {
srv.SetJetStream(js, *kvReplicas)
}
// On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS
// has no per-subject ACL, so cleartext content would be readable by any
// registered peer. Forcing E2E keeps message content confidential regardless
@@ -305,6 +377,24 @@ func main() {
Cluster: clustered,
Store: *storeBackend,
}
// CORS allowlist for the browser-native client (uniweb). splitRoutes is reused
// as a generic comma-list parser (trim + drop empties). Empty flag => empty
// slice => CORS stays off, identical to the pre-flag behavior.
if origins := splitRoutes(*corsOrigins); len(origins) > 0 {
srv.AllowedOrigins = origins
log.Printf("CORS: allowing %d browser origin(s): %s", len(origins), strings.Join(origins, ", "))
}
// Trusted reverse proxies for the per-IP rate limit. Behind the same-origin
// Caddy proxy every request arrives with the proxy's IP, which would collapse
// the per-IP rate limit into one bucket for the whole world; naming the proxy
// here lets the limiter believe its X-Forwarded-For and key on the real client
// instead. Empty flag => trust the direct connection only (unchanged behavior).
if proxies := splitRoutes(*trustedProxies); len(proxies) > 0 {
if err := srv.SetTrustedProxies(proxies); err != nil {
log.Fatalf("invalid --trusted-proxies: %v", err)
}
log.Printf("rate limit: trusting forwarded client IP from proxies: %s", strings.Join(proxies, ", "))
}
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
// share its nonce store across the cluster, or a request accepted on one node
+83 -16
View File
@@ -1,7 +1,7 @@
package main
import (
"encoding/hex"
"errors"
"flag"
"fmt"
"os"
@@ -50,13 +50,26 @@ commands:
list List all registered users
revoke Revoke a user (denies access on both planes immediately)
store backends (--store):
sqlite local SQLite database (default; seeds the first admin offline)
kv the RUNNING cluster's replicated JetStream KV allowlist, via the
privileged internal connection — add users with the cluster live,
no stop-seed-restart needed (run over loopback/SSH on a node)
examples:
membershipd user add --handle alice --sign-pub <64-hex> --role admin
membershipd user list
membershipd user add --store kv --handle bob --sign-pub <64-hex> --role member
membershipd user list --store kv
membershipd user revoke <64-hex>
common flags:
--db <path> SQLite database path (default ./local_files/unibus.db)
--db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
--store kv flags (defaults assume an on-node invocation):
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
--kv-replicas <n> KV replication factor, match the cluster (default 3)
`)
}
@@ -76,16 +89,56 @@ func openStore(path string) membership.Store {
// validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in
// hex (64 hex chars). Catching this here turns a silent "authorized nobody" into
// an explicit error at seed time.
// an explicit error at seed time. It delegates to membership.ValidateSignPubHex
// so the CLI and the HTTP user-management handlers share one rule.
func validateSignPubHex(signPub string) error {
b, err := hex.DecodeString(signPub)
if err != nil {
return fmt.Errorf("sign-pub is not valid hex: %w", err)
return membership.ValidateSignPubHex(signPub)
}
// kvFlags holds the connection flags shared by the --store kv path of the user
// subcommands. registerKVFlags wires them onto a flag set so add and list expose
// an identical interface.
type kvFlags struct {
store *string
natsURL *string
internalID *string
ca *string
replicas *int
}
func registerKVFlags(fs *flag.FlagSet) kvFlags {
return kvFlags{
store: fs.String("store", "sqlite", "user store backend: sqlite (local DB) | kv (the live cluster's replicated allowlist)"),
natsURL: fs.String("nats-url", defaultClusterNatsURL, "cluster NATS url for --store kv"),
internalID: fs.String("internal-id-file", defaultInternalIDFile, "persisted internal service identity for --store kv"),
ca: fs.String("ca", defaultClusterCAFile, "CA cert pinning TLS on the --store kv NATS connection"),
replicas: fs.Int("kv-replicas", 3, "KV replication factor for --store kv (match the cluster)"),
}
if len(b) != 32 {
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
}
// resolveStore returns the membership store for the chosen backend plus a cleanup
// func. For --store kv it opens the privileged connection to the live cluster; for
// sqlite it opens the local file. It exits the process with a clear message on any
// failure (a dead NATS, a missing identity file), so a broken --store kv add fails
// loudly instead of silently — Error case of the GAP A DoD. The returned *kvConn
// is non-nil only for the kv backend (so the caller can report replication).
func resolveStore(cmd string, kf kvFlags, dbPath string) (membership.Store, *kvConn, func()) {
switch *kf.store {
case "sqlite":
store := openStore(dbPath)
return store, nil, func() { store.Close() }
case "kv":
kv, err := connectKVStore(*kf.natsURL, *kf.internalID, *kf.ca, *kf.replicas)
if err != nil {
fmt.Fprintf(os.Stderr, "membershipd %s: --store kv: %v\n", cmd, err)
os.Exit(1)
}
return kv.store, kv, kv.Close
default:
fmt.Fprintf(os.Stderr, "membershipd %s: --store must be \"sqlite\" or \"kv\", got %q\n", cmd, *kf.store)
os.Exit(2)
return nil, nil, func() {}
}
return nil
}
func userAdd(args []string) {
@@ -94,6 +147,7 @@ func userAdd(args []string) {
signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)")
role := fs.String("role", membership.RoleMember, "role: admin or member")
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
_ = fs.Parse(args)
if *handle == "" || *signPub == "" {
@@ -105,23 +159,35 @@ func userAdd(args []string) {
os.Exit(2)
}
store := openStore(*dbPath)
defer store.Close()
store, kv, closeStore := resolveStore("user add", kf, *dbPath)
defer closeStore()
if err := store.AddUser(*signPub, *handle, *role); err != nil {
if errors.Is(err, membership.ErrUserExists) {
// Idempotency contract (GAP A): re-adding the same key is an EXPLICIT,
// non-destructive error — the existing row is left untouched (no silent
// upsert that could flip a role or clobber status, which would corrupt the
// allowlist). To replace a user, `user revoke <key>` then add again.
fmt.Fprintf(os.Stderr, "membershipd user add: user %s already registered (unchanged); revoke it first to replace\n", *signPub)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
os.Exit(1)
}
fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role)
if kv != nil {
reportKVReplication(kv.js)
}
}
func userList(args []string) {
fs := flag.NewFlagSet("user list", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
_ = fs.Parse(args)
store := openStore(*dbPath)
defer store.Close()
store, _, closeStore := resolveStore("user list", kf, *dbPath)
defer closeStore()
users, err := store.ListUsers()
if err != nil {
@@ -143,6 +209,7 @@ func userList(args []string) {
func userRevoke(args []string) {
fs := flag.NewFlagSet("user revoke", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
// Go's flag package stops at the first non-flag argument, so `revoke <key>
// --db path` would otherwise leave --db unparsed. Pull a leading positional
@@ -167,8 +234,8 @@ func userRevoke(args []string) {
os.Exit(2)
}
store := openStore(*dbPath)
defer store.Close()
store, _, closeStore := resolveStore("user revoke", kf, *dbPath)
defer closeStore()
if err := store.RevokeUser(signPub); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// users_kv.go is the `--store kv` half of the user administration CLI (issue 0011
// gaps, GAP A): adding and listing bus users directly against the RUNNING
// cluster's replicated JetStream KV allowlist, with no need to stop the cluster,
// seed a standalone node, and restart (the procedure the 0011 deploy required).
//
// The mechanism is the cluster's own privileged internal connection. Under
// enforce every bus user is confined by the per-subject ACL to the JetStream API
// of its own rooms, so no ordinary identity may touch the control-plane buckets
// (KV_UNIBUS_*). The ONLY identity the authenticator grants full JetStream
// permissions is membershipd's internal service identity. By persisting that
// identity to a file (membershipd --internal-id-file) the same key becomes
// available to this CLI, which presents it as its NATS nkey and is therefore
// recognized as the privileged internal client and allowed to read/write the KV.
//
// Intended invocation is over loopback on a cluster node (SSH): the data-plane
// TLS certificate's SAN covers 127.0.0.1/localhost and the internal identity file
// lives 0600 next to the node's TLS keys. Using the file requires root on the
// node, which already implies full control of that node — so co-locating it adds
// no practical exposure beyond what the TLS server key and cluster password
// already represent.
// defaultClusterNatsURL is the node-local NATS listener. The CLI is meant to run
// on a cluster node over SSH, talking to that node's own embedded server.
const defaultClusterNatsURL = "nats://127.0.0.1:4250"
// Deploy-default paths for the privileged identity and the data-plane CA, so an
// on-node invocation needs only --handle/--sign-pub/--role. Override for other
// layouts.
const (
defaultInternalIDFile = "/opt/unibus/secrets/internal.id"
defaultClusterCAFile = "/opt/unibus/tls/ca.crt"
)
// kvConn bundles the privileged NATS connection to a live cluster and the
// KV-backed control-plane store opened over it. Close releases both.
type kvConn struct {
nc *nats.Conn
js jetstream.JetStream
store membership.Store
}
func (k *kvConn) Close() {
if k == nil {
return
}
if k.store != nil {
_ = k.store.Close()
}
if k.nc != nil {
k.nc.Close()
}
}
// connectKVStore opens the privileged internal connection to the cluster's NATS
// and the JetStream KV control-plane store on top of it. internalIDFile is the
// membershipd-persisted internal service identity whose nkey the authenticator
// grants full permissions; caPath pins the data-plane TLS (empty only for a
// non-TLS dev cluster). A non-loopback target without --ca is refused, mirroring
// migrate-to-kv (audit 0008 N6): the allowlist write must not travel in cleartext.
func connectKVStore(natsURL, internalIDFile, caPath string, replicas int) (*kvConn, error) {
if internalIDFile == "" {
return nil, fmt.Errorf("--internal-id-file is required for --store kv (the privileged identity membershipd persists with --internal-id-file)")
}
// Confidentiality guard: a remote NATS without TLS would expose the allowlist
// (handles/roles/sign-pubs) and the privileged nkey handshake in cleartext.
if !isLoopbackURL(natsURL) && caPath == "" {
return nil, fmt.Errorf("refusing to connect to remote %q without --ca: the allowlist write would travel in cleartext — pin TLS with --ca, or run over a loopback --nats-url on a node", natsURL)
}
id, err := client.LoadIdentity(internalIDFile)
if err != nil {
return nil, fmt.Errorf("load internal identity: %w", err)
}
nkeyPub, nkeySign, err := busauth.ClientNkey(id.SignPriv)
if err != nil {
return nil, fmt.Errorf("derive nkey from internal identity: %w", err)
}
opts := []nats.Option{
nats.Name("membershipd-user-cli"),
nats.Nkey(nkeyPub, nkeySign),
}
if caPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
if err != nil {
return nil, fmt.Errorf("load CA %q: %w", caPath, err)
}
opts = append(opts, nats.Secure(tlsCfg))
}
nc, err := nats.Connect(natsURL, opts...)
if err != nil {
return nil, fmt.Errorf("connect cluster NATS %q: %w", natsURL, err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, fmt.Errorf("jetstream: %w", err)
}
store, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: replicas})
if err != nil {
nc.Close()
return nil, fmt.Errorf("open KV control-plane store: %w", err)
}
return &kvConn{nc: nc, js: js, store: store}, nil
}
// reportKVReplication prints the replication status of the allowlist bucket
// stream (KV_UNIBUS_users) right after a write, so the operator sees the add
// landed on a quorum and replicated to the followers — executable evidence that
// the live-cluster add is HA, not single-node. Best-effort: a read failure is a
// note, not an error (the write itself already succeeded).
func reportKVReplication(js jetstream.JetStream) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := js.Stream(ctx, "KV_UNIBUS_users")
if err != nil {
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
return
}
info, err := st.Info(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
return
}
if info.Cluster == nil {
fmt.Printf("KV_UNIBUS_users: standalone (R1, no cluster replication); msgs=%d\n", info.State.Msgs)
return
}
current := 0
for _, r := range info.Cluster.Replicas {
if r.Current {
current++
}
}
fmt.Printf("KV_UNIBUS_users: leader=%s followers_current=%d/%d msgs=%d\n",
info.Cluster.Leader, current, len(info.Cluster.Replicas), info.State.Msgs)
}
+84
View File
@@ -0,0 +1,84 @@
# Same-origin reverse proxy for the browser-native uniweb chat client.
#
# This is the self-contained fragment that exposes uniweb on magnus
# (organic-machine.com). It is merged into magnus's /etc/caddy/Caddyfile, which
# also hosts unrelated services; only this service's blocks are versioned here
# (the other vhosts carry basic-auth secrets that do not belong in git). The live
# file imports the shared (security_headers) snippet that is duplicated below so
# this fragment validates on its own.
#
# One origin fronts the whole app so the SPA and the bus share an origin: no CORS,
# and the unibus cluster node IPs stay hidden behind this proxy. Caddy obtains and
# renews the Let's Encrypt certificate automatically (the *.organic-machine.com
# wildcard A record points here).
#
# / -> the static SPA (uniweb web/dist) with a single-page-app fallback
# /api/* -> the signed HTTPS control plane (membershipd :8470), prefix stripped
# /nats -> the NATS-over-WebSocket data plane (:8485 magnus / :8480 peers)
#
# Upstreams speak TLS with the bus's own self-signed CA, so Caddy skips upstream
# verification (the hop is still encrypted). The control plane signs requests over
# the UNPREFIXED path, so /api MUST be stripped (handle_path) or signatures fail.
#
# The membershipd nodes must run with the same-origin host in --cors-origins (so
# the NATS WebSocket Origin check accepts it) and with --trusted-proxies naming
# this Caddy node (127.0.0.1,::1,135.125.201.30) so the per-IP rate limit keys on
# the real client behind the proxy instead of collapsing to the proxy's one IP.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
-Server
}
}
chat-c200aa64c3125ce8b5f068e0.organic-machine.com {
import security_headers
# Control plane: strip /api so /api/rooms reaches membershipd as /rooms (the
# path the client signs). Prefer the local node; lb_try_duration retries the
# next node within the request on a dial error (safe: a refused connection sent
# no bytes, so even a POST cannot double-apply), and fail_duration plus the
# active /healthz check take a down node out of rotation.
handle_path /api/* {
reverse_proxy https://127.0.0.1:8470 https://141.94.69.66:8470 https://51.91.100.142:8470 {
transport http {
tls_insecure_skip_verify
}
lb_policy first
lb_try_duration 5s
lb_try_interval 250ms
fail_duration 10s
health_uri /healthz
health_interval 10s
health_timeout 5s
}
}
# Data plane: NATS over WebSocket. Strip /nats so the upgrade reaches the ws
# listener at its root. Caddy proxies the WebSocket upgrade natively. The ws
# listener speaks TLS on :8485 (magnus; :8480 is taken by unibus_admin there)
# and :8480 on the peers. Passive failover only (an HTTP health probe would be
# rejected by the NATS ws endpoint).
handle_path /nats* {
reverse_proxy https://127.0.0.1:8485 https://141.94.69.66:8480 https://51.91.100.142:8480 {
transport http {
tls_insecure_skip_verify
}
lb_policy first
lb_try_duration 5s
lb_try_interval 250ms
fail_duration 30s
}
}
# SPA: static files with a client-side-routing fallback to index.html.
handle {
root * /opt/uniweb/dist
try_files {path} /index.html
file_server
}
}
+201 -39
View File
@@ -5,9 +5,12 @@ This directory holds the material to bring up unibus as a **3-node cluster**
plane (rooms/members/keys/users on JetStream KV + the anti-replay nonce bucket)
survives the loss of any one node (quorum 2/3).
> **The agent that authored this never touched a VPS.** Every step that changes a
> remote host is marked **HUMAN** and is executed by the operator. `deploy-cluster.sh`
> defaults to a dry run.
> **Status: this cluster is DEPLOYED in production** (magnus + homer + datardos,
> R3, enforce+ACL+TLS) — see report 0011. The runbook below was authored before any
> VPS existed and has since been **corrected against the real deploy** (report 0012):
> the start ordering, the R1→R3 reality, and the live user-add path were all wrong
> or missing. Steps that change a remote host are marked **HUMAN**; `deploy-cluster.sh`
> still defaults to a dry run.
## Files
@@ -22,18 +25,22 @@ Generated keys/secrets (`out/`, `build/`, `secrets/`) are **gitignored** — the
secret and never leave the operator's trusted machine except over the secure
rsync channel.
## Topology
## Topology (as deployed, report 0011)
| Node | SSH | Public IP | WireGuard IP | Role |
|---|---|---|---|---|
| magnus | `magnus` | `<MAGNUS_PUBLIC_IP>` | `<MAGNUS_WG_IP>` | seed (first up) |
| homer | `homer` | `141.94.69.66` | `<HOMER_WG_IP>` | replica |
| datardos | `dd` | `51.91.100.142` | `<DATARDOS_WG_IP>` (10.21.0.x) | replica |
| Node | SSH | Public IP | Role |
|---|---|---|---|
| magnus | `magnus` (root) | `135.125.201.30` | node — **= organic-machine.com = `om`**, the critical host (caddy + gitea + registry-api + monitoring); the bus runs alongside, untouched |
| homer | `homer` (ubuntu+sudo) | `141.94.69.66` | node |
| datardos | `dd` (ubuntu+sudo) | `51.91.100.142` | node |
The route layer (server-to-server) prefers the **WireGuard mesh**
(`ROUTE_NETWORK=wg`); the client data plane and the HTTP control plane are reached
over the public IPs. The route CA is **separate** from the client CA, so a client
cert can never be presented to the route port.
`ROUTE_NETWORK=public`, **not `wg`**: there is no WireGuard mesh between the three
nodes (homer and datardos do not even have the `wg` binary; om's only WG peers are
the operator's PCs). The server-to-server routes therefore travel over the public
IPs, protected by the **separate cluster route CA** (mutual route TLS) — a client
data-plane cert can never be presented to the route port. The client data plane and
the HTTP control plane are also reached over the public IPs. There is no fixed
"seed" node: with R3 the three are peers (see "Bring up" for why a lone node cannot
self-serve).
## Prerequisites (HUMAN, once)
@@ -93,25 +100,48 @@ SEED
> The KV written here lives in `./local_files/jetstream`, which the cluster unit
> reuses (`--nats-store` default), so the admin is present when the enforce cluster
> starts. Additional users are added the same loopback way until a
> `user add --store kv` exists (see GAP in report 0009).
> starts. This loopback bootstrap is needed ONLY for the very first admin (the
> chicken-and-egg). **Every user after that is added with the cluster live** — no
> stop-seed-restart — via `user add --store kv` (see "Add users to the live
> cluster" below, report 0012).
## Bring up (HUMAN — staggered)
## Bring up (HUMAN)
Bring up the seed first, then the replicas one at a time, checking each joins.
> **CORRECTION (report 0012).** The original instruction — "start magnus alone and
> verify healthz, then add the others" — is **WRONG and will look like a hung
> deploy.** A 3-node JetStream cluster forms a RAFT meta-group that needs a quorum
> (2 of 3) to elect a leader. A single started node has no quorum, so its JetStream
> meta never becomes current: `--store kv` blocks creating the KV buckets and
> **`/healthz` never returns ok** until a second node joins. Waiting for magnus to
> "go green" before starting the others therefore deadlocks the rollout.
Start the nodes so a quorum forms. On a **clean cluster** the simplest correct
procedure is to start all three close together and let the meta-group converge:
```bash
# 1. Seed node (after the seed step above).
ssh root@magnus 'systemctl enable --now membershipd-cluster'
ssh root@magnus 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt'
# Start all three (order does not matter); each blocks on the others until a
# 2/3 quorum elects a JetStream meta leader, then the KV buckets are created.
for h in magnus homer datardos; do ssh "$h" 'sudo systemctl enable --now membershipd-cluster'; done
# 2. Replicas, one at a time.
ssh root@homer 'systemctl enable --now membershipd-cluster'
ssh root@datardos 'systemctl enable --now membershipd-cluster'
# Only NOW does healthz return ok — once the meta-group has a leader (give it
# ~10-30s on a cold start). Poll, do not assume the first node is broken.
for h in magnus homer datardos; do
echo "== $h =="; ssh "$h" 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt || echo "(not ready yet — needs quorum)"'
done
```
> Initial rollout runs at **R1** (`KV_REPLICAS=1` in `nodes.env`): the buckets live
> on the seed only. This is NOT HA yet — see "Scale to R3".
A **staggered** start also works, but only because `membershipd`'s KV open RETRIES
the bucket creation for a 120s bootstrap budget (issue 0006g, fix #3): the first
node sits in that retry loop — NOT serving healthz — until the second node makes a
quorum, then both converge and the third catches up. Either way, a lone node never
self-serves; do not gate the next node's start on the previous one's healthz.
> A cold multi-node start only converges because of **three cold-start fixes**
> (report 0011): route pooling off (`PoolSize=-1`), `NoAdvertise=true` (Docker
> bridge IPs not gossiped), and the KV-open retry loop above. Without them the
> meta-group re-elects leaders forever and bucket creation hangs. If a fresh
> cluster will not form, confirm the running binary contains these fixes before
> touching config.
## Promote an existing single-node (SQLite) deployment (HUMAN, optional)
@@ -137,11 +167,80 @@ ssh root@magnus 'nats --server nats://127.0.0.1:4250 server list' # 3 servers,
A healthy cluster shows 3 routed servers and a JetStream meta-group with a leader.
## Scale to R3 (HUMAN — real HA)
## Add users to the live cluster (HUMAN — `user add --store kv`)
Once all three nodes are up and routed, raise the replication factor of every
control-plane stream from 1 to 3 IN PLACE (no data loss), then flip `KV_REPLICAS=3`
in `nodes.env` so future (re)deploys keep it:
With the cluster up, add (and revoke) bus users **without stopping anything**,
directly against the replicated KV allowlist. This replaces the stop-seed-restart
procedure the original runbook implied for every user beyond the first admin.
The mechanism is the cluster's own **privileged internal connection**: under
`enforce` every bus user is confined by the per-subject ACL to its own rooms, so no
ordinary identity may write the control-plane buckets. The only identity the
authenticator grants full JetStream permissions is `membershipd`'s internal service
identity. The unit persists that identity to `${INTERNAL_ID_FILE}`
(`/opt/unibus/secrets/internal.id`, 0600) via `--internal-id-file`, so the same key
is available to the CLI. Run the CLI **on a node, over loopback** (the data-plane
TLS cert SAN covers `127.0.0.1`); reading the identity file requires root on that
node, which already implies full control of it, so this adds no practical exposure.
```bash
# Add a member to the live cluster's replicated allowlist (run on any node).
ssh root@magnus 'sudo /opt/unibus/membershipd user add --store kv \
--handle alice --role member --sign-pub <64-hex-ed25519-pub>'
# -> added user "alice" (...) role=member
# -> KV_UNIBUS_users: leader=<node> followers_current=2/2 msgs=N (replicated, HA)
# List / revoke against the same live KV:
ssh root@magnus 'sudo /opt/unibus/membershipd user list --store kv'
ssh root@magnus 'sudo /opt/unibus/membershipd user revoke --store kv <64-hex-ed25519-pub>'
```
Defaults assume an on-node invocation (`--nats-url nats://127.0.0.1:4250`,
`--internal-id-file /opt/unibus/secrets/internal.id`, `--ca /opt/unibus/tls/ca.crt`,
`--kv-replicas 3`). Semantics:
- **Idempotent / non-destructive**: re-adding the same key is an explicit
`already registered` error (exit 1), never a silent overwrite — a re-add cannot
flip a member to admin. To replace a user, `revoke` then add.
- **HA**: the write commits through the JetStream quorum, so it succeeds even with
one node down (2/3); the printed `followers_current` shows replication.
- **No hard delete**: `revoke` flips status to `revoked` (denied on both planes,
auditable); the KV has no row deletion, matching the SQLite store.
> **Rollout note (report 0012):** the live verification deployed this binary +
> `--internal-id-file` to **datardos only** (the non-critical node). magnus and
> homer still run the 0011 binary. To make the capability available (and the unit)
> on all three — recommended, the posture is identical so there is no urgency — roll
> the new binary with backups, one node at a time, verifying healthz between each:
> ```bash
> for h in homer magnus; do
> ssh "$h" 'sudo cp -a /opt/unibus/membershipd /opt/unibus/membershipd.bak' # backup
> scp build/membershipd "$h:/tmp/m" && ssh "$h" 'sudo install -o ubuntu -g ubuntu -m0775 /tmp/m /opt/unibus/membershipd'
> # add INTERNAL_ID_FILE=/opt/unibus/secrets/internal.id to /opt/unibus/cluster.env
> # add `--internal-id-file ${INTERNAL_ID_FILE} \` to the unit before `--store kv`
> ssh "$h" 'sudo systemctl daemon-reload && sudo systemctl restart membershipd-cluster'
> ssh "$h" 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt' # green before next
> done
> ```
> (`deploy-cluster.sh` + the unit template already emit `INTERNAL_ID_FILE` and the
> flag, so a fresh `./deploy-cluster.sh --yes` is correct for all three.)
## Replication: go straight to R3 (HUMAN — real HA)
> **CORRECTION (report 0012).** The original "start at R1, then scale to R3" plan
> assumed R1 is a usable interim state. **It is not, in this cluster.** At R1 all six
> control-plane buckets (`KV_UNIBUS_users/rooms/members/room_keys/rooms_by_member`
> + `KV_UNIBUS_nonces`) live on a SINGLE node — a hard **SPOF for authentication**:
> if that node dies, the nonce/KV control plane is unreachable and EVERY
> authenticated request fails closed (auth DoS). Worse, the cold multi-node start
> only converges at all because of the three cold-start fixes (see "Bring up"); the
> real deploy never ran a healthy R1 and **jumped straight to R3 once the cluster
> formed.** Treat R1 as a transient artifact of bucket creation, not a milestone.
The deployed config already sets `KV_REPLICAS=3` in `nodes.env`. If buckets were
created at R1 (e.g. only one node was up when `--store kv` first opened them), raise
every control-plane stream to R3 IN PLACE (no data loss) once all three nodes are
routed:
```bash
for s in KV_UNIBUS_users KV_UNIBUS_rooms KV_UNIBUS_members KV_UNIBUS_room_keys \
@@ -151,27 +250,32 @@ done
# (also OBJ_UNIBUS_blobs if the object store is in use)
```
Until this is done, R1 means the seed node is a **single point of failure for
authentication**: if it dies, the nonce/KV control plane is unreachable and every
authenticated request fails closed (auth DoS). R1 is a rollout step, not HA.
After this each bucket shows `followers_current=2/2` (quorum 2/3). The
`user add --store kv` command prints that figure for `KV_UNIBUS_users` on every add,
which is a cheap live HA check.
## Chaos test (HUMAN — requires the 3 live VPS; NOT run here)
## Chaos test (HUMAN — requires the 3 live VPS)
Validate quorum tolerance after R3:
```bash
# Kill one node; the cluster keeps serving (quorum 2/3).
ssh root@datardos 'systemctl stop membershipd-cluster'
# Kill one node; the cluster keeps serving (quorum 2/3). On ubuntu nodes use sudo.
ssh dd 'sudo systemctl stop membershipd-cluster'
# -> clients fail over (multiple seed URLs); reads/writes still succeed.
ssh root@datardos 'systemctl start membershipd-cluster' # rejoins, catches up
ssh dd 'sudo systemctl start membershipd-cluster' # rejoins, catches up
# Kill two nodes; quorum is LOST — the control plane should fail CLOSED (deny),
# never fail open. Verify a request is rejected, not silently served.
```
This network-level chaos test (kill 1/3, kill 2/3, partition/split-brain) is part
of the deploy validation (issue 0003f) and runs against the real VPS — it is
deliberately out of scope for the authoring agent.
> **Validated (report 0012).** The 0011 chaos run checked only the control plane
> (healthz + meta/stream-leader failover + KV readable with 2/3). Report 0012 added
> the missing data-plane proofs against the live cluster: a real authenticated
> client (`cmd/clientcheck`, operator identity, nkey+TLS) creating an E2E room and
> publishing/subscribing — including a node stopped mid-stream, where the client
> failed over to a survivor and kept receiving with zero loss (quorum 2/3) — and
> `user add --store kv` committing with one node (the KV leader) down. The kill-2/3
> fail-closed case remains a documented manual step.
## Rollback
@@ -179,3 +283,61 @@ deliberately out of scope for the authoring agent.
the unit and start it without `--store kv`/`--cluster-name`; the KV buckets remain
for a later retry. To rotate the cluster CA, re-run `generate-cluster-certs.sh
--force` and re-stage (every node must get the new `cluster-ca.crt` together).
## NATS server metrics (loopback monitoring — optional)
The embedded NATS server can expose its own monitoring HTTP endpoint so a local
scraper reads server-level metrics that `/healthz` does not surface: msgs/s,
connections, slow consumers, memory, KV bucket message counts, the RAFT leader per
stream and per-stream restarts. This feeds the `unibus-nats` dashboard in
`fleet_monitoring` (the scraper hits `127.0.0.1:8222/varz|/connz|/jsz` over
loopback and pushes to VictoriaMetrics).
The endpoint is opened by the **dedicated** environment toggle `UNIBUS_NATS_MONITOR=1`
(0.11.0+ binary). It is **decoupled** from `UNIBUS_NATS_DEBUG`: it opens the
monitoring endpoint WITHOUT enabling the verbose nats-server debug log, so no room
subjects or routing metadata leak to journald (keeps the hardened posture, issue
0007). The endpoint binds `127.0.0.1:8222` **only** — the binary hardcodes the
loopback bind, so it is never reachable from the network and needs no auth. Never
use `UNIBUS_NATS_DEBUG` in production just to get the endpoint.
### Enable it (HUMAN — requires the 0.11.0+ binary on the node)
The clean way is the additive systemd drop-in in this directory:
```bash
# On each node, AFTER the 0.11.0+ binary is in /opt/unibus/membershipd:
ssh <node> 'sudo mkdir -p /etc/systemd/system/membershipd-cluster.service.d'
scp membershipd-cluster.service.d/nats-monitor.conf <node>:/tmp/nats-monitor.conf
ssh <node> 'sudo cp /tmp/nats-monitor.conf /etc/systemd/system/membershipd-cluster.service.d/ \
&& sudo systemctl daemon-reload && sudo systemctl restart membershipd-cluster'
```
(Equivalently, add `UNIBUS_NATS_MONITOR=1` to `/opt/unibus/cluster.env`, which the
unit already sources via `EnvironmentFile`; the drop-in is preferred because it is
self-documenting and does not edit the generated env file.)
### Rolling restart with the R3 reconvergence gate (CRITICAL)
`systemctl restart membershipd-cluster` restarts that node's JetStream RAFT member.
**Never restart two nodes at once** — that would drop the cluster below quorum
(2/3) and fail the control plane closed. Roll **one node at a time**, in the order
`magnus → homer → datardos`, and between each node wait until the cluster has
reconverged to R3 (every control-plane bucket back to `followers_current=2/2`):
```bash
# After restarting ONE node, gate on R3 reconvergence before touching the next:
ssh root@magnus 'for s in KV_UNIBUS_users KV_UNIBUS_rooms KV_UNIBUS_members \
KV_UNIBUS_room_keys KV_UNIBUS_rooms_by_member KV_UNIBUS_nonces; do
nats --server nats://127.0.0.1:4250 stream info "$s" -j \
| jq -r --arg s "$s" \"\\($s): replicas=\\(.cluster.replicas|length) leader=\\(.cluster.leader)\"
done'
# Proceed to the next node ONLY when all six show 3 replicas with a leader
# (i.e. 2/2 followers current). Also confirm healthz is green on the just-restarted
# node first:
ssh <node> 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt'
```
This restart is normally **not** done as a standalone step: the 0.11.0 binary that
carries the flag is rolled to the three nodes in the consolidated rollout, and the
drop-in is installed during that same rolling restart.
+27 -8
View File
@@ -69,6 +69,12 @@ routes_for() {
echo "==> [2/3] stage each node (REMOTE_DIR=$REMOTE_DIR)"
for row in "${CLUSTER_NODES[@]}"; do
read -r name ssh _pub _wg <<<"$row"
# Rolling deploy: DEPLOY_ONLY=<name> stages just that node, so a new binary can be
# rolled out one node at a time (the other nodes keep the cluster quorum). Empty =
# stage every node (the original behavior).
if [[ -n "${DEPLOY_ONLY:-}" && "$name" != "$DEPLOY_ONLY" ]]; then
continue
fi
target="${SSH_USER}@${ssh}"
nodedir="out/${name}"
if [[ ! -d "$nodedir" ]]; then
@@ -79,6 +85,13 @@ for row in "${CLUSTER_NODES[@]}"; do
echo "-- node ${name} (ssh ${ssh}) routes=${routes}"
# Resolve this node's WebSocket port. magnus runs unibus_admin on 127.0.0.1:8480,
# so the bus WS cannot bind 0.0.0.0:8480 there (it crash-loops). A per-node
# override (WS_PORT_<NAME> in nodes.env) lets magnus use a free port while the
# rest share the default — keeping the deploy reproducible (issue uniweb/0001).
node_ws_var="WS_PORT_${name^^}"
node_ws="${!node_ws_var:-$WS_PORT}"
# Generate this node's cluster.env locally, then ship it.
envfile="build/cluster-${name}.env"
mkdir -p build
@@ -90,6 +103,8 @@ KV_REPLICAS=${KV_REPLICAS}
HTTP_PORT=${HTTP_PORT}
NATS_CLIENT_PORT=${NATS_CLIENT_PORT}
NATS_ROUTE_PORT=${NATS_ROUTE_PORT}
WS_PORT=${node_ws}
CORS_ORIGINS=${CORS_ORIGINS}
ROUTES=${routes}
CLUSTER_PASS_FILE=${REMOTE_DIR}/secrets/cluster.pass
TLS_CERT=${REMOTE_DIR}/tls/server-${name}.crt
@@ -97,6 +112,7 @@ TLS_KEY=${REMOTE_DIR}/tls/server-${name}.key
ROUTE_TLS_CERT=${REMOTE_DIR}/tls/route-${name}.crt
ROUTE_TLS_KEY=${REMOTE_DIR}/tls/route-${name}.key
ROUTE_TLS_CA=${REMOTE_DIR}/tls/cluster-ca.crt
INTERNAL_ID_FILE=${REMOTE_DIR}/secrets/internal.id
EOF
run ssh "$target" "mkdir -p ${REMOTE_DIR}/tls ${REMOTE_DIR}/secrets"
@@ -114,13 +130,16 @@ if [[ $APPLY -eq 0 ]]; then
fi
cat <<'NEXT'
HUMAN — staggered start (do NOT enable all at once; see README "Bring up"):
1. Seed node first (e.g. magnus):
ssh root@magnus 'systemctl enable --now membershipd-cluster'
ssh root@magnus '/opt/unibus/membershipd user add --admin ...' # seed admin
2. Then the other two, one at a time, checking quorum after each:
ssh root@homer 'systemctl enable --now membershipd-cluster'
ssh root@datardos 'systemctl enable --now membershipd-cluster'
HUMAN — bring up (see README "Bring up" — a LONE node has no quorum and never
serves healthz, so do NOT gate the next node on the previous one going green):
1. Seed the FIRST admin into the KV via the loopback bootstrap (README
"Seed the first admin"); this is needed only for the chicken-and-egg admin.
2. Start all three so a 2/3 quorum forms (order does not matter); healthz
turns ok only once the meta-group elects a leader (~10-30s cold):
for h in magnus homer datardos; do ssh "$h" 'sudo systemctl enable --now membershipd-cluster'; done
3. Verify posture + quorum (README "Verify").
4. Scale replicas 1 -> 3 once all three are up (README "Scale to R3").
4. Ensure R3 on every control-plane stream (README "Replication: go straight to
R3"); R1 is a SPOF, not a milestone.
5. Add further users with the cluster LIVE — no restart — via
`membershipd user add --store kv` (README "Add users to the live cluster").
NEXT
+4 -1
View File
@@ -33,8 +33,11 @@ ExecStart=/opt/unibus/membershipd \
--route-tls-cert ${ROUTE_TLS_CERT} \
--route-tls-key ${ROUTE_TLS_KEY} \
--route-tls-ca ${ROUTE_TLS_CA} \
--internal-id-file ${INTERNAL_ID_FILE} \
--store kv \
--kv-replicas ${KV_REPLICAS}
--kv-replicas ${KV_REPLICAS} \
--ws-port ${WS_PORT} \
--cors-origins ${CORS_ORIGINS}
# Restart=always (NOT on-failure): a clean SIGTERM exits success, and on-failure
# would then NOT restart, leaving the node silently dead (see function_tags.md).
Restart=always
@@ -0,0 +1,27 @@
# Drop-in: enable the embedded NATS server monitoring HTTP endpoint so a local
# metrics scraper can read /varz, /connz and /jsz for server-level metrics
# (msgs/s, connections, KV bucket msgs, RAFT leader per stream, restarts).
#
# ADDITIVE and minimal: it only sets one environment variable; the base unit
# (membershipd-cluster.service) is otherwise unchanged.
#
# UNIBUS_NATS_MONITOR is DECOUPLED from UNIBUS_NATS_DEBUG: it opens the monitoring
# endpoint WITHOUT enabling the verbose nats-server debug log, so no room subjects
# or routing metadata are written to journald (keeps the hardened posture, issue
# 0007). Do NOT use UNIBUS_NATS_DEBUG in production just to get the endpoint.
#
# The endpoint binds 127.0.0.1:8222 ONLY — the binary hardcodes the loopback bind,
# so it is never reachable from the network and needs no auth. The scraper runs on
# the same host and reads it over loopback.
#
# Requires the 0.11.0+ membershipd binary (the one that honors UNIBUS_NATS_MONITOR).
# Install on a node:
# sudo mkdir -p /etc/systemd/system/membershipd-cluster.service.d
# sudo cp nats-monitor.conf /etc/systemd/system/membershipd-cluster.service.d/
# sudo systemctl daemon-reload && sudo systemctl restart membershipd-cluster
#
# Restarting a node restarts its JetStream RAFT member, so roll ONE node at a time
# and wait for R3 reconvergence (followers 2/2) before touching the next. See the
# "NATS server metrics" section of this directory's README for the full runbook.
[Service]
Environment=UNIBUS_NATS_MONITOR=1
+34 -8
View File
@@ -2,10 +2,10 @@
#
# This file is SOURCED by generate-cluster-certs.sh and deploy-cluster.sh.
#
# HUMAN: fill in every <PLACEHOLDER> with the real value before running the
# HUMAN: fill in every placeholder with the real value before running the
# scripts. The public IPs known at authoring time are pre-filled; the WireGuard
# mesh IPs and magnus's public IP must be supplied. The scripts refuse to run
# while any <PLACEHOLDER> remains.
# while any unfilled placeholder remains.
# Cluster identity (must be identical on every node).
CLUSTER_NAME="unibus"
@@ -16,13 +16,26 @@ CLUSTER_USER="unibus-cluster"
# KV/nonce replication factor. START AT 1 for the initial 1->3 rollout, then raise
# to 3 IN PLACE (see README "Scale to R3") once all three nodes have joined. Only
# set this to 3 here after the third node is up and you re-run the KV update.
KV_REPLICAS=1
KV_REPLICAS=3
# Ports (same on every node; the route port is server-to-server only).
NATS_CLIENT_PORT=4250
NATS_ROUTE_PORT=6250
HTTP_PORT=8470
# Browser data-plane: WebSocket listener so the browser-native uniweb client
# (nats.ws) reaches NATS, and the CORS allowlist for its calls to the control
# plane. WS reuses the data-plane TLS, so it serves wss:// (the cluster runs with
# TLS). CORS_ORIGINS is a comma-separated list of allowed browser origins (no
# spaces). Issue uniweb/0001. The node's firewall must allow WS_PORT.
WS_PORT=8480
# Per-node WS port override (WS_PORT_<NAME>). magnus runs unibus_admin on
# 127.0.0.1:8480, so the bus WebSocket cannot bind 0.0.0.0:8480 there — it would
# crash-loop. magnus therefore serves the browser WS on 8485; homer and datardos
# keep 8480 (no admin panel). Verified during the 2026-06-13 rollout.
WS_PORT_MAGNUS=8485
CORS_ORIGINS="http://localhost:5173"
# Remote install layout and SSH login user.
REMOTE_DIR="/opt/unibus"
SSH_USER="root"
@@ -30,15 +43,28 @@ SSH_USER="root"
# Which address family the inter-node routes use. "wg" builds --routes from the
# WireGuard mesh IPs (private server-to-server links, preferred); "public" uses
# the public IPs. The route layer is always mutual-TLS regardless.
ROUTE_NETWORK="wg"
#
# DEPLOY DECISION (2026-06-07): set to "public". No WireGuard mesh exists between
# the three cluster nodes — homer and datardos do not even have the `wg` binary
# installed, and om's only WG peers are the operator's personal PCs, not the VPS.
# Rather than stand up a fresh mesh blindly, the routes go over the public IPs,
# still protected by the separate cluster route CA (mutual-TLS). On magnus (the
# only node with ufw active) the route port 6250 is restricted to the homer and
# datardos public IPs; homer/datardos run ufw inactive (Docker hosts) and rely on
# the route mutual-TLS for 6250.
ROUTE_NETWORK="public"
# One row per node: NAME SSH_HOST PUBLIC_IP WG_IP
# NAME -> --server-name and the per-node cert filenames (unique).
# SSH_HOST -> the `ssh <SSH_HOST>` alias (see ~/.ssh/config).
# SSH_HOST -> the `ssh ALIAS` alias (see ~/.ssh/config).
# PUBLIC_IP -> public address; goes in the cert SANs (client-facing data plane).
# WG_IP -> WireGuard mesh address; cert SAN + route target when ROUTE_NETWORK=wg.
# NOTE: with ROUTE_NETWORK=public and no WireGuard mesh, the WG_IP column is set to
# each node's public IP so the cert SAN covers the address actually used by the
# public routes and no unfilled placeholder remains (scripts refuse to run otherwise).
# magnus == organic-machine.com == om (135.125.201.30); SSH alias `magnus` enters as root.
CLUSTER_NODES=(
"magnus magnus <MAGNUS_PUBLIC_IP> <MAGNUS_WG_IP>"
"homer homer 141.94.69.66 <HOMER_WG_IP>"
"datardos dd 51.91.100.142 <DATARDOS_WG_IP>"
"magnus magnus 135.125.201.30 135.125.201.30"
"homer homer 141.94.69.66 141.94.69.66"
"datardos dd 51.91.100.142 51.91.100.142"
)
@@ -0,0 +1,78 @@
---
issue: 0007
title: Cifrado at-rest del control plane (JetStream KV / SQLite en disco)
status: spec
created: 2026-06-07
domain: security
scope: unibus (pkg/embeddednats, cmd/membershipd, deploy/cluster) + procedimiento de migración del store existente
---
# Objetivo
Cifrar en reposo el almacenamiento del plano de control para que un nodo comprometido
(root en el VPS) o un disco robado no exponga los metadatos de control en claro.
Estado actual (auditado el 07/06/2026, report 0012 y siguientes):
- **Contenido de los mensajes**: cifrado E2E por room (megolm/olm). El servidor nunca ve el
plaintext; no vive en el plano de control. **No es el objeto de este issue.**
- **Claves de room** (`UNIBUS_room_keys`): guardadas **selladas** (sealed box X25519, cifradas
para cada miembro). El servidor las almacena y reparte pero no puede abrirlas. **Ya protegidas.**
- **Metadatos de control** (`UNIBUS_rooms`, `UNIBUS_members`, `UNIBUS_rooms_by_member`,
`UNIBUS_users`): se serializan con `json.Marshal` y se escriben **en claro** en el store. En
cluster ese store es el directorio `local_files/jetstream/` de cada nodo; en single-node es el
archivo SQLite `local_files/unibus.db`. Hoy **no hay cifrado at-rest**: con root en un nodo se
pueden leer subjects de salas, la pertenencia (quién está en qué sala con qué rol), los handles
y roles de los usuarios, y las claves públicas (signPub/kexPub). No se exponen mensajes (E2E) ni
se pueden descifrar salas (claves selladas), pero sí toda la topología.
Tras este issue, los buckets/archivos del control plane quedan cifrados en disco con una clave por
nodo gestionada fuera de git. El modelo de amenaza pasa de "root del nodo ve la topología" a "root
del nodo necesita además la clave at-rest (que puede vivir en un secreto separado / TPM / variable
de entorno inyectada) para leer cualquier cosa".
# Contexto técnico
- NATS Server / JetStream soporta **encryption at-rest** nativo: se configura una cifra
(`aes` o `chacha20`) y una clave; JetStream cifra los ficheros de los streams/KV en disco. El
bus usa un NATS **embebido** (`pkg/embeddednats`), así que la activación es por opciones del
servidor embebido, no por un `nats-server.conf` externo.
- Para el backend SQLite (single-node) el equivalente sería SQLCipher o cifrado a nivel de
archivo/FS; queda como sub-tarea de menor prioridad porque el despliegue real es cluster (KV).
# Tareas
1. Confirmar la API de encryption-at-rest del NATS embebido en la versión usada (opción de
servidor para cipher + clave; cómo se pasa la clave de forma que no quede en argv ni en git).
2. Activar el cifrado en `pkg/embeddednats` detrás de una opción de configuración. La clave se
inyecta por archivo (`--jetstream-encryption-key-file`, 0600, junto a las claves TLS del nodo)
o variable de entorno desde el unit systemd; nunca en argv ni commiteada.
3. `cmd/membershipd`: flag/env para la clave + reflejar el estado en la posture publicada en
`/healthz` (p.ej. `"at_rest":true`) para que el monitor lo verifique.
4. `deploy/cluster`: provisionar la clave at-rest por nodo (generación + `pass`/secrets gitignored)
y cablearla en `cluster.env` + el unit. Documentar en el runbook.
5. **Migración del store existente** (gotcha crítico): JetStream no re-cifra retroactivamente los
datos ya escritos en claro. Diseñar y documentar el procedimiento seguro para el cluster en
producción (probable: backup → exportar snapshot del control plane → parar nodo → recrear el
store con la clave activa → re-importar; o rotación nodo a nodo aprovechando la replicación R3).
Respetar la regla de migraciones (aditivo, sin pérdida de datos).
6. Tests: arrancar un nodo con clave at-rest, escribir un user/room, y verificar que el fichero en
disco **no** contiene en claro un subject/handle conocido (grep negativo), y que el nodo sigue
leyéndolos con la clave. Verificar que sin la clave el store no se abre.
# Definition of Done
- Cifrado at-rest activo en los 3 nodos del cluster; `/healthz` lo refleja en la posture.
- Evidencia ejecutable: un valor conocido (subject de sala / handle de usuario) **no** aparece en
claro al hacer `grep` sobre `local_files/jetstream/`; el nodo lo sigue sirviendo con la clave.
- Procedimiento de migración probado sobre datos reales sin pérdida (snapshot/restore verificado).
- La clave at-rest nunca está en git ni en argv; vive en archivo 0600 / secreto inyectado.
- No baja ninguna otra capa de seguridad (enforce + ACL + TLS + E2E + sealed keys intactas).
# Notas
Aditivo y ortogonal al resto de la seguridad: TLS protege en tránsito, E2E el contenido, las claves
de room van selladas; este issue cierra el último hueco (metadatos de control en claro en disco)
para el modelo de amenaza "VPS comprometido / disco robado". Prioridad media: el despliegue ya es
seguro frente a ataques de red (enforce+TLS+ACL); esto endurece frente a compromiso físico/root del
host. Relacionado con el endurecimiento de los issues 0004/0005/0006.
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
# Regenera el binding gomobile (unibus.aar) a partir de ./mobile sobre pkg/client.
#
# El .aar (~38 MB, con libgojni.so para 4 ABIs) NO se versiona: es un artefacto
# de build reproducible. Este script lo regenera. Requisitos:
# - Go con gomobile/gobind instalados:
# go install golang.org/x/mobile/cmd/gomobile@latest
# go install golang.org/x/mobile/cmd/gobind@latest
# gomobile init
# - Android NDK (este repo usó 26.3.11579264 dentro del Android SDK).
#
# En un worktree fuera del árbol del registry, pkg/client importa
# "fn-registry/functions/cybersecurity" vía el `replace` del go.mod. Si ese
# replace relativo no resuelve (p. ej. worktree en /tmp), crea un go.work local
# (gitignored) con: replace fn-registry => /ruta/absoluta/a/fn_registry
set -euo pipefail
cd "$(dirname "$0")/.."
: "${ANDROID_HOME:=$HOME/android-sdk}"
: "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/26.3.11579264}"
export ANDROID_HOME ANDROID_NDK_HOME
export PATH="$HOME/go/bin:$PATH"
OUT="android/app/libs/unibus.aar"
mkdir -p "$(dirname "$OUT")"
echo "==> gomobile bind -> $OUT"
gomobile bind \
-target=android \
-androidapi 21 \
-javapkg com.unibus.core \
-o "$OUT" \
./mobile
echo "==> OK: $OUT"
ls -lh "$OUT"
-236
View File
@@ -1,236 +0,0 @@
// Package mobile exposes a flat, gomobile-friendly API over the unibus client
// so an Android app can join rooms, publish, and receive messages with the same
// end-to-end encryption as any native Go peer.
//
// gomobile only supports a limited set of types across the binding boundary
// (string, []byte, int, bool, error, named structs, and interfaces). This layer
// translates the richer client API into those primitives and delivers incoming
// frames through a Java/Kotlin-implemented FrameListener callback. No protocol
// or cryptography is reimplemented here: every call delegates to pkg/client,
// which is the single source of truth shared with every other peer on the bus.
package mobile
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// FrameListener receives decrypted messages for a subscribed room. The Android
// side implements this interface.
//
// IMPORTANT (threading): OnFrame is invoked from a NATS delivery goroutine, NOT
// the Android main thread. A Kotlin implementation MUST hop back to the UI
// thread before touching any Compose state or Android view — for example with
// `withContext(Dispatchers.Main)` from a coroutine, or by posting to a
// MutableStateFlow that the UI collects. Touching views directly from here
// crashes with CalledFromWrongThreadException.
type FrameListener interface {
OnFrame(roomID string, sender string, msgID string, text string)
}
// Session is a connected unibus peer. Create it with NewSession and close it
// with Close when the app stops.
type Session struct {
c *client.Client
}
// GenerateIdentity creates (or loads) the long-term keypair stored at path.
// Call it once on first launch. The resulting file holds the peer's private
// Ed25519 and X25519 keys and must be kept private to the app sandbox
// (use Context.getFilesDir() on Android).
func GenerateIdentity(path string) error {
_, err := client.LoadOrCreateIdentity(path)
return err
}
// NewSession loads the identity at idPath and connects to the bus. natsURL is
// the data plane (for example tls://host:4250) and ctrlURL is the control plane
// HTTP endpoint (for example https://host:8470). caPath is the path to the bus
// CA certificate (ca.crt) bundled with the app: when set, the session connects
// securely (TLS pinned to that CA + nkey authentication on the data plane),
// matching a bus running with auth + TLS. Pass an empty caPath to connect in
// plaintext to an unsecured (dev) bus.
func NewSession(idPath, natsURL, ctrlURL, caPath string) (*Session, error) {
id, err := client.LoadOrCreateIdentity(idPath)
if err != nil {
return nil, err
}
c, err := client.Connect(natsURL, ctrlURL, id, caPath)
if err != nil {
return nil, err
}
return &Session{c: c}, nil
}
// EndpointID returns this peer's stable endpoint identifier, derived from its
// signing public key. It is the value that appears as the sender of frames.
func (s *Session) EndpointID() string {
return s.c.Endpoint().ID
}
// ConnectedServer returns the NATS URL the session is currently connected to,
// useful for surfacing a "connected to" hint in the UI.
func (s *Session) ConnectedServer() string {
return s.c.ConnectedServer()
}
// IsConnected reports whether the underlying NATS connection is live.
func (s *Session) IsConnected() bool {
return s.c.IsConnected()
}
// CreateRoom opens a room on the given subject. mode is "matrix" for the
// encrypted, persisted and signed policy, or "nats" for plain cleartext. It
// returns the room id used by Join, Publish and Subscribe.
//
// On a secured bus, call RefreshSession after CreateRoom and before
// Subscribe/Publish so the bus re-derives this peer's per-subject permissions
// from its new membership (issue 0006e).
func (s *Session) CreateRoom(subject, mode string) (string, error) {
p := room.ModeNATS
if mode == "matrix" {
p = room.ModeMatrix
}
return s.c.CreateRoom(subject, p)
}
// Join fetches the room key when the room is encrypted and prepares the session
// to publish to and receive from the room.
func (s *Session) Join(roomID string) error {
return s.c.Join(roomID)
}
// RefreshSession reconnects the data plane so the bus re-derives this peer's
// per-subject permissions from its current room membership.
//
// Membership-change contract (issue 0006e): a secured bus (--bus-auth enforce)
// freezes a connection's permissions at connect time. After ANY membership change
// — a room you just created, were invited to, or joined — call RefreshSession
// BEFORE Publish/Subscribe on that room, or the bus denies the new room's subject.
// It also drops active subscriptions, so re-Subscribe afterwards. On an unsecured
// bus it is a harmless reconnect. A mobile/gateway caller wires this exactly like
// cmd/chat and cmd/worker do: CreateRoom -> RefreshSession -> Subscribe/Publish.
func (s *Session) RefreshSession() error {
return s.c.RefreshSession()
}
// Publish sends a UTF-8 text message to the room.
func (s *Session) Publish(roomID, text string) error {
return s.c.Publish(roomID, []byte(text))
}
// Subscribe streams decrypted messages of the room to the listener until the
// session is closed. See FrameListener for the threading contract.
func (s *Session) Subscribe(roomID string, l FrameListener) error {
_, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext))
})
return err
}
// roomJSON is the flat shape returned by ListRoomsJSON for each room the peer
// belongs to. It mirrors the fields the UI needs to render a room list item.
type roomJSON struct {
RoomID string `json:"room_id"`
Subject string `json:"subject"`
Epoch int `json:"epoch"`
Encrypted bool `json:"encrypted"`
Role string `json:"role"`
}
// ListRoomsJSON returns the peer's rooms as a JSON array string. gomobile does
// not bind slices of structs cleanly across the boundary, so the list is
// marshalled to JSON and the Kotlin side decodes it (kotlinx.serialization).
// Each element is a roomJSON object.
func (s *Session) ListRoomsJSON() (string, error) {
refs, err := s.c.ListMyRooms()
if err != nil {
return "", err
}
out := make([]roomJSON, 0, len(refs))
for _, r := range refs {
out = append(out, roomJSON{
RoomID: r.RoomID,
Subject: r.Subject,
Epoch: r.Epoch,
Encrypted: r.Policy.Encrypt,
Role: r.Role,
})
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
// cardJSON is the portable, copy-pasteable public identity a peer shares so a
// room owner can invite it to an encrypted room. It carries no secret: only the
// endpoint id and the two public keys (signing + key-exchange), base64-encoded
// for transport over text or a QR code.
type cardJSON struct {
ID string `json:"id"`
SignPub string `json:"sign_pub"` // base64 std of the Ed25519 public key
KexPub string `json:"kex_pub"` // base64 std of the X25519 public key
}
// Card returns this peer's public identity as a portable JSON string. Share it
// (paste, QR) with a room owner so they can Invite you to an encrypted room. It
// contains no private key and is safe to transmit in the clear.
func (s *Session) Card() string {
ep := s.c.Endpoint()
b, _ := json.Marshal(cardJSON{
ID: ep.ID,
SignPub: base64.StdEncoding.EncodeToString(ep.SignPub),
KexPub: base64.StdEncoding.EncodeToString(ep.KexPub),
})
return string(b)
}
// Invite adds the holder of peerCard to roomID. peerCard is the JSON string the
// invitee produced with Card(). For encrypted rooms this seals the current room
// key to the invitee's X25519 public key and signs the request; the caller must
// be the room owner.
func (s *Session) Invite(roomID, peerCard string) error {
var card cardJSON
if err := json.Unmarshal([]byte(peerCard), &card); err != nil {
return fmt.Errorf("mobile: bad peer card: %w", err)
}
signPub, err := base64.StdEncoding.DecodeString(card.SignPub)
if err != nil {
return fmt.Errorf("mobile: bad sign_pub in card: %w", err)
}
kexPub, err := base64.StdEncoding.DecodeString(card.KexPub)
if err != nil {
return fmt.Errorf("mobile: bad kex_pub in card: %w", err)
}
return s.c.Invite(roomID, client.Endpoint{ID: card.ID, SignPub: signPub, KexPub: kexPub})
}
// Kick removes endpointID from roomID and, for encrypted rooms, rotates the room
// key to a new epoch so the removed peer cannot decrypt messages published after
// the kick (forward secrecy). The caller must be the room owner.
func (s *Session) Kick(roomID, endpointID string) error {
return s.c.Kick(roomID, endpointID)
}
// Request performs an RPC request/reply against subject and returns the reply
// payload as text. timeoutMs bounds the wait in milliseconds.
func (s *Session) Request(subject, text string, timeoutMs int) (string, error) {
out, err := s.c.Request(subject, []byte(text), time.Duration(timeoutMs)*time.Millisecond)
if err != nil {
return "", err
}
return string(out), nil
}
// Close disconnects the peer from the bus.
func (s *Session) Close() error {
return s.c.Close()
}
+70
View File
@@ -456,6 +456,23 @@ type memberRoomJSON struct {
Role string `json:"role"`
}
// userJSON mirrors the server's wire type on the admin user-management endpoints.
type userJSON struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
}
// addUserReq is the POST /users body (mirror of the server type).
type addUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// ---- room operations ------------------------------------------------------
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
@@ -490,6 +507,59 @@ func (c *Client) ListMyRooms() ([]RoomRef, error) {
return out, nil
}
// ---- user administration (admin-only) ------------------------------------
// UserInfo is a bus user as returned by the admin user-management endpoints. It
// is a flat view (no nested types) for the admin panel: the signing key
// (lowercase hex), handle, role ("admin"|"member"), status ("active"|"revoked"),
// and timestamps. RevokedAt is empty for an active user.
type UserInfo struct {
SignPub string
Handle string
Role string
Status string
CreatedAt string
RevokedAt string
}
// ListUsers returns the full bus allowlist, including revoked users. The caller
// must be signing as an admin: a non-admin signer is rejected by the server with
// 403, surfaced here as an error.
func (c *Client) ListUsers() ([]UserInfo, error) {
var resp []userJSON
if err := c.doJSON("GET", "/users", nil, &resp); err != nil {
return nil, err
}
out := make([]UserInfo, 0, len(resp))
for _, u := range resp {
out = append(out, UserInfo{
SignPub: u.SignPub,
Handle: u.Handle,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
RevokedAt: u.RevokedAt,
})
}
return out, nil
}
// AddUser registers a bus user from their Ed25519 signing public key (64-hex).
// role is "admin" or "member" (empty defaults to member, matching the server).
// The caller must be signing as an admin. Re-adding an already-registered key
// returns an error (the server replies 409 and leaves the existing row
// untouched — no silent role/status change).
func (c *Client) AddUser(signPub, handle, role string) error {
return c.doJSON("POST", "/users", addUserReq{SignPub: signPub, Handle: handle, Role: role}, nil)
}
// RevokeUser revokes a bus user by their signing public key (64-hex). Revocation
// is a status flip (no hard delete): the identity stays auditable and is denied
// on both planes immediately. The caller must be signing as an admin.
func (c *Client) RevokeUser(signPub string) error {
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
}
// newRoomKey returns 32 random bytes for a symmetric room key.
func newRoomKey() ([]byte, error) {
k := make([]byte, 32)
+43 -11
View File
@@ -33,20 +33,36 @@ type identityFile struct {
KexPriv string `json:"kex_priv"`
}
// LoadIdentity loads an existing identity from path. Unlike LoadOrCreateIdentity
// it NEVER creates one: a missing or unreadable file is an error. It is for
// callers that must consume a specific, pre-provisioned identity rather than mint
// a fresh one — for example membershipd's persisted internal service identity,
// which `membershipd user add --store kv` reads to present the privileged nkey
// the cluster authenticator recognizes.
func LoadIdentity(path string) (cs.Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("client: read identity %q: %w", path, err)
}
var f identityFile
if err := json.Unmarshal(data, &f); err != nil {
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
}
id, err := f.toIdentity()
if err != nil {
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
}
return id, nil
}
// LoadOrCreateIdentity loads the identity at path, or generates and persists a
// new one if the file does not exist. The file is written with 0600
// permissions because it holds private keys.
// permissions because it holds private keys. A file that exists but is
// unreadable or corrupt is an error (NOT silently regenerated), so a damaged
// identity surfaces instead of minting a new key that cannot decrypt old data.
func LoadOrCreateIdentity(path string) (cs.Identity, error) {
if data, err := os.ReadFile(path); err == nil {
var f identityFile
if err := json.Unmarshal(data, &f); err != nil {
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
}
id, err := f.toIdentity()
if err != nil {
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
}
return id, nil
if _, statErr := os.Stat(path); statErr == nil {
return LoadIdentity(path)
}
id, err := cs.GenerateIdentity()
@@ -59,6 +75,22 @@ func LoadOrCreateIdentity(path string) (cs.Identity, error) {
return id, nil
}
// WriteNewIdentity writes id to path in the canonical identity-file format read
// by LoadIdentity, but REFUSES to overwrite an existing file: provisioning a new
// identity must never silently clobber another process's private keys. The file
// is created 0600 (it holds private keys). It is the write half of one-command
// bot provisioning (`membershipd bot add --out <path>`): the freshly minted
// identity it writes is exactly what LoadIdentity reconstructs, so a bot binary
// (worker/clientcheck) consumes the credentials with no extra conversion step.
func WriteNewIdentity(path string, id cs.Identity) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("client: identity file %q already exists; refusing to overwrite", path)
} else if !os.IsNotExist(err) {
return fmt.Errorf("client: stat identity %q: %w", path, err)
}
return saveIdentity(path, id)
}
func saveIdentity(path string, id cs.Identity) error {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
+99
View File
@@ -0,0 +1,99 @@
package client_test
import (
"encoding/hex"
"strings"
"testing"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/membership"
)
// findUserInfo returns the row with the given signing key (case-insensitive).
func findUserInfo(users []client.UserInfo, signPub string) (client.UserInfo, bool) {
want := strings.ToLower(signPub)
for _, u := range users {
if strings.ToLower(u.SignPub) == want {
return u, true
}
}
return client.UserInfo{}, false
}
// TestClientUsersAdminAPI drives the admin user-management API through the real
// pkg/client methods against an in-process membershipd under enforce: an admin
// client adds a user, lists it, revokes it, and sees the status flip — and a
// non-admin client is denied. This is the path the admin panel uses, so it locks
// the client/server contract the panel depends on.
func TestClientUsersAdminAPI(t *testing.T) {
h := newHarnessMode(t, membership.AuthEnforce)
waitHealth(t, h.ctrlURL)
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
if err != nil {
t.Fatalf("connect admin: %v", err)
}
defer admin.Close()
registerClient(t, h, admin, "admin", membership.RoleAdmin)
member, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
if err != nil {
t.Fatalf("connect member: %v", err)
}
defer member.Close()
registerClient(t, h, member, "member", membership.RoleMember)
// A brand-new identity the admin will register over HTTP.
carol := mustIdentity(t)
carolPub := hex.EncodeToString(carol.SignPub)
// Admin adds carol as a member.
if err := admin.AddUser(carolPub, "carol", membership.RoleMember); err != nil {
t.Fatalf("admin AddUser: %v", err)
}
// Admin lists: carol present and active.
users, err := admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers: %v", err)
}
row, ok := findUserInfo(users, carolPub)
if !ok {
t.Fatalf("carol missing from list after add: %+v", users)
}
if row.Status != membership.StatusActive || row.Role != membership.RoleMember {
t.Fatalf("carol row wrong after add: %+v", row)
}
// Re-adding the same key is a conflict surfaced as an error (no silent upsert).
if err := admin.AddUser(carolPub, "carol-again", membership.RoleAdmin); err == nil {
t.Fatalf("re-adding carol should error (409), got nil")
}
// Admin revokes carol; list shows the status flip (no hard delete).
if err := admin.RevokeUser(carolPub); err != nil {
t.Fatalf("admin RevokeUser: %v", err)
}
users, err = admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers after revoke: %v", err)
}
row, ok = findUserInfo(users, carolPub)
if !ok {
t.Fatalf("carol vanished after revoke (should be a status flip): %+v", users)
}
if row.Status != membership.StatusRevoked {
t.Fatalf("carol should be revoked, got status %q", row.Status)
}
// A non-admin (member) is denied on every user-management method.
if _, err := member.ListUsers(); err == nil {
t.Fatalf("non-admin ListUsers should error (403), got nil")
}
if err := member.AddUser(carolPub, "x", membership.RoleMember); err == nil {
t.Fatalf("non-admin AddUser should error (403), got nil")
}
if err := member.RevokeUser(carolPub); err == nil {
t.Fatalf("non-admin RevokeUser should error (403), got nil")
}
}
+120 -2
View File
@@ -9,6 +9,7 @@ import (
"crypto/tls"
"fmt"
"net/url"
"os"
"time"
server "github.com/nats-io/nats-server/v2/server"
@@ -78,6 +79,42 @@ type ServerConfig struct {
// availability (issue 0003a). Nil keeps the server standalone (the legacy
// single-node behavior).
Cluster *ClusterConfig
// Websocket, when non-nil, opens an ADDITIONAL WebSocket listener on the
// embedded nats-server so browser clients (nats.ws) can reach the data plane
// directly, the same way native TCP peers (Go, Kotlin) do (issue uniweb/0001).
// Native TCP clients are unaffected: the WebSocket listener is a separate port
// layered on top of the existing TCP listener, and the client authenticator
// (Auth) applies to both. Nil keeps the server TCP-only (legacy behavior).
Websocket *WebsocketConfig
}
// WebsocketConfig configures the embedded nats-server's WebSocket listener so a
// browser can speak the NATS protocol over ws://. A browser cannot open a raw TCP
// socket, so this is the only way the SPA reaches the data plane without a Go
// gateway in between.
//
// Security: off loopback a browser requires wss:// (TLS) — set TLS with a
// certificate the browser trusts. NoTLS plain ws:// is acceptable only for a
// loopback dev stack. The WebSocket upgrade also enforces an Origin allowlist
// (browser same-origin policy); AllowedOrigins must list the SPA's origins or the
// browser handshake is refused.
type WebsocketConfig struct {
// Host is the bind interface for the WebSocket listener; "" lets nats-server
// pick its default. Use "127.0.0.1" to keep it loopback-only in dev.
Host string
// Port is the WebSocket listen port (e.g. 8480). Required (non-zero) for the
// listener to open.
Port int
// NoTLS serves plain ws:// instead of wss://. Loopback/dev only: browsers refuse
// ws:// to a non-loopback origin. Ignored when TLS is set (TLS implies wss://).
NoTLS bool
// TLS, when set, serves wss:// with this certificate. Required for any browser
// origin that is not loopback.
TLS *tls.Config
// AllowedOrigins is the allowlist of browser Origin headers permitted to upgrade
// the WebSocket. Empty = same-origin only (nats-server SameOrigin). Never use a
// wildcard in production; list the exact SPA origins.
AllowedOrigins []string
}
// Start is a thin backward-compatible wrapper: embedded JetStream server on the
@@ -102,10 +139,38 @@ func StartHostAuth(storeDir, host string, port int, auth server.Authentication)
return StartServer(ServerConfig{StoreDir: storeDir, Host: host, Port: port, Auth: auth})
}
// natsLogOpts maps the two independent environment toggles to the embedded
// nats-server logging and monitoring flags. It is a pure function (no I/O) so the
// decoupling between the two toggles can be unit-tested directly.
//
// - UNIBUS_NATS_DEBUG="1" enables the nats-server logger (route/RAFT/JetStream
// errors); "2" additionally enables protocol tracing. Off by default so the
// server stays silent (NoLog) and production behavior is unchanged.
// - UNIBUS_NATS_MONITOR="1" opens the monitoring HTTP endpoint (loopback only)
// for a local metrics scraper to read /varz, /connz and /jsz.
//
// The two are DECOUPLED on purpose: enabling the monitoring endpoint must NOT turn
// on the verbose debug log, which would write room subjects and routing metadata
// to journald in clear and regress the hardened posture (issue 0007). The reverse
// coupling is kept for backward compatibility: debug mode still exposes the
// monitoring endpoint as well (debug implies monitor), so existing debugging
// workflows are unchanged.
func natsLogOpts(debugEnv, monitorEnv string) (noLog, debug, trace, monitor bool) {
debug = debugEnv == "1" || debugEnv == "2"
trace = debugEnv == "2"
monitor = monitorEnv == "1" || debug
noLog = !debug
return noLog, debug, trace, monitor
}
// StartServer launches an embedded nats-server with JetStream from cfg. It
// blocks until the server is ready to accept connections (up to 5s) and returns
// the running server; the caller must Shutdown it.
func StartServer(cfg ServerConfig) (*server.Server, error) {
// Map the two independent env toggles to the nats-server logging + monitoring
// flags. See natsLogOpts for the decoupling rationale (issue 0007).
noLog, debugNATS, traceNATS, monitorNATS := natsLogOpts(
os.Getenv("UNIBUS_NATS_DEBUG"), os.Getenv("UNIBUS_NATS_MONITOR"))
opts := &server.Options{
JetStream: true,
StoreDir: cfg.StoreDir,
@@ -114,8 +179,19 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
ServerName: cfg.ServerName,
DontListen: false,
// Keep the embedded server quiet by default; the host app logs the URLs.
NoLog: true,
NoSigs: true,
NoLog: noLog,
Debug: debugNATS,
Trace: traceNATS,
Logtime: true,
NoSigs: true,
}
if monitorNATS {
// Expose the nats-server monitoring endpoint on LOOPBACK ONLY (never public):
// the operator (or a local metrics scraper) inspects /varz, /connz, /jsz,
// /routez. The 127.0.0.1 bind is mandatory because this endpoint has no auth;
// it must stay unreachable from the network.
opts.HTTPHost = "127.0.0.1"
opts.HTTPPort = 8222
}
if cfg.Auth != nil {
opts.CustomClientAuthentication = cfg.Auth
@@ -130,6 +206,29 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
opts.TLS = true
}
if cfg.Websocket != nil {
// Layer a WebSocket listener on top of the TCP data plane so browser
// clients (nats.ws) can connect. The client authenticator (opts.*Auth above)
// applies to WebSocket connections too, so a browser still has to pass the
// nkey + allowlist check; this only adds a transport, not a trust bypass.
ws := server.WebsocketOpts{
Host: cfg.Websocket.Host,
Port: cfg.Websocket.Port,
AllowedOrigins: cfg.Websocket.AllowedOrigins,
}
if cfg.Websocket.TLS != nil {
ws.TLSConfig = cfg.Websocket.TLS
} else {
// No certificate: plain ws:// (loopback/dev only). Browsers refuse this
// off-loopback, which is the intended guard rail.
ws.NoTLS = true
}
// Empty AllowedOrigins means "same-origin only": tell nats-server to enforce
// it rather than defaulting to accept-any-origin.
ws.SameOrigin = len(cfg.Websocket.AllowedOrigins) == 0
opts.Websocket = ws
}
if cfg.Cluster != nil {
if err := applyClusterOpts(opts, cfg.Cluster); err != nil {
return nil, err
@@ -141,6 +240,10 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
return nil, fmt.Errorf("embeddednats: new server: %w", err)
}
if debugNATS {
ns.ConfigureLogger()
}
go ns.Start()
if !ns.ReadyForConnections(5 * time.Second) {
@@ -162,6 +265,21 @@ func applyClusterOpts(opts *server.Options, c *ClusterConfig) error {
Port: c.Port,
Username: c.Username,
Password: c.Password,
// Disable route connection pooling (nats-server 2.10+ defaults to a pool of
// 3 connections per peer). On a small cluster the pool churns with
// "duplicate route"/"client closed" reconnects that interrupt the meta-group
// RAFT heartbeats, causing perpetual leader re-elections so the JetStream
// meta never becomes current and stream/KV creation hangs (issue 0006g).
// PoolSize=-1 forces the classic single route per peer, which is stable for
// the 3-node unibus cluster.
PoolSize: -1,
// NoAdvertise stops the server from gossiping its locally-discovered IPs to
// peers. The cluster nodes are Docker hosts, so without this NATS advertises
// the docker bridge addresses (172.x / 10.0.x) as reachable routes; peers
// then try to dial those private, mutually-unreachable IPs, churning the
// route layer and destabilizing the JetStream meta-group. With NoAdvertise
// the nodes use ONLY the explicit public-IP routes we configure (issue 0006g).
NoAdvertise: true,
}
if c.TLS != nil {
opts.Cluster.TLSConfig = c.TLS
+134
View File
@@ -0,0 +1,134 @@
package embeddednats
import (
"io"
"net"
"net/http"
"testing"
"time"
)
// TestNatsLogOptsDecoupled is the core regression guard for issue 0007: turning
// on the monitoring endpoint must NEVER turn on the verbose nats-server debug log
// (which would leak room subjects/routing metadata to journald). It also checks
// the backward-compatible coupling (debug still implies monitoring) and the quiet
// default.
func TestNatsLogOptsDecoupled(t *testing.T) {
cases := []struct {
name string
debugEnv, monitorEnv string
noLog, debug, trace, monitor bool
}{
{"default off — quiet, no monitor", "", "", true, false, false, false},
{"monitor only — endpoint on, log stays quiet", "", "1", true, false, false, true},
{"debug implies monitor", "1", "", false, true, false, true},
{"trace implies debug+monitor", "2", "", false, true, true, true},
{"both set", "1", "1", false, true, false, true},
{"monitor garbage value ignored", "", "yes", true, false, false, false},
{"debug garbage value ignored", "true", "", true, false, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
noLog, debug, trace, monitor := natsLogOpts(c.debugEnv, c.monitorEnv)
if noLog != c.noLog || debug != c.debug || trace != c.trace || monitor != c.monitor {
t.Fatalf("natsLogOpts(%q,%q) = (noLog=%v debug=%v trace=%v monitor=%v), want (noLog=%v debug=%v trace=%v monitor=%v)",
c.debugEnv, c.monitorEnv, noLog, debug, trace, monitor,
c.noLog, c.debug, c.trace, c.monitor)
}
})
}
// Explicit golden assertion of the security property: monitor on, log off.
noLog, debug, _, monitor := natsLogOpts("", "1")
if !monitor {
t.Fatal("UNIBUS_NATS_MONITOR=1 must open the monitoring endpoint")
}
if debug || !noLog {
t.Fatalf("UNIBUS_NATS_MONITOR=1 must NOT enable the debug log (got debug=%v noLog=%v)", debug, noLog)
}
}
// TestMonitorEndpointLoopback boots a real embedded server with
// UNIBUS_NATS_MONITOR=1 (and DEBUG explicitly off) and proves the monitoring HTTP
// endpoint answers on loopback only — the exact contract the metrics scraper
// relies on. The pure decoupling check above already guarantees the log stays out
// of debug mode for this same env combination.
func TestMonitorEndpointLoopback(t *testing.T) {
t.Setenv("UNIBUS_NATS_DEBUG", "")
t.Setenv("UNIBUS_NATS_MONITOR", "1")
ns, err := StartServer(ServerConfig{
StoreDir: t.TempDir(),
Host: "127.0.0.1",
Port: freeLoopbackPort(t),
})
if err != nil {
t.Fatalf("start server with monitoring: %v", err)
}
defer func() { ns.Shutdown(); ns.WaitForShutdown() }()
addr := ns.MonitorAddr()
if addr == nil {
t.Fatal("monitoring endpoint not open with UNIBUS_NATS_MONITOR=1 (MonitorAddr is nil)")
}
if !addr.IP.IsLoopback() {
t.Fatalf("monitoring endpoint bound to %s, must be loopback only", addr.IP)
}
if addr.Port != 8222 {
t.Fatalf("monitoring endpoint on port %d, want the fixed loopback port 8222", addr.Port)
}
// /varz must answer 200 with a non-empty body on loopback.
url := "http://" + addr.String() + "/varz"
var resp *http.Response
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
resp, err = http.Get(url) //nolint:gosec // loopback monitoring endpoint, no auth by design
if err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s -> %d, want 200", url, resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
t.Fatalf("GET %s returned an empty body", url)
}
}
// TestMonitorDisabledByDefault proves a server started without either toggle does
// NOT open the monitoring endpoint, so production stays closed unless opted in.
func TestMonitorDisabledByDefault(t *testing.T) {
t.Setenv("UNIBUS_NATS_DEBUG", "")
t.Setenv("UNIBUS_NATS_MONITOR", "")
ns, err := StartServer(ServerConfig{
StoreDir: t.TempDir(),
Host: "127.0.0.1",
Port: freeLoopbackPort(t),
})
if err != nil {
t.Fatalf("start server: %v", err)
}
defer func() { ns.Shutdown(); ns.WaitForShutdown() }()
if addr := ns.MonitorAddr(); addr != nil {
t.Fatalf("monitoring endpoint open (%s) without UNIBUS_NATS_MONITOR — must stay closed by default", addr)
}
}
func freeLoopbackPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
+108
View File
@@ -0,0 +1,108 @@
package embeddednats_test
import (
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/enmanuel/unibus/pkg/embeddednats"
)
// wsFreePort returns an OS-assigned free TCP port on loopback. Kept local to this
// file so the WebSocket tests do not depend on the cluster test helpers.
func wsFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve free port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestWebsocketListenerOpens verifies that when a ServerConfig carries a
// WebsocketConfig the embedded nats-server opens the additional WebSocket port and
// accepts a connection there, while the regular TCP client port keeps working. A
// browser cannot speak raw TCP, so this WebSocket listener is the only path the SPA
// has to the data plane (issue uniweb/0001).
func TestWebsocketListenerOpens(t *testing.T) {
clientPort := wsFreePort(t)
wsPort := wsFreePort(t)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(),
Host: "127.0.0.1",
Port: clientPort,
Websocket: &embeddednats.WebsocketConfig{
Host: "127.0.0.1",
Port: wsPort,
NoTLS: true, // loopback dev: plain ws://
},
})
if err != nil {
t.Fatalf("StartServer with websocket: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
// The WebSocket listener must accept a TCP connection on its dedicated port.
addr := fmt.Sprintf("127.0.0.1:%d", wsPort)
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("websocket port %d not accepting connections: %v", wsPort, err)
}
conn.Close()
// And it must speak the WebSocket upgrade handshake: a GET with the upgrade
// headers should get a 101 Switching Protocols (nats-server's ws endpoint),
// proving it is a real WebSocket listener, not just an open socket.
req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/", nil)
if err != nil {
t.Fatalf("build upgrade request: %v", err)
}
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("websocket upgrade request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("websocket upgrade: got status %d, want 101 Switching Protocols", resp.StatusCode)
}
}
// TestNoWebsocketByDefault verifies the listener stays TCP-only when WebsocketConfig
// is nil: opening the browser transport must be an explicit opt-in so existing
// single-node and cluster deployments are unchanged.
func TestNoWebsocketByDefault(t *testing.T) {
clientPort := wsFreePort(t)
// Reserve a port, then free it, so we can assert nothing is listening there.
maybeWSPort := wsFreePort(t)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(),
Host: "127.0.0.1",
Port: clientPort,
// Websocket intentionally nil.
})
if err != nil {
t.Fatalf("StartServer: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", maybeWSPort), 300*time.Millisecond)
if err == nil {
conn.Close()
t.Fatalf("a listener is unexpectedly open on %d with no WebsocketConfig", maybeWSPort)
}
if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "timeout") {
t.Logf("dial error (acceptable, port closed): %v", err)
}
}
+157
View File
@@ -0,0 +1,157 @@
package membership_test
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/membership"
)
// newCORSServer builds a control-plane server with the given CORS allowlist over a
// throwaway store, and returns a live httptest server. /healthz is auth-exempt, so
// the CORS tests can exercise the cross-origin pipeline without signing requests.
func newCORSServer(t *testing.T, origins ...string) *httptest.Server {
t.Helper()
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
srv := membership.NewServer(store, blobs, membership.AuthOff)
srv.AllowedOrigins = origins
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
return ts
}
// TestCORSPreflightAllowedOrigin: a preflight (OPTIONS) from an allow-listed origin
// is answered 204 with the Access-Control headers, and never reaches auth. This is
// what lets the browser-native uniweb client call the control plane (issue
// uniweb/0001).
func TestCORSPreflightAllowedOrigin(t *testing.T) {
const origin = "http://localhost:5173"
ts := newCORSServer(t, origin)
req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil)
req.Header.Set("Origin", origin)
req.Header.Set("Access-Control-Request-Method", "POST")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("preflight: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Fatalf("Allow-Origin = %q, want %q", got, origin)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" {
t.Fatalf("Allow-Methods missing on preflight")
}
// The control-plane request-auth headers a browser signs every request with must
// be allow-listed, or the browser's preflight blocks the real request (the bug a
// live browser surfaced: listRooms failed with "Failed to fetch").
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Unibus-Sig") {
t.Fatalf("Allow-Headers must include the X-Unibus-* auth headers, got %q", got)
}
}
// TestCORSPreflightDisallowedOrigin: a preflight from an origin NOT in the allowlist
// gets 403 and no Access-Control headers, so the browser blocks the real request.
func TestCORSPreflightDisallowedOrigin(t *testing.T) {
ts := newCORSServer(t, "http://localhost:5173")
req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil)
req.Header.Set("Origin", "https://evil.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("preflight: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("disallowed preflight status = %d, want 403", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin leaked for disallowed origin: %q", got)
}
}
// TestCORSActualRequestCarriesHeader: a real GET from an allow-listed origin is
// served normally AND carries the Allow-Origin header so the browser accepts the
// response.
func TestCORSActualRequestCarriesHeader(t *testing.T) {
const origin = "http://localhost:5173"
ts := newCORSServer(t, origin)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil)
req.Header.Set("Origin", origin)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Fatalf("Allow-Origin = %q, want %q", got, origin)
}
}
// TestCORSDisabledByDefault: with an empty allowlist no Access-Control header is
// ever emitted (CORS off) and requests behave exactly as before. This guards the
// opt-in invariant: untouched deployments are unaffected.
func TestCORSDisabledByDefault(t *testing.T) {
ts := newCORSServer(t) // no origins
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil)
req.Header.Set("Origin", "http://localhost:5173")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin emitted with CORS off: %q", got)
}
}
// TestCORSNativeClientUnaffected: a request with no Origin header (a native Go/Kotlin
// client) is processed normally and gets no CORS headers, even when an allowlist is
// configured.
func TestCORSNativeClientUnaffected(t *testing.T) {
ts := newCORSServer(t, "http://localhost:5173")
resp, err := http.Get(ts.URL + "/healthz") // no Origin header
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin set for a no-Origin native client: %q", got)
}
}
+155
View File
@@ -0,0 +1,155 @@
package membership
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"testing"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/frame"
)
// directory signs a GET /directory as id and decodes the response envelope. The
// path has no /api prefix: Caddy strips /api before forwarding to membershipd, so
// the route is registered (and hit here) as /directory, matching production.
func directory(t *testing.T, h *authHarness, id cs.Identity, n int) (int, directoryResp) {
t.Helper()
code, body := signedJSON(t, h, "GET", "/directory", nil, id, n)
var resp directoryResp
if code == http.StatusOK {
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("decode directory: %v (%s)", err, body)
}
}
return code, resp
}
// findMember returns the directory entry for a signing key (case-insensitive).
func findMember(members []directoryMember, signPub string) (directoryMember, bool) {
want := normalizeSignPub(signPub)
for _, m := range members {
if normalizeSignPub(m.SignPub) == want {
return m, true
}
}
return directoryMember{}, false
}
// TestDirectoryGolden is the happy path: an authenticated bus user (here the seed
// admin alice, plus a registered member bob) reads the directory and gets every
// active user's handle, role, and an endpoint derived server-side from the
// sign_pub with the bus's own construction (frame.EndpointID). Two users in ->
// 200 with both handles and correct endpoints.
func TestDirectoryGolden(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob") // role member
bobPub := hex.EncodeToString(bob.SignPub)
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200 for an authenticated user, got %d", code)
}
aliceRow, ok := findMember(resp.Members, h.alicePub)
if !ok {
t.Fatalf("seed admin alice missing from directory: %+v", resp.Members)
}
if aliceRow.Handle != "alice" || aliceRow.Role != RoleAdmin {
t.Fatalf("alice row wrong: %+v", aliceRow)
}
if want := frame.EndpointID(h.alice.SignPub); aliceRow.Endpoint != want {
t.Fatalf("alice endpoint = %q, want %q", aliceRow.Endpoint, want)
}
bobRow, ok := findMember(resp.Members, bobPub)
if !ok {
t.Fatalf("registered member bob missing from directory: %+v", resp.Members)
}
if bobRow.Handle != "bob" || bobRow.Role != RoleMember {
t.Fatalf("bob row wrong: %+v", bobRow)
}
if want := frame.EndpointID(bob.SignPub); bobRow.Endpoint != want {
t.Fatalf("bob endpoint = %q, want %q", bobRow.Endpoint, want)
}
}
// TestDirectoryUnauthenticatedRejected is the auth contract: under enforce an
// unsigned GET /directory is rejected with 401 by the middleware, before the
// handler ever runs — the directory is not public.
func TestDirectoryUnauthenticatedRejected(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
req, _ := http.NewRequest("GET", h.ts.URL+"/directory", nil)
code, _ := do(t, req)
if code != http.StatusUnauthorized {
t.Fatalf("unsigned directory request under enforce should be 401, got %d", code)
}
}
// TestDirectoryExcludesRevoked: a revoked user must not appear in the directory
// (status=active filter), while active users still do.
func TestDirectoryExcludesRevoked(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
gone, _ := cs.GenerateIdentity()
register(t, h, gone, "gone")
gonePub := hex.EncodeToString(gone.SignPub)
if err := h.store.RevokeUser(gonePub); err != nil {
t.Fatalf("revoke gone: %v", err)
}
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200, got %d", code)
}
if _, ok := findMember(resp.Members, gonePub); ok {
t.Fatalf("revoked user must not appear in directory: %+v", resp.Members)
}
if _, ok := findMember(resp.Members, h.alicePub); !ok {
t.Fatalf("active admin alice should still appear: %+v", resp.Members)
}
}
// TestDirectoryEndpointParity pins the server-side endpoint derivation to the
// cross-language parity vector emitted by cmd/busvectors (and consumed by the
// uniweb crypto.ts endpointID test): for a FIXED sign_pub the directory must
// return the exact base64url(sha256(signPub)) endpoint, byte-for-byte. The
// expected value is recomputed here independently of frame.EndpointID so the test
// fails if the handler ever diverges from the canonical construction.
func TestDirectoryEndpointParity(t *testing.T) {
// Vector from cmd/busvectors (seed 000102..1f -> Ed25519 public key).
const vectorSignPub = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
const vectorEndpoint = "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw"
// Independent recomputation: base64url(sha256(raw signPub bytes)), unpadded.
raw, err := hex.DecodeString(vectorSignPub)
if err != nil {
t.Fatalf("decode vector sign_pub: %v", err)
}
sum := sha256.Sum256(raw)
if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != vectorEndpoint {
t.Fatalf("vector self-check: recomputed endpoint %q != pinned %q", got, vectorEndpoint)
}
h := newAuthHarness(t, AuthEnforce)
if err := h.store.AddUser(vectorSignPub, "vectorbot", RoleMember); err != nil {
t.Fatalf("add vector user: %v", err)
}
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200, got %d", code)
}
row, ok := findMember(resp.Members, vectorSignPub)
if !ok {
t.Fatalf("vector user missing from directory: %+v", resp.Members)
}
if row.Endpoint != vectorEndpoint {
t.Fatalf("endpoint parity broken: directory returned %q, want %q", row.Endpoint, vectorEndpoint)
}
}
+198
View File
@@ -0,0 +1,198 @@
package membership
// Server-side durable history for persisted rooms (room.ModeMatrix / Persist).
//
// A persisted room's messages ride a file-backed JetStream stream named
// "UNIBUS_<roomID>" (roomStreamName, identical to pkg/client.streamName). Until
// now that stream was created only by the Go client's first publish/subscribe; a
// client that speaks only core NATS (the browser client uniweb, which has no
// JetStream) therefore never created it, so its messages were captured nowhere and
// vanished on reload. This file moves stream ownership to the server: the control
// plane ensures the stream when a persisted room is created (so capture starts at
// minute zero whoever publishes) and exposes GET /rooms/{id}/history so a
// JetStream-less client can read the backlog over plain HTTP.
//
// The server never decrypts: each stored message is the E2E frame exactly as it
// was published (ciphertext for an encrypted room). The history endpoint returns
// those bytes verbatim (base64-encoded for JSON safety), so end-to-end encryption
// is preserved — the server only relays the bytes it already holds.
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/nats-io/nats.go/jetstream"
)
const (
// defaultHistoryLimit is the number of most-recent messages returned when the
// caller does not specify ?limit.
defaultHistoryLimit = 200
// maxHistoryLimit is the hard ceiling on a single history response, so a caller
// cannot ask the server to buffer an unbounded backlog into one JSON payload.
maxHistoryLimit = 1000
// historyOpTimeout bounds each JetStream operation the history path performs
// (stream lookup/ensure, info, per-message get) so a stalled data plane cannot
// hang a control-plane request indefinitely.
historyOpTimeout = 5 * time.Second
)
// historyResp is the GET /rooms/{id}/history response envelope. messages is the
// ordered (oldest→newest) list of the room's most recent frames, each the base64
// (standard encoding) of the marshaled, still-encrypted frame as it was published.
// The key is a stable contract consumed by the browser client; do not rename it.
type historyResp struct {
Messages []string `json:"messages"`
}
// streamConfigForRoom builds the JetStream stream config for a persisted room.
//
// It MUST stay byte-for-byte compatible with pkg/client/persist.go's ensureStream
// (the original owner of this format): same name derivation (roomStreamName ==
// pkg/client.streamName), same single subject, LimitsPolicy retention, file
// storage. pkg/client is the source of truth for the format; we copy it here
// rather than import it because pkg/client imports pkg/membership and importing it
// back would be a cycle. The only addition is Replicas, matched to the cluster's
// control-plane replication so a persisted room's history is as available as its
// metadata (1 standalone, up to 3 in an HA cluster). CreateOrUpdateStream treats a
// matching config as a no-op, so the client's later ensureStream is harmless.
func streamConfigForRoom(roomID, subject string, replicas int) jetstream.StreamConfig {
if replicas < 1 {
replicas = 1
}
return jetstream.StreamConfig{
Name: roomStreamName(roomID),
Subjects: []string{subject},
Retention: jetstream.LimitsPolicy,
Storage: jetstream.FileStorage,
Replicas: replicas,
}
}
// ensureRoomStream idempotently creates (or no-ops on) the durable stream that
// captures a persisted room's subject. CreateOrUpdateStream returns the existing
// stream unchanged when the config matches, so this is safe to call on every room
// creation and on every history read (lazy backfill of pre-existing rooms).
func ensureRoomStream(ctx context.Context, js jetstream.JetStream, roomID, subject string, replicas int) error {
if _, err := js.CreateOrUpdateStream(ctx, streamConfigForRoom(roomID, subject, replicas)); err != nil {
return fmt.Errorf("membership: ensure stream for room %s: %w", roomID, err)
}
return nil
}
// readRoomHistory returns the last `limit` messages of a room's durable stream in
// chronological order (oldest→newest), each base64-encoded (standard encoding). A
// stream that does not exist yet, or that holds no messages, yields an empty slice
// (not an error): a freshly created or never-used room simply has no history. It
// reads by sequence via the stream MSG.GET API rather than binding a consumer, so
// it has no side effects on any peer's durable ack position. A gap in the sequence
// range (a purged/deleted message) is skipped rather than failing the whole read,
// so the result length is bounded by `limit` but may be smaller.
func readRoomHistory(ctx context.Context, js jetstream.JetStream, roomID string, limit int) ([]string, error) {
out := []string{}
stream, err := js.Stream(ctx, roomStreamName(roomID))
if err != nil {
if errors.Is(err, jetstream.ErrStreamNotFound) {
return out, nil
}
return nil, fmt.Errorf("membership: lookup stream for room %s: %w", roomID, err)
}
si, err := stream.Info(ctx)
if err != nil {
return nil, fmt.Errorf("membership: stream info for room %s: %w", roomID, err)
}
first, last := si.State.FirstSeq, si.State.LastSeq
if si.State.Msgs == 0 || last == 0 {
return out, nil
}
// Window of the last `limit` sequence numbers, clamped to the first stored seq.
// last >= limit guards the unsigned subtraction against underflow.
start := first
if last >= uint64(limit) {
if cand := last - uint64(limit) + 1; cand > start {
start = cand
}
}
for seq := start; seq <= last; seq++ {
raw, err := stream.GetMsg(ctx, seq)
if err != nil {
// A purged/deleted sequence leaves a gap; skip it rather than abort.
continue
}
out = append(out, base64.StdEncoding.EncodeToString(raw.Data))
}
return out, nil
}
// parseHistoryLimit reads the ?limit query value, applying the default when it is
// absent and clamping out-of-range / malformed values to [1, maxHistoryLimit].
func parseHistoryLimit(q string) int {
if q == "" {
return defaultHistoryLimit
}
n, err := strconv.Atoi(q)
if err != nil || n <= 0 {
return defaultHistoryLimit
}
if n > maxHistoryLimit {
return maxHistoryLimit
}
return n
}
// handleRoomHistory serves GET /rooms/{id}/history: the last ?limit (default 200,
// hard cap 1000) messages of a persisted room, oldest→newest, each the base64 of
// the still-encrypted frame as published. The server never decrypts — it relays
// the ciphertext bytes the stream already holds, preserving E2E.
//
// Authorization mirrors the sibling room reads (/key, /members): the request must
// be a member of the room (requireMember; allowed under AuthOff/dev where no signer
// is verified). A missing room is 404; a non-member is 403; an unsigned request
// under enforce is rejected with 401 by the auth middleware before this runs.
//
// For a persisted room the stream is ensured first (lazy backfill): a room created
// before the server managed streams begins capturing from now on. Messages sent
// before the stream existed were never captured and are unrecoverable — only
// messages from stream creation onward appear here.
func (s *Server) handleRoomHistory(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
// Existence first so a missing room is a clean 404 (the documented contract),
// distinct from a 403 for an existing room the caller is not a member of.
info, err := s.store.GetRoom(roomID)
if err != nil {
writeErr(w, http.StatusNotFound, "room not found")
return
}
if _, ok := s.requireMember(w, r, roomID); !ok {
return
}
limit := parseHistoryLimit(r.URL.Query().Get("limit"))
// No JetStream wired (e.g. an external-NATS deployment without a cluster/KV
// feature): there is no durable stream to read, so report an empty history
// rather than 500 — a client degrades to "no backlog" gracefully.
if s.js == nil {
writeJSON(w, http.StatusOK, historyResp{Messages: []string{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), historyOpTimeout)
defer cancel()
if info.Persist {
if err := ensureRoomStream(ctx, s.js, roomID, info.Subject, s.streamReplicas); err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
}
msgs, err := readRoomHistory(ctx, s.js, roomID, limit)
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
writeJSON(w, http.StatusOK, historyResp{Messages: msgs})
}
+400
View File
@@ -0,0 +1,400 @@
package membership
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// historyHarness is an enforce-mode control plane wired to a real embedded NATS
// JetStream, so the history path exercises the production code: the server ensures
// and reads actual durable streams. alice is a seeded admin (and any room's owner),
// bob is a registered user added as a room member, and carol is a registered user
// that is NOT a member of the test room (to exercise the 403 path).
type historyHarness struct {
ts *httptest.Server
store Store
js jetstream.JetStream
nc *nats.Conn
alice cs.Identity // admin + room owner
bob cs.Identity // room member
carol cs.Identity // registered, non-member
}
func newHistoryHarness(t *testing.T) *historyHarness {
t.Helper()
dir := t.TempDir()
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: filepath.Join(dir, "jetstream"),
Host: "127.0.0.1",
Port: kvFreePort(t),
})
if err != nil {
t.Fatalf("embedded nats: %v", err)
}
nc, err := nats.Connect(ns.ClientURL())
if err != nil {
ns.Shutdown()
t.Fatalf("nats connect: %v", err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
ns.Shutdown()
t.Fatalf("jetstream: %v", err)
}
store, err := Open(filepath.Join(dir, "unibus.db"))
if err != nil {
nc.Close()
ns.Shutdown()
t.Fatalf("open store: %v", err)
}
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
if err != nil {
store.Close()
nc.Close()
ns.Shutdown()
t.Fatalf("open blobs: %v", err)
}
mustID := func(name string) cs.Identity {
id, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("identity %s: %v", name, err)
}
return id
}
alice, bob, carol := mustID("alice"), mustID("bob"), mustID("carol")
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", RoleAdmin); err != nil {
t.Fatalf("seed admin: %v", err)
}
for _, u := range []struct {
id cs.Identity
handle string
}{{bob, "bob"}, {carol, "carol"}} {
if err := store.AddUser(hex.EncodeToString(u.id.SignPub), u.handle, RoleMember); err != nil {
t.Fatalf("register %s: %v", u.handle, err)
}
}
srv := NewServer(store, blobs, AuthEnforce)
srv.SetJetStream(js, 1)
ts := httptest.NewServer(srv)
t.Cleanup(func() {
ts.Close()
store.Close()
nc.Close()
ns.Shutdown()
ns.WaitForShutdown()
})
return &historyHarness{ts: ts, store: store, js: js, nc: nc, alice: alice, bob: bob, carol: carol}
}
// seedPersistRoom creates a persisted (Matrix-policy) room directly in the store
// with alice as owner and bob as a member, returning its id and subject. It does
// NOT create the stream — that is left to the code under test (handleCreateRoom or
// the lazy ensure in the history endpoint), which is exactly what we want to verify.
func (h *historyHarness) seedPersistRoom(t *testing.T) (roomID, subject string) {
t.Helper()
roomID = newULID()
subject = "unibus.room." + roomID
aliceEp := frame.EndpointID(h.alice.SignPub)
info := RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: aliceEp, Encrypt: true, Persist: true}
if err := h.store.CreateRoom(info, h.alice.SignPub, h.alice.KexPub, []byte("alice-sealed")); err != nil {
t.Fatalf("seed room: %v", err)
}
bobEp := frame.EndpointID(h.bob.SignPub)
bobM := Member{Endpoint: bobEp, Role: RoleMember, SignPub: h.bob.SignPub, KexPub: h.bob.KexPub}
if err := h.store.AddMember(roomID, bobM, 0, []byte("bob-sealed")); err != nil {
t.Fatalf("add member bob: %v", err)
}
return roomID, subject
}
// makeFrame builds a marshaled PUB frame whose payload identifies it, so a test can
// assert exact bytes and ordering after a round trip through the stream + endpoint.
func makeFrame(t *testing.T, subject, sender string, i int) []byte {
t.Helper()
f := frame.Frame{
Type: frame.PUB,
Subject: subject,
Sender: sender,
MsgID: fmt.Sprintf("msg-%02d", i),
Payload: []byte(fmt.Sprintf("ciphertext-%02d", i)),
}
b, err := f.Marshal()
if err != nil {
t.Fatalf("marshal frame %d: %v", i, err)
}
return b
}
// getHistory signs a GET /rooms/{id}/history request as id and returns the status,
// the raw body, and the decoded envelope. query is the raw query string (e.g.
// "limit=2") or "". The signed path includes the query because the server verifies
// the signature over r.URL.RequestURI(), which carries it.
func (h *historyHarness) getHistory(t *testing.T, id cs.Identity, roomID, query string, n int) (int, string, historyResp) {
t.Helper()
path := "/rooms/" + roomID + "/history"
if query != "" {
path += "?" + query
}
req := signedReq(t, h.ts.URL, "GET", path, nil, id, time.Now().Unix(), nonceN(n))
code, body := do(t, req)
var out historyResp
if code == 200 {
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode history: %v (%s)", err, body)
}
}
return code, body, out
}
// TestCreateRoomEnsuresStream verifies handleCreateRoom creates the durable stream
// for a persisted room before responding, so capture starts at room creation.
func TestCreateRoomEnsuresStream(t *testing.T) {
h := newHistoryHarness(t)
aliceEp := frame.EndpointID(h.alice.SignPub)
reqBody := createRoomReq{
Subject: "unibus.room.created",
Policy: policyJSON{Encrypt: true, Persist: true},
Owner: endpointJSON{Endpoint: aliceEp, SignPub: h.alice.SignPub, KexPub: h.alice.KexPub},
SealedKeySelf: []byte("alice-sealed"),
}
body, _ := json.Marshal(reqBody)
req := signedReq(t, h.ts.URL, "POST", "/rooms", body, h.alice, time.Now().Unix(), nonceN(1))
code, respBody := do(t, req)
if code != 201 {
t.Fatalf("create room: want 201, got %d (%s)", code, respBody)
}
var cr createRoomResp
if err := json.Unmarshal([]byte(respBody), &cr); err != nil {
t.Fatalf("decode create resp: %v (%s)", err, respBody)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := h.js.Stream(ctx, roomStreamName(cr.RoomID)); err != nil {
t.Fatalf("stream for created persist room should exist: %v", err)
}
}
// TestRoomHistoryGolden is the golden path: three frames published to a persisted
// room's stream come back from the endpoint base64-encoded, in chronological order,
// and decode to the exact frames that were published.
func TestRoomHistoryGolden(t *testing.T) {
h := newHistoryHarness(t)
roomID, subject := h.seedPersistRoom(t)
bobEp := frame.EndpointID(h.bob.SignPub)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
t.Fatalf("ensure stream: %v", err)
}
want := make([][]byte, 3)
for i := 0; i < 3; i++ {
want[i] = makeFrame(t, subject, bobEp, i)
// js.Publish waits for the stream ack, so the message is durably stored before
// the next iteration — no sleeps, deterministic ordering.
if _, err := h.js.Publish(ctx, subject, want[i]); err != nil {
t.Fatalf("publish %d: %v", i, err)
}
}
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 10)
if code != 200 {
t.Fatalf("history: want 200, got %d (%s)", code, raw)
}
if len(hr.Messages) != 3 {
t.Fatalf("want 3 messages, got %d (%s)", len(hr.Messages), raw)
}
for i, m := range hr.Messages {
decoded, err := base64.StdEncoding.DecodeString(m)
if err != nil {
t.Fatalf("message %d not valid base64: %v", i, err)
}
if string(decoded) != string(want[i]) {
t.Fatalf("message %d bytes mismatch (order or content)", i)
}
f, err := frame.Unmarshal(decoded)
if err != nil {
t.Fatalf("message %d does not decode to a frame: %v", i, err)
}
if f.MsgID != fmt.Sprintf("msg-%02d", i) {
t.Fatalf("message %d: want MsgID msg-%02d, got %q", i, i, f.MsgID)
}
}
}
// TestRoomHistoryCapturesCoreNATSPublish proves the central fix: a message
// published over PLAIN core NATS (as the JetStream-less browser client uniweb does)
// is captured by the server-owned stream and served by the endpoint. Without the
// server ensuring the stream, this message would be captured nowhere.
func TestRoomHistoryCapturesCoreNATSPublish(t *testing.T) {
h := newHistoryHarness(t)
roomID, subject := h.seedPersistRoom(t)
bobEp := frame.EndpointID(h.bob.SignPub)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
t.Fatalf("ensure stream: %v", err)
}
sent := makeFrame(t, subject, bobEp, 7)
if err := h.nc.Publish(subject, sent); err != nil {
t.Fatalf("core publish: %v", err)
}
if err := h.nc.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
// Core NATS publish has no stream ack; poll the stream until the message lands.
h.waitMsgs(t, roomID, 1)
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 11)
if code != 200 {
t.Fatalf("history: want 200, got %d (%s)", code, raw)
}
if len(hr.Messages) != 1 {
t.Fatalf("want 1 captured message, got %d (%s)", len(hr.Messages), raw)
}
decoded, err := base64.StdEncoding.DecodeString(hr.Messages[0])
if err != nil || string(decoded) != string(sent) {
t.Fatalf("captured core-NATS message round-trip mismatch (err=%v)", err)
}
}
// TestRoomHistoryLimit verifies ?limit caps the response to the most recent N
// messages, oldest→newest within the window.
func TestRoomHistoryLimit(t *testing.T) {
h := newHistoryHarness(t)
roomID, subject := h.seedPersistRoom(t)
bobEp := frame.EndpointID(h.bob.SignPub)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ensureRoomStream(ctx, h.js, roomID, subject, 1); err != nil {
t.Fatalf("ensure stream: %v", err)
}
for i := 0; i < 5; i++ {
if _, err := h.js.Publish(ctx, subject, makeFrame(t, subject, bobEp, i)); err != nil {
t.Fatalf("publish %d: %v", i, err)
}
}
code, raw, hr := h.getHistory(t, h.bob, roomID, "limit=2", 12)
if code != 200 {
t.Fatalf("history: want 200, got %d (%s)", code, raw)
}
if len(hr.Messages) != 2 {
t.Fatalf("limit=2 over 5 messages: want 2, got %d", len(hr.Messages))
}
// The window is the last two messages (indices 3 and 4), in order.
for off, m := range hr.Messages {
decoded, _ := base64.StdEncoding.DecodeString(m)
f, err := frame.Unmarshal(decoded)
if err != nil {
t.Fatalf("limited message %d does not decode: %v", off, err)
}
want := fmt.Sprintf("msg-%02d", off+3)
if f.MsgID != want {
t.Fatalf("limited message %d: want MsgID %s, got %q", off, want, f.MsgID)
}
}
}
// TestRoomHistoryEmptyRoom verifies a persisted room with no messages returns an
// empty (non-null) array, lazily ensuring the stream on the way.
func TestRoomHistoryEmptyRoom(t *testing.T) {
h := newHistoryHarness(t)
roomID, _ := h.seedPersistRoom(t)
code, raw, hr := h.getHistory(t, h.bob, roomID, "", 13)
if code != 200 {
t.Fatalf("history: want 200, got %d (%s)", code, raw)
}
if hr.Messages == nil {
t.Fatalf("empty room must return [] not null (%s)", raw)
}
if len(hr.Messages) != 0 {
t.Fatalf("empty room: want 0 messages, got %d", len(hr.Messages))
}
// The lazy ensure should have created the stream even though no message exists.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := h.js.Stream(ctx, roomStreamName(roomID)); err != nil {
t.Fatalf("lazy ensure should have created the stream: %v", err)
}
}
// TestRoomHistoryUnauthenticated verifies an unsigned request is rejected with 401
// under enforce, before the handler runs.
func TestRoomHistoryUnauthenticated(t *testing.T) {
h := newHistoryHarness(t)
roomID, _ := h.seedPersistRoom(t)
// No signing headers: plain GET against the enforce-mode control plane.
req, err := http.NewRequest("GET", h.ts.URL+"/rooms/"+roomID+"/history", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
code, body := do(t, req)
if code != 401 {
t.Fatalf("unauthenticated history: want 401, got %d (%s)", code, body)
}
}
// TestRoomHistoryNonMember verifies a registered user who is NOT a member of the
// room is rejected with 403.
func TestRoomHistoryNonMember(t *testing.T) {
h := newHistoryHarness(t)
roomID, _ := h.seedPersistRoom(t)
code, body, _ := h.getHistory(t, h.carol, roomID, "", 14)
if code != 403 {
t.Fatalf("non-member history: want 403, got %d (%s)", code, body)
}
}
// TestRoomHistoryRoomNotFound verifies a request for a non-existent room is a 404,
// distinct from the 403 a non-member of an existing room gets.
func TestRoomHistoryRoomNotFound(t *testing.T) {
h := newHistoryHarness(t)
code, body, _ := h.getHistory(t, h.alice, newULID(), "", 15)
if code != 404 {
t.Fatalf("missing room history: want 404, got %d (%s)", code, body)
}
}
// waitMsgs polls the room's stream until it holds at least want messages or a short
// deadline elapses, so a core-NATS publish (which carries no stream ack) is observed
// deterministically without a fixed sleep.
func (h *historyHarness) waitMsgs(t *testing.T, roomID string, want uint64) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
st, err := h.js.Stream(ctx, roomStreamName(roomID))
if err == nil {
si, ierr := st.Info(ctx)
if ierr == nil && si.State.Msgs >= want {
cancel()
return
}
}
cancel()
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("stream for room %s never reached %d message(s)", roomID, want)
}
+33 -10
View File
@@ -85,8 +85,18 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) {
if opTimeout <= 0 {
opTimeout = defaultKVOpTime
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Bootstrap budget for creating/opening the buckets. On a single node JetStream
// is ready the instant the server starts, so the first attempt succeeds. On a
// COLD multi-node cluster the JetStream meta-group must first elect a leader and
// each node must establish contact with it before its $JS.API responds. A KV
// op is a NATS request/reply: if it is published before the node's JetStream is
// ready the request is dropped (not queued), and a single long-context call then
// just blocks until it times out (issue 0006g). So we RETRY each bucket op with
// short per-attempt contexts until it succeeds or the overall bootstrap budget
// is exhausted; once the cluster is ready the next retry lands and the buckets
// are created, after which they persist and every node opens them quickly.
bootstrapBudget := 120 * time.Second
deadline := time.Now().Add(bootstrapBudget)
s := &jetstreamStore{opTimeout: opTimeout}
for _, b := range []struct {
@@ -99,14 +109,27 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) {
{bucketRoomKeys, &s.keys},
{bucketUsers, &s.users},
} {
kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: b.name,
Replicas: cfg.Replicas,
History: 1,
Storage: jetstream.FileStorage,
})
if err != nil {
return nil, fmt.Errorf("membership: open KV bucket %q (replicas=%d): %w", b.name, cfg.Replicas, err)
var kv jetstream.KeyValue
var lastErr error
for {
opCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
kv, lastErr = js.CreateOrUpdateKeyValue(opCtx, jetstream.KeyValueConfig{
Bucket: b.name,
Replicas: cfg.Replicas,
History: 1,
Storage: jetstream.FileStorage,
})
cancel()
if lastErr == nil {
break
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("membership: open KV bucket %q (replicas=%d) after %s: %w", b.name, cfg.Replicas, bootstrapBudget, lastErr)
}
// JetStream not ready yet (no meta leader / request dropped). Wait and
// re-publish the op; in a cluster cold start this lands once the meta
// group settles.
time.Sleep(1 * time.Second)
}
*b.dst = kv
}
+111 -8
View File
@@ -1,8 +1,10 @@
package membership
import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
@@ -78,16 +80,117 @@ func (l *ipRateLimiter) reapLocked(now time.Time) {
}
}
// clientIP extracts the source IP of an HTTP request, stripping the port. It
// trusts the transport's RemoteAddr only (no X-Forwarded-For parsing): a public
// deployment terminates TLS at this process or behind a proxy that the operator
// controls, and honoring an attacker-supplied header would let a single IP fan
// its quota across forged identities. If parsing fails the whole RemoteAddr is
// used as the key (still a stable per-connection bucket).
func clientIP(r *http.Request) string {
// clientIP extracts the rate-limit key for a request: the source IP, with the
// port stripped. By default it trusts the transport's RemoteAddr ONLY (no
// X-Forwarded-For parsing): honoring an attacker-supplied header would let a
// single IP fan its quota across forged identities. When the operator runs the
// control plane behind a reverse proxy they control (the same-origin Caddy
// deployment), SetTrustedProxies names that proxy's address(es); only then, and
// only when the immediate peer is one of them, is the forwarded client IP
// believed. This keeps the per-IP rate limit meaningful behind the proxy, where
// every request would otherwise share the proxy's single IP. If parsing fails the
// whole RemoteAddr is used as the key (still a stable per-connection bucket).
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
host = r.RemoteAddr
}
if !s.trustedProxies.has(host) {
return host
}
if fwd := forwardedClientIP(r, s.trustedProxies); fwd != "" {
return fwd
}
return host
}
// forwardedClientIP returns the real client IP a trusted proxy reported, or "" if
// none is present. X-Forwarded-For is read RIGHT-TO-LEFT: the rightmost entry is
// the one our immediate (trusted) proxy appended and therefore cannot be spoofed
// by the client, which can only prepend entries to the left. Trusted-proxy hops
// are skipped so a chain of proxies we own resolves to the first address none of
// them owns — the actual external client. X-Real-IP is a single-value fallback for
// proxies that set it instead. A non-trusted immediate peer never reaches here, so
// a direct attacker's forged header is ignored entirely.
func forwardedClientIP(r *http.Request, trusted trustedProxyMatcher) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" || trusted.has(ip) {
continue
}
if net.ParseIP(ip) != nil {
return ip
}
}
}
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
if net.ParseIP(xrip) != nil {
return xrip
}
}
return ""
}
// trustedProxyMatcher is the set of reverse-proxy addresses whose forwarding
// headers may be honored. The zero value (nil) matches nothing, so the default
// behavior is RemoteAddr-only.
type trustedProxyMatcher []*net.IPNet
// SetTrustedProxies configures the proxies whose X-Forwarded-For / X-Real-IP this
// server trusts for the per-IP rate limit. Each entry is an IP (treated as a /32
// or /128) or a CIDR. It returns an error on the first unparseable entry and
// leaves the previous configuration unchanged. Passing no entries clears the set.
func (s *Server) SetTrustedProxies(entries []string) error {
m, err := parseTrustedProxies(entries)
if err != nil {
return err
}
s.trustedProxies = m
return nil
}
// parseTrustedProxies turns a list of IPs/CIDRs into a matcher. A bare IP becomes
// a host route (/32 for IPv4, /128 for IPv6); blanks are skipped.
func parseTrustedProxies(entries []string) (trustedProxyMatcher, error) {
var m trustedProxyMatcher
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, ipnet, err := net.ParseCIDR(e); err == nil {
m = append(m, ipnet)
continue
}
ip := net.ParseIP(e)
if ip == nil {
return nil, fmt.Errorf("trusted proxy %q is not an IP or CIDR", e)
}
bits := 32
if ip.To4() == nil {
bits = 128
}
m = append(m, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
return m, nil
}
// has reports whether host (an IP string with no port) falls inside any trusted
// range. A nil matcher and an unparseable host both report false.
func (m trustedProxyMatcher) has(host string) bool {
if len(m) == 0 {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, n := range m {
if n.Contains(ip) {
return true
}
}
return false
}
+113
View File
@@ -0,0 +1,113 @@
package membership
import (
"net/http"
"testing"
)
// TestClientIPTrustedProxy covers the rate-limit key extraction behind a reverse
// proxy: forwarding headers are believed ONLY when the immediate peer is a
// configured trusted proxy, and never otherwise. This is what keeps the per-IP
// rate limit per-client once the control plane runs behind the same-origin Caddy
// proxy, without opening a quota-fanning hole for a direct attacker.
func TestClientIPTrustedProxy(t *testing.T) {
const caddy = "135.125.201.30"
cases := []struct {
name string
proxies []string
remote string
xff string
xRealIP string
want string
}{
{
name: "no trusted proxies ignores XFF",
remote: "203.0.113.7:5000",
xff: "1.2.3.4",
want: "203.0.113.7",
},
{
name: "trusted proxy honors XFF client",
proxies: []string{caddy},
remote: caddy + ":4451",
xff: "198.51.100.23",
want: "198.51.100.23",
},
{
name: "loopback proxy honors XFF (magnus-local hop)",
proxies: []string{"127.0.0.1/32", "::1/128"},
remote: "127.0.0.1:33344",
xff: "198.51.100.99",
want: "198.51.100.99",
},
{
name: "untrusted peer cannot spoof XFF",
proxies: []string{caddy},
remote: "203.0.113.7:5000",
xff: "10.0.0.1",
want: "203.0.113.7",
},
{
name: "XFF read right-to-left, trusted hops skipped",
proxies: []string{caddy},
remote: caddy + ":4451",
xff: "198.51.100.23, " + caddy,
want: "198.51.100.23",
},
{
name: "client-prepended forgery is skipped, real appended wins",
proxies: []string{caddy},
remote: caddy + ":4451",
xff: "9.9.9.9, 198.51.100.23",
want: "198.51.100.23",
},
{
name: "X-Real-IP fallback when no XFF",
proxies: []string{caddy},
remote: caddy + ":4451",
xRealIP: "198.51.100.77",
want: "198.51.100.77",
},
{
name: "trusted peer but no forwarding header falls back to peer",
proxies: []string{caddy},
remote: caddy + ":4451",
want: caddy,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := &Server{}
if len(tc.proxies) > 0 {
if err := s.SetTrustedProxies(tc.proxies); err != nil {
t.Fatalf("SetTrustedProxies(%v): %v", tc.proxies, err)
}
}
r, _ := http.NewRequest(http.MethodGet, "/rooms", nil)
r.RemoteAddr = tc.remote
if tc.xff != "" {
r.Header.Set("X-Forwarded-For", tc.xff)
}
if tc.xRealIP != "" {
r.Header.Set("X-Real-IP", tc.xRealIP)
}
if got := s.clientIP(r); got != tc.want {
t.Fatalf("clientIP = %q, want %q", got, tc.want)
}
})
}
}
// TestParseTrustedProxiesRejectsGarbage proves a malformed entry is a hard error
// (the command turns it into a startup failure) rather than a silently ignored
// misconfiguration that would leave the rate limit collapsed behind the proxy.
func TestParseTrustedProxiesRejectsGarbage(t *testing.T) {
if _, err := parseTrustedProxies([]string{"not-an-ip"}); err == nil {
t.Fatal("expected error for non-IP/CIDR entry, got nil")
}
if _, err := parseTrustedProxies([]string{"10.0.0.0/8", "127.0.0.1"}); err != nil {
t.Fatalf("valid entries rejected: %v", err)
}
}
+368 -8
View File
@@ -3,6 +3,7 @@ package membership
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -87,6 +88,42 @@ type Server struct {
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
// the zero value (all false) reflects an unsecured dev node.
Posture Posture
// AllowedOrigins is the CORS allowlist of browser Origin headers permitted to
// call the control plane cross-origin. It exists so a browser-native client
// (uniweb) can talk to membershipd directly, the way the Go/Kotlin clients
// already do over a non-browser transport (issue uniweb/0001). Native clients
// send no Origin header and are unaffected. The zero value (empty) keeps CORS
// OFF — no Access-Control headers are emitted and the server behaves exactly as
// before — so this is opt-in per deployment. Entries are matched exactly (scheme
// + host + port); never use "*" with credentials. Set by the command from a flag.
AllowedOrigins []string
// trustedProxies names the reverse proxies whose forwarding headers
// (X-Forwarded-For / X-Real-IP) the rate limiter is allowed to believe. It
// exists for the same-origin deployment where a single proxy (Caddy) fronts
// the control plane: without it every proxied request would share the proxy's
// one IP and collapse the per-IP rate limit into a single bucket for the whole
// world. Only when the immediate peer is one of these addresses is the
// forwarded client IP trusted; the zero value (nil) trusts nobody, preserving
// the RemoteAddr-only behavior that predates the flag. Set by the command via
// SetTrustedProxies. See clientIP.
trustedProxies trustedProxyMatcher
// js is the privileged JetStream context the server uses to own the durable
// per-room streams of persisted rooms: it ensures a room's stream on creation
// so the room's subject is captured from the first message — even from a
// JetStream-less browser client (uniweb) that speaks only core NATS — and reads
// it back for GET /rooms/{id}/history. It is wired by the command via
// SetJetStream whenever a JetStream-capable data plane is available (always for
// the embedded server). nil leaves history empty and stream-ensure a no-op,
// preserving the pre-feature behavior for a deployment without JetStream.
js jetstream.JetStream
// streamReplicas is the replication factor for the room streams the server
// creates, matched to the cluster's control-plane (KV) replication — 1 for a
// standalone node, up to 3 in an HA cluster — so a persisted room's history is
// as available as its metadata. Used only when js != nil. See SetJetStream.
streamReplicas int
}
// Posture describes the security posture a membershipd node runs with. It is
@@ -121,6 +158,19 @@ func NewServer(store Store, blobs blobstore.Store, authMode AuthMode) *Server {
return s
}
// SetJetStream wires the privileged JetStream context (and the room-stream
// replication factor) the server uses to ensure and read the durable streams of
// persisted rooms. replicas below 1 is clamped to 1. It must be called once at
// startup, before the server begins serving; leaving it unset keeps history empty
// and stream-ensure a no-op, the behavior for a deployment without JetStream.
func (s *Server) SetJetStream(js jetstream.JetStream, replicas int) {
if replicas < 1 {
replicas = 1
}
s.js = js
s.streamReplicas = replicas
}
// UseReplicatedNonces switches the server's anti-replay store from the
// per-process in-memory cache to a JetStream KV bucket shared across the cluster
// (issue 0003e). It MUST be called on every node of a multi-node deployment:
@@ -143,10 +193,19 @@ func (s *Server) UseReplicatedNonces(js jetstream.JetStream, replicas int) error
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
now := time.Now()
// CORS runs before everything else so a browser preflight never pays the rate
// limit or auth cost. When the request carries an allowed Origin we echo the
// Access-Control headers; a preflight (OPTIONS) is answered here and short-
// circuits the pipeline. With an empty allowlist this is a no-op, so non-browser
// clients and untouched deployments behave exactly as before (issue uniweb/0001).
if s.applyCORS(w, r) {
return // preflight handled
}
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
// shed at the cheapest possible point. The health probe is exempt so liveness
// checks are never throttled.
if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) {
if !isAuthExempt(r) && !s.limiter.allow(s.clientIP(r), now) {
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
@@ -213,9 +272,63 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
return
}
// Carry the authenticated signer's endpoint into the handler so room handlers
// can authorize by membership (audit H3). Only set on a verified identity.
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint)))
// Carry the authenticated signer's endpoint AND signing key into the handler.
// Room handlers authorize by membership via the endpoint (audit H3); the
// user-management handlers authorize by role via the signing key (the endpoint
// id is a one-way hash of the key, so it cannot be reversed to look the signer
// up in the user allowlist). Both are set only on a verified identity.
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex)))
}
// applyCORS handles cross-origin requests for the control plane. When the request
// carries an Origin in the allowlist it sets the Access-Control-Allow-* response
// headers so the browser accepts the eventual response; when the request is a CORS
// preflight (OPTIONS) it writes the preflight reply and returns true so ServeHTTP
// short-circuits before the rate limiter and auth ever run. It returns false for
// every non-preflight request — including same-origin and native clients that send
// no Origin header — leaving the normal pipeline to run unchanged. With an empty
// AllowedOrigins it never sets a header (CORS is off): the opt-in default.
func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) (preflight bool) {
origin := r.Header.Get("Origin")
allowed := origin != "" && s.originAllowed(origin)
if allowed {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
// Vary: Origin so a cache never serves an allow-listed response to another
// origin. Add (not Set) to preserve any Vary the handler may add later.
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
// Allow the control-plane request-auth headers a browser client signs every
// request with (busauth.signedHeaders), or the browser's CORS preflight blocks
// the real request. Content-Type/Authorization stay for JSON bodies.
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Unibus-Pub, X-Unibus-Ts, X-Unibus-Nonce, X-Unibus-Sig")
h.Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
// Answer the preflight here so it never reaches the rate limiter or auth. An
// allowed origin gets 204 with the headers above; a disallowed or missing
// origin gets 403 with no Access-Control headers, so the browser blocks the
// real cross-origin request.
if allowed {
w.WriteHeader(http.StatusNoContent)
} else {
w.WriteHeader(http.StatusForbidden)
}
return true
}
return false
}
// originAllowed reports whether origin is in the CORS allowlist. Matching is exact
// (scheme + host + port): a browser Origin is an opaque string, so an exact compare
// is both correct and the safest policy (no wildcard, no suffix tricks).
func (s *Server) originAllowed(origin string) bool {
for _, o := range s.AllowedOrigins {
if o == origin {
return true
}
}
return false
}
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
@@ -229,11 +342,19 @@ func isBodyTooLarge(err error) bool {
// values cannot collide with keys set by other packages.
type ctxKey int
const ctxSignerEndpoint ctxKey = iota
const (
ctxSignerEndpoint ctxKey = iota
ctxSignerPub
)
// withSigner returns a context carrying the authenticated signer's endpoint id.
func withSigner(ctx context.Context, endpoint string) context.Context {
return context.WithValue(ctx, ctxSignerEndpoint, endpoint)
// withSigner returns a context carrying the authenticated signer's endpoint id
// and signing public key (lowercase hex). The endpoint authorizes room
// membership; the signing key authorizes user-management by role, because the
// endpoint id is a one-way hash of the key (base64url(sha256(signPub))) and so
// cannot be reversed to look the signer up in the user allowlist.
func withSigner(ctx context.Context, endpoint, pubHex string) context.Context {
ctx = context.WithValue(ctx, ctxSignerEndpoint, endpoint)
return context.WithValue(ctx, ctxSignerPub, pubHex)
}
// signerEndpoint returns the authenticated signer's endpoint id and whether one
@@ -245,6 +366,16 @@ func signerEndpoint(r *http.Request) (string, bool) {
return v, ok && v != ""
}
// signerPubHex returns the authenticated signer's signing public key (lowercase
// hex) and whether one is present. Like signerEndpoint it is absent under
// AuthOff and on a soft-mode pass-through; the user-management handlers treat
// that absence as "no admin identity" and deny (default-deny), since a
// privilege-granting operation must never run without a verified admin.
func signerPubHex(r *http.Request) (string, bool) {
v, ok := r.Context().Value(ctxSignerPub).(string)
return v, ok && v != ""
}
// requireMember authorizes a room request by membership (audit H3): it returns
// the signer endpoint and true when the request may proceed, or writes 403 and
// returns false when an authenticated signer is not a member of roomID. When no
@@ -262,6 +393,31 @@ func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID st
return signer, true
}
// requireAdmin authorizes a user-management request: it returns the signer's
// signing-key hex and true ONLY when the authenticated signer is a user with
// role admin and active status; otherwise it writes 403 and returns false.
//
// Default-deny, with no dev relaxation: unlike requireMember (which allows a
// request when no authenticated signer is present, preserving AuthOff/dev
// behavior for room reads), this denies whenever the signer is absent or is not
// a verified active admin. The user-management endpoints grant and revoke bus
// access, so they must never be reachable without a verified admin identity —
// the store is consulted on every call so a just-revoked admin is denied
// immediately, and any store error fails closed.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
pubHex, ok := signerPubHex(r)
if !ok {
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
return "", false
}
u, err := s.store.GetUser(pubHex)
if err != nil || u.Role != RoleAdmin || u.Status != StatusActive {
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
return "", false
}
return pubHex, true
}
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
// Only the unauthenticated health probe qualifies: it carries no data and is
// needed by load balancers / smoke checks / systemd before any identity exists.
@@ -275,11 +431,34 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /rooms/{id}/invite", s.handleInvite)
s.mux.HandleFunc("GET /rooms/{id}/key", s.handleGetKey)
s.mux.HandleFunc("GET /rooms/{id}/members", s.handleListMembers)
// Durable message history for a persisted room, read server-side from the room's
// JetStream stream so a client without JetStream (the browser client uniweb) can
// load the backlog over plain HTTP. Member-only, like /key and /members.
// Registered without the /api prefix like every other control-plane route: Caddy
// strips /api via handle_path /api/* before forwarding, so the SPA's
// GET /api/rooms/{id}/history arrives here as GET /rooms/{id}/history.
s.mux.HandleFunc("GET /rooms/{id}/history", s.handleRoomHistory)
s.mux.HandleFunc("GET /members/{endpoint}/rooms", s.handleListMemberRooms)
s.mux.HandleFunc("POST /rooms/{id}/rekey", s.handleRekey)
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
// User-management (admin-only) — the HTTP-signed equivalent of the local
// `membershipd user` CLI, so the admin panel manages the bus allowlist by
// signing as an admin instead of needing direct store/KV access. All three
// pass through requireAdmin; they hit the same store the room handlers do.
s.mux.HandleFunc("GET /users", s.handleListUsers)
s.mux.HandleFunc("POST /users", s.handleAddUser)
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
// Member directory — any authenticated bus user (member or admin) may map an
// endpoint id back to its human handle, so clients can render readable sender
// names instead of raw endpoint hashes. Unlike /users it is NOT admin-only and
// returns only active users; under enforce the auth middleware already rejects
// an unauthenticated caller with 401 before this handler runs (uniweb/0002).
// Registered without the /api prefix like every other control-plane route:
// Caddy strips /api via handle_path /api/* before forwarding to membershipd,
// so the SPA's GET /api/directory arrives here as GET /directory.
s.mux.HandleFunc("GET /directory", s.handleDirectory)
}
// ---- wire types -----------------------------------------------------------
@@ -357,6 +536,45 @@ type blobResp struct {
Hash string `json:"hash"`
}
// userJSON is the wire representation of a bus user on the admin endpoints. It
// carries the full record the panel needs to render the allowlist, including
// status (so revoked users are visible) and the timestamps. revoked_at is
// omitted for an active user.
type userJSON struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
}
// addUserReq is the POST /users body: the new user's Ed25519 signing key
// (64-hex), human handle, and role. role is optional and defaults to member.
type addUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// directoryMember is one entry of the GET /directory response: enough for a
// client to map a message's endpoint id (which the bus stamps on every frame)
// back to a readable handle. endpoint is derived server-side from sign_pub with
// the SAME construction the bus uses (frame.EndpointID = base64url(sha256(signPub)),
// unpadded), so it matches the sender id a client already has byte-for-byte.
type directoryMember struct {
SignPub string `json:"sign_pub"`
Endpoint string `json:"endpoint"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// directoryResp is the GET /directory response envelope. The members key is a
// stable contract consumed by the browser client; do not rename it.
type directoryResp struct {
Members []directoryMember `json:"members"`
}
// ---- helpers --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
@@ -449,6 +667,21 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
SignMsgs: req.Policy.SignMsgs,
OwnerEndpoint: req.Owner.Endpoint,
}
// Own the durable stream for a persisted room (issue room-history): ensure it
// BEFORE the room row is written so the subject is captured from the very first
// message whoever publishes it — a Go client OR a JetStream-less browser client.
// Done first so a stream failure aborts cleanly with no orphan room row (the
// rare orphan empty stream it can leave is harmless and idempotently reused).
// Skipped when no JetStream is wired: the room still works, just without history.
if info.Persist && s.js != nil {
ctx, cancel := context.WithTimeout(r.Context(), historyOpTimeout)
err := ensureRoomStream(ctx, s.js, roomID, info.Subject, s.streamReplicas)
cancel()
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
}
if err := s.store.CreateRoom(info, req.Owner.SignPub, req.Owner.KexPub, req.SealedKeySelf); err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
@@ -674,3 +907,130 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// ---- user-management handlers (admin-only) --------------------------------
// handleListUsers returns the full bus allowlist, including revoked users, so an
// admin sees the complete picture (a revoked identity stays auditable). Admin-only.
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
users, err := s.store.ListUsers()
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
out := make([]userJSON, 0, len(users))
for _, u := range users {
out = append(out, userJSON{
SignPub: u.SignPub,
Handle: u.Handle,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
RevokedAt: u.RevokedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleDirectory returns the active bus user directory so a client can resolve a
// sender's endpoint id to a readable handle. Unlike handleListUsers it is NOT
// admin-only: every authenticated bus user may read it (the auth middleware has
// already verified the caller is an active user under enforce, and rejected an
// unauthenticated one with 401). Only active users are listed, and each endpoint
// is computed server-side from the user's sign_pub with frame.EndpointID — the
// exact derivation the bus stamps on every frame, so the returned endpoint matches
// the sender id a client already holds. A user with a malformed sign_pub (which
// the add path rejects, so this is defensive) is skipped rather than failing the
// whole listing.
func (s *Server) handleDirectory(w http.ResponseWriter, r *http.Request) {
users, err := s.store.ListUsers()
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
out := make([]directoryMember, 0, len(users))
for _, u := range users {
if u.Status != StatusActive {
continue
}
signPub, err := hex.DecodeString(u.SignPub)
if err != nil || len(signPub) != 32 {
continue
}
out = append(out, directoryMember{
SignPub: u.SignPub,
Endpoint: frame.EndpointID(signPub),
Handle: u.Handle,
Role: u.Role,
})
}
writeJSON(w, http.StatusOK, directoryResp{Members: out})
}
// handleAddUser registers a new bus user from an admin-supplied Ed25519 signing
// key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the
// role must be admin or member (empty defaults to member), and re-adding an
// already-registered key is a 409 that leaves the existing row untouched — no
// silent upsert that could flip a role or clobber status. Admin-only.
func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
var req addUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
if req.SignPub == "" || req.Handle == "" {
writeErr(w, http.StatusBadRequest, "sign_pub and handle required")
return
}
if err := ValidateSignPubHex(req.SignPub); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
role := req.Role
if role == "" {
role = RoleMember
}
if role != RoleAdmin && role != RoleMember {
writeErr(w, http.StatusBadRequest,
fmt.Sprintf("invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember))
return
}
if err := s.store.AddUser(req.SignPub, req.Handle, role); err != nil {
if errors.Is(err, ErrUserExists) {
// Idempotency contract (mirrors the CLI): re-adding a key is an explicit,
// non-destructive conflict. To replace a user, revoke then add again.
writeErr(w, http.StatusConflict,
"user already registered (unchanged); revoke it first to replace")
return
}
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
}
// handleRevokeUser revokes a bus user by signing key. Revocation is a status
// flip (no hard delete) so the identity stays auditable and IsAuthorized denies
// it on both planes immediately. Revoking an unknown or already-revoked key is a
// 404. Admin-only.
func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
signPub := r.PathValue("signpub")
if err := ValidateSignPubHex(signPub); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := s.store.RevokeUser(signPub); err != nil {
writeServerErr(w, r, http.StatusNotFound, "no active user with that key", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
+18
View File
@@ -2,6 +2,7 @@ package membership
import (
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
@@ -35,6 +36,23 @@ type User struct {
RevokedAt string // empty unless revoked
}
// ValidateSignPubHex ensures signPub is exactly a 32-byte Ed25519 public key in
// hex (64 hex chars). It is the single source of truth for that check, shared by
// the local admin CLI (which validates before seeding the first admin) and the
// HTTP user-management handlers (which validate an admin-supplied key before it
// reaches the store). Catching a malformed key here turns a silent "authorized
// nobody" into an explicit error at the boundary.
func ValidateSignPubHex(signPub string) error {
b, err := hex.DecodeString(signPub)
if err != nil {
return fmt.Errorf("sign-pub is not valid hex: %w", err)
}
if len(b) != 32 {
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
}
return nil
}
// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the
// primary key is stored lowercase and every query normalizes its input the same
// way, so a caller passing uppercase hex still matches.
+164
View File
@@ -0,0 +1,164 @@
package membership
import (
"encoding/hex"
"encoding/json"
"net/http"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
)
// signedJSON is signedReq for a JSON body: it marshals v and signs the request
// as id with a distinct nonce. It returns the response status and body, reusing
// the auth_test harness so these tests exercise the real signed wire contract.
func signedJSON(t *testing.T, h *authHarness, method, path string, v any, id cs.Identity, n int) (int, string) {
t.Helper()
var body []byte
if v != nil {
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
body = b
}
return do(t, signedReq(t, h.ts.URL, method, path, body, id, time.Now().Unix(), nonceN(n)))
}
// TestUsersHTTP_NonAdminForbidden is the security spine: a REGISTERED but
// non-admin signer (bob, role member) is denied on every user-management
// endpoint. His signature clears auth (he is in the allowlist), so each request
// reaches the handler, where requireAdmin returns 403 — default-deny by role.
func TestUsersHTTP_NonAdminForbidden(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob") // role member (see register in authz_test.go)
bobPub := hex.EncodeToString(bob.SignPub)
victim, _ := cs.GenerateIdentity()
victimPub := hex.EncodeToString(victim.SignPub)
checks := []struct {
name string
method string
path string
body any
}{
{"list users", "GET", "/users", nil},
{"add user", "POST", "/users", addUserReq{SignPub: victimPub, Handle: "mallory", Role: RoleMember}},
{"revoke user", "POST", "/users/" + bobPub + "/revoke", nil},
}
for i, c := range checks {
code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1)
if code != http.StatusForbidden {
t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body)
}
}
}
// TestUsersHTTP_AdminRoundtrip exercises the golden path end to end: alice (the
// seeded admin) adds carol, sees her in the list as active, revokes her, then
// sees her status flip to revoked (no hard delete — she stays in the list).
func TestUsersHTTP_AdminRoundtrip(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
carol, _ := cs.GenerateIdentity()
carolPub := hex.EncodeToString(carol.SignPub)
// Add carol as a member.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated {
t.Fatalf("admin add carol should be 201, got %d (%s)", code, body)
}
// List: carol present and active; alice (the seed admin) also present.
users := listUsers(t, h, 2)
carolRow, ok := findUser(users, carolPub)
if !ok {
t.Fatalf("carol missing from list after add: %+v", users)
}
if carolRow.Status != StatusActive || carolRow.Role != RoleMember || carolRow.Handle != "carol" {
t.Fatalf("carol row wrong after add: %+v", carolRow)
}
if _, ok := findUser(users, h.alicePub); !ok {
t.Fatalf("seeded admin alice missing from list: %+v", users)
}
// Revoke carol.
if code, body := signedJSON(t, h, "POST", "/users/"+carolPub+"/revoke", nil, h.alice, 3); code != http.StatusOK {
t.Fatalf("admin revoke carol should be 200, got %d (%s)", code, body)
}
// List again: carol still present, now revoked (status flip, not delete).
users = listUsers(t, h, 4)
carolRow, ok = findUser(users, carolPub)
if !ok {
t.Fatalf("carol vanished from list after revoke (should be a status flip): %+v", users)
}
if carolRow.Status != StatusRevoked {
t.Fatalf("carol should be revoked, got status %q", carolRow.Status)
}
}
// TestUsersHTTP_Validation covers the input-validation contract: a malformed hex
// key is 400, an unknown role is 400, and re-adding an already-registered key is
// 409 (the existing row is left untouched — no silent upsert).
func TestUsersHTTP_Validation(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
good, _ := cs.GenerateIdentity()
goodPub := hex.EncodeToString(good.SignPub)
// Invalid hex (too short) -> 400.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: "abcd", Handle: "shorty", Role: RoleMember}, h.alice, 1); code != http.StatusBadRequest {
t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body)
}
// Invalid role -> 400.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: goodPub, Handle: "weirdrole", Role: "superuser"}, h.alice, 2); code != http.StatusBadRequest {
t.Fatalf("invalid role should be 400, got %d (%s)", code, body)
}
// Re-adding the seeded admin's own key -> 409 (idempotency, no overwrite).
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: h.alicePub, Handle: "alice-again", Role: RoleMember}, h.alice, 3); code != http.StatusConflict {
t.Fatalf("re-adding an existing key should be 409, got %d (%s)", code, body)
}
// And the existing row is untouched: alice is still an active admin.
u, err := h.store.GetUser(h.alicePub)
if err != nil {
t.Fatalf("get alice after conflicting re-add: %v", err)
}
if u.Role != RoleAdmin || u.Status != StatusActive || u.Handle != "alice" {
t.Fatalf("conflicting re-add mutated the existing row: %+v", u)
}
}
// listUsers signs a GET /users as alice and decodes the response.
func listUsers(t *testing.T, h *authHarness, n int) []userJSON {
t.Helper()
code, body := signedJSON(t, h, "GET", "/users", nil, h.alice, n)
if code != http.StatusOK {
t.Fatalf("admin list users should be 200, got %d (%s)", code, body)
}
var users []userJSON
if err := json.Unmarshal([]byte(body), &users); err != nil {
t.Fatalf("decode users: %v (%s)", err, body)
}
return users
}
// findUser returns the row with the given signing key (case-insensitive).
func findUser(users []userJSON, signPub string) (userJSON, bool) {
want := normalizeSignPub(signPub)
for _, u := range users {
if normalizeSignPub(u.SignPub) == want {
return u, true
}
}
return userJSON{}, false
}
-5
View File
@@ -1,5 +0,0 @@
node_modules/
dist/
*.local
.vite/
*.tsbuildinfo
File diff suppressed because one or more lines are too long
+2191
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>unibus</title>
<script type="module" crossorigin src="/assets/index-Mo3n05uO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNbOx14c.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>unibus</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Generated Vendored Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/browserslist@4.28.2/node_modules/browserslist/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/browserslist@4.28.2/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/browserslist@4.28.2/node_modules/browserslist/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/browserslist@4.28.2/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../.pnpm/browserslist@4.28.2/node_modules/browserslist/cli.js" "$@"
else
exec node "$basedir/../.pnpm/browserslist@4.28.2/node_modules/browserslist/cli.js" "$@"
fi
# cmd-shim-target=/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/browserslist@4.28.2/node_modules/browserslist/cli.js
Generated Vendored Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules/typescript/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules/typescript/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
# cmd-shim-target=/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/typescript/bin/tsc
Generated Vendored Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules/typescript/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules/typescript/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/typescript@5.6.3/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
# cmd-shim-target=/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/typescript/bin/tsserver
Generated Vendored Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/vite@6.4.3_sugarss@5.0.1_postcss@8.5.15_/node_modules/vite/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/vite@6.4.3_sugarss@5.0.1_postcss@8.5.15_/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/vite@6.4.3_sugarss@5.0.1_postcss@8.5.15_/node_modules/vite/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/vite@6.4.3_sugarss@5.0.1_postcss@8.5.15_/node_modules:/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi
# cmd-shim-target=/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web/node_modules/vite/bin/vite.js
+345
View File
@@ -0,0 +1,345 @@
{
"hoistedDependencies": {
"@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": {
"@floating-ui/react": "private"
},
"clsx@2.1.1": {
"clsx": "private"
},
"react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": {
"react-number-format": "private"
},
"react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7)": {
"react-remove-scroll": "private"
},
"type-fest@5.7.0": {
"type-fest": "private"
},
"@tabler/icons@3.44.0": {
"@tabler/icons": "private"
},
"csstype@3.2.3": {
"csstype": "private"
},
"@babel/core@7.29.7": {
"@babel/core": "private"
},
"react-refresh@0.17.0": {
"react-refresh": "private"
},
"@types/babel__core@7.20.5": {
"@types/babel__core": "private"
},
"@rolldown/pluginutils@1.0.0-beta.27": {
"@rolldown/pluginutils": "private"
},
"@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)": {
"@babel/plugin-transform-react-jsx-self": "private"
},
"@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)": {
"@babel/plugin-transform-react-jsx-source": "private"
},
"postcss-mixins@12.1.2(postcss@8.5.15)": {
"postcss-mixins": "private"
},
"postcss-nested@7.0.2(postcss@8.5.15)": {
"postcss-nested": "private"
},
"nanoid@3.3.12": {
"nanoid": "private"
},
"picocolors@1.1.1": {
"picocolors": "private"
},
"source-map-js@1.2.1": {
"source-map-js": "private"
},
"scheduler@0.27.0": {
"scheduler": "private"
},
"esbuild@0.25.12": {
"esbuild": "private"
},
"fdir@6.5.0(picomatch@4.0.4)": {
"fdir": "private"
},
"picomatch@4.0.4": {
"picomatch": "private"
},
"rollup@4.61.1": {
"rollup": "private"
},
"tinyglobby@0.2.17": {
"tinyglobby": "private"
},
"sugarss@5.0.1(postcss@8.5.15)": {
"sugarss": "private"
},
"@babel/code-frame@7.29.7": {
"@babel/code-frame": "private"
},
"@babel/generator@7.29.7": {
"@babel/generator": "private"
},
"@babel/helper-compilation-targets@7.29.7": {
"@babel/helper-compilation-targets": "private"
},
"@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)": {
"@babel/helper-module-transforms": "private"
},
"@babel/helpers@7.29.7": {
"@babel/helpers": "private"
},
"@babel/parser@7.29.7": {
"@babel/parser": "private"
},
"@babel/template@7.29.7": {
"@babel/template": "private"
},
"@babel/traverse@7.29.7": {
"@babel/traverse": "private"
},
"@babel/types@7.29.7": {
"@babel/types": "private"
},
"@jridgewell/remapping@2.3.5": {
"@jridgewell/remapping": "private"
},
"convert-source-map@2.0.0": {
"convert-source-map": "private"
},
"debug@4.4.3": {
"debug": "private"
},
"gensync@1.0.0-beta.2": {
"gensync": "private"
},
"json5@2.2.3": {
"json5": "private"
},
"semver@6.3.1": {
"semver": "private"
},
"@babel/helper-plugin-utils@7.29.7": {
"@babel/helper-plugin-utils": "private"
},
"tabbable@6.4.0": {
"tabbable": "private"
},
"@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)": {
"@floating-ui/react-dom": "private"
},
"@floating-ui/utils@0.2.11": {
"@floating-ui/utils": "private"
},
"@types/babel__template@7.4.4": {
"@types/babel__template": "private"
},
"@types/babel__traverse@7.28.0": {
"@types/babel__traverse": "private"
},
"@types/babel__generator@7.27.0": {
"@types/babel__generator": "private"
},
"@esbuild/linux-x64@0.25.12": {
"@esbuild/linux-x64": "private"
},
"postcss-js@4.1.0(postcss@8.5.15)": {
"postcss-js": "private"
},
"postcss-selector-parser@7.1.1": {
"postcss-selector-parser": "private"
},
"react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7)": {
"react-remove-scroll-bar": "private"
},
"react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7)": {
"react-style-singleton": "private"
},
"tslib@2.8.1": {
"tslib": "private"
},
"use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7)": {
"use-callback-ref": "private"
},
"use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7)": {
"use-sidecar": "private"
},
"@rollup/rollup-linux-x64-gnu@4.61.1": {
"@rollup/rollup-linux-x64-gnu": "private"
},
"@types/estree@1.0.9": {
"@types/estree": "private"
},
"tagged-tag@1.0.0": {
"tagged-tag": "private"
},
"@babel/helper-validator-identifier@7.29.7": {
"@babel/helper-validator-identifier": "private"
},
"js-tokens@4.0.0": {
"js-tokens": "private"
},
"@jridgewell/gen-mapping@0.3.13": {
"@jridgewell/gen-mapping": "private"
},
"@jridgewell/trace-mapping@0.3.31": {
"@jridgewell/trace-mapping": "private"
},
"jsesc@3.1.0": {
"jsesc": "private"
},
"@babel/compat-data@7.29.7": {
"@babel/compat-data": "private"
},
"@babel/helper-validator-option@7.29.7": {
"@babel/helper-validator-option": "private"
},
"browserslist@4.28.2": {
"browserslist": "private"
},
"lru-cache@5.1.1": {
"lru-cache": "private"
},
"@babel/helper-module-imports@7.29.7": {
"@babel/helper-module-imports": "private"
},
"@babel/helper-globals@7.29.7": {
"@babel/helper-globals": "private"
},
"@babel/helper-string-parser@7.29.7": {
"@babel/helper-string-parser": "private"
},
"@floating-ui/dom@1.7.6": {
"@floating-ui/dom": "private"
},
"ms@2.1.3": {
"ms": "private"
},
"camelcase-css@2.0.1": {
"camelcase-css": "private"
},
"cssesc@3.0.0": {
"cssesc": "private"
},
"util-deprecate@1.0.2": {
"util-deprecate": "private"
},
"get-nonce@1.0.1": {
"get-nonce": "private"
},
"detect-node-es@1.1.0": {
"detect-node-es": "private"
},
"@floating-ui/core@1.7.5": {
"@floating-ui/core": "private"
},
"@jridgewell/sourcemap-codec@1.5.5": {
"@jridgewell/sourcemap-codec": "private"
},
"@jridgewell/resolve-uri@3.1.2": {
"@jridgewell/resolve-uri": "private"
},
"baseline-browser-mapping@2.10.34": {
"baseline-browser-mapping": "private"
},
"caniuse-lite@1.0.30001797": {
"caniuse-lite": "private"
},
"electron-to-chromium@1.5.368": {
"electron-to-chromium": "private"
},
"node-releases@2.0.47": {
"node-releases": "private"
},
"update-browserslist-db@1.2.3(browserslist@4.28.2)": {
"update-browserslist-db": "private"
},
"yallist@3.1.1": {
"yallist": "private"
},
"escalade@3.2.0": {
"escalade": "private"
},
"@scure/base@2.2.0": {
"@scure/base": "private"
}
},
"hoistPattern": [
"*"
],
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true
},
"injectedDeps": {},
"layoutVersion": 5,
"nodeLinker": "isolated",
"packageManager": "pnpm@11.5.0",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Sun, 07 Jun 2026 15:50:34 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
"@jsr": "https://npm.jsr.io/"
},
"skipped": [
"@esbuild/aix-ppc64@0.25.12",
"@esbuild/android-arm64@0.25.12",
"@esbuild/android-arm@0.25.12",
"@esbuild/android-x64@0.25.12",
"@esbuild/darwin-arm64@0.25.12",
"@esbuild/darwin-x64@0.25.12",
"@esbuild/freebsd-arm64@0.25.12",
"@esbuild/freebsd-x64@0.25.12",
"@esbuild/linux-arm64@0.25.12",
"@esbuild/linux-arm@0.25.12",
"@esbuild/linux-ia32@0.25.12",
"@esbuild/linux-loong64@0.25.12",
"@esbuild/linux-mips64el@0.25.12",
"@esbuild/linux-ppc64@0.25.12",
"@esbuild/linux-riscv64@0.25.12",
"@esbuild/linux-s390x@0.25.12",
"@esbuild/netbsd-arm64@0.25.12",
"@esbuild/netbsd-x64@0.25.12",
"@esbuild/openbsd-arm64@0.25.12",
"@esbuild/openbsd-x64@0.25.12",
"@esbuild/openharmony-arm64@0.25.12",
"@esbuild/sunos-x64@0.25.12",
"@esbuild/win32-arm64@0.25.12",
"@esbuild/win32-ia32@0.25.12",
"@esbuild/win32-x64@0.25.12",
"@rollup/rollup-android-arm-eabi@4.61.1",
"@rollup/rollup-android-arm64@4.61.1",
"@rollup/rollup-darwin-arm64@4.61.1",
"@rollup/rollup-darwin-x64@4.61.1",
"@rollup/rollup-freebsd-arm64@4.61.1",
"@rollup/rollup-freebsd-x64@4.61.1",
"@rollup/rollup-linux-arm-gnueabihf@4.61.1",
"@rollup/rollup-linux-arm-musleabihf@4.61.1",
"@rollup/rollup-linux-arm64-gnu@4.61.1",
"@rollup/rollup-linux-arm64-musl@4.61.1",
"@rollup/rollup-linux-loong64-gnu@4.61.1",
"@rollup/rollup-linux-loong64-musl@4.61.1",
"@rollup/rollup-linux-ppc64-gnu@4.61.1",
"@rollup/rollup-linux-ppc64-musl@4.61.1",
"@rollup/rollup-linux-riscv64-gnu@4.61.1",
"@rollup/rollup-linux-riscv64-musl@4.61.1",
"@rollup/rollup-linux-s390x-gnu@4.61.1",
"@rollup/rollup-linux-x64-musl@4.61.1",
"@rollup/rollup-openbsd-x64@4.61.1",
"@rollup/rollup-openharmony-arm64@4.61.1",
"@rollup/rollup-win32-arm64-msvc@4.61.1",
"@rollup/rollup-win32-ia32-msvc@4.61.1",
"@rollup/rollup-win32-x64-gnu@4.61.1",
"@rollup/rollup-win32-x64-msvc@4.61.1",
"fsevents@2.3.3"
],
"storeDir": "/home/enmanuel/.local/share/pnpm/store/v11",
"virtualStoreDir": ".pnpm",
"virtualStoreDirMaxLength": 120,
"allowBuilds": {
"esbuild": true
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"lastValidatedTimestamp": 1781378216062,
"projects": {
"/home/enmanuel/fn_registry/projects/message_bus/apps/unibus/web": {
"name": "unibus-web",
"version": "0.1.0"
}
},
"pnpmfiles": [],
"settings": {
"allowBuilds": {
"esbuild": true
},
"autoInstallPeers": true,
"catalogs": {},
"dedupeDirectDeps": false,
"dedupeInjectedDeps": true,
"dedupePeerDependents": true,
"dedupePeers": false,
"dev": true,
"excludeLinksFromLockfile": false,
"hoistPattern": [
"*"
],
"hoistWorkspacePackages": true,
"injectWorkspacePackages": false,
"linkWorkspacePackages": false,
"minimumReleaseAge": 1440,
"minimumReleaseAgeIgnoreMissingTime": true,
"nodeLinker": "isolated",
"optional": true,
"peersSuffixMaxLength": 1000,
"preferWorkspacePackages": false,
"production": true,
"publicHoistPattern": [],
"workspacePackagePatterns": [
"."
]
},
"filteredInstall": false
}
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,19 @@
# @babel/code-frame
> Generate errors that contain a code frame that point to source locations.
See our website [@babel/code-frame](https://babeljs.io/docs/babel-code-frame) for more information.
## Install
Using npm:
```sh
npm install --save-dev @babel/code-frame
```
or using yarn:
```sh
yarn add @babel/code-frame --dev
```
@@ -0,0 +1,217 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var picocolors = require('picocolors');
var jsTokens = require('js-tokens');
var helperValidatorIdentifier = require('@babel/helper-validator-identifier');
function isColorSupported() {
return (typeof process === "object" && (process.env.FORCE_COLOR === "0" || process.env.FORCE_COLOR === "false") ? false : picocolors.isColorSupported
);
}
const compose = (f, g) => v => f(g(v));
function buildDefs(colors) {
return {
keyword: colors.cyan,
capitalized: colors.yellow,
jsxIdentifier: colors.yellow,
punctuator: colors.yellow,
number: colors.magenta,
string: colors.green,
regex: colors.magenta,
comment: colors.gray,
invalid: compose(compose(colors.white, colors.bgRed), colors.bold),
gutter: colors.gray,
marker: compose(colors.red, colors.bold),
message: compose(colors.red, colors.bold),
reset: colors.reset
};
}
const defsOn = buildDefs(picocolors.createColors(true));
const defsOff = buildDefs(picocolors.createColors(false));
function getDefs(enabled) {
return enabled ? defsOn : defsOff;
}
const sometimesKeywords = new Set(["as", "async", "from", "get", "of", "set"]);
const NEWLINE$1 = /\r\n|[\n\r\u2028\u2029]/;
const BRACKET = /^[()[\]{}]$/;
let tokenize;
const JSX_TAG = /^[a-z][\w-]*$/i;
const getTokenType = function (token, offset, text) {
if (token.type === "name") {
const tokenValue = token.value;
if (helperValidatorIdentifier.isKeyword(tokenValue) || helperValidatorIdentifier.isStrictReservedWord(tokenValue, true) || sometimesKeywords.has(tokenValue)) {
return "keyword";
}
if (JSX_TAG.test(tokenValue) && (text[offset - 1] === "<" || text.slice(offset - 2, offset) === "</")) {
return "jsxIdentifier";
}
const firstChar = String.fromCodePoint(tokenValue.codePointAt(0));
if (firstChar !== firstChar.toLowerCase()) {
return "capitalized";
}
}
if (token.type === "punctuator" && BRACKET.test(token.value)) {
return "bracket";
}
if (token.type === "invalid" && (token.value === "@" || token.value === "#")) {
return "punctuator";
}
return token.type;
};
tokenize = function* (text) {
let match;
while (match = jsTokens.default.exec(text)) {
const token = jsTokens.matchToToken(match);
yield {
type: getTokenType(token, match.index, text),
value: token.value
};
}
};
function highlight(text) {
if (text === "") return "";
const defs = getDefs(true);
let highlighted = "";
for (const {
type,
value
} of tokenize(text)) {
if (type in defs) {
highlighted += value.split(NEWLINE$1).map(str => defs[type](str)).join("\n");
} else {
highlighted += value;
}
}
return highlighted;
}
let deprecationWarningShown = false;
const NEWLINE = /\r\n|[\n\r\u2028\u2029]/;
function getMarkerLines(loc, source, opts, startLineBaseZero) {
const startLoc = Object.assign({
column: 0,
line: -1
}, loc.start);
const endLoc = Object.assign({}, startLoc, loc.end);
const {
linesAbove = 2,
linesBelow = 3
} = opts || {};
const startLine = startLoc.line - startLineBaseZero;
const startColumn = startLoc.column;
const endLine = endLoc.line - startLineBaseZero;
const endColumn = endLoc.column;
let start = Math.max(startLine - (linesAbove + 1), 0);
let end = Math.min(source.length, endLine + linesBelow);
if (startLine === -1) {
start = 0;
}
if (endLine === -1) {
end = source.length;
}
const lineDiff = endLine - startLine;
const markerLines = {};
if (lineDiff) {
for (let i = 0; i <= lineDiff; i++) {
const lineNumber = i + startLine;
if (!startColumn) {
markerLines[lineNumber] = true;
} else if (i === 0) {
const sourceLength = source[lineNumber - 1].length;
markerLines[lineNumber] = [startColumn, sourceLength - startColumn + 1];
} else if (i === lineDiff) {
markerLines[lineNumber] = [0, endColumn];
} else {
const sourceLength = source[lineNumber - i].length;
markerLines[lineNumber] = [0, sourceLength];
}
}
} else {
if (startColumn === endColumn) {
if (startColumn) {
markerLines[startLine] = [startColumn, 0];
} else {
markerLines[startLine] = true;
}
} else {
markerLines[startLine] = [startColumn, endColumn - startColumn];
}
}
return {
start,
end,
markerLines
};
}
function codeFrameColumns(rawLines, loc, opts = {}) {
const shouldHighlight = opts.forceColor || isColorSupported() && opts.highlightCode;
const startLineBaseZero = (opts.startLine || 1) - 1;
const defs = getDefs(shouldHighlight);
const lines = rawLines.split(NEWLINE);
const {
start,
end,
markerLines
} = getMarkerLines(loc, lines, opts, startLineBaseZero);
const hasColumns = loc.start && typeof loc.start.column === "number";
const numberMaxWidth = String(end + startLineBaseZero).length;
const highlightedLines = shouldHighlight ? highlight(rawLines) : rawLines;
let frame = highlightedLines.split(NEWLINE, end).slice(start, end).map((line, index) => {
const number = start + 1 + index;
const paddedNumber = ` ${number + startLineBaseZero}`.slice(-numberMaxWidth);
const gutter = ` ${paddedNumber} |`;
const hasMarker = markerLines[number];
const lastMarkerLine = !markerLines[number + 1];
if (hasMarker) {
let markerLine = "";
if (Array.isArray(hasMarker)) {
const markerSpacing = line.slice(0, Math.max(hasMarker[0] - 1, 0)).replace(/[^\t]/g, " ");
const numberOfMarkers = hasMarker[1] || 1;
markerLine = ["\n ", defs.gutter(gutter.replace(/\d/g, " ")), " ", markerSpacing, defs.marker("^").repeat(numberOfMarkers)].join("");
if (lastMarkerLine && opts.message) {
markerLine += " " + defs.message(opts.message);
}
}
return [defs.marker(">"), defs.gutter(gutter), line.length > 0 ? ` ${line}` : "", markerLine].join("");
} else {
return ` ${defs.gutter(gutter)}${line.length > 0 ? ` ${line}` : ""}`;
}
}).join("\n");
if (opts.message && !hasColumns) {
frame = `${" ".repeat(numberMaxWidth + 1)}${opts.message}\n${frame}`;
}
if (shouldHighlight) {
return defs.reset(frame);
} else {
return frame;
}
}
function index (rawLines, lineNumber, colNumber, opts = {}) {
if (!deprecationWarningShown) {
deprecationWarningShown = true;
const message = "Passing lineNumber and colNumber is deprecated to @babel/code-frame. Please use `codeFrameColumns`.";
if (process.emitWarning) {
process.emitWarning(message, "DeprecationWarning");
} else {
const deprecationError = new Error(message);
deprecationError.name = "DeprecationWarning";
console.warn(new Error(message));
}
}
colNumber = Math.max(colNumber, 0);
const location = {
start: {
column: colNumber,
line: lineNumber
}
};
return codeFrameColumns(rawLines, location, opts);
}
exports.codeFrameColumns = codeFrameColumns;
exports.default = index;
exports.highlight = highlight;
//# sourceMappingURL=index.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
{
"name": "@babel/code-frame",
"version": "7.29.7",
"description": "Generate errors that contain a code frame that point to source locations.",
"author": "The Babel Team (https://babel.dev/team)",
"homepage": "https://babel.dev/docs/en/next/babel-code-frame",
"bugs": "https://github.com/babel/babel/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/babel/babel.git",
"directory": "packages/babel-code-frame"
},
"main": "./lib/index.js",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"charcodes": "^0.2.0",
"import-meta-resolve": "^4.1.0",
"strip-ansi": "^4.0.0"
},
"engines": {
"node": ">=6.9.0"
},
"type": "commonjs"
}
@@ -0,0 +1 @@
../../../@babel+helper-validator-identifier@7.29.7/node_modules/@babel/helper-validator-identifier
+1
View File
@@ -0,0 +1 @@
../../js-tokens@4.0.0/node_modules/js-tokens
+1
View File
@@ -0,0 +1 @@
../../picocolors@1.1.1/node_modules/picocolors
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,19 @@
# @babel/compat-data
> The compat-data to determine required Babel plugins
See our website [@babel/compat-data](https://babeljs.io/docs/babel-compat-data) for more information.
## Install
Using npm:
```sh
npm install --save @babel/compat-data
```
or using yarn:
```sh
yarn add @babel/compat-data
```
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file as Babel 8 drop support of core-js 2
module.exports = require("./data/corejs2-built-ins.json");
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file now that it is included in babel-plugin-polyfill-corejs3
module.exports = require("./data/corejs3-shipped-proposals.json");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
[
"esnext.promise.all-settled",
"esnext.string.match-all",
"esnext.global-this"
]
@@ -0,0 +1,18 @@
{
"es6.module": {
"chrome": "61",
"and_chr": "61",
"edge": "16",
"firefox": "60",
"and_ff": "60",
"node": "13.2.0",
"opera": "48",
"op_mob": "45",
"safari": "10.1",
"ios": "10.3",
"samsung": "8.2",
"android": "61",
"electron": "2.0",
"ios_saf": "10.3"
}
}
@@ -0,0 +1,38 @@
{
"transform-async-to-generator": [
"bugfix/transform-async-arrows-in-class"
],
"transform-parameters": [
"bugfix/transform-edge-default-parameters",
"bugfix/transform-safari-id-destructuring-collision-in-function-expression"
],
"transform-function-name": [
"bugfix/transform-edge-function-name"
],
"transform-block-scoping": [
"bugfix/transform-safari-block-shadowing",
"bugfix/transform-safari-for-shadowing"
],
"transform-destructuring": [
"bugfix/transform-safari-rest-destructuring-rhs-array"
],
"transform-template-literals": [
"bugfix/transform-tagged-template-caching"
],
"transform-optional-chaining": [
"bugfix/transform-v8-spread-parameters-in-optional-chaining"
],
"proposal-optional-chaining": [
"bugfix/transform-v8-spread-parameters-in-optional-chaining"
],
"transform-class-properties": [
"bugfix/transform-v8-static-class-fields-redefine-readonly",
"bugfix/transform-firefox-class-in-computed-class-key",
"bugfix/transform-safari-class-field-initializer-scope"
],
"proposal-class-properties": [
"bugfix/transform-v8-static-class-fields-redefine-readonly",
"bugfix/transform-firefox-class-in-computed-class-key",
"bugfix/transform-safari-class-field-initializer-scope"
]
}
@@ -0,0 +1,231 @@
{
"bugfix/transform-async-arrows-in-class": {
"chrome": "55",
"opera": "42",
"edge": "15",
"firefox": "52",
"safari": "11",
"node": "7.6",
"deno": "1",
"ios": "11",
"samsung": "6",
"opera_mobile": "42",
"electron": "1.6"
},
"bugfix/transform-edge-default-parameters": {
"chrome": "49",
"opera": "36",
"edge": "18",
"firefox": "52",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"bugfix/transform-edge-function-name": {
"chrome": "51",
"opera": "38",
"edge": "79",
"firefox": "53",
"safari": "10",
"node": "6.5",
"deno": "1",
"ios": "10",
"samsung": "5",
"rhino": "1.9",
"opera_mobile": "41",
"electron": "1.2"
},
"bugfix/transform-safari-block-shadowing": {
"chrome": "49",
"opera": "36",
"edge": "12",
"firefox": "44",
"safari": "11",
"node": "6",
"deno": "1",
"ie": "11",
"ios": "11",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"bugfix/transform-safari-for-shadowing": {
"chrome": "49",
"opera": "36",
"edge": "12",
"firefox": "4",
"safari": "11",
"node": "6",
"deno": "1",
"ie": "11",
"ios": "11",
"samsung": "5",
"rhino": "1.7.13",
"opera_mobile": "36",
"electron": "0.37"
},
"bugfix/transform-safari-id-destructuring-collision-in-function-expression": {
"chrome": "49",
"opera": "36",
"edge": "14",
"firefox": "2",
"safari": "16.3",
"node": "6",
"deno": "1",
"ios": "16.3",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"bugfix/transform-safari-rest-destructuring-rhs-array": {
"chrome": "49",
"opera": "36",
"edge": "14",
"firefox": "34",
"safari": "14.1",
"node": "6",
"deno": "1",
"ios": "14.5",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"bugfix/transform-tagged-template-caching": {
"chrome": "41",
"opera": "28",
"edge": "12",
"firefox": "34",
"safari": "13",
"node": "4",
"deno": "1",
"ios": "13",
"samsung": "3.4",
"rhino": "1.7.14",
"opera_mobile": "28",
"electron": "0.21"
},
"bugfix/transform-v8-spread-parameters-in-optional-chaining": {
"chrome": "91",
"opera": "77",
"edge": "91",
"firefox": "74",
"safari": "13.1",
"node": "16.9",
"deno": "1.9",
"ios": "13.4",
"samsung": "16",
"opera_mobile": "64",
"electron": "13.0"
},
"transform-optional-chaining": {
"chrome": "80",
"opera": "67",
"edge": "80",
"firefox": "74",
"safari": "13.1",
"node": "14",
"deno": "1",
"ios": "13.4",
"samsung": "13",
"rhino": "1.8",
"opera_mobile": "57",
"electron": "8.0"
},
"proposal-optional-chaining": {
"chrome": "80",
"opera": "67",
"edge": "80",
"firefox": "74",
"safari": "13.1",
"node": "14",
"deno": "1",
"ios": "13.4",
"samsung": "13",
"rhino": "1.8",
"opera_mobile": "57",
"electron": "8.0"
},
"transform-parameters": {
"chrome": "49",
"opera": "36",
"edge": "15",
"firefox": "52",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"transform-async-to-generator": {
"chrome": "55",
"opera": "42",
"edge": "15",
"firefox": "52",
"safari": "10.1",
"node": "7.6",
"deno": "1",
"ios": "10.3",
"samsung": "6",
"opera_mobile": "42",
"electron": "1.6"
},
"transform-template-literals": {
"chrome": "41",
"opera": "28",
"edge": "13",
"firefox": "34",
"safari": "9",
"node": "4",
"deno": "1",
"ios": "9",
"samsung": "3.4",
"rhino": "1.9",
"opera_mobile": "28",
"electron": "0.21"
},
"transform-function-name": {
"chrome": "51",
"opera": "38",
"edge": "14",
"firefox": "53",
"safari": "10",
"node": "6.5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "41",
"electron": "1.2"
},
"transform-destructuring": {
"chrome": "51",
"opera": "38",
"edge": "15",
"firefox": "53",
"safari": "10",
"node": "6.5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "41",
"electron": "1.2"
},
"transform-block-scoping": {
"chrome": "50",
"opera": "37",
"edge": "14",
"firefox": "53",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "37",
"electron": "1.1"
}
}
@@ -0,0 +1,843 @@
{
"transform-explicit-resource-management": {
"chrome": "141",
"edge": "141",
"firefox": "141",
"node": "25",
"electron": "39.0"
},
"transform-duplicate-named-capturing-groups-regex": {
"chrome": "126",
"opera": "112",
"edge": "126",
"firefox": "129",
"safari": "17.4",
"node": "23",
"ios": "17.4",
"rhino": "1.9",
"electron": "31.0"
},
"transform-regexp-modifiers": {
"chrome": "125",
"opera": "111",
"edge": "125",
"firefox": "132",
"node": "23",
"samsung": "27",
"electron": "31.0"
},
"transform-unicode-sets-regex": {
"chrome": "112",
"opera": "98",
"edge": "112",
"firefox": "116",
"safari": "17",
"node": "20",
"deno": "1.32",
"ios": "17",
"samsung": "23",
"opera_mobile": "75",
"electron": "24.0"
},
"bugfix/transform-v8-static-class-fields-redefine-readonly": {
"chrome": "98",
"opera": "84",
"edge": "98",
"firefox": "75",
"safari": "15",
"node": "12",
"deno": "1.18",
"ios": "15",
"samsung": "11",
"opera_mobile": "52",
"electron": "17.0"
},
"bugfix/transform-firefox-class-in-computed-class-key": {
"chrome": "74",
"opera": "62",
"edge": "79",
"firefox": "126",
"safari": "16",
"node": "12",
"deno": "1",
"ios": "16",
"samsung": "11",
"opera_mobile": "53",
"electron": "6.0"
},
"bugfix/transform-safari-class-field-initializer-scope": {
"chrome": "74",
"opera": "62",
"edge": "79",
"firefox": "69",
"safari": "16",
"node": "12",
"deno": "1",
"ios": "16",
"samsung": "11",
"opera_mobile": "53",
"electron": "6.0"
},
"transform-class-static-block": {
"chrome": "94",
"opera": "80",
"edge": "94",
"firefox": "93",
"safari": "16.4",
"node": "16.11",
"deno": "1.14",
"ios": "16.4",
"samsung": "17",
"opera_mobile": "66",
"electron": "15.0"
},
"proposal-class-static-block": {
"chrome": "94",
"opera": "80",
"edge": "94",
"firefox": "93",
"safari": "16.4",
"node": "16.11",
"deno": "1.14",
"ios": "16.4",
"samsung": "17",
"opera_mobile": "66",
"electron": "15.0"
},
"transform-private-property-in-object": {
"chrome": "91",
"opera": "77",
"edge": "91",
"firefox": "90",
"safari": "15",
"node": "16.9",
"deno": "1.9",
"ios": "15",
"samsung": "16",
"opera_mobile": "64",
"electron": "13.0"
},
"proposal-private-property-in-object": {
"chrome": "91",
"opera": "77",
"edge": "91",
"firefox": "90",
"safari": "15",
"node": "16.9",
"deno": "1.9",
"ios": "15",
"samsung": "16",
"opera_mobile": "64",
"electron": "13.0"
},
"transform-class-properties": {
"chrome": "74",
"opera": "62",
"edge": "79",
"firefox": "90",
"safari": "14.1",
"node": "12",
"deno": "1",
"ios": "14.5",
"samsung": "11",
"opera_mobile": "53",
"electron": "6.0"
},
"proposal-class-properties": {
"chrome": "74",
"opera": "62",
"edge": "79",
"firefox": "90",
"safari": "14.1",
"node": "12",
"deno": "1",
"ios": "14.5",
"samsung": "11",
"opera_mobile": "53",
"electron": "6.0"
},
"transform-private-methods": {
"chrome": "84",
"opera": "70",
"edge": "84",
"firefox": "90",
"safari": "15",
"node": "14.6",
"deno": "1",
"ios": "15",
"samsung": "14",
"opera_mobile": "60",
"electron": "10.0"
},
"proposal-private-methods": {
"chrome": "84",
"opera": "70",
"edge": "84",
"firefox": "90",
"safari": "15",
"node": "14.6",
"deno": "1",
"ios": "15",
"samsung": "14",
"opera_mobile": "60",
"electron": "10.0"
},
"transform-numeric-separator": {
"chrome": "75",
"opera": "62",
"edge": "79",
"firefox": "70",
"safari": "13",
"node": "12.5",
"deno": "1",
"ios": "13",
"samsung": "11",
"rhino": "1.7.14",
"opera_mobile": "54",
"electron": "6.0"
},
"proposal-numeric-separator": {
"chrome": "75",
"opera": "62",
"edge": "79",
"firefox": "70",
"safari": "13",
"node": "12.5",
"deno": "1",
"ios": "13",
"samsung": "11",
"rhino": "1.7.14",
"opera_mobile": "54",
"electron": "6.0"
},
"transform-logical-assignment-operators": {
"chrome": "85",
"opera": "71",
"edge": "85",
"firefox": "79",
"safari": "14",
"node": "15",
"deno": "1.2",
"ios": "14",
"samsung": "14",
"opera_mobile": "60",
"electron": "10.0"
},
"proposal-logical-assignment-operators": {
"chrome": "85",
"opera": "71",
"edge": "85",
"firefox": "79",
"safari": "14",
"node": "15",
"deno": "1.2",
"ios": "14",
"samsung": "14",
"opera_mobile": "60",
"electron": "10.0"
},
"transform-nullish-coalescing-operator": {
"chrome": "80",
"opera": "67",
"edge": "80",
"firefox": "72",
"safari": "13.1",
"node": "14",
"deno": "1",
"ios": "13.4",
"samsung": "13",
"rhino": "1.8",
"opera_mobile": "57",
"electron": "8.0"
},
"proposal-nullish-coalescing-operator": {
"chrome": "80",
"opera": "67",
"edge": "80",
"firefox": "72",
"safari": "13.1",
"node": "14",
"deno": "1",
"ios": "13.4",
"samsung": "13",
"rhino": "1.8",
"opera_mobile": "57",
"electron": "8.0"
},
"transform-optional-chaining": {
"chrome": "91",
"opera": "77",
"edge": "91",
"firefox": "74",
"safari": "13.1",
"node": "16.9",
"deno": "1.9",
"ios": "13.4",
"samsung": "16",
"opera_mobile": "64",
"electron": "13.0"
},
"proposal-optional-chaining": {
"chrome": "91",
"opera": "77",
"edge": "91",
"firefox": "74",
"safari": "13.1",
"node": "16.9",
"deno": "1.9",
"ios": "13.4",
"samsung": "16",
"opera_mobile": "64",
"electron": "13.0"
},
"transform-json-strings": {
"chrome": "66",
"opera": "53",
"edge": "79",
"firefox": "62",
"safari": "12",
"node": "10",
"deno": "1",
"ios": "12",
"samsung": "9",
"rhino": "1.7.14",
"opera_mobile": "47",
"electron": "3.0"
},
"proposal-json-strings": {
"chrome": "66",
"opera": "53",
"edge": "79",
"firefox": "62",
"safari": "12",
"node": "10",
"deno": "1",
"ios": "12",
"samsung": "9",
"rhino": "1.7.14",
"opera_mobile": "47",
"electron": "3.0"
},
"transform-optional-catch-binding": {
"chrome": "66",
"opera": "53",
"edge": "79",
"firefox": "58",
"safari": "11.1",
"node": "10",
"deno": "1",
"ios": "11.3",
"samsung": "9",
"opera_mobile": "47",
"electron": "3.0"
},
"proposal-optional-catch-binding": {
"chrome": "66",
"opera": "53",
"edge": "79",
"firefox": "58",
"safari": "11.1",
"node": "10",
"deno": "1",
"ios": "11.3",
"samsung": "9",
"opera_mobile": "47",
"electron": "3.0"
},
"transform-parameters": {
"chrome": "49",
"opera": "36",
"edge": "18",
"firefox": "52",
"safari": "16.3",
"node": "6",
"deno": "1",
"ios": "16.3",
"samsung": "5",
"opera_mobile": "36",
"electron": "0.37"
},
"transform-async-generator-functions": {
"chrome": "63",
"opera": "50",
"edge": "79",
"firefox": "57",
"safari": "12",
"node": "10",
"deno": "1",
"ios": "12",
"samsung": "8",
"opera_mobile": "46",
"electron": "3.0"
},
"proposal-async-generator-functions": {
"chrome": "63",
"opera": "50",
"edge": "79",
"firefox": "57",
"safari": "12",
"node": "10",
"deno": "1",
"ios": "12",
"samsung": "8",
"opera_mobile": "46",
"electron": "3.0"
},
"transform-object-rest-spread": {
"chrome": "60",
"opera": "47",
"edge": "79",
"firefox": "55",
"safari": "11.1",
"node": "8.3",
"deno": "1",
"ios": "11.3",
"samsung": "8",
"opera_mobile": "44",
"electron": "2.0"
},
"proposal-object-rest-spread": {
"chrome": "60",
"opera": "47",
"edge": "79",
"firefox": "55",
"safari": "11.1",
"node": "8.3",
"deno": "1",
"ios": "11.3",
"samsung": "8",
"opera_mobile": "44",
"electron": "2.0"
},
"transform-dotall-regex": {
"chrome": "62",
"opera": "49",
"edge": "79",
"firefox": "78",
"safari": "11.1",
"node": "8.10",
"deno": "1",
"ios": "11.3",
"samsung": "8",
"rhino": "1.7.15",
"opera_mobile": "46",
"electron": "3.0"
},
"transform-unicode-property-regex": {
"chrome": "64",
"opera": "51",
"edge": "79",
"firefox": "78",
"safari": "11.1",
"node": "10",
"deno": "1",
"ios": "11.3",
"samsung": "9",
"rhino": "1.9",
"opera_mobile": "47",
"electron": "3.0"
},
"proposal-unicode-property-regex": {
"chrome": "64",
"opera": "51",
"edge": "79",
"firefox": "78",
"safari": "11.1",
"node": "10",
"deno": "1",
"ios": "11.3",
"samsung": "9",
"rhino": "1.9",
"opera_mobile": "47",
"electron": "3.0"
},
"transform-named-capturing-groups-regex": {
"chrome": "64",
"opera": "51",
"edge": "79",
"firefox": "78",
"safari": "11.1",
"node": "10",
"deno": "1",
"ios": "11.3",
"samsung": "9",
"rhino": "1.9",
"opera_mobile": "47",
"electron": "3.0"
},
"transform-async-to-generator": {
"chrome": "55",
"opera": "42",
"edge": "15",
"firefox": "52",
"safari": "11",
"node": "7.6",
"deno": "1",
"ios": "11",
"samsung": "6",
"opera_mobile": "42",
"electron": "1.6"
},
"transform-exponentiation-operator": {
"chrome": "52",
"opera": "39",
"edge": "14",
"firefox": "52",
"safari": "10.1",
"node": "7",
"deno": "1",
"ios": "10.3",
"samsung": "6",
"rhino": "1.7.14",
"opera_mobile": "41",
"electron": "1.3"
},
"transform-template-literals": {
"chrome": "41",
"opera": "28",
"edge": "13",
"firefox": "34",
"safari": "13",
"node": "4",
"deno": "1",
"ios": "13",
"samsung": "3.4",
"rhino": "1.9",
"opera_mobile": "28",
"electron": "0.21"
},
"transform-literals": {
"chrome": "44",
"opera": "31",
"edge": "12",
"firefox": "53",
"safari": "9",
"node": "4",
"deno": "1",
"ios": "9",
"samsung": "4",
"rhino": "1.7.15",
"opera_mobile": "32",
"electron": "0.30"
},
"transform-function-name": {
"chrome": "51",
"opera": "38",
"edge": "79",
"firefox": "53",
"safari": "10",
"node": "6.5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "41",
"electron": "1.2"
},
"transform-arrow-functions": {
"chrome": "47",
"opera": "34",
"edge": "13",
"firefox": "43",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"rhino": "1.7.13",
"opera_mobile": "34",
"electron": "0.36"
},
"transform-block-scoped-functions": {
"chrome": "41",
"opera": "28",
"edge": "12",
"firefox": "46",
"safari": "10",
"node": "4",
"deno": "1",
"ie": "11",
"ios": "10",
"samsung": "3.4",
"opera_mobile": "28",
"electron": "0.21"
},
"transform-classes": {
"chrome": "46",
"opera": "33",
"edge": "13",
"firefox": "45",
"safari": "10",
"node": "5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "33",
"electron": "0.36"
},
"transform-object-super": {
"chrome": "46",
"opera": "33",
"edge": "13",
"firefox": "45",
"safari": "10",
"node": "5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "33",
"electron": "0.36"
},
"transform-shorthand-properties": {
"chrome": "43",
"opera": "30",
"edge": "12",
"firefox": "33",
"safari": "9",
"node": "4",
"deno": "1",
"ios": "9",
"samsung": "4",
"rhino": "1.7.14",
"opera_mobile": "30",
"electron": "0.27"
},
"transform-duplicate-keys": {
"chrome": "42",
"opera": "29",
"edge": "12",
"firefox": "34",
"safari": "9",
"node": "4",
"deno": "1",
"ios": "9",
"samsung": "3.4",
"opera_mobile": "29",
"electron": "0.25"
},
"transform-computed-properties": {
"chrome": "44",
"opera": "31",
"edge": "12",
"firefox": "34",
"safari": "7.1",
"node": "4",
"deno": "1",
"ios": "8",
"samsung": "4",
"rhino": "1.8",
"opera_mobile": "32",
"electron": "0.30"
},
"transform-for-of": {
"chrome": "51",
"opera": "38",
"edge": "15",
"firefox": "53",
"safari": "10",
"node": "6.5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "41",
"electron": "1.2"
},
"transform-sticky-regex": {
"chrome": "49",
"opera": "36",
"edge": "13",
"firefox": "3",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"rhino": "1.7.15",
"opera_mobile": "36",
"electron": "0.37"
},
"transform-unicode-escapes": {
"chrome": "44",
"opera": "31",
"edge": "12",
"firefox": "53",
"safari": "9",
"node": "4",
"deno": "1",
"ios": "9",
"samsung": "4",
"rhino": "1.7.15",
"opera_mobile": "32",
"electron": "0.30"
},
"transform-unicode-regex": {
"chrome": "50",
"opera": "37",
"edge": "13",
"firefox": "46",
"safari": "12",
"node": "6",
"deno": "1",
"ios": "12",
"samsung": "5",
"opera_mobile": "37",
"electron": "1.1"
},
"transform-spread": {
"chrome": "46",
"opera": "33",
"edge": "13",
"firefox": "45",
"safari": "10",
"node": "5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "33",
"electron": "0.36"
},
"transform-destructuring": {
"chrome": "51",
"opera": "38",
"edge": "15",
"firefox": "53",
"safari": "14.1",
"node": "6.5",
"deno": "1",
"ios": "14.5",
"samsung": "5",
"opera_mobile": "41",
"electron": "1.2"
},
"transform-block-scoping": {
"chrome": "50",
"opera": "37",
"edge": "14",
"firefox": "53",
"safari": "11",
"node": "6",
"deno": "1",
"ios": "11",
"samsung": "5",
"opera_mobile": "37",
"electron": "1.1"
},
"transform-typeof-symbol": {
"chrome": "48",
"opera": "35",
"edge": "12",
"firefox": "36",
"safari": "9",
"node": "6",
"deno": "1",
"ios": "9",
"samsung": "5",
"rhino": "1.8",
"opera_mobile": "35",
"electron": "0.37"
},
"transform-new-target": {
"chrome": "46",
"opera": "33",
"edge": "14",
"firefox": "41",
"safari": "10",
"node": "5",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "33",
"electron": "0.36"
},
"transform-regenerator": {
"chrome": "50",
"opera": "37",
"edge": "13",
"firefox": "53",
"safari": "10",
"node": "6",
"deno": "1",
"ios": "10",
"samsung": "5",
"opera_mobile": "37",
"electron": "1.1"
},
"transform-member-expression-literals": {
"chrome": "7",
"opera": "12",
"edge": "12",
"firefox": "2",
"safari": "5.1",
"node": "0.4",
"deno": "1",
"ie": "9",
"android": "4",
"ios": "6",
"phantom": "1.9",
"samsung": "1",
"rhino": "1.7.13",
"opera_mobile": "12",
"electron": "0.20"
},
"transform-property-literals": {
"chrome": "7",
"opera": "12",
"edge": "12",
"firefox": "2",
"safari": "5.1",
"node": "0.4",
"deno": "1",
"ie": "9",
"android": "4",
"ios": "6",
"phantom": "1.9",
"samsung": "1",
"rhino": "1.7.13",
"opera_mobile": "12",
"electron": "0.20"
},
"transform-reserved-words": {
"chrome": "13",
"opera": "10.50",
"edge": "12",
"firefox": "2",
"safari": "3.1",
"node": "0.6",
"deno": "1",
"ie": "9",
"android": "4.4",
"ios": "6",
"phantom": "1.9",
"samsung": "1",
"rhino": "1.7.13",
"opera_mobile": "10.1",
"electron": "0.20"
},
"transform-export-namespace-from": {
"chrome": "72",
"deno": "1.0",
"edge": "79",
"firefox": "80",
"node": "13.2.0",
"opera": "60",
"opera_mobile": "51",
"safari": "14.1",
"ios": "14.5",
"samsung": "11.0",
"android": "72",
"electron": "5.0"
},
"proposal-export-namespace-from": {
"chrome": "72",
"deno": "1.0",
"edge": "79",
"firefox": "80",
"node": "13.2.0",
"opera": "60",
"opera_mobile": "51",
"safari": "14.1",
"ios": "14.5",
"samsung": "11.0",
"android": "72",
"electron": "5.0"
}
}
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file, in Babel 8 users import the .json directly
module.exports = require("./data/native-modules.json");
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file, in Babel 8 users import the .json directly
module.exports = require("./data/overlapping-plugins.json");
@@ -0,0 +1,40 @@
{
"name": "@babel/compat-data",
"version": "7.29.7",
"author": "The Babel Team (https://babel.dev/team)",
"license": "MIT",
"description": "The compat-data to determine required Babel plugins",
"repository": {
"type": "git",
"url": "https://github.com/babel/babel.git",
"directory": "packages/babel-compat-data"
},
"publishConfig": {
"access": "public"
},
"exports": {
"./plugins": "./plugins.js",
"./native-modules": "./native-modules.js",
"./corejs2-built-ins": "./corejs2-built-ins.js",
"./corejs3-shipped-proposals": "./corejs3-shipped-proposals.js",
"./overlapping-plugins": "./overlapping-plugins.js",
"./plugin-bugfixes": "./plugin-bugfixes.js"
},
"scripts": {
"build-data": "./scripts/download-compat-table.sh && node ./scripts/build-data.mjs && node ./scripts/build-modules-support.mjs && node ./scripts/build-bugfixes-targets.mjs"
},
"keywords": [
"babel",
"compat-table",
"compat-data"
],
"devDependencies": {
"@mdn/browser-compat-data": "^6.0.8",
"core-js-compat": "^3.48.0",
"electron-to-chromium": "^1.5.278"
},
"engines": {
"node": ">=6.9.0"
},
"type": "commonjs"
}
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file, in Babel 8 users import the .json directly
module.exports = require("./data/plugin-bugfixes.json");
@@ -0,0 +1,2 @@
// Todo (Babel 8): remove this file, in Babel 8 users import the .json directly
module.exports = require("./data/plugins.json");

Some files were not shown because too many files have changed in this diff Show More