Runtime and Boundaries¶
The daemon owns the catalog, file storage, and background work. The CLI and web app send it requests. This page explains that call path and the access checks at each boundary.
Process shape¶
Fotobank is one Go binary with two main uses:
- Cobra commands in
internal/cliprovide import, maintenance, administration, and server entry points. fotobank daemon run(orserve) composes the HTTP API, embedded frontend, repositories, services, content and artifact stores, and background workers.
The Svelte application is built into internal/web/dist and embedded in the Go
binary. The outer HTTP mux sends /api/ to the API handler and all other paths
to the single-page application.
Server composition happens at the process edge in internal/cli/server.go.
Packages receive explicit collaborators; they do not open a second database,
Docbank vault, or storage tree for convenience.
Domain layering¶
The normal call path is:
CLI → typed HTTP client → daemon HTTP handler
Web app ────────────────→ daemon HTTP handler
HTTP handler → authorization service → repository → SQLite
└→ content/artifact storage
- Repositories under
internal/<domain>own SQL and domain persistence. They take domain identifiers and values, not caller credentials. - Services under
internal/serviceare the authorization boundary. Every user-scoped operation receives anowners.Principal, clamps queries to that caller, and normally returnserrs.ErrNotFoundfor another owner's object. - HTTP handlers call services; CLI commands reach those handlers through the typed HTTP client. This keeps owner checks in one place. The host operator is trusted to control local configuration and storage; application ownership checks do not isolate data from that operator.
- Background workers claim durable queue rows, perform bounded work, and finalize the claim. They do not depend on request goroutines remaining alive.
Cross-cutting sentinels live in internal/errs. Code wraps them with operation
context. internal/httpapi/errors.go translates them to HTTP responses; a
surface with different disclosure rules may override a mapping locally and
delegate the rest.
HTTP¶
internal/httpapi/api.go builds one http.ServeMux and one Huma API. Huma owns
JSON operations. Raw handlers own byte streams and server-sent events:
/api/v1/media/{id}/original/api/v1/media/{id}/files/{fileID}/content/api/v1/media/{id}/thumb/api/v1/shared/media/{id}/original/api/v1/shared/media/{id}/thumb/api/v1/events
The runtime API and OpenAPI generator use the same JSON operation definitions, including search, AI, and facets. Registration does not need a database, Docbank vault, or provider. Missing search, AI, or facets services produce a 503 response after the handler's identity check, rather than removing the operation from the contract. A configured server keeps its existing behavior.
The server publishes the contract at /api/openapi.json and interactive docs
at /api/docs. make api-generate writes the checked-in openapi.yaml,
Go client, and frontend TypeScript bindings. The Go client uses
github.com/doordash-oss/oapi-codegen-dd/v3 and shares the Huma wire types.
The API installs an encoding/json/v2 format for JSON requests and responses.
Primary and attached-file downloads, thumbnails, and event registrations also
publish their contracts through Huma. Their raw handlers still own streaming,
headers, and range behavior. The shared-media byte routes remain outside the
generated contract. See
frontend.md for the frontend boundary.
The middleware execution order is request metrics and recovery, identity, hidden-media unlock validation, principal-display caching, then routing. Request IDs and principals are put in context before domain handlers run.
Identity and authorization¶
An owner is the pair (hub, user_id). owners.Principal is the in-process
value. Each owner also has an immutable opaque storage UUID that is safe for
internal path construction.
Two identity modes exist:
stubsupplies one configured principal for local development and single-user operation.headeraccepts identity headers only after its direct-access guard accepts the request. The guard accepts a loopback or Unix-socket listener, configured proxy network ranges, or a configured shared proxy secret. Network ranges and the secret are additive when both are configured.
Fotobank does not terminate TLS or inspect client certificates. A deployment that uses mutual TLS must terminate it at the external proxy and must still restrict Fotobank access with one of the guards above. The application never treats a CA-file setting as evidence that a request passed mutual TLS.
Knowing an asset UUID, file UUID, Docbank node ID, or virtual path grants no access. Services and share-capability checks make the authorization decision.
Concurrency and lifecycle¶
SQLite uses separate read and write pools. Repository write operations group related changes in explicit transactions. Queue claims use persisted lease timestamps so abandoned work can be reclaimed.
The server owns one embedded Docbank adapter for its whole lifetime. Shutdown stops incoming requests and workers before closing storage and database resources. The content adapter translates errors that happen during streaming, not only errors returned while opening a reader.
Docbank holds an exclusive vault lock for that lifetime. GPS backfill, import, interrupted-import recovery, checkout creation, commits, manual and scheduled archives, and checkout scanning reuse the server's adapter. Every checkout command uses the daemon. List and status inspect saved catalog state there; the CLI opens neither the catalog nor the vault.
Local operator commands¶
The server also owns a separate configurable loopback control listener
from internal/operator. Import, every checkout command, and manual backup creation
use the typed internal/client HTTP client to discover it beside the canonical configuration file, in
<config>.operator/. Kit publishes a runtime record atomically
inside a current-user-only directory. A fresh random credential lives in that
record. The client requires Kit's possession proof before sending the bearer
credential and accepts only loopback endpoints with a matching service and
reported application version. internal/client/lifecycle.go uses Kit's Manager
and start lock to coordinate automatic or explicit startup, and StartDetached
to launch the current executable with the same configuration, working directory,
and environment. Normal discovery resolves the catalog path for comparison but
does not open the database or vault. Recovery discovery does neither. A different running build version is stopped through
the authenticated shutdown operation before replacement. Development builds
with the same version string require explicit restart after rebuilding. There
is one API contract, with no protocol negotiation or compatibility layer.
daemon start, restart, stop, and status live in internal/cli/daemon.go.
Start/status return the shared DaemonStatus result; start/restart print its
web UI URL. The server derives that URL from http.base_url, or the actual
bound web address if unset. The web, control, and metrics ports are configured
with http.listen_address, daemon.listen_address, and
observability.admin_listen. See setup.
Stop/status never launch a process. The stop endpoint acknowledges before
canceling the server. Every shutdown path closes the operator listener before
draining the photo listener and workers, so it cannot accept new commands
during that drain. The client waits for the runtime record to disappear
after workers, storage, and lifetime locks have closed. Restart then starts
the replacement. Stop uses daemon.stop_timeout; startup/replacement uses
daemon.start_timeout. Timeouts report an error rather than force-killing
unfinished writes. Background logs live at <config>.operator/daemon.log.
The configuration directory must be writable by the daemon's OS account and
available independently of photo storage. The setup guide uses a service-owned
local directory rather than /etc, since discovery and locks live beside the
configuration in both normal and recovery mode.
Catalog selection¶
Normal daemons record their startup catalog selection in discovery metadata:
FOTOBANK_DB_PATH, or [flash].root/fotobank.sqlite when no override is set.
Application clients compare the selection from their current configuration and
environment, rejecting a different catalog rather than silently reusing or
replacing the daemon. Discovery and database opening share the same resolver:
relative paths include their working directory and existing symlinks resolve
before comparison. The runtime record contains the resolved startup database
path, so retargeting an alias requires an explicit restart. Unresolvable source
paths fail normal application discovery. Status and stop remain scoped to the
configuration so changes cannot prevent stopping the old daemon. Recovery bypasses
source-path resolution and comparison because it opens no source catalog.
Recovery mode¶
daemon start --recovery (or restart --recovery) starts only the operator
listener, without opening the catalog, Docbank, NAS, or flash storage. It
validates the control-listener settings rather than normal storage settings.
The config file must remain available. Both modes hold the same configuration
lifetime lock; status and stop use the same discovery record. Changing modes
requires explicit restart. Recovery mode advertises no web UI and rejects photo
operations. daemon restart returns to normal operation.
backup init, list, and verify use BackupRepositoryService through the
documented /api/v1/operator/backup-repository/ operations in either mode.
They accept --config, normalize repository paths before discovery, and do not
open repositories in the CLI. They require an already-running daemon and never
start or replace one. When none is running, the error explains how to start
normal mode or recovery mode explicitly, without recreating lost photo storage.
backup restore uses ArchiveRestoreService only in recovery mode. The daemon
captures the original database selection at startup without opening source
storage, then uses saved configuration to protect source paths during restore.
It also retains the configured vault, NAS, flash, and backup repository roots.
If those settings change, restore refuses the request until an explicit recovery
restart; it validates and resolves paths from the same configuration it compared.
An overlapping restore target returns HTTP 400; changed storage settings return
HTTP 409 with recovery-restart guidance. These expected refusals are distinct
from internal restore failures.
The CLI normalizes target and repository paths and submits one request; it never
opens either database or retries a partially completed restore. Photo and normal
operator listeners reject this operation. Recovery also serves both OpenAPI
3.1 and 3.0 JSON/YAML variants. --listen is invalid with --recovery, which
binds only the configured control listener.
Shared API and command routes¶
The local and photo listeners use the same httpapi.New registrations and
OpenAPI document at /api/openapi.json, with documentation at /api/docs.
internal/httpapi owns the wire types shared with internal/client, following
Docbank's typed-client pattern. Kit owns runtime records, endpoints, proof,
process identity, launch locking, and detached startup;
Fotobank owns authorization and application services. There is no separate
operator-only schema. The local listener requires its credential before any
API request. Only it receives OperatorDeps and DaemonDeps; the photo listener rejects
operator operations even for an authenticated photo owner. The shared schema
marks these operations with the localOperator bearer requirement.
The command/API pairs are below (paths start with
/api/v1/operator). List and status take hub and user_id query parameters;
photo operations take the configured principal in their JSON body. Lifecycle
operations use the host credential without a photo principal.
| CLI command | HTTP operation |
|---|---|
import <source> |
POST /imports (streamed progress and result) |
content recover |
POST /content/recover |
gps backfill |
POST /gps/backfill |
checkout list |
GET /checkouts |
checkout status <id> |
GET /checkouts/{checkout_id} |
checkout estimate |
POST /checkouts/estimate |
checkout create <root> |
POST /checkouts |
checkout commit <id> |
POST /checkouts/{id}/commit |
backup create |
POST /backups |
backup restore |
POST /backup-repository/restore (recovery only) |
daemon status |
GET /daemon |
daemon stop |
POST /daemon/stop, then wait for cleanup |
Start launches the process locally when needed; restart combines stop and start. They are process lifecycle operations, not alternate data paths.
Album commands use the existing photo API routes on the authenticated local connection, not a second set of operator-only album handlers:
| CLI command | HTTP operation |
|---|---|
albums create |
POST /api/v1/albums |
media list |
GET /api/v1/media |
media search |
GET /api/v1/search |
media show |
GET /api/v1/media/{id} |
media download |
GET /api/v1/media/{id}/original |
media download --file |
GET /api/v1/media/{id}/files/{fileID}/content |
albums rename |
PATCH /api/v1/albums/{id} |
albums delete |
DELETE /api/v1/albums/{id} |
albums list |
GET /api/v1/albums |
albums show |
GET /api/v1/albums/{id} and GET /api/v1/albums/{id}/media |
albums add |
POST /api/v1/albums/{id}/media |
albums remove |
DELETE /api/v1/albums/{id}/media/{media_id} |
internal/client/media.go shares the media Huma input and response types.
The CLI validates IDs and pagination before automatic startup, then delegates
filtering, owner scope and hidden-media handling to the existing media service.
List JSON is a single API page; detail JSON includes attached files. Neither
command provides hidden unlock credentials or direct storage access.
internal/client/media_download.go reads metadata and downloads through the
same proven daemon connection. It checks a complete response against the size
and SHA-256 in those details; changed content fails rather than silently saving
a different file. The CLI owns only its requested local output. It validates
the parent directory before startup, writes a temporary copy, verifies and
syncs it, then publishes it with a no-replace hardlink in the same directory.
Cleanup removes the temporary name. There is no link to authoritative storage
and no local catalog access. The result describes a plain copy, not a checkout.
See download commands
for destination requirements, receipts, and failure behavior.
internal/client/search.go shares the search Huma input and response types.
media search validates filter syntax and page limits before automatic startup,
then requests one page from the existing search service. JSON preserves its
cursor and semantic-availability fields. Search has the same stub-owner and
hidden-media restrictions as the other media commands; it adds no local index
or provider calls. Cursor rejection fails the command without printing results
or automatically retrying. The search page
owns the ranking, candidate limits, and cursor contract.
internal/client/albums.go uses request and response types from the Huma
registrations in internal/httpapi/albums.go. The CLI validates argument syntax
before startup and requires stub mode. The daemon supplies the configured
principal; callers cannot override it. The same AlbumService owner checks,
hidden-media restrictions, and share-related deletion checks apply to HTTP and
CLI requests. These commands neither open the catalog nor construct services.
They start a missing daemon through the shared lifecycle and never retry an
HTTP mutation automatically. albums list --json returns the HTTP page shape
with items and optional next_offset.
Album creation, renaming and member addition also offer JSON using their HTTP
response types. show --json groups the two existing responses under album
and media, retaining member pagination. Both reads must succeed before the
CLI writes the combined output; they do not form a transactional snapshot.
Sharing commands follow the same pattern through internal/client/shares.go
and the existing Huma registrations in internal/httpapi/shares.go:
| CLI command | HTTP operation |
|---|---|
shares create |
POST /api/v1/shares |
shares list |
GET /api/v1/shares |
shares show |
GET /api/v1/shares/{uuid} |
shares revoke |
POST /api/v1/shares/{uuid}/revoke |
shares retry |
POST /api/v1/shares/{uuid}/retry |
The CLI requires stub mode, validates argument syntax before automatic startup, and uses the daemon's configured principal. It opens no catalog and constructs no share service. Shared HTTP types define creation options, list filters, detail results and pages; the server applies list limits. Broker work remains asynchronous in the daemon. Already-revoked and retry-not-applicable operations return HTTP 409 and a nonzero CLI exit, not a CLI-only success. Sharing access checks and hidden-content filtering remain in the existing services and byte handlers. No new sharing authorization is granted by this transport change.
Import requests and cancellation¶
POST /api/v1/operator/imports calls ImportService using the daemon's catalog,
content adapter, and geo resolver. The configured owner is checked at both
the transport and service boundary. The service takes the existing import
lock and captures the current AI settings when that import begins. Workers,
settling, grouping, deduplication, and exact-content receipts use the existing
ingest.Importer; the CLI constructs neither storage handles nor an importer.
The Huma contract describes newline-delimited JSON ImportEvent records:
progress followed by one final result with partial counts and failures. The
typed client rejects EOF without a result and never resubmits a request.
Disconnect cancels the request, stops new file dispatch, and joins workers.
Discovery checks cancellation for every entry, including skipped files and
directories; source hashing checks between reads and closes its file on
cancellation. Completed imports and durable reservations remain available for a rerun.
Shutdown closes and joins the operator handlers before storage cleanup. There
is no detached import job or CLI storage fallback. Human progress remains on
stdout normally, or stderr with import --json; JSON stdout is the final result.
Checkout and backup requests¶
POST /api/v1/operator/checkouts/{id}/commit accepts the configured hub
and user ID, checks them against the server's stub owner, and calls the existing
CheckoutService.Commit. Estimate and create use
the same owner check and service for selection and creation. The create request
includes an absolute local destination, selectors, and capacity limit. The CLI
prefixes relative destinations with its working directory without cleaning
symlink-sensitive .. components. CheckoutService.CreateAt binds and validates
the destination through the server's content adapter before materialization.
Only authenticated local operators can request host-file creation, not photo users.
Header identity mode rejects these checkout operations. It permits the explicit
host-administration operations described below, including owner management,
GPS backfill, and thumbnail regeneration.
POST /api/v1/operator/backups checks the same configured stub principal and invokes
BackupService.Create with an absolute repository path and optional tag. It
captures all owners and hidden media, not just the configured owner's photos.
Only initialized repositories are accepted, and the scheduler's reserved tag
is rejected by the service. Relative CLI repository paths are made absolute
without cleaning symlink-sensitive parent components. Successful
backup create --json output remains the snapshot itself. Failures exit nonzero; connection
loss is not retried automatically because the archive may already be published.
List and verify the repository before retrying. Request cancellation reaches
archive capture, and shutdown joins the handler before closing the vault.
Checkout commit returns pending, committed, and conflict counts plus an optional error. An operation may finish some entries before failing; its result retains those counts and the CLI exits nonzero. Connection loss is not automatically retried: the operator checks saved status before retrying. Existing exact-version receipts handle retries without appending an already-adopted version. Request cancellation reaches estimate, creation, and commit processing. Creation returns selected file/byte totals, the number of recorded materialized files, and the checkout ID once reserved, including on failure. Failure records an errored checkout without deleting partial working files; an interrupted process is reconciled by the next creation under the existing creation lock. Creation is not automatically retried. Shutdown cancels operator requests, stops accepting work, and joins handlers before storage closes. Discovery is removed only after all storage and lifetime-lock cleanup has completed.
Import recovery and GPS maintenance¶
content recover calls ImportService.Recover through the operator API. The
configured host operator can reconcile every registered owner's interrupted
imports; this is not a photo-user permission. Recovery holds the same import
lock, captures current AI settings after acquiring it, and uses the daemon's
catalog, vault, and geo resolver. It returns per-owner reports, including the
partly completed owner if an error occurs, without deleting unmatched files.
Requests are canceled and joined during shutdown. The CLI validates --wait
before automatic startup and formats the shared result, including errors.
gps backfill uses GPSService with the daemon's content resolver, vault and
gazetteer. GPSOperatorDeps is provided only to the authenticated local
listener, in both stub and header modes. The host operator may select any
registered owner or all owners; without a scope, only stub mode supplies a
default owner. These are host-administration rights, not photo-user rights.
The service preserves keyset paging, skips videos, validates exact Docbank
content before extracting GPS, and writes only while the expected content
version remains current. Relabeling reads catalog coordinates, not source
bytes. Cancellation stops further rows; per-photo failures are collected while
other rows continue. The final result contains counts, failures and any error;
the CLI exits nonzero on partial failure and never retries automatically.
Owner administration¶
Owner registration, listing, and removal use internal/client/owners.go and
the host-operator routes in internal/httpapi/operator_owners.go:
| CLI command | HTTP operation |
|---|---|
owners add |
POST /api/v1/operator/owners |
owners list |
GET /api/v1/operator/owners |
owners remove |
DELETE /api/v1/operator/owners?hub=…&user_id=… |
The daemon supplies OwnersOperator only on the authenticated local listener,
in both stub and header mode. Photo-user identity does not grant these operations.
The CLI validates required arguments and storage UUIDs before automatic startup;
it never constructs an owner service or opens a catalog. OwnerService retains
registration idempotency, immutable storage keys, display-handle updates, and
refusal to remove owners referenced by assets or checkouts. Removal does not
delete files; bulk purge remains unsupported. owners add --json returns the
shared HTTP owner record, and owners list --json returns records under items.
Neither emits a success record when the request fails. The configured stub owner
is ensured at daemon startup, so a fresh stub deployment already contains that
owner.
OwnerAdminService serializes registration/removal and updates the NAS store's
locked owner-key map before returning. The thumbnail cache uses that same map,
so newly registered owners can use artifacts without restarting. Removal of the
active configured stub owner is rejected with HTTP 409; change the identity
configuration and restart before unregistering it. Owners referenced by assets,
checkouts, albums, or shares also return HTTP 409, as do duplicate storage keys.
Foreign-key enforcement remains the final reference check. Unsupported requests
remain invalid arguments rather than owner-in-use conflicts.
Thumbnail regeneration¶
Thumbnail regeneration uses POST /api/v1/operator/thumbs/regenerate and the
typed client in internal/client/thumbs.go. ThumbAdminService resolves the
requested owner scope and enqueues through the server-owned thumbnail queue.
The CLI validates selectors before automatic startup and never opens the catalog.
Default scope is the configured stub owner; explicit single/all-owner scopes also
work in header mode, behind the host-operator credential. Queue eligibility stays
restricted to ready, visible assets. Results contain per-owner queued counts and
an optional error for an incomplete run; earlier owner updates are not rolled
back. Repeating the request increments thumbnail versions again.
Privacy commands¶
Privacy commands call the existing /api/v1/auth/hidden/setup, /change, and
/disable operations through the typed client. They remain scoped to the
configured stub owner. Passcode prompts, confirmation, and input validation
finish before automatic startup; the CLI never opens the catalog.
admin reset-hidden-passcode calls the host-operator-only
POST /api/v1/operator/hidden/reset operation with explicit confirmation.
It defaults to the stub owner or accepts --owner hub:user; header deployments
require an explicit owner. Reset atomically removes the credential and revokes sessions
but preserves hidden flags. Disable clears hidden flags as well. Changing a
passcode revokes existing sessions without changing hidden flags.
The daemon shares one hidden-auth service between listeners. Its mutation lock
serializes setup, change, disable, unlock, and reset through their database
writes, so an in-flight credential check cannot undo a completed reset.
AI commands¶
ai status reads /api/v1/ai/health through the typed client, including the
daemon's provider probe and owner-scoped queue counts. ai acknowledge calls
/api/v1/ai/acknowledge for the configured stub owner; it requires the explicit
--hidden-processing flag before automatic startup. Both reuse the web API's
contract and open no catalog connection in the CLI.
AI backfill and retry-failed use POST /api/v1/ai/backfill and
POST /api/v1/ai/retry-failed for tag, caption, and embed tasks. The daemon
selects embedding generations and queues work using its current configuration.
The CLI validates task names before startup and formats per-task counts.
Embedding-generation listing, promotion, and compaction use the local operator
API under /api/v1/operator/ai/generations. Only stub deployments supply this
service; photo listeners and header-mode deployments reject these operations.
The daemon reuses its generation registry, activation counter, and compactor.
The CLI performs no catalog reads, including promotion inspection and dry-run.
Bootstrap and diagnostics¶
The accepted boundary is one daemon-owned implementation per application
operation, shared by HTTP, the CLI, and a future MCP client. Bootstrap and
lost-source recovery must retain that ownership boundary.
Local configuration setup and validation remain bootstrap tools. Read-only
config diagnose inspects storage paths and file headers without opening a live
catalog connection or performing migrations or repairs. Its human and --json
output use the same diagnostic checks. JSON includes failed checks on stdout;
any error check produces a nonzero exit and a summary on stderr.
Background work and shutdown¶
Long-running operations honor context.Context. Background loops use bounded
polling, concurrency, and shutdown waits; they do not start untracked
goroutines from transports.
The checkout scanner is one of those server-owned loops. It runs an immediate
full scan at startup and repeats at checkouts.scan_interval. Per-checkout
errors are logged without preventing other active roots from being scanned;
the next interval retries from the durable settle observations in SQLite.
The server may start while the external NAS root is absent, including when a
configured NAS symlink has no reachable target, so /readyz can report the
outage. Checkout and import root validation still fails closed until every
managed boundary resolves; the scanner retries after the NAS returns.
NAS artifact writers open the externally managed root rather than creating it,
so an absent mount cannot silently become a local directory.
The optional archive worker also belongs to the server. [backup].enabled
defaults to false; enabling it requires an explicit initialized
backup.repository. The worker reuses the server's live Docbank adapter and
catalog path to capture complete archives. It reads the latest persisted
scheduled recovery point at startup, captures immediately if none exists, and
otherwise honors backup.interval across restarts. Failed attempts retry after
the shorter of that interval and five minutes. Successful capture precedes
scheduled retention and pruning through the content boundary. Shutdown waits
for this worker before closing its storage collaborators. See
backup and restore for capture and retention
ownership.