Skip to content

feat: complete the beta admin and runtime config stack - #2

Merged
ValentinKolb merged 49 commits into
mainfrom
feat/admin-auth
Aug 4, 2026
Merged

feat: complete the beta admin and runtime config stack#2
ValentinKolb merged 49 commits into
mainfrom
feat/admin-auth

Conversation

@ValentinKolb

Copy link
Copy Markdown
Collaborator

Summary

  • harden the standalone admin with per-user sessions, OIDC, actor propagation, operator feedback, responsive navigation, and configurable instance branding
  • add the operational Files, System, S3, Settings, version-history, upload, thumbnail, and observability surfaces
  • introduce Pebble-backed runtime configuration, declarative manifest plan/apply, runtime S3 access-key resources, configuration APIs, and SDK support
  • extend system metrics, health, activity, h2c, upload-tree support, storage performance, benchmarks, and operator documentation

Verification

  • go test ./...
  • admin typecheck and build
  • 57 admin tests with 163 assertions
  • docs-site build
  • desktop and exact 390 CSS-pixel admin smoke test
  • exact-SHA GitHub CI dispatched separately for this branch

No release is part of this PR.

Replace the constant-HMAC session cookie with a signed payload carrying
subject, label, kind and expiry, verified server-side on every request.
This is the foundation OIDC plugs into.

Close three login weaknesses:

- ADMIN_TOKEN no longer falls back to FILEGATE_TOKEN, so brute forcing
  the admin login can no longer yield the Filegate master token. Startup
  fails when the two are equal.
- Rate limit POST /login to 10 attempts per 5 minutes per client via
  @valentinkolb/sync. In-memory by default, Redis-backed when REDIS_URL
  is set. Note that Bun resolves REDIS_URL at process start.
- Drive the Secure cookie flag from ADMIN_COOKIE_SECURE instead of the
  request URL protocol, which is http behind a TLS-terminating ingress.

X-Forwarded-For is only trusted with ADMIN_TRUST_PROXY, otherwise a
client could pick a fresh rate-limit bucket per request.

Also upgrades @valentinkolb/ssr to 0.11.2 and stdlib to 0.16.0, adds
DOM.Iterable to tsconfig so typecheck passes, and sets up bun test with
an SSR plugin preload.

BREAKING CHANGE: ADMIN_TOKEN is now required and must differ from
FILEGATE_TOKEN. Existing session cookies are invalidated.
Authorization code flow with PKCE, enabled by setting OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL together.
Endpoints come from discovery, and the ID token is verified against the
provider JWKS with issuer, audience and nonce checked.

OIDC_ALLOWED_GROUPS is optional: providers like Authentik restrict access
on the client itself, so requiring a second allowlist here would be
duplicate bookkeeping. When unset, startup warns that access control is
delegated to the provider.

ADMIN_TOKEN becomes optional once OIDC is configured and stays available
as a break-glass login. Dropping it hides the token form entirely.

The flow state cookie is SameSite=Lax because the callback arrives as a
cross-site navigation; Strict would drop it and fail every sign-in.

Kept deliberately small: no refresh tokens, no userinfo call, no session
store, no provider-side logout. Sessions last 12 hours, then re-login.

Extracts the signing helpers into lib/signed.ts so the session and the
OIDC flow state share one implementation.
These subsystems measured useful state and kept it to themselves, so an
operator had no way to see queue saturation, cache effectiveness, or a
detector falling behind.

- jobs: Stats reports workers, queue depth against capacity, in-flight
  count, and cumulative ErrQueueFull rejections and job panics. Queue
  pressure previously surfaced only as a 503 to the caller that hit it.
- cache: Stats reports entries, capacity and cumulative hits/misses.
  Hit ratio was tracked nowhere, for either the path or thumbnail cache.
- detect: Stats joins the Runner interface, reporting backend, interval,
  cycles, last scan time and duration, scan errors and outbound channel
  depth. The btrfs backend also reports the last generation per base
  path, which makes detector lag computable for the first time.

Also counts scan errors that were previously silently swallowed: poll
lstat failures other than "gone", and every logged btrfs failure.
Exposes state the server already tracked but kept in logs, Prometheus,
or memory. All additive; no existing route changes behavior.

- GET /v1/system/info: build version and commit, uptime, detector
  backend, effective versioning mode, per-mount health including
  writable and xattr support, and a curated set of limits. Mount health
  was computed once at startup and thrown away. Config exposure is an
  explicit allowlist, not a dump, so no secret can leak by default.
- GET /v1/system/runtime: detector cycles, staleness and per-path btrfs
  generations, worker pool saturation, path and thumbnail cache hit
  ratios, and upload sessions by phase with write-slot usage. Every
  field is an in-memory counter, so this one is safe to poll.
- GET /v1/health: real dependency checks (index, detector, mounts),
  returning 503 on failure. The bare GET /health stays byte-identical
  for existing probes.
- GET /v1/uploads/sessions: lists sessions, optionally by phase. An
  interrupted upload previously left a session nothing could find,
  since abort needs an id that was no longer known anywhere.

Health deliberately probes only mount existence; the write probe stays
on system/info so a polled endpoint never writes to every mount.

Adds Service.PingIndex as a cheap liveness probe, since Stats walks
every entity and is unfit for a health check.
- Prometheus gains filegate_detector_stale_seconds, _cycles_total and
  _errors_total plus filegate_path_cache_lookups_total{result}. Detector
  staleness is the signal that detection died and the index is drifting.
- Authentication failures now reach the activity log as auth.denied.
  The activity middleware only records requests with a known actor, so
  401s previously left no trace at all.
- Go and TS SDKs cover the new system, health and upload-session routes.
- docs/http-routes.md and docs/metrics.md document both.

Worker-pool gauges are intentionally absent from Prometheus: the
scheduler lives inside the router, out of the metrics provider's reach.
They are on GET /v1/system/runtime, and metrics.md says so.
Filegate attributes every audit entry to the bearer token, so with one
shared token several admins were indistinguishable in the log. It already
accepted X-Filegate-Actor and recorded it as delegatedActor; nothing sent
it.

The admin now sends the session label on every upstream request. The
actor lives in async-local storage rather than a parameter because
client() is called from route handlers and from load helpers several
frames deep, and threading an argument through all of them would touch
every call site to move one string.

The System page needed no change: actorName already prefers
delegatedActor over the credential id.
Six defects where the interface reported something untrue.

- Directory listings silently truncated at 100 entries and the item
  counter reported the truncated number as the total. The server has
  cursor pagination; the UI took page one and rendered it as the whole
  folder. Now follows the cursor, with a 5000-entry cap that is labelled
  when it bites rather than hidden.
- The topbar health dot was hardcoded green and stayed green through a
  total outage. It now reflects GET /v1/health: Healthy, Degraded,
  Unreachable, or Unknown.
- /search and /system had no error handling at all, so a Filegate outage
  produced a raw 500 instead of the error banner the other pages show.
- Failed delete, rename, metadata and transfer threw the user back to
  the mount roots, losing their place. They now return to the folder
  they were working in.
- Index rescan was fire-and-forget with console.error, so "rescan
  started" appeared even when the call failed. It now awaits and reports
  the actual outcome.
- Search declared an error prop that was never passed, so glob failures
  were invisible.

Verified against the running stack: a folder of 250 files renders all
250 with a correct counter, and with Filegate stopped all four pages
return 200 with an error banner and the dot reads Unreachable.
Completes the remaining correctness gaps in the files view.

- Upload conflict mode was pinned to skip-existing even though the
  broker supports skip-identical, rename, overwrite and error. It is now
  a control next to the upload buttons, still defaulting to skip-existing
  since that is the only mode that cannot destroy data when someone
  re-drops a folder.
- Uploads had no cancel; the panel could only be closed, leaving the
  transfer running and the sessions orphaned. A Cancel button now aborts
  the client via AbortSignal and aborts every session created so far, so
  staged bytes are released instead of waiting for the cleanup loop.
- The transfer dialog let the target folder be empty, which reached
  resolveDirectory("") and failed with a bare "folder required". The
  field is now required and prefilled with the current folder, and the
  errors name the actual problem.
client().versions was used nowhere, so eight REST endpoints and the
whole versioning feature were unreachable from the UI despite being a
headline capability of Filegate.

The detail pane now carries a Versions panel for files: history newest
first with timestamp, size, label and pinned state, plus snapshot with
an optional label, pin and unpin, restore in place or as a new file,
delete a single version, and download the bytes of any version.

Version content has no signed direct-URL endpoint, so unlike normal
file downloads this one streams through the admin server.

Mounts without versioning answer 404 on every versions endpoint, which
the SDK documents as the capability check; the panel reports that as an
explanation rather than as an error.

The restore dialog states that the pre-restore snapshot is subject to
the versioning cooldown. That is not a detail: with a recent capture the
current bytes are replaced without a new version, which was confirmed
against a running server rather than assumed from the docs.
nodes.thumbnailRaw was unused, so the server's thumbnail endpoint with
its LRU cache and worker pool was never exercised by the UI.

The files list gains a List/Grid toggle carried in the URL, so it
survives navigation without any client state. Grid tiles show image
previews for jpeg, png, gif and webp.

Thumbnails proxy through the admin server because the browser has no
Filegate token. ETag, Cache-Control and Last-Modified pass through in
both directions, so a repeat view costs a 304 instead of a re-encode;
this needed ifNoneMatch on the SDK's thumbnailRaw, which had no way to
send a conditional request.

The preview stacks on top of the file icon rather than replacing it and
carries no background, so a thumbnail the server refuses -- unsupported
format, oversized source, or a full job queue answering 503 -- leaves
the icon visible with no error-handling script.

Verified against the running server: previews render, a conditional
request returns 304, and an unknown id answers 404 so the tile falls
back to its icon.
Upload is one button again. It opens a modal holding everything the
action needs: a dropzone that takes files and whole folders, the
conflict-mode select, Upload folder bottom-left, and a primary Upload
bottom-right that stays disabled until something is staged. Clicking the
dropzone opens the native picker. Staged entries can be removed
individually before starting.

Dropped folders keep their structure. The drag-and-drop entry API yields
File objects without webkitRelativePath, so directory entries are
wrapped in a small object carrying the relative path; the SDK's upload
contract is structural, so name, size and slice are enough. Directory
reads loop until an empty batch because readEntries returns at most 100
entries per call and a large folder would otherwise lose its tail.

Uses stdlib's showFileDialog and showFolderDialog instead of hand-rolled
hidden inputs, and stdlib's writeCookie for the layout preference.

The List/Grid choice now persists in a cookie, so it survives navigating
away. The cookie is written client-side: the SSR handler builds its own
Response and drops cookies set on the Hono context, which is the same
reason the theme cookie works that way.

Grid tiles lost their per-item borders; the thumbnail is boundary enough.
Adds Scope, Secret and Reason to the existing flag spec table rather
than a second registry that could drift from it.

10 of 55 keys stay static: the three listener binds, write_timeout
(an http.Server field), the two storage paths, detection.backend,
the two metrics keys that decide route mounting, and auth.bearer_token,
which stays static on purpose so a damaged runtime store cannot lock an
operator out of the API used to repair it. Each carries its reason.

The other 45 become runtime-adjustable.

Three guard tests: every key is classified and every static one explains
itself; secret-looking keys are on the deny list, since the config
endpoints are meant to be complete and a new credential would otherwise
leak by default; and the runtime majority is asserted, so a careless
classification pass shows up as a failure.
A durable home for config overrides and runtime resources such as S3
access keys, in its own Pebble instance under a path separate from the
index.

That separation is the point, not a preference. `fg index rescan --new`
removes the index directory outright, and with --skip-backup it does so
without a copy, so credentials sharing that store would be destroyed by
a routine rebuild. The index is derived and must stay disposable;
runtime config is authoritative and reconstructible from nothing. A test
simulates the rebuild and asserts the store survives it.

Only overrides are stored, never the resolved configuration, so a value
nobody touched keeps following its built-in default when that default
changes in a later release.

A bootstrapped_at marker makes environment and CLI seeding one-time
rather than reconciling. Deletion therefore survives a restart even with
the seed variable still set, which is what keeps a revoked credential
from reappearing at the next deploy. An unparseable marker still counts
as bootstrapped, since treating corruption as "never seeded" would
resurrect exactly what the marker exists to prevent.

Close is idempotent: the shutdown path has several error branches that
can each reach it, and closing Pebble twice panics.
Introduces the snapshot the runtime half of the configuration will be
read from, plus the resolver that produces it.

Precedence is runtime override, then environment, then config file,
then built-in default. Runtime wins because it is the most deliberate:
someone changed it through an API while the service was running.

domain.ConfigHolder publishes the resolved config behind an atomic
pointer, so reads are lock-free and a reload swaps everything at once
rather than letting a request observe half an update.

ConfigManager applies changes: validate, persist to the runtime store,
republish. A static key is accepted and stored but comes back in a
restart-required list naming the running and desired values, instead of
being silently ignored -- the failure a generic config API falls into
most easily. Validate runs the same resolution without applying, so a UI
can check input as it is typed.

Provenance is tracked per key, because an operator needs to tell "I
chose 3s" from "3s is the default". Viper cannot answer this (IsSet is
true for defaults too), so the file layer is probed with a second viper
that has no defaults registered.

Reading a value by dotted path walks the existing mapstructure tags
rather than a second 55-case switch that could drift. Writing keeps its
explicit switch, since it must reject unknown keys loudly.

Seven tests cover a runtime change taking effect without a restart, a
static change reporting instead of lying, invalid input leaving the
previous snapshot intact, overrides outranking the file and surviving a
reload, clearing an override falling back to the file rather than the
default, provenance across all four sources, and secrets rendering as a
presence flag whose value tracks reality.
Wires the pieces together: serve opens the runtime config store,
re-resolves the configuration with any overrides a previous run stored,
and hands the resulting snapshot to the router.

Adds storage.runtime_config_path, defaulting to /var/lib/filegate/config
next to the index rather than inside it. Validation rejects a path that
nests under storage.index_path, because rebuilding the index removes
that directory and would take every stored credential with it. That is
the one invariant this design rests on, so it is enforced rather than
documented, with a test covering the nested, sibling and empty cases.

RouterOptions.Config carries the snapshot. It is optional: a nil holder
falls back to the values captured in the options struct, so existing
callers and every current test keep working while consumers are
converted one at a time.

Verified against the docker stack: the service starts with the new store
(startup fails if it cannot be opened), health and system/info answer,
and the admin app passes its live checks unchanged.
Five routes over the config manager:

  GET   /v1/config/schema     every key with type, scope, default, usage
  GET   /v1/config            effective values with provenance
  PATCH /v1/config            apply a batch
  POST  /v1/config/validate   resolve and validate without applying
  POST  /v1/config/reload     re-read the sources

Schema exists so clients stop hardcoding the key list: an admin UI picks
its input control from the declared type, and a key added later appears
without a client change.

Changes arrive as a batch because some settings are only valid together
-- cors.allow_credentials cannot combine with a wildcard origin, so
one-at-a-time edits would have to pass through an invalid state.

A patch has three outcomes rather than two: applied and live, applied
but restart-required with the running and desired values named, or
rejected. The middle case is what a config API most easily gets wrong.

Secrets report {"configured": bool} and never their value, decided by
the spec deny list rather than by whichever fields someone remembered.

The service is an interface on RouterOptions because the specs, defaults
and validation live in the CLI package, which already imports the HTTP
adapter; passing the implementation in keeps the dependency pointing one
way. A nil service leaves the routes unmounted, so existing callers and
tests are unaffected.

Verified against the running stack: 56 keys reported as 11 static, 45
runtime, 5 secret; the bearer token renders as configured-only; a
runtime change took effect with no restart and its source flipped to
runtime; a static change came back as restart-required and did not take
effect; invalid values and unknown keys are refused with 400; validate
left the value untouched; and a runtime override survived a container
restart while a cleared one fell back to its environment value.
Makes the configuration API honest. Until now it reported keys as
runtime-adjustable and answered success, while handlers kept using the
values they captured when the router was built.

liveConfig reads each runtime-scoped setting from the published snapshot
per request, falling back to the boot-time options when no holder is
supplied, so existing callers and tests are unaffected.

CORS, real-IP and access-log middleware now evaluate their settings per
request instead of being decided once when the chain is assembled.
Upload body caps and the capabilities endpoint read live values too.

Also fixes a pre-existing bug this made easy to observe: a body that
exceeded upload.max_upload_bytes answered 500. Only the direct-upload
handler translated http.MaxBytesError; path and node writes fell through
to the internal-error branch, telling the caller the server had broken
when the correct answer is 413 and "send less". Handled centrally in
statusFromErr so every write path benefits, with a test.

Verified end to end against the running stack: a 300 KB upload succeeds,
the cap is lowered to 1000 bytes over the API, the same upload is
refused with 413 and /v1/capabilities reports the new limit -- all
without a restart -- and clearing the override restores the original
behaviour.
Access keys move out of the config file and into the runtime store with
real CRUD: create, rotate, update grants and limits, disable, delete.
An access key with a secret, bucket grants and a rate limit is a
principal, not a setting, and every change to one belongs in an audit
trail.

The adapter reads its key set through an atomic pointer, so a key
created or revoked a moment ago takes effect on the next request rather
than at the next restart. Validation runs before the swap, so a rejected
update leaves the running set untouched.

Secrets are returned exactly once, at creation or rotation, and never by
the list endpoint.

Seeding from the static configuration happens once, into a store that
has never held keys, and is per resource kind so a deployment that
enables S3 only later still gets its configured keys imported. Later
starts log what they ignored and why.

Two bugs found while wiring this up:
- Attaching the adapter published before seeding ran, leaving it with an
  empty key set on first boot. Seeding now precedes attachment.
- buildKeyStore rejects an empty set, which is right at startup and
  wrong at runtime: deleting the last key must mean nothing
  authenticates, not that the previous set stays live. SetKeys now
  installs an explicitly empty store.

Verified against the running stack with the S3 listener enabled: the
configured key is seeded and listed without its secret; create returns a
40-character secret once; rotate changes it; disable and delete work and
a second delete answers 404. The property that matters most also holds:
deleting the seeded key and restarting with FILEGATE_S3_ACCESS_KEY still
set does not bring it back, and the log says the configured key was
ignored because the store owns it now.
Removes the last two settings a fresh install had to provide.

storage.base_paths defaults to /var/lib/filegate/data. Only that exact
default is created on startup: a configured path is left alone so a typo
fails the mount health check loudly instead of quietly serving a freshly
made empty directory, which looks indistinguishable from data loss.

auth.bearer_token is no longer required. When unset, a token is
generated on first boot, stored in the runtime store, and printed once
in a framed block that says it will not be shown again. Later starts
reuse it silently. The REST auth middleware still fails closed until a
token exists, so dropping the requirement cannot open the API.

The image now ships its runtime directories. Distroless has no shell, so
they are staged in the build image and copied with the nonroot uid;
without them a container that configures nothing could not create its
own mount and refused to start.

Verified: docker run with no environment and no config file starts,
prints a usable token, answers 200 for that token and 401 without one,
and a restart neither reprints nor regenerates it.
The previous test required auth.bearer_token unless s3.enabled was set.
Serve now generates and stores one on first boot, so rejecting an empty
value at load time would make a zero-configuration start impossible.
Loading is not the same as opening the API: the auth middleware still
fails closed while no token exists.

Adds coverage for the generated token being created once and reused, and
for a configured token always winning over the stored one. A credential
that changes on every restart is not a credential.

This should have been part of the previous commit; it was committed with
this test failing.
Adds a Settings page rendering every configuration key grouped by
section, with its value, provenance (default, file, env or runtime) and
scope. Runtime keys that are neither secret nor structured get inline
editing; a runtime override can be reset so the key falls back to the
file or its default.

Controls are chosen from the schema endpoint rather than hardcoded, so a
key added in a later release shows up without touching the UI.

Static keys whose stored value differs from the running one appear in a
restart-required banner naming both, instead of looking applied.

S3 access keys get full management: create, rotate, enable, disable and
delete. Secrets appear once in the confirmation notice, because they
cannot be retrieved afterwards, and never in the table.

TS SDK gains fg.config and fg.s3Keys with types.

Verified against the running stack: all sections render, the bearer
token shows as configured without its value, an int and a duration edit
both apply, an invalid value is refused with the server's message, reset
restores the previous source, and an S3 key can be created with a
one-time secret, found in the table and deleted.
Completes the files view. Every action was strictly one file at a time,
which hurts as soon as a folder holds more than a handful.

Sorting by name, size or modified time, carried in the URL so it
survives navigation and is shareable. Clicking the active column flips
direction; a different column starts ascending. Headers carry aria-sort,
which the plain th elements never did. Directories stay ahead of files
regardless of the field, so sorting by size does not scatter folders
through the listing.

A filter box narrows the folder by name, and the item counter says "2 of
3 items" so a filtered view cannot be mistaken for a small folder.

Bulk selection adds per-row checkboxes with a select-all that reflects
the indeterminate state, plus bulk delete and bulk move. Each item is
applied on its own and results are reported per item: "Deleted 2, 1
failed" with the failing ids, because one aggregate outcome for twenty
files tells an operator nothing about what needs attention. Items run
sequentially so a bulk delete cannot saturate the server.

Selection deliberately lives only in the DOM and is not carried across
navigation: acting on files you can no longer see is how accidental mass
deletions happen.

Sorting and filtering run in the admin server rather than as new query
parameters upstream, because the whole directory is already loaded for
the listing and a round trip per sort click would buy nothing.

Verified against the running stack: default and both size directions
order correctly, aria-sort is set, the filter narrows and the counter
says 2 of 3, a bulk delete of two of three reports the count and leaves
the third, an empty move target is refused with a usable message, and a
bulk move succeeds.
Two layout defects on the settings page.

Row borders ended at different x positions depending on whether the row
had action buttons. The cause was display:flex on a <td>, which takes
the cell out of the table box model so it no longer participates in
column sizing. The cell is a table cell again and an inner span carries
the flex layout.

The stacked section panels had no vertical gap. Every other page nests
its panels in .stack or .grid, which supply it; these sat directly in
.main. Added a page-stack container rather than per-panel margins, so
spacing stays a property of the layout instead of the component.
Three fixes to the settings page.

versioning.retention_buckets rendered as "[object Object], ..." because
the generic array path stringified its objects. Retention rules are the
answer to "what happens to my versions?", so they now read as sentences:
"all within 1h · max 24 within 1d · max 30 within 30d".

Values are formatted through stdlib rather than printed raw: byte counts
as 64 KiB, durations without Go's trailing 0s, counts with thousand
separators. The exact value stays one click away in the edit dialog,
which is where precision actually matters.

The schema gained a unit field for this. The type alone cannot carry it:
a byte limit and a max-count are both ints, so 65536 and 100 are
indistinguishable without knowing what the number means.

Scrollbars use scrollbar-gutter: stable, which is what removes the
layout shift when one appears between pages of different height, plus a
thin thumb over a transparent track. Deliberately not a JS-driven
overlay scrollbar: forcing overlay behaviour everywhere takes the element
out of the platform's keyboard handling, assistive tech and the OS
"always show scrollbars" setting, which is a bad trade for a few pixels.
…editable

The config endpoint emitted Go field names and nanosecond durations for
versioning.retention_buckets, because RetentionBucketConfig carries only
mapstructure tags and nothing told encoding/json otherwise. The UI could
not read it, so every rule rendered as "all within ?".

Adds apiv1.RetentionBucket with keepFor and maxCount, converted at the
boundary. The domain struct stays as it is; the API shape is now a
decision rather than a side effect of Go's defaults.

Retention is also editable now. It was excluded as "structured", but it
is the policy an operator most wants to change: it decides what happens
to their versions. Editing uses the syntax the CLI flag already
documents, keep_for=1h,max_count=-1, one rule per line, instead of
inventing a second format for the UI.

Note the asymmetry, which is commented at the parser: reading returns
camelCase while writing must send the mapstructure names, so echoing the
response shape back would silently produce empty buckets.

s3Keys stays non-editable here on purpose. Access keys are managed as
resources above, with rotation and an audit trail, not as a config value.

Verified against the running stack: rules read as "keep all within 1h ·
keep 24 within 1d · ...", a new policy applies and renders, a rule
missing keep_for is refused by name, and reset restores the default.
The raw text field was the wrong shape for this value, and it was also
broken: rules were joined with newlines, which a single-line input
collapses, so the dialog showed them run together as one string.

Retention is now edited as what it is -- a list of (window, count) rules.
Each row has a window with presets for the common spans, a count, and a
"keep all" checkbox instead of asking anyone to type -1. A live summary
reads back the policy as sentences while it is being edited. Raw mode
stays one click away, because copying a policy between installs wants
text and a structured form should never be the only way to express
something; switching between the two carries the current rules across.

Saving refuses an incomplete policy rather than accepting it. That is not
cosmetic: tidyDuration turned an empty window into "0s", which is a valid
zero-length window, so an unfilled row would have silently started
pruning recent versions. Empty now stays empty and the editor says which
rows need a window.

Both modes serialize to the syntax the CLI flag documents, so the server
sees one format from one parser.

10 tests on the pure helpers, since the text round trip is exactly where
a bug produces empty buckets without failing. 53 admin tests total.
Fixes a leak I introduced with the settings page: a newly created or
rotated S3 secret was carried in the redirect as ?notice=..., which puts
it in the browser history and in every access log between the browser and
the server. Creation and rotation now go over JSON and the secret is shown
in a dialog that says it cannot be recovered, with a copy button using
stdlib's clipboard helper rather than asking anyone to select 40
characters by hand.

Byte counts and durations get a number-plus-unit editor instead of a raw
field. Typing 1073741824 by hand is an error waiting to happen, and the
plain field gave no hint which unit a number was even in. The two kinds
cover roughly a third of the configuration. Values are decomposed into
the largest unit that divides them exactly, so 1073741824 opens as 1 GiB
and goes back as an integer.

The key dialog now offers the mounts that actually exist instead of free
text: a grant naming a bucket the server does not have is rejected on
save, which is a slow way to learn it.

Key generation stays on the server. Doing it in the browser with stdlib's
crypto would mean the secret exists in the page before the server ever
sees it, and would depend on browser entropy for a long-lived credential.

quantity.ts imports prompts lazily, because prompts registers document
listeners at module load and that made the file unimportable without a
DOM, which in turn made its pure helpers untestable.
Icon plus label on every action, with the label collapsing on narrow
screens so buttons become icon-only there. The label stays in the DOM
rather than being dropped, so the accessible name never changes with the
viewport.

The font is self-hosted. An admin panel for a self-hosted file gateway
must not depend on a CDN at runtime; it may well run somewhere with no
route to the internet. The shipped @font-face is rewritten at build time
to reference only the woff2 we serve, and to use font-display: block so a
slow load leaves the space blank instead of flashing tofu boxes.

Every icon element is aria-hidden: an icon font renders private-use
codepoints that a screen reader would otherwise read as garbage. Meaning
comes from the adjacent label, or from aria-label where the button is
icon-only. The sort indicator replaces the ▲▼ characters, which some
screen readers announce as "black up-pointing triangle".

Both copy buttons in the credential dialog are plain now. Marking the
secret copy as primary put two competing primaries in one dialog when
only the flow-completing action should be one. Copy feedback swaps the
icon to a check, or to a warning when the clipboard is denied.

Found while wiring the font routes: c.header on a handler that returns a
fresh Response is discarded, so Cache-Control never reached the client.
The existing static routes have the same mistake, masked because Bun.file
infers Content-Type from the extension. The new routes set their headers
on the Response.

Cost worth stating: 460 KB woff2 plus 205 KB CSS covers all ~5900 Tabler
icons for the 31 this app uses. Both are immutable-cached, so it is a
one-time transfer, but a generated subset would be roughly 10 KB if that
ever matters.
Completes the icon pass. Several buttons were missed by the first sweep;
an audit over every element styled as a button now reports none without
one. Dialog buttons get theirs from the single prompts factory rather
than at each call site, so confirm, cancel and danger are consistent
everywhere by construction.

Every numeric config key now declares what it counts. This started from
cache.path_cache_size reading as a bare 100,000: the obvious fix looked
like formatting it as bytes, but it is an entry count, and rendering it
as 97 KiB would have claimed a cache holding 100,000 paths occupies
97 KiB when it is closer to tens of megabytes.

Three keys are named "size" while holding a count, which is what makes
the guess tempting. They now read "100,000 entries", "1,024 entries" and
"500 events", and their usage text no longer says "size" where it means
a maximum number of items. Byte keys are unchanged and still render
through stdlib as 1 GiB or 64 KiB.

Two tests pin this: every numeric key must declare a unit, and the four
count-holding keys must not be marked as bytes.
Six panels on the System page over the endpoints from phase 1: service
health with its individual checks, change detection with a stalled state,
worker and write-slot saturation, cache hit ratios, version retention,
and an upload-session inspector that can abort an orphan.

Also closes the gap phase 1 left open. The pruner computed six numbers,
fed three to Prometheus and dropped the rest along with any record of
when it last ran, so "is retention working?" had nothing to answer it.
A lifecycle register keeps the last run: all six PruneStats fields,
duration, next scheduled run, and the error when a run fails, which
previously left only a log line.

Values refresh by polling rather than SSE. The payload is one small JSON
document, the server keeps no per-client state and a dropped request
needs no recovery path; an SSE stream would hold a connection per open
tab for values that move on a seconds timescale. One request covers
runtime, health and sessions so a tick is not three round trips.

The first frame is server-rendered, so the page is complete without
JavaScript and the poll only keeps it current. Each part degrades on its
own: a stalled detector must not blank the panels that would explain it.
Only changed nodes are written, so a value being read is not reflowed
under the cursor every five seconds, and after three consecutive failures
the grid says updates are interrupted rather than showing numbers that
quietly stopped moving.

Verified against the running stack: panels render with real values,
detector cycles advance between polls, and with Filegate stopped the
endpoint answers 502 while the page still renders with an error banner
and per-panel fallbacks.
Three items left over from the retention analysis.

POST /v1/versions/prune runs a retention round on demand. The background
loop runs on an interval an operator cannot see the effect of, so after
tightening a policy the obvious question -- did that do anything -- had
no way to be answered. Both the ticker and the manual trigger go through
one guard: the per-file locks already keep concurrent rounds from
corrupting anything, but two scans would duplicate the work and report
halves of the same result separately, so a second request answers 409.
It deletes data, so it is a POST, it is confirmed in the UI, and it
appears in the activity log as versions.prune. Without versioning the
route answers 501 rather than pretending a round ran and found nothing.

List-valued settings get a chips editor. A comma-separated field made it
easy to lose an entry to a stray comma and gave no feedback until after
saving. Each entry is now discrete and removable on its own, and pasting
a comma-separated value splits it rather than creating one long entry.
Covers the four CORS lists and trusted proxies.

The editor validates against the server before it closes. Cross-field
rules live there -- allow_credentials cannot combine with a wildcard
origin -- and learning that from a redirect means retyping the list.

Found while running the Linux suite in a container: a linux-only test
still called runVersioningPruner with the old signature. It does not
compile on macOS, so a local go build never saw it.

Verified against the running stack: the prune button reports all six
PruneStats fields and lands in the audit log; an invalid backend and the
credentials-plus-wildcard clash are both refused by the validate
endpoint before anything is stored; a two-entry origin list applies and
reads back.
SystemClient.UploadSessions folded "?phase=..." into the endpoint string, but
newRequest assigns the endpoint to URL.Path, which escapes the "?" to %3F. The
request went to /v1/uploads/sessions%3Fphase=in_progress, matched no route and
404'd, so the filter never worked at all.
The session listing and abort endpoints already satisfy the design ticket's
cleanup and security criteria, but three properties were untested: a session
token only works against its own session, abort refuses tokens minted without
the abort scope, and the expiry sweep removes aborted rows. Aborting also has
to leave the row behind so a late segment PUT cannot resurrect the upload.
The TypeScript SDK has had folder orchestration since the batch-upload work;
the Go SDK only had the session primitives, so every caller wanting to upload a
directory had to rebuild hashing, scheduling and progress themselves.

uploadtree mirrors the browser pipeline: bounded hashing, batched session
creation, one global segment-concurrency limit rather than a per-file one, and
a single progress view. It differs in one place on purpose — a Go caller holds
the bearer token, so it creates sessions itself instead of going through the
browser's "allow" callback.

Beyond parity it adds retry with jittered backoff (transport errors and
retryable statuses only), resume by adopting in-progress sessions whose
checksum still matches, and a direct threshold that routes small files to a
one-shot PUT. That last one matters: sessions cost 2.5-3x a plain PUT for small
files (see bench/results/2026-07-26-many-small-files.md).
The smart-batched-uploads design ticket made measurement an acceptance
criterion and it was never done. The existing load generator answers "how many
ops/s at steady state", which cannot show per-file overhead or session
scheduling because it never runs a corpus to completion, so --mode tree uploads
a fixed corpus and reports wall time plus a hash/create/PUT/commit split.

Shapes and levers come from the ticket: photo folder, tiny logs and a
node_modules-like tree, against keep-alive, HTTP/2, parallelism, batched
session creation and segment sizing. HTTP/2 needs a TLS edge because the
Filegate listener serves cleartext HTTP/1.1 only, so the bench stack includes
one and the tool records the protocol it actually negotiated.

Findings in bench/results/2026-07-26-many-small-files.md: per-file server work
dominates, concurrency saturates near 32-64 in flight, HTTP/2 and batched
session creation change nothing measurable, and commit is 2.4-10x the segment
upload time because it copies every byte a second time. Packed segments are
rejected on that basis. The runs also surfaced a 404 under load on
PUT /v1/paths, documented in the same file.
Indexing a path took the parent's xattr as proof that the parent was in
the index. Claiming an ID on the file and writing the entity are two
separate steps, so a sibling request landing between them read the ID,
skipped indexing the parent, and anchored its child to an entity that did
not exist yet. Walking that parent chain answered not-found, surfacing as
a 404 from PUT /v1/paths for a directory the caller was creating itself.

parentIDForSync now requires an index row, not just an attribute, and
ensureIndexed applies the same check before handing an ID back.

First-time ID assignment also goes through claimID, which uses
XATTR_CREATE so concurrent claimants adopt the winner's value instead of
each keeping its own.
The config rewrite left the docs describing a service that no longer
exists: a required bearer token, S3 keys owned by the config file, and no
mention of the static/runtime split or the settings API.

`fg config schema` prints the key catalog as a table, JSON, or the
published reference page. `make docs-config` writes the page and a test
fails when the committed copy drifts, so 55 keys across two scopes cannot
quietly fall out of sync with the spec table the server validates against.

Prose updates: the static/runtime split, starting with no configuration at
all, runtime store persistence and backup, seed-once resources, the config
and operations routes, and runtime S3 key management.

The beta scope the epic asked for is now stated where a reader meets it --
single tenant, one all-or-nothing bearer token, trusted network, admin
access equals config authority -- on the overview and in full on the
security page.
Assembling a one-segment upload read the staged segment back and wrote the
whole file out again, then read it a third time to hash. Renaming produces
the same file for one directory update, and the per-segment checksum the
copy verified is covered by the whole-file size and SHA-256 check the
commit already performs.

Halves assembly for single-segment commits, 2.29 ms to 0.91 ms median on
16 KiB files, and cuts photos-corpus commit time from 34-41 s to 30 s where
the copied bytes actually mattered.

The move consumes the segment, so commit now reuses an already-assembled
file instead of rebuilding it. That is sound because a recorded segment is
immutable -- re-uploading different bytes is refused with a conflict -- so
the assembled file can only hold what the session declared, and the
checksum is verified on every attempt regardless. Without it a commit that
failed after assembly, on a destination conflict say, could not be retried.
Both SDKs had a direct-upload threshold and left it off, so the slow path
was the default for the file shape that dominates real trees. A session
costs three round trips and several fsyncs per file, and for a file that
fits in one segment its resumability amounts to retrying that one segment.

The threshold now defaults to the segment size: a file that would have been
a single segment takes one PUT instead. Measured at 2.4x the throughput of
sessions on 5000 files averaging 16 KiB.

Go uses a negative value as the opt-out because Options' zero value has to
keep meaning "use the defaults"; the browser helper uses 0. Four existing
tests now opt out explicitly -- they exercise the session machinery and only
used small files as fixtures.
The ticket blamed commit's cost on assembly writing the file a second time.
That holds for large files and not at all for small ones: assembly is 8-20%
of a 16 KiB commit, while publishing plus index write is 40% and the three
bookkeeping phases are another 47%, all fsync bound. Removing the copy
leaves the logs and node-modules totals inside the noise.

Adds a commit preset to the harness -- the narrow set of session runs plus a
large-file regression check, small enough to run once per build in one
sitting, which this comparison needs because host drift here reaches 2.4x.
Committing an upload into a tree that does not exist yet cost one synced
index write per path level, because syncSingle indexes one path per call
and reaches ancestors by recursing. Writing a file at depth 8 took 10
durable writes against 4 at depth 2; it now takes 3 either way.

Three changes, each measured by counting index batches rather than by wall
time -- the host was saturated while this landed and wall-clock arms taken
then disagreed by 3x in both directions.

indexNewDirChain writes a freshly created chain in one batch. The levels
were created together and are only reachable through each other, so one
atomic write is also a stronger guarantee than a chain a reader can observe
half-indexed. A crash before it leaves directories on disk and out of the
index, which is the state the index is built to recover from.

ensureSessionParent resolves the parent before trying to create it, which
is what every commit after the first file in a directory does. The per-level
loop took a path lock and walked its own prefix for each level, once per
file.

removeSessionArtifacts derives segment paths instead of globbing the stage
directory, which every session on the mount shares -- a 5000-file upload was
doing 5000 scans of a 5000-entry directory. Its two directory fsyncs are
gone: they only made the deletion of staging garbage durable, and a
resurrected staging file is swept by the cleanup loop.

The committing marker and the commit record keep their fsyncs. The marker is
what makes a crash between publishing bytes and recording the commit
recoverable instead of a permanent conflict.
The listener spoke cleartext HTTP/1.1 only, so an operator whose proxy or
service mesh is configured to speak h2 to its backends had no way to
connect, and the absence was undocumented.

server.http2_cleartext accepts unencrypted HTTP/2 alongside HTTP/1.1 on the
same port. Go 1.24's Protocols.SetUnencryptedHTTP2 covers this in the
standard library, so no new dependency: the server switches only for a
connection that opens with the HTTP/2 preface, leaving HTTP/1.1 clients
untouched. The listener's timeouts keep their meaning, because Go arms
ReadTimeout and WriteTimeout per stream for HTTP/2 and IdleTimeout is set
explicitly rather than falling back to ReadTimeout.

Off by default. Accepting a second protocol on a listener should be an
operator's decision, not something that arrives with an upgrade. Static,
since the protocol set is fixed once the listener accepts connections. REST
only -- S3 clients sign and stream over HTTP/1.1 in practice.

Not a performance setting: across four repeats per configuration the two
protocols differ by 1.5% to 6.7% at the median, inside a spread that reaches
2.6x within a single arm. The first run of the session pair looked like a
clean 20% regression and the repeat reversed it; both are recorded in
bench/results/2026-07-27-h2c.md.

The bench client learned h2c too, so protocols can be compared on one port
without a TLS edge in between.
Add authenticated plan/apply workflows and read-only configuration views. Move S3 resources into a dedicated operator page.
@ValentinKolb
ValentinKolb merged commit 6f90930 into main Aug 4, 2026
7 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