Skip to content

Phase10 followups and clients#1

Merged
codad5 merged 13 commits into
feat/dynamic-domains-tlsfrom
phase10-followups-and-clients
Jul 12, 2026
Merged

Phase10 followups and clients#1
codad5 merged 13 commits into
feat/dynamic-domains-tlsfrom
phase10-followups-and-clients

Conversation

@codad5

@codad5 codad5 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Closes out the client/dashboard reconciliation gap left over from Phase 10, adds CI, and lands branding/licensing — all follow-ups identified while wrapping up hardening on feat/dynamic-domains-tls.

  • CLI/server reconciliation: raba-core/raba-cli had drifted from the real server API (missing team_id/subdomain on project creation, no team/domain/stats support at all). Brought current: list_teams, update_team_domain/verify_team_domain, get_project_stats/get_team_stats, new raba team/raba stats subcommands.
  • Dashboard traffic stats + custom-domain UI: ProjectDetail/TeamSettings now show live traffic stats; TeamSettings gained the custom-domain view/set/verify form it never had (plus the GET /api/teams/:id/domain endpoint that was missing server-side to support it).
  • Smarter project creation: team_id is now optional (defaults to the caller's oldest team membership instead of erroring on ambiguity), and a new explicit --domain flag resolves whichever team owns it — scoped to the caller's own teams, global for instance admins. Silent unverified-domain fallback now surfaces a warning in the response instead of failing silently.
  • Fun default subdomains: replaced name-derived subdomain generation (which leaked arbitrary directory names into public URLs) with a random adverb-adjective-noun-NN generator, no new dependency.
  • CI: ci.yml — test + clippy (-D warnings) across server and client, typecheck + build + lint for dashboard and docs-site, 4 parallel jobs. Server and client both brought to a clean clippy slate first. Found and fixed a real gap: docs-site/ had no package-lock.json and incomplete node_modulesnpm install had never actually been run there.
  • Branding + licensing: real logo/wordmark/favicon SVGs built from TECH_DOC.md's existing brand spec, dashboard re-themed from placeholder blue to the brand green (via a proper CSS custom-property token, not hardcoded utility classes), a real README.md, BRAND.md, and LICENSE (Functional Source License 1.1 — free for any use including commercial self-hosting, except reselling as a competing hosted service; converts to Apache-2.0 after 2 years).
  • Small fixes along the way: docker/Dockerfile's RABA_CERTS_DIR wasn't pointed at the persistent volume (certs would've been lost on container recreate); .env.example brought current; systemd template comment and :80 shared-VPS gap documented.

Test plan

  • cargo test + cargo clippy --all-targets -D warnings clean across server and client (153 server tests, 34 client tests)
  • npx tsc --noEmit, npm run build, npm run lint clean for dashboard
  • Manual verification: npm run dev and docker compose up both confirmed working end-to-end
  • Manual dashboard smoke test of the new custom-domain UI in a browser (build/typecheck-verified only so far — no JS test framework exists in this repo yet)
  • ci.yml itself has not yet run for real on GitHub (no local Actions runner available) — first real signal will be this PR's own checks

Notes for reviewers

  • CreateProjectRequest.team_id changed from required to optional — API-compatible (existing callers passing it explicitly are unaffected), but worth flagging as a contract change.
  • The --domain resolution is scoped to the caller's own teams for regular users, and instance-wide for admins/owners — deliberate, matches require_team_role's existing admin bypass elsewhere in this file; flagging in case that scope decision needs a second look.

codad5 and others added 13 commits July 12, 2026 01:27
…tem)

TECH_DOC.md S9 Phase 10 -- request_count/avg-duration/bytes/requests-per-sec
aggregation over a trailing window, both per-project and per-team.

server/migrations/0001_init.sql: request_logs gains duration_ms (connection
lifetime -- this server forwards bytes opaquely and never sees an actual
HTTP response, S2, so "response time" here means stream-open to
stream-close/session-eviction, not true latency -- same honest gap already
documented for status_code) plus an index on (project_id, timestamp), since
the new /stats endpoints filter+aggregate by project over a time window.
Edited in place rather than an additive migration, per this project's
established pre-beta convention (nothing deployed yet).

duration_ms captured at every logging::log_queue call site: https_listener.rs
and tcp_router.rs via an Instant captured at connection start;
udp_sessions.rs's ForwardEntry gained created_at so the reaper's
EvictedSession carries a real session lifetime instead of a placeholder.

New endpoints (server/src/api/mod.rs holds the shared TrafficStats/
StatsQuery types, window clamped to [60s, 30 days]):
- GET /api/projects/:id/stats?window_secs=
- GET /api/teams/:id/stats?window_secs= -- team totals plus a per_project
  breakdown array

Real bug caught by the new team-stats test, not just plumbing:
ProjectTrafficStats' nested `stats: TrafficStats` field was missing
#[serde(flatten)], so the JSON response nested every stat under a `stats`
key instead of flattening it alongside project_id/project_name -- the test
asserting on `per_project[i]["request_count"]` failed against a real `null`
until this was added.

Also fixes a stale doc comment on get_project_logs claiming nothing writes
to request_logs yet -- false since logging::log_queue was wired in.

6 new tests (project stats: aggregates within window / empty-state; team
stats: totals + per-project breakdown / non-member forbidden). 126 total
passing (was 122).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
raba.service.template's AmbientCapabilities comment only mentioned
RABA_HTTPS_PORT/:443 -- stale since Phase 10 added RABA_HTTP_PORT
(default 8080, e.g. :80 for the plain-HTTP redirect listener), which
needs the same capability if bound below 1024. Functionally harmless
(AmbientCapabilities already covered any privileged port), just
misleading to read.

dynamic-domains-tls.md: the existing SNI-passthrough templates
(nginx stream/Caddy layer4) only work for :443 because they forward
opaque encrypted bytes with no need to understand the payload -- :80
plaintext HTTP has no SNI to route by, so a shared box already running
another webserver on :80 has no equivalent passthrough option. Recorded
as a known, deliberately unaddressed gap rather than silently missing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds TrafficStats/ProjectTrafficStats/TeamTrafficStats to api/client.ts
(projectsApi.stats, teamsApi.stats) matching the server's
GET /api/projects/:id/stats and GET /api/teams/:id/stats shapes.

ProjectDetail gets a "Traffic (last hour)" stat-card row (requests,
req/sec, avg duration, bytes in/out); TeamSettings gets the same totals
plus a per-project breakdown list. Both reuse the existing StatCard
component rather than introducing a new one.

avg_duration_ms is documented at the type level as connection lifetime,
not true HTTP response latency (the server forwards bytes opaquely and
never sees an actual response) -- same caveat carried from the server's
logging::log_queue doc comment, so the dashboard doesn't imply a
precision the data doesn't have.

Extracted formatMs/formatBytes into a shared src/lib/format.ts rather
than duplicating them across ProjectDetail and TeamSettings.

tsc --noEmit and `npm run build` both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
client/raba-core/src/management_api.rs was built against TECH_DOC.md's
documented endpoint list before Phase 7's server routes existed (its
own doc comment flagged this, and TODO.md tracked it as a known,
blocking gap since 2026-07-10). Now that the server is real:

- create_project was missing the now-required team_id and (for HTTP)
  subdomain -- every HTTP project creation from the CLI would 400
  against a real server. Fixed, plus ProjectInfo/CreatedProject gained
  the fields the server actually returns (team_id, domain,
  local_target, online).
- No team/domain/stats support existed at all. Added list_teams,
  update_team_domain/verify_team_domain, get_project_stats/
  get_team_stats -- new TeamInfo/DomainInfo/TrafficStats/
  ProjectTrafficStats/TeamTrafficStats types matching the server's
  response shapes exactly.

client/raba-cli/src/cli.rs:

- New resolve_team() helper: an explicit --team (id or name, case-
  insensitive) if given, otherwise the caller's sole team if they
  belong to exactly one, otherwise an error listing the options rather
  than guessing.
- New default_subdomain(): auto-generates a subdomain from the project
  name when --subdomain is omitted (lowercased, non-alphanumeric runs
  collapsed to '-', trimmed) -- previously nothing generated one at
  all, a real functional gap since the server requires subdomain for
  HTTP projects (server/src/api/project_routes.rs). Avoids the
  server-reserved "tunnel" word as an exact match.
- http/tcp/udp/connect gained --team (all three) and --subdomain (http
  only) flags; resolved choices persist into .raba.toml's new
  team_id/subdomain fields so a later run in the same directory
  doesn't need to re-decide.
- A 409 from create_project (projects_domain_subdomain_unique -- see
  0001_init.sql) now gets a specific "subdomain already taken, retry
  with --subdomain" message instead of a generic wrapped error.
- New `raba team list` / `raba team domain-set <domain>` /
  `raba team domain-verify` subcommands (team custom domains,
  docs/plan/dynamic-domains-tls.md) and `raba stats [--team|--project]`
  for the new traffic-stats endpoints.

spawn_mock_server (management_api.rs test helper) changed from
&'static str to impl Into<String> so per-test response bodies can be
built with format! instead of leaking memory to satisfy 'static.

23 raba-core + 9 raba-cli tests passing (added: 6 management_api
endpoint tests, 5 default_subdomain tests). TODO.md's Phase 10 table
and the standalone "CLI/server API reconciliation" row both updated to
[x].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The dashboard was still on Vite's default scaffold branding -- a
hardcoded blue accent (ui.tsx's own comment: "Accent is blue") with no
relation to TECH_DOC.md's brand spec, and a blue "R" square standing in
for a logo in three separate places (sidebar, Login, Signup) because
the real mark didn't exist yet.

dashboard/src/index.css: new --color-accent-{50,400,500,600,700}
tokens via Tailwind v4's @theme, aliased to the brand green (#22C55E,
same hex as Tailwind's stock green-500). Named semantically ("accent"),
not by hue, so a future rebrand is a one-line edit here instead of a
grep-and-replace across every consumer. Every hardcoded blue-* utility
across ui.tsx, Layout.tsx, and 6 pages swapped to accent-* at the same
shade number.

Real bug caught while wiring this in: @theme blocks only accept custom
properties, no comments -- Tailwind's parser silently miscompiled the
CSS when a comment (with an apostrophe, which the minifier also chokes
on outside @theme) lived inside the block. Moved the explanation above
the block instead, both times fixed before it shipped, not after.

dashboard/src/components/Logo.tsx (new): LogoMark + Logo, the real
arch/badge mark inlined as SVG (crisp at small sizes, no extra
request), replacing the placeholder "R" square in all three spots.
dashboard/public/favicon.svg swapped from the default Vite React logo
to the real mark.

npx tsc --noEmit and npm run build both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md §12 already fully specced the logo (arch bridging two
connection points, dark badge, green arch, orange connection-point
circles) -- built the actual SVGs from that spec rather than inventing
a new concept: docs-site/static/img/brand/{mark,wordmark,
lockup-compact,lockup-full}.svg, plus favicon.svg/.ico derived from
the mark via ImageMagick (4-size .ico: 16/32/48/64).

Two real bugs caught before shipping, not after: text used
fill="currentColor", which only resolves inside an inlined SVG with a
CSS color context -- referenced as a plain image file (README, <img>,
favicons) it rendered invisible, fixed to a fixed dark fill instead.
Separately, the explanatory comments used "--" as a separator (this
codebase's usual comment style), which XML forbids anywhere inside a
comment, not just as the closing delimiter -- broke well-formedness,
caught by parsing every file with expat before considering them done.

BRAND.md (new): the user/contributor-facing version of TECH_DOC.md
§12's spec -- name meaning, mark concept, colors, asset list. Kept
deliberately separate from TECH_DOC.md/TODO.md, which track internal
build progress and may not survive to v1 in their current form.

README.md (new, was an empty placeholder): logo, name meaning, feature
list, npm/Docker/CLI quickstarts, project status, license -- makes no
reference to TECH_DOC.md or TODO.md, both treated as internal-only per
this round's discussion.

LICENSE (new): Functional Source License 1.1, Apache-2.0 future
license (FSL-1.1-ALv2) -- verbatim from the official
getsentry/fsl.software template, not reconstructed from memory.
Permits any use, including commercial/internal-business self-hosting,
except offering the software as a competing hosted/managed service;
each version automatically converts to Apache-2.0 two years after its
release. Chosen over plain MIT (no restriction at all) and SSPL
(considered too aggressive/non-OSI) for this project's specific goal:
freely usable, but not resellable as a competing SaaS.

TODO.md: Build Phases summary table brought in sync with the actual
per-phase tables (Phase 9 still not started, Phase 10 now complete) --
was already correct in the detailed tables but stale in the summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md §9 Phase 9. Four independent parallel jobs (server, client,
dashboard, docs-site) -- website/ deliberately excluded, already marked
superseded in TODO.md (merging into docs-site, not built out further).
Every job runs exactly the commands already verified by hand this
session, nothing new invented for CI specifically.

Prep fixes, made first so the gate isn't red on day one:
- server/src/auth/jwt.rs: fixed 3 pre-existing clippy warnings (doc
  comment continuation-line indentation) -- server is now fully
  clippy-clean, so its job can enforce -D warnings.
- client/raba-core/src/config.rs + tunnel_conn.rs: fixed the same for
  client (field_reassign_with_default in a test, and a documented
  #[allow(clippy::await_holding_lock)] for a std Mutex guard
  deliberately held across await in a test-only env-var-serializing
  helper -- explained inline why it's safe: shared with sync #[test]
  fns elsewhere so it can't become an async-aware Mutex, and every
  #[tokio::test] in this crate uses the default current-thread runtime
  flavor, so it can't deadlock against another thread).

Real gap found and fixed while verifying docs-site's checks actually
run clean: docs-site/node_modules was ~32KB with no package-lock.json
at all -- scaffolded but npm install was never actually completed
there. Ran it for real (1304 packages); typecheck and build both now
pass and the lockfile is committed (ci.yml's cache-dependency-path and
`npm ci` both need it to exist).

rustfmt is deliberately NOT enforced yet -- the codebase isn't
rustfmt-clean (894 lines would change in client/, 3773 in server/), and
normalizing that is its own separate, deliberate change, not bundled
into standing up CI.

YAML verified structurally valid via js-yaml (no local Actions runner
available to fully exercise it) -- real end-to-end verification happens
on the first real PR/push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The server API for team custom domains (PATCH .../domain,
POST .../verify-domain, docs/plan/dynamic-domains-tls.md) has existed
since Phase 7/10, and raba-cli got team domain-set/domain-verify in
the client reconciliation pass -- but the dashboard itself never got a
form for it. TeamSettings.tsx had zero domain UI at all.

server/src/api/team_routes.rs: adds GET /api/teams/:id/domain -- a
real gap, not just a client-side omission. PATCH and verify-domain
both return the current DomainResponse as the *result of an action*,
but there was no way to plain-read the current state, so the
dashboard couldn't show existing config on page load without
provoking a domain change or a re-verify as a side effect.
TeamRole::Member (view-only), same tier as get_team, not the Admin
tier the two mutating endpoints require. 2 new tests.

dashboard/src/api/client.ts: DomainResponse/CertStatus types plus
teamsApi.getDomain/setDomain/verifyDomain.

dashboard/src/pages/TeamSettings.tsx: new DomainSection -- shows the
current domain (if any) with verified/cert-status badges and the DNS
records to add, a set/change form and a verify button (both gated
canManage, matching every other mutating action on this page), and a
plain read-only view for non-admins. getDomain's 404 (no domain set
yet) is handled as an expected empty state, not an error -- fetched
separately from the page's other Promise.all data so it can't reject
the whole load and hide the rest of the page.

dashboard/src/components/Logo.tsx: arch color now reuses
--color-accent-500 instead of duplicating its hex -- real DRY, since
that token already exists dashboard-wide and the two values could
otherwise silently drift apart. Badge background and connection-point
orange stay plain hex (TECH_DOC.md §12's fixed brand colors, used
nowhere else in the dashboard, so a token would only add indirection
without preventing any actual duplication).

tsc --noEmit, npm run build, npm run lint all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…team

Previously POST /api/projects required team_id unconditionally, and
raba-cli errored out ("multiple teams found, pick one") the moment an
account had more than one team and no --team was given -- annoying for
what's supposed to be a zero-config CLI, and not what was actually
wanted: a silent, sensible default (the caller's oldest team
membership, in practice almost always their signup-created personal
team) rather than forcing every invocation to specify one.

server/src/api/project_routes.rs: CreateProjectRequest.team_id becomes
Option<String>. New resolve_default_team_id -- no schema change
needed, team_members.created_at already exists -- ORDER BY created_at
ASC LIMIT 1 for the caller. This is deliberately the *only* place the
default lives (not duplicated client-side) so it can never drift from
what the server actually does. 4 new tests: sole-team case (regression
check), multi-team defaults to the oldest (seeded with an explicit
created_at via direct SQL, not wall-clock timing -- SQLite's
CURRENT_TIMESTAMP only has second resolution, so two teams created
back-to-back through the API within the same test could tie and make
"oldest" nondeterministic), explicit --team still overrides the
default even with multiple teams available, and the defensive
zero-teams case (can't happen through normal signup, exercised via
direct team_members deletion).

client/raba-core/src/management_api.rs: create_project's team_id
becomes Option<&str>, omitted (not sent as null) from the JSON body
via #[serde(skip_serializing_if)] when absent -- the server
distinguishes "omitted, please default it" from an explicit null,
which serde wouldn't accept for a String field anyway. spawn_mock_server
(test helper) now captures the full request (was request-line only) so
tests can assert on JSON body content, not just method/path -- needed
to actually prove team_id is omitted, not just null. 2 tests (send
it when given, omit it entirely when not).

client/raba-cli/src/cli.rs: resolve_secret's Create branch only calls
resolve_team (local validation against the caller's own teams, nicer
error than a raw 403) when --team was actually given; when omitted, no
client-side resolution/guessing at all, team_id just isn't sent and
the server decides. resolve_team itself is unchanged and still used
as-is by `raba team`/`raba stats`, which always need a concrete
team_id for their URL path -- a different situation from project
creation, where omission is now a legitimate choice.

132 server tests, 33 client tests (24 raba-core + 9 raba-cli), all
passing. cargo clippy --all-targets -D warnings clean across server
and client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ller's teams

Adds the third way to pick which team a project belongs to (alongside
the existing team_id and the just-added default-to-oldest-team):
an explicit domain, resolved server-side to whichever team owns it.

server/src/api/project_routes.rs: CreateProjectRequest gains an
optional `domain` field.

- Equal to the instance's own domain -> treated identically to
  omitting it entirely (not a team domain, nothing to resolve).
- Otherwise resolved via resolve_team_id_from_domain, scoped to teams
  the caller can actually act on -- never a global lookup, or any
  authenticated caller could probe which team owns an arbitrary domain
  string just by trying to create a project under it. Instance
  admins/owners search every team's domain instead (consistent with
  require_team_role's existing is_admin_or_owner() bypass elsewhere in
  this file -- they can already act on any team's projects regardless
  of membership, so scoping --domain differently for them would just
  be a confusing gap, not an extra safety boundary).
- Matched but unverified -> explicit 400, not a silent fallback to the
  instance domain -- the caller asked for this domain specifically, so
  silence would be worse here than in the implicit (no domain given)
  path, which does still fall back silently (existing behavior,
  unchanged).
- domain given alongside an explicit team_id that disagrees with the
  resolved owner -> 400.

9 new tests: resolves to the owning team (single team and picked
correctly among several), rejected when no team the caller can act on
owns it, rejected (not silently ignored) when matched but unverified,
team_id+domain agreeing/disagreeing, instance-domain-equals-omitted,
and the two security-relevant ones -- scoped away from an unrelated
account, but global for instance admins.

client/raba-core/src/management_api.rs: create_project gains a
`domain: Option<&str>` parameter, sent (or omitted) the same way
team_id already is. 1 new test.

client/raba-cli/src/cli.rs: new --domain flag on http/tcp/udp/connect,
persisted into .raba.toml the same way --team/--subdomain already are.
Passed straight through to create_project unresolved -- the server
decides which team it belongs to, not this module.

150 server tests, 34 client tests (25 raba-core + 9 raba-cli), all
passing. cargo clippy --all-targets -D warnings clean across server
and client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… domain

The implicit domain-resolution path (no explicit `domain` in the
create request) has always silently used the instance domain when the
resolved team's own domain isn't verified yet -- correct behavior,
but silent, leaving the caller to wonder why their project didn't
land on their team's domain. The explicit-domain path already 400s
outright on the same situation (added earlier this session); this is
the implicit path's counterpart, minus the hard failure -- it can't
error, since nothing was explicitly requested to fail on.

CreatedProjectResponse gains an optional `warning` field, set only
when: the team resolved (via team_id or the default-team fallback,
never via an explicit `domain` -- that path already rejects the
unverified case outright) has a domain row that exists but isn't
verified. The domain-resolution query no longer filters `WHERE
verified = 1` -- it needed the unverified row's value too, to name it
in the warning, not just know a verified one didn't exist.

2 new tests: the warning fires with the right domain name when the
team's domain is set-but-unverified, and stays absent when the team
has no domain configured at all (only the more specific
"set but unverified" case should ever warn).

153 server tests passing (was 150). clippy --all-targets -D warnings
clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
default_subdomain(name) slugified the *project* name into a subdomain
when --subdomain was omitted. The project name commonly falls back to
the current directory's basename (config::resolve_project_name) --
for a tunneling CLI invoked from wherever, that's often a completely
unrelated folder name (downloads, scratch, whatever you happened to
be cd'd into) with no business ending up in a public subdomain.

Replaced with random_subdomain(): a fun, human-readable
adverb-adjective-noun-NN name (e.g. briskly-brave-otter-42),
Docker/Heroku-style, matching how ngrok/localtunnel actually behave by
default (random unless you ask for something specific via
--subdomain). No stability lost -- once created, the subdomain is
already persisted into .raba.toml and the secret cached, so a later
`raba connect` in the same directory reuses the exact same one
regardless of how it was first picked; the randomness only affects
the very first run.

No new dependency: a nanosecond timestamp mixed with the process id is
plenty of entropy for a friendly, non-security-sensitive name (unlike
auth::secret's actual project secrets, which do need a real CSPRNG).
SmallRng (splitmix64's finalizer, hand-rolled, no crate) decorrelates
the four picks drawn from that one seed -- naively shifting/masking
the raw seed per pick would make adjacent choices visibly correlated.

30 adverbs x 34 adjectives x 30 nouns x 100 suffixes = over 3 million
combinations. 3 new tests (shape, DNS-safe charset, variation across
50 calls) replacing the 4 old default_subdomain-specific ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codad5
codad5 merged commit fa20657 into feat/dynamic-domains-tls Jul 12, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant