Last updated

Developer manual

For senior engineers taking a fork of the openshelf_os codebase. The goal is to get the stack running, understand the architecture, and extend it. Read time: 25–35 minutes.

What this is

An Openshelf device is a robot arm in a cabinet that stores and retrieves vials by barcode. Each lab device runs the Openshelf OS stack: a FastAPI backend (api, port 8000) and a SvelteKit frontend (dev_ui, port 8080) in one docker compose project.

The API is the contract. Robot motion, inventory, calibration, diagnostics, and printing are all exposed through /api/* endpoints; the Local API reference is the maintained reference and is generated from the code. The UI is one consumer of that API; lab software can talk to the API directly.

Forking this repo means owning three Python packages in a clean dependency chain: low-level device drivers, domain and orchestration logic, and the HTTP service. This manual maps them, shows how to run the stack in every mode, and walks through which files to touch for the common extensions.

Repository layout

openshelf_os/
  dev_ui/                    SvelteKit frontend (local and device UI)
  openshelf_hardware/        Python uv workspace
    packages/hardware/           low-level device drivers
    packages/openshelf_os/       domain + orchestration (robot, storage, config)
    packages/openshelf_os_server/ FastAPI service (routers, core, database)
    data/                      live runtime state (sqlite, config, mode files)
  scripts/                   start/stop lifecycle + hardware debug helpers
  docker-compose.yml           base stack (api, dev_ui)
  docker-compose.dev.yml       development overrides (source hot reload)
  docker-compose.hardware.yml  USB/device passthrough
flowchart LR subgraph repo["openshelf_os checkout"] subgraph stack["docker compose project"] ui["dev_ui
SvelteKit · port 8080"] api["api
FastAPI · port 8000"] end subgraph ws["openshelf_hardware (uv workspace)"] server["openshelf_os_server
FastAPI service"] domain["openshelf_os
domain + orchestration"] hw["hardware
device drivers"] end data[("data/
sqlite + config + mode files")] end ui -- "HTTP /api" --> api api --- server server --> domain domain --> hw server --> data domain --> data

dev_ui/ talks to the API over HTTP and consumes its SSE event streams. Everything else lives inside the api container: openshelf_os_server is the HTTP surface, openshelf_os is the robot and storage domain, and hardware is driver code only. data/ is the live source of truth for local state; delete or edit it carefully.

Running the stack

Prerequisites: docker with compose v2, uv, and node for frontend checks.

First-time local development start:

./scripts/start --mode development --build

Normal local development start:

./scripts/start --mode development

Stop the stack with ./scripts/stop. Always start and stop through scripts/start and scripts/stop rather than raw docker compose commands, so the mode tracking in data/.mode stays in sync. On a machine with connected robot, camera, scanner, or printer devices, pass --hardware (and stop first when restarting: ./scripts/stop then ./scripts/start -d -h), otherwise the API container can restart without the hardware compose overlay and lose device passthrough.

Every checkout of this repo shares one compose project name (openshelf_os), so starting a second checkout replaces the containers of the first. Set OPSHELF_COMPOSE_PROJECT=<name> per worktree when two stacks have to coexist.

scripts/start enables the hardware overlay on its own when the selected mode's config sets robot.enabled or robot_app.enabled to true, never in mock mode, and takes --force-recreate alongside -d, -h, -b, and -p.

Operating modes

The mode is selected only through scripts/start and tracked in data/.mode; the API exposes it read-only. A plain ./scripts/start with no mode file starts production, while an API process launched without scripts/start (no data/.mode, no OPERATING_MODE) falls back to development.

Mode What it is
production The deployed device mode; slim images, no dev tooling
development Hot reload: the three packages/*/src/<package> trees and dev_ui/src are bind-mounted and the container reloads on .py changes, so source-only changes need no rebuild; scripts/ and tests/ are not mounted; dependency or dockerfile changes need --build
demo Demo workspace; robot storage restricted to the barcode-to-cell mappings in data/demo.json
mock The hardware-free workbench: the real authenticated FastAPI service and sqlite persistence without robot, camera, scanner, sizer, printer, cups, or sentry initialization

Modes use persistent mode-specific state variants (base.development.db, config.development.json, ...). The first start in development or demo copies the existing production state without modifying it, so returning to production restores the original workspace. Mock creates a fresh base.mock.db (empty inventory, seeded users and roles) and never copies production database or item state. It may still copy config.json to config.mock.json, so configuration values travel into mock. Sentry follows should_enable_sentry, which is true for demo and production and false for mock and development, so a demo run does send events to team telemetry. When demo state has to be recovered from production, the .demo_workspace_seeded marker is deleted and the next demo start re-runs the demo seed, replacing demo data.

Mock is the default environment for API and runtime work without a device:

Useful variants:

./scripts/start --mode mock --detached
./scripts/start --mode development --hardware
./scripts/start --mode development --pull   # devices: pull prebuilt images from the team registry

Quick checks: the UI at http://localhost:8080, API health at http://localhost:8000/api/health, and the seeded developer login [email protected] with PIN 1111. Container logs are capped at 10 MiB x 5 files; docker logs stops at the first truncated line, so after an unclean shutdown read the container JSON log directly.

Quality gates

cd openshelf_hardware
uv sync --all-packages      # bare `uv sync` prunes workspace member dependencies
uv run pytest
uv run ruff check packages
uv run mypy packages/openshelf_os_server/src

cd dev_ui
npm install
npm run check
npm run build

The backend suite is order-dependent by default only because collection order is stable; before landing test changes, run it shuffled at least once:

cd openshelf_hardware && uv run pytest --random-order

Both commands read the operating mode from openshelf_hardware/data/.mode, because importing the app resolves runtime state at import time. A leftover mock mode file makes robot tests take the mock paths, so unrelated tests that post a home command fail with 400 on a clean tree. Use a throwaway data dir whenever the run itself is the evidence:

cd openshelf_hardware && OPENSHELF_DATA_DIR=$(mktemp -d) uv run pytest --random-order

Tests take their identity from openshelf_os_server/tests/conftest.py, which seeds [email protected] with PIN 1234. Its autouse cleanup clears the bin, item, and location tables only for modules named test_storage*, and the audit, user, and role tables only for modules named test_audit*, before and after each test. A new module that writes those tables leaks rows into the shuffled run until it is added there. openshelf_os/__init__.py configures Sentry at import time, so a test that wants Sentry off must set SENTRY_DSN_HARDWARE before that package is first imported.

Focused runs take a package path directly, for example uv run pytest packages/openshelf_os_server/tests/test_storage_endpoints.py.

Nothing runs these gates for you: the only workflow in .github/workflows/ dispatches a release build, so every command above is a human step before a merge.

Async tests mix pytest-asyncio (configured in auto mode by openshelf_os_server/pytest.ini alone, while the other two packages set no asyncio_mode) with AnyIO. A test marked pytest.mark.anyio must take its background-task fixtures from @pytest.fixture, not @pytest_asyncio.fixture, or the worker attaches to a different event loop in the full suite.

Architecture

flowchart TB ui["dev_ui
SvelteKit views: storage, sizer, scale,
camera, scanner, printer, motors, power, wifi"] subgraph server_pkg["openshelf_os_server"] routers["routers/
auth · users · roles · access_levels · audit
robot · storage · cabinet · calibration · chemicals
camera · iobox_sizer · printer · report · telemetry
onboarding · system · wifi · demo"] middleware["middleware/
audit (HMAC rows) · correlation (X-Correlation-ID)"] core["core/
long-lived services: robot runtime, mock robot,
command lifecycle, permissions, laser auto-calibration,
camera managers, crash recorder, bin storage"] db["database/
SQLAlchemy models + Alembic migrations"] openapi["openapi.py
generator (checks live in openapi_checks.py)"] end subgraph os_pkg["openshelf_os"] robot["robot/
core · worker · commands · status"] storage["storage grid + cabinet"] config["config loader + schema + migrations"] laser["laser calibration (scan, angle, z-stall)"] io["iobox · tool · serial ports · barcode scanner service · operating mode"] end subgraph hw_pkg["hardware"] drivers["motors (Modbus) · laser · scale · TOF · cameras
printer · GPIO · curtain interlock"] end ui --> routers routers --> middleware routers --> core core --> db routers --> openapi core --> robot core --> storage robot --> config robot --> laser robot --> io io --> drivers

Startup sequence

Startup resolves configuration before the main.py app lifespan, then initializes the services in this order:

  1. Resolve the mode's runtime paths (config file, database file, calibration directory) and migrate the active config before loading it through settings.get_root_config.
  2. Run Alembic migrations and create any missing tables.
  3. Seed default roles, role permissions, access levels, and the developer user (plus the configured development auto-user, when auth.dev_mode is on).
  4. Ensure onboarding state and apply the mode transition.
  5. Recover interrupted robot commands (queued/running become failed).
  6. Sync storage bin identities from config and recover interrupted bin workflows.
  7. Start the mock robot runtime (mock mode) or the background robot initialization task (real hardware), then the camera managers, I/O box sizer, and crash video recorder.
  8. The app is ready; /api/health reports the mode and robot readiness (status, mode, robot_status, robot_phase) and answers 503 when initialization failed. It reports no database, camera, or other dependency checks.

Robot initialization on real hardware is a tracked, multi-phase process. Robot core connects the motor, scale, and laser buses, then GPIO, then Tool.configure, which configures the gripper servo and the five axis motors in the order y_axis, x_axis, z_axis, stage, rotate, initializes the laser, and only then homes: z_axis and stage first, then rotate, then x_axis and y_axis. IOBox.configure runs afterwards and homes door last, before the cabinet (openshelf_os/tool.py configure and _home_sequence, openshelf_os/robot/core.py). Progress is tracked per component in core/init_progress.py through the phases pending, connecting, configuring, homing, ready, failed, and surfaced through /api/health and the status stream. The app serves requests while that runs, so an initializing phase is not a failed start. The overall phase turns degraded when a component registered as degrading fails after the tracker already reached ready, and returns to ready when every degrading component is ready again. A machine without a limit switch must have limit_switches_enabled: false on that motor entry, or homing fails because it verifies the home limit input.

Config

Runtime configuration is config.<mode>.json, loaded and validated through pydantic models. The top-level schema_version is the persisted schema: absent means 1, the pre-versioning layout that device setup writes, and the current version is config.schema.CURRENT_SCHEMA_VERSION (5). The server automatically migrates the active config before loading it; each other mode's config migrates when that mode next starts. config_migrations/apply.py runs the full migration chain, validates the result, preserves the original bytes in an exclusively created <filename>.schema-v<N>.bak (where N is the source version), and replaces the config atomically. A newer schema, failed migration, or conflicting backup stops startup. Preserve and reconcile an existing backup before retrying. Device scripts and other offline entry points still need scripts/migrate_config.py (preview first, --write with the application stopped). Never fix a startup error by changing only schema_version — the old field names would then lose their meaning. Version 5 renames the left/right scan config keys and bin home sides to front/back.

Database and state

One sqlite database per mode (base.db in production, base.development.db, base.mock.db, ...), managed with SQLAlchemy and migrated with Alembic. The tables cover users/roles/access levels, the HMAC-chained audit_logs, items and item_locations, robot_commands, storage bins and workflows, item_sizer_measurements, telemetry, and storage_cells. The storage grid itself is a JSON file under data/ (storage.json and mode variants) describing modules, shelves, and cells.

Current side values are front_module and back_module, with F/B in demo location strings. The database side rename is also applied after startup baseline stamping, so item_locations and storage_bins are updated even when Alembic skips the historical revision chain. Demo mapping readers, including the demo seed script, migrate old L/R locations in place through demo_side_rename.py, preserving a .demo-side-rename.bak copy. Saved laser runs map historical side names in memory when loaded; their files stay intact.

Live status and results fan out over the SSE endpoints /api/robot/status and /api/robot/events from in-process queues. /api/telemetry is an ordinary JSON GET, not a stream. Camera video is a separate transport: MJPEG over HTTP plus a WebSocket at /api/camera/ws/{camera_id}, and both take the token as a token query parameter because EventSource and WebSocket clients cannot set an Authorization header.

Frontend conventions

The shared client in dev_ui/src/lib/api.ts fetches the storage grid to infer whether the device uses left/right or front/back names. lib/side.ts maps both vocabularies to stable side indices (0 and 1); UI state uses canonical front/back tokens, while API wrappers translate outbound sides to the device vocabulary. Load the grid before side-specific requests, compare received side strings with sideIndex(), and use the shared label helpers. Laser snapshot normalization in the client also maps legacy per-side field names.

+layout.svelte owns the shared robot status stream, NeedsHomeAlert, SafetyAckAlert, and ToastStack. Homing and acknowledgement prompts follow server status; the status page also shows the required actions and blocking sources. ErrorMessage and StatusMessage post to lib/toasts.ts: errors stay until dismissed, status messages expire after ten seconds, and identical visible messages are deduplicated. Reuse these components for action feedback.

The status page reads identity and alias from /api/system/info and mode from /api/system/mode. The public info endpoint also exposes nullable release_sha and release_branch: RELEASE_SHA/RELEASE_BRANCH override /etc/opshelf/release; the alias comes from /etc/opshelf/device-alias. Readiness still comes from health and robot status.

Calibration views live under lib/components/calibration/. CellCalibration.svelte places cell selection beside motion controls, with explicit calibrate and save-and-test actions. StorageLayoutEditor.svelte owns bulk deletion and coordinate inference for newly added cells; those are client workflows over existing grid endpoints. Bulk deletion can partially succeed and reloads the grid afterwards. New cell coordinates are inferred from the origin or calibrated neighbours when possible, then persisted with a separate grid update.

A command's life

This is the reference path for anything you add: a store_item command from client to persisted result.

sequenceDiagram participant C as client participant M as middleware (auth + audit) participant R as router /api/robot participant DB as sqlite participant W as robot worker (FIFO) participant RC as robot core participant H as hardware participant S as SSE stream C->>M: POST /api/robot/commands M->>M: validate token, check permissions R->>R: validate item, access, location rules R->>DB: persist command (queued) R-->>C: command id M->>DB: write HMAC audit row after the response W->>DB: dequeue next command W->>RC: execute store_item RC->>H: home, move, gripper, scale/laser confirm H-->>RC: status snapshots + confirmations RC->>DB: mark succeeded RC->>S: publish event S-->>C: robot events stream C->>R: GET /api/robot/commands/{id} R-->>C: persisted lifecycle + result
  1. CorrelationIdMiddleware adopts or generates X-Correlation-ID and echoes it on the response. The audit middleware reads that header from the request instead, so only a client-supplied id ever lands in an audit row; a generated one reaches logs through the logging context, not through the audit table. Both wrap every router, but the audit middleware records nothing before the handler runs and not everything after it: OPTIONS, /api/health, /api/robot/debug/motors/probe, and /api/calibration/laser/stream are skipped, every response of 400 or above is written, successful reads on an allowlist are skipped, and /api/storage/items/lookup counts as a read. Rows are kept for 120 days.
  2. The router validates the payload: the item exists, the caller's access level covers it, and the location rules hold. retrieve validates the requested location and access before queueing, then removes the item location on completion.
  3. The command is persisted as queued; a full queue (32 active) returns 429, and a request_id seen within seven days replays the stored command instead of queueing.
  4. The FIFO worker dequeues and hands the command to the robot core.
  5. The robot core orchestrates the motion: homing, moves along the tool axes, gripper action, scale and laser confirmations, with the shared MotionSafetyInterlock gating motion for curtain, cabinet-door, and weight-change interruptions. Status snapshots publish continuously.
  6. The outcome (succeeded / failed) is persisted and broadcast on the event stream. In mock mode the same lifecycle runs against one deterministic worker with a 30-second simulated execution.
  7. A motion fault passes through Tool._handle_fault, which attaches a fault_context (per axis motor snapshot, phase, coordinates, and any previous fault context) to the exception, and the worker reports it with that context plus crash-recorder footage. The machine does not recover on its own; the log line asks for manual intervention.

Barcodes never travel in a URL path (uvicorn unquotes %2F before routing, so a barcode containing / could not be addressed); item and print endpoints take the barcode in the POST body.

Weight-change interruptions

openshelf_os/robot/weight_guard.py samples the scale during Y-axis motion, including the startup clearance jog before the worker exists. It compares readings against the last idle weight and blocks motion on a sustained change. The guard is enabled by default when the scale and Y motor are configured; settings live under robot.io.scale. Defaults are motion_guard_delta_grams: 10, motion_guard_min_samples: 2, motion_guard_arm_delay_s: 0.05, and motion_guard_ack_grace_s: 1.0. Sampling uses Scale.read_weight_fast() with one Modbus transaction, no retries, and a short timeout. A busy scale bus skips a sample; repeated read failures suspend sampling with backoff, so inspect the source's error and failed fields when diagnosing coverage. Trip records, including recent samples and operation context, are written to the relative data/weight_faults/ directory after the motion block is requested.

The worker reports weight_change in robot_status.safety.sources with clears_on_ack. Clients should use the server's can_acknowledge flag: a weight trip can be acknowledged while its source is still active, because acknowledgement resets the baseline. A physically active curtain or cabinet-door source still makes POST /api/robot/safety/acknowledge return 409 with active_sources. Acknowledgement does not home the robot. The engineering component override endpoint also accepts weight_change, under the same robot:control permission as the other component bypasses.

At the driver layer, jogs now count as motion for monitoring. A relative jog interrupted by the safety guard fails instead of reporting completion or repeating its full distance; preserve this behavior when extending motor commands.

The hardware layer

flowchart TB subgraph buses["serial + I2C buses"] m["RS-485 ch1
motor bus · 115200"] s["RS-485 ch2
scale bus · 9600"] l["RS-485 ch3
laser bus"] t["TOF bus"] g["I2C / rpi GPIO"] sc["barcode scanner
serial by-id"] end subgraph drivers["hardware package"] mc["SharedModbusController"] mo["Motor and Servo classes
instances are built in Tool and IOBox
x · y · z · stage · rotate · door, one bus"] scd["scale"] la["laser sensor"] to["TOF sensors"] ca["cameras (UVC, tool, overhead, overview)"] pr["label printer (CUPS)"] gi["GPIO manager"] cu["curtain interlock"] end subgraph domain["openshelf_os"] ro["robot core / worker · iobox · tool · barcode service"] end m --> mc --> mo s --> mc --> scd l --> mc --> la t --> to g --> gi gi --> cu mo --> ro scd --> ro la --> ro to --> ro sc --> ro ca --> ro pr --> ro cu --> ro

Mock mode never initializes any of this; the mock robot and cabinet are deterministic software stand-ins in openshelf_os_server/core/mock_robot.py, and the printer stand-in is the mock branch of _print_payload in the printer router. Do not add hardware fallbacks to either.

Config and state, in practice

openshelf_hardware/data/ holds everything: data/.mode and data/.last_start_mode (both written by scripts/start), data/.last_mode and data/.workspace.<mode>.json (written by the API to record the mode transition and what was copied versus adopted), data/audit-hmac-key.<mode> (a locally generated signing key), the per-mode sqlite database and config files, the storage grid files, calibration recordings, and generated state. The server migrates the selected mode's config at startup. To preview or pre-migrate all mode configs on a containerized device, obtain the matching released api image and run the offline migration runner with the application stopped:

docker compose -f docker-compose.yml run --rm --no-deps --pull never --entrypoint sh \
  -v "$PWD/scripts:/app/scripts:ro" api -lc \
  'cd /app/openshelf_hardware && uv run --no-sync python ../scripts/migrate_config.py data'

Repeat with --write added once the preview is correct, then restart through the repo-managed flow preserving the selected mode and hardware overlay.

Adding a config migration:

  1. Add v006_<description>.py under openshelf_os/config_migrations/; its migrate(config) accepts the raw previous-schema dict and returns the transformed dict plus the list of changed field paths.
  2. Register it under the destination version in runner.MIGRATIONS.
  3. Bump config.schema.CURRENT_SCHEMA_VERSION.
  4. Never modify a released migration — append a new step. Test value preservation, conflicting keys, optional fields, and the whole chain, and update generated config defaults, fixtures, and the device config template.

Adding a database migration: a new Alembic revision under openshelf_os_server/alembic/versions/; migrations run automatically at startup, so the next scripts/start applies it. The startup runner looks for openshelf_hardware/alembic.ini and, when it is absent, logs alembic_ini_missing and falls back to a bare Alembic config, so the checked-in packages/openshelf_os_server/alembic.ini steers manual alembic commands and not startup. A failed migration does not stop the app: the lifespan logs continuing_without_migrations, then Base.metadata.create_all creates only the missing tables, so a broken revision leaves a silently un-migrated database. Read the startup log after the first boot with a new revision.

Outside the containers

The API entry point is uv run start from openshelf_hardware/ (uvicorn on 0.0.0.0:8000), and the dev image runs it under debugpy listening on 0.0.0.0:5678, so a local debugger attaches without a rebuild. Every runtime path resolves from the environment, which is how a second checkout or a scratch workspace stays isolated.

Variable Effect
OPENSHELF_DATA_DIR data directory that config, database, calibration, and generated audit keys resolve against
OPENSHELF_CONFIG_PATH config file inside the data directory; scripts/start rejects host paths outside it
DATABASE_URL full sqlalchemy URL, overrides the per-mode sqlite file
OPERATING_MODE, DEPLOYMENT_ENVIRONMENT, SENTRY_ENVIRONMENT mode and environment labels set by scripts/start
JWT_SECRET, AUDIT_HMAC_KEY, AUDIT_HMAC_KEY_ID signing material, delivered in the checkout .env
OPSHELF_RELAY_SERVICE_TOKEN_SHA256 lowercase SHA-256 digest of the relay bearer token
OPSHELF_COMPOSE_PROJECT compose project name for this checkout
OPENSHELF_BARCODE_PORT, OPENSHELF_BARCODE_BAUDRATE, OPENSHELF_DISABLE_BARCODE scanner path, scanner speed, or scanner bypass
OPENSHELF_PRINTER_DEVICE printer device path, default /dev/usb/lp0
LOG_LEVEL, MODBUS_TRACE, UVICORN_RELOAD log level, per-register Modbus traces, in-container reload
CRASH_RECORDER_ENABLED, CRASH_RECORDER_LOOKBACK_SECONDS, CRASH_RECORDER_FPS, CRASH_RECORDER_MAX_WIDTH, CRASH_RECORDER_MAX_CAMERA_BUFFER_MIB, CRASH_RECORDER_MAX_ATTACHMENT_MIB, CRASH_RECORDER_MAX_TOTAL_ATTACHMENT_MIB crash recorder window (15 s), frame rate (3 fps), and attachment caps (640 px wide, 16 MiB camera buffer, 4 MiB per file, 14 MiB total)
OPENSHELF_CORS_ORIGINS comma-separated list of exact origins; the default any-origin policy never advertises credentialed access
CAS_COMMON_CHEMISTRY_API_KEY optional Common Chemistry lookup key, server environment only
VITE_API_BASE_URL API base for dev_ui; otherwise a /<8-hex device id>/ path prefix or a stored sessionStorage.deviceId gives <origin>/<id>/api, and the last resort is http://<hostname>:8000/api (dev_ui/src/lib/api.ts)

Auth and audit

Extending Openshelf OS

You want to add Touch these
A new API endpoint openshelf_os_server/routers/<name>.py, register it in main.py (include_router), pydantic request/response models with the 2xx response typed as a model (an empty object schema fails the contract check), an auth dependency (require_permissions) whose permission string is granted to a role, error docs via responses=response_docs(errors=(...)), a route summary plus docstring, an audit exclusion decision when the route must stay out of the log, tests under openshelf_os_server/tests/
A new robot command openshelf_os/robot/commands.py (pydantic command model), robot/core.py (execution), robot/worker.py (queueing), openshelf_os_server/core/mock_robot.py (mock behavior), openshelf_os_server/routers/robot.py (endpoint)
A new hardware device hardware/src/hardware/<device>.py (driver, no domain logic), its openshelf_os integration (iobox / tool / robot core), a config key. An extra axis is not config-driven: Tool builds five literal motors, IOBox builds door, and robot/core.py keeps the literal _INIT_MOTOR_COMPONENTS and POWER_RAILS lists, so all of them need a new entry
A new config key openshelf_os/config/ models and loader; if the schema changes, a config_migrations/v00N_*.py step plus the CURRENT_SCHEMA_VERSION bump (see above)
A new DB table or column openshelf_os_server/database/models.py plus an Alembic revision
A new UI view dev_ui/src/routes/<view>/, an entry in the links list in dev_ui/src/routes/+layout.svelte or it stays unreachable, one typed wrapper per endpoint in the shared client dev_ui/src/lib/api.ts, and dev_ui/src/lib/sse.ts for streams. The client logs in as the developer account when no token is in memory, so a permission-restricted view can look healthy while every call runs as that account

The audit and correlation middleware apply to every router automatically; permission checks are explicit, through require_permissions("<resource>:<action>"), or require_relay_or_permissions(...) on a relay-facing route, from routers/auth_deps.py, so a new endpoint that touches items must repeat the access-level check of the endpoints it resembles. The contract checks also gate documentation: every operation needs a summary, a docstring description that is not the summary repeated verbatim, and tags that already exist in the document, which means tags declared in documentation.TAGS (openshelf_os_server/openapi_checks.py). require_permissions matches only the permissions stored on the caller's role, with *:read and *:* wildcards, so a brand-new resource:action string returns 403 for everyone until it is added to a role, through the roles API or DEFAULT_ROLES in main.py.

After every change:

  1. Run the focused tests for the touched package, then the full shuffled suite (OPENSHELF_DATA_DIR=$(mktemp -d) uv run pytest --random-order), plus uv run ruff check packages and uv run mypy packages/openshelf_os_server/src.
  2. For API surface changes, validate the contract from openshelf_hardware: uv run openapi --check regenerates the document from the live routes and validates it in memory, and uv run pytest packages/openshelf_os_server/tests/test_openapi.py compares it with the served routes. There is nothing to commit: uv run openapi --output <path> writes the JSON on demand, the generated file is gitignored, and the docs site regenerates it during its own build. Keep endpoint prose in route docstrings and field semantics on the models.
  3. For frontend changes, cd dev_ui && npm run check && npm run build.
  4. On a development machine, source-only changes hot-reload through the bind mounts; dependency or dockerfile changes need ./scripts/start --mode development --build.

Cloud relay portal

Customers of the cloud relay sign in at https://api.opshelf.com/settings with a one-time email link. From there they copy or rotate the API key for their organization and see the virtual machines assigned to them. Those customers, keys, and assignments live in the relay database. Device containers and ports still come from the relay config.json. Creating a customer or assigning a machine is a relay-admin command on the relay, described in the relay repository README.

Where to go next