# Shelf > Generated: 2026-07-19 · Source commit: ecc96a9 · Product: Shelf · Canonical: https://docs.cocoar.dev/shelf/ · Version: v2.2 --- url: /guide/getting-started.md description: >- What Shelf is and the five-minute path from a VitePress build to hosted, versioned documentation: run the container, register a product, upload a ZIP. --- # Getting Started Shelf is a static documentation hosting platform for Cocoar products. It serves VitePress-generated documentation with support for multiple products and multiple versions per product. ## What It Does * Serves static HTML/CSS/JS files from a mounted volume * Routes requests to the correct product and version * Automatically determines the "latest" version per product * Provides an [Upload API](./upload-api.md) for deploying docs from CI/CD pipelines * Includes a built-in [Admin UI](./admin-ui.md) for managing products and versions * Caches version information and invalidates on filesystem changes ## Quick Start ### 1. Run with Docker ```yaml # docker-compose.yml services: shelf: image: ghcr.io/cocoar-dev/shelf:latest ports: - "80:8080" volumes: - docs-data:/data/docs - config-data:/data/config environment: - Shelf__Database__ConnectionString=Host=postgres;Database=shelf;Username=postgres;Password=${POSTGRES_PASSWORD} - Shelf__Modgud__Issuer=https://auth.example.com - Shelf__Modgud__WebClientId=shelf-web - Shelf__Modgud__WebClientSecret=${SHELF_MODGUD_SECRET} - Shelf__ApiKey=${SHELF_API_KEY:-} restart: unless-stopped postgres: image: postgres:17 environment: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=shelf volumes: - pg-data:/var/lib/postgresql/data restart: unless-stopped ``` ```bash docker compose up -d ``` Shelf needs PostgreSQL and, for the admin login, a [Modgud](./authentication.md) identity provider. CI/CD-only setups can start with just the `Shelf__ApiKey` and add the admin login later. ### 2. Add Documentation **Option A: Admin UI** (recommended for getting started) Open `http://localhost/admin/` and sign in with your email — you'll receive a one-time code. Create a product and upload a ZIP of your VitePress build output directly from the browser. See [Admin UI](./admin-ui.md) for details. **Option B: Upload API** (recommended for CI/CD) Register the product, then upload a ZIP: ```bash # Create product via API curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "configuration", "displayName": "Cocoar.Configuration", "source": "upload"}' \ http://localhost/_api/products # Upload docs curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/zip" \ --data-binary @docs.zip \ http://localhost/_api/products/configuration/versions/v5 ``` See [Product Registration](./product-registration.md) and [Upload API](./upload-api.md) for details. **Option C: Manual** (copy files directly) Place your VitePress build output in the volume: ``` /data/docs/ └── configuration/ └── v5/ ├── index.html ├── assets/ ├── guide/ ├── llms.txt └── llms-full.txt ``` ### 3. Access Your Docs Your documentation is now available at: * `http://localhost/configuration/` -- latest version (redirects to v5) * `http://localhost/configuration/v5/` -- specific version * `http://localhost/admin/` -- Admin UI for management --- --- url: /guide/how-it-works.md description: >- Architecture in one page — an ASP.NET Core server over a docs volume: version auto-detection, latest-redirects, base path rewriting, and PostgreSQL for products, users and analytics. --- # How It Works Shelf is intentionally simple. It's an ASP.NET Core application that serves static files from a volume with smart URL routing and automatic base path rewriting. ## Volume Structure Shelf expects documentation files organized by product and version: ``` /data/docs/ ├── configuration/ │ ├── v4/ │ │ ├── index.html │ │ ├── assets/ │ │ └── ... │ └── v5/ │ ├── index.html │ ├── assets/ │ └── ... ├── capabilities/ │ └── v1/ │ └── ... └── filesystem/ └── v1/ └── ... ``` Each product is a directory. Each version within a product is a subdirectory matching the pattern `v{number}` (e.g., `v1`, `v5`, `v12`). ## Version Detection Shelf scans the filesystem to determine available versions. The **highest version number** is automatically the "latest". There is no manifest file or configuration needed. When a request for `/configuration/` comes in (no version), Shelf redirects to the latest version: 1. Identifies `configuration` as the product 2. Scans its subdirectories for version folders 3. Picks the highest version (e.g., `v5`) 4. Redirects to `/configuration/v5/` Requests with an explicit version (e.g., `/configuration/v5/guide/getting-started.html`) are served directly. ## Base Path Rewriting VitePress bakes the `base` path into all generated files at build time. Normally, you'd have to build your docs with the exact base path matching where they'll be hosted (e.g., `base: '/configuration/v5/'`). **Shelf removes this requirement.** You can build your VitePress site with the default `base: '/'` and Shelf rewrites all paths on the fly when serving responses. See [Base Path Rewriting](./base-path-rewriting.md) for details on how this works. ## Automatic Cache Invalidation Shelf watches the docs directory for changes using [Cocoar.FileSystem](https://github.com/cocoar-dev/Cocoar.FileSystem)'s `ResilientFileSystemMonitor`. When a new version directory is created or removed, the cached version list for that product is automatically invalidated. This means deploying a new version is instant — whether via the [Upload API](./upload-api.md) or by copying files directly into the volume. Shelf picks it up without a restart. We use `ResilientFileSystemMonitor` instead of .NET's raw `FileSystemWatcher` because it provides: * **Automatic recovery** — if the docs volume is temporarily unavailable (Docker mount issues, network glitches), the monitor recovers automatically instead of silently dying * **Debouncing** — rapid filesystem changes during a deployment are consolidated into a single cache invalidation * **Audit timer** — periodically verifies that no events were missed, a known issue with `FileSystemWatcher` under high load * **Polling fallback** — if native filesystem events aren't available (certain network shares, Docker volume edge cases), the monitor falls back to polling automatically --- --- url: /guide/docker.md description: >- Run Shelf as a Docker container: image tags, the docs/config volume mounts, a compose example and reverse-proxy notes. --- # Docker Shelf runs as a Docker container with documentation and configuration files mounted as volumes. ## Basic Setup ```yaml # docker-compose.yml services: shelf: image: ghcr.io/cocoar-dev/shelf:latest ports: - "80:8080" volumes: - docs-data:/data/docs - config-data:/data/config environment: - Shelf__Database__ConnectionString=Host=postgres;Database=shelf;Username=postgres;Password=${POSTGRES_PASSWORD} - Shelf__Modgud__Issuer=https://auth.example.com - Shelf__Modgud__WebClientId=shelf-web - Shelf__Modgud__WebClientSecret=${SHELF_MODGUD_SECRET} - Shelf__ApiKey=${SHELF_API_KEY:-} restart: unless-stopped postgres: image: postgres:17 environment: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=shelf volumes: - pg-data:/var/lib/postgresql/data restart: unless-stopped ``` Shelf needs a PostgreSQL database (see [Configuration](./configuration.md#database)) and two volumes: * **Docs volume** -- writable, so the [Upload API](./upload-api.md) can deploy versions * **Config volume** -- [product registration](./product-registration.md) seed files, imported into the database at startup Shelf stores its configuration in `data/configuration.json`. If you need to customize settings beyond environment variables, mount a config file: ```yaml volumes: - ./configuration.json:/data/configuration.json:ro ``` ## Volume Mounting ### Single Volume The simplest approach. All products live in one volume: ```yaml volumes: - docs-data:/data/docs - config-data:/data/config ``` ### One Volume Per Product Useful when products are deployed independently: ```yaml volumes: - config-docs:/data/docs/configuration - caps-docs:/data/docs/capabilities ``` ### Host Directory For development or simple setups, mount host directories directly: ```yaml volumes: - /srv/docs:/data/docs - /srv/config:/data/config ``` ## Environment Variables See [Configuration](./configuration.md) for all options. The most important ones for Docker: | Variable | Default | Description | |---|---|---| | `Shelf__AppUrl` | `http://0.0.0.0:8080` | Server binding URL and port | | `Shelf__DocsRoot` | `/data/docs` | Root directory for documentation files | | `Shelf__ConfigRoot` | `/data/config` | Root directory for product config seed files | | `Shelf__Database__ConnectionString` | — | **Required.** PostgreSQL connection string | | `Shelf__Modgud__Issuer` | `https://auth.cocoar.dev` | Identity provider for the [admin login](./authentication.md) | | `Shelf__Modgud__WebClientId` / `__WebClientSecret` | — | Confidential client for the login broker | | `Shelf__ApiKey` | *(empty)* | Bootstrap master API key for CI/CD | | `Shelf__AccessLog__Enabled` | `false` | Record page views for [Analytics](./admin-ui.md#analytics) | | `Shelf__PathBase` | *(empty)* | Global URL prefix (e.g. `/docs`) | Environment variables override values from `data/configuration.json`. ## Building From Source ```bash docker compose build ``` Or build the image directly: ```bash docker build -t cocoar/shelf . ``` ::: tip Node.js Required The build process requires Node.js for the Vue client (Admin UI and landing page). The Dockerfile handles this automatically, but if building outside Docker, ensure Node.js is installed. ::: --- --- url: /guide/configuration.md description: >- Every configuration option — PostgreSQL connection, volumes, Modgud federation, API keys, access log, upload limits — via configuration.json or Shelf__ environment variables. --- # Configuration Shelf needs a PostgreSQL database, two volume mounts and a handful of environment variables. ## Configuration File Shelf uses its own configuration file at `data/configuration.json` (relative to the application root, typically `/data/configuration.json` in Docker). This is powered by [Cocoar.Configuration](https://github.com/cocoar-dev/Cocoar.Configuration) and replaces the standard ASP.NET Core `appsettings.json` pattern. ```json { "AppUrl": "http://0.0.0.0:8080", "DocsRoot": "/data/docs", "ConfigRoot": "/data/config", "PathBase": "", "ApiKey": "", "MaxUploadSizeBytes": 104857600, "VersionPattern": "^v?\\d+(\\.\\d+(\\.\\d+(-[\\w.-]+)?)?)?$", "Database": { "ConnectionString": "Host=postgres;Database=shelf;Username=postgres;Password=..." }, "Modgud": { "Issuer": "https://auth.example.com", "Audience": "shelf", "WebClientId": "shelf-web" }, "AccessLog": { "Enabled": true, "RetentionDays": 90 }, "Logging": { "LogLevels": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } } ``` ## Options Configuration can be set via `data/configuration.json` or overridden with environment variables using the `Shelf__` prefix: | Option | Env Variable | Default | Description | |---|---|---|---| | `AppUrl` | `Shelf__AppUrl` | `http://0.0.0.0:8080` | Server binding URL and port | | `DocsRoot` | `Shelf__DocsRoot` | `/data/docs` | Root directory for documentation files | | `ConfigRoot` | `Shelf__ConfigRoot` | `/data/config` | Root directory for [product config](./product-registration.md) seed files | | `PathBase` | `Shelf__PathBase` | *(empty)* | Global URL prefix for running under a sub-path | | `VersionPattern` | `Shelf__VersionPattern` | `^v?\d+(\.\d+(\.\d+(-[\w.-]+)?)?)?$` | Regex pattern to identify version directories | | `ApiKey` | `Shelf__ApiKey` | *(empty)* | Bootstrap master API key. See [Authentication](./authentication.md#api-keys-cicd) | | `MaxUploadSizeBytes` | `Shelf__MaxUploadSizeBytes` | `104857600` | Maximum upload size in bytes (100 MB) | | `Database.ConnectionString` | `Shelf__Database__ConnectionString` | — | **Required.** PostgreSQL connection string | | `Modgud.*` | `Shelf__Modgud__*` | — | Identity provider federation. See [Authentication](./authentication.md#configuration) | | `AccessLog.Enabled` | `Shelf__AccessLog__Enabled` | `false` | Record documentation page views for [Analytics](./admin-ui.md#analytics) | | `AccessLog.RetentionDays` | `Shelf__AccessLog__RetentionDays` | `90` | How long access log entries are kept | ## Database Shelf requires PostgreSQL (via [Marten](https://martendb.io/)). It stores product registrations, users, runtime settings and the access log; the schema is created and migrated automatically on startup. Documentation files themselves stay on the filesystem. Product registration JSON files found in `{ConfigRoot}/products/` are imported into the database once at startup (existing database entries win) — this makes upgrades from file-based setups seamless. After the import, products are managed via the Admin UI or API. ## Access Log When `AccessLog.Enabled` is true, Shelf records one entry per documentation page view (HTML page loads only — assets and HEAD requests are not counted) with IP, product, version, path, user agent and referrer. The [Analytics](./admin-ui.md#analytics) page visualizes this data; the optional [GeoIP database](./admin-ui.md#administration) adds country/city resolution. ## PathBase When Shelf runs behind a reverse proxy under a sub-path (e.g., `example.com/docs/` instead of `docs.example.com/`), set `PathBase` to the prefix: ```yaml environment: - Shelf__PathBase=/docs ``` All routes, redirects, and base path rewriting automatically include the prefix: | PathBase | Docs URL | API URL | |----------|----------|---------| | *(empty)* | `/configuration/v5/` | `/_api/products` | | `/docs` | `/docs/configuration/v5/` | `/docs/_api/products` | ## Landing Page Shelf serves a Vue SPA as the landing page at the root URL (`/` or `{PathBase}/`). The page shows cards for all [registered products](./product-registration.md) that have at least one deployed stable version, or that have the `showWhenEmpty` flag enabled. ### Tag Filter All tags used across any registered product are automatically collected and displayed as clickable filter chips in a toolbar above the product grid. Clicking a tag narrows the visible products to those that carry that tag. Multiple tags can be active simultaneously (AND filter). The active selection is persisted in `localStorage` so visitors get the same view on their next visit. Next to the tags, once any product is marked [Open Source or Proprietary](./product-registration.md#openness-repository-link), the toolbar also shows **Open Source** / **Proprietary** filter chips so visitors can narrow the list to one kind. This selection is persisted the same way. ### Preview Toggle Products with `visibility: "preview"` and pre-release-only versions are hidden by default. A "Show preview" toggle reveals: * Products with `visibility: "preview"` * Products that only have pre-release versions * Pre-release versions on public products This setting is also persisted in `localStorage`. ### Teaser Cards Products with `showWhenEmpty: true` appear in the grid even without any deployed versions. The card is rendered with a dashed border and a **Coming soon** indicator at the bottom to make the teaser state visually distinct from products with actual documentation. ## Version Pattern By default, Shelf recognizes a wide range of version formats: | Format | Examples | |--------|----------| | Major only | `v1`, `v5`, `v10` | | Major.Minor | `v5.1`, `v5.2`, `5.2` | | Full SemVer | `v5.2.0`, `5.2.0` | | Pre-release | `v6.0.0-beta.1`, `v6.0.0-rc.1`, `v1.0.0-vue-ui.10` | The `v` prefix is optional. Pre-release labels support hyphens (e.g., `v1.0.0-vue-ui.10`). Directories that don't match the pattern are ignored: ``` /data/docs/configuration/ ├── v5.1.0/ ← recognized ├── v5.2.0/ ← recognized ├── v6.0.0-beta.1/ ← recognized (pre-release) ├── v1.0.0-vue-ui.10/ ← recognized (pre-release with hyphens) ├── assets/ ← ignored └── .hidden/ ← ignored ``` ### Version Sorting Versions are sorted numerically by Major, Minor, and Patch. The **latest** version is determined as follows: 1. **Stable versions** (without pre-release label) are always preferred 2. Among stable versions, the highest Major.Minor.Patch wins 3. If only pre-release versions exist, the highest one is used as latest Example: With `v5.2.0`, `v6.0.0-beta.1`, and `v5.1.0`, the latest is `v5.2.0` -- because `v6.0.0-beta.1` is a pre-release. ::: tip GitVersion / SemVer Shelf works well with [GitVersion](https://gitversion.net/) or any SemVer-based versioning. Use the version output from your CI pipeline directly as the version directory name. ::: ### Customizing the Version Pattern If you need a stricter or different versioning scheme, override the pattern: ```yaml environment: # Only allow full SemVer with v prefix - Shelf__VersionPattern=^v\d+\.\d+\.\d+$ # Only major versions - Shelf__VersionPattern=^v\d+$ ``` ## Logging Shelf uses [Serilog](https://serilog.net/) for structured logging. Log levels are configured in the `Logging.LogLevels` section of `data/configuration.json`: ```json { "Logging": { "LogLevels": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Cocoar.Shelf": "Debug" } } } ``` Log levels can also be set via environment variables: ```yaml environment: - Shelf__Logging__LogLevels__Default=Information - Shelf__Logging__LogLevels__Microsoft.AspNetCore=Warning ``` Available levels: `Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`. --- --- url: /guide/authentication.md description: >- Shelf's two auth surfaces: human admin login federated to Modgud (OIDC code flow with SSO, no local passwords) and Bearer API keys for CI/CD uploads. --- # Authentication Shelf has two separate authentication surfaces: 1. **Admin UI login** — for humans. Federated to [Modgud](https://docs.cocoar.dev/modgud/), Cocoar's OpenID Connect identity provider. Shelf stores no passwords. 2. **API keys** — for machines. CI/CD pipelines authenticate with a Bearer token. ## Admin Login (Modgud Federation) Login is the standard **OpenID Connect authorization-code flow** (code + PKCE): 1. Visiting `/login` (or any admin page) redirects the browser to Modgud's login page 2. You authenticate on Modgud (password, email code, passkey — Modgud's choice) 3. Modgud redirects back to `/signin-oidc`; Shelf mints a cookie session (`shelf.auth`) Shelf acts as a backend-for-frontend: tokens stay server-side and the cookie is the session. An existing Modgud browser session signs you in **silently** — single sign-on across Cocoar apps. Users are created on first login automatically; there is no local user registration or password management. `/logout` ends both the Shelf cookie and the Modgud session. ### Configuration Federation is configured in the `Modgud` section (see [Configuration](./configuration.md)): | Option | Env Variable | Description | |---|---|---| | `Modgud.Issuer` | `Shelf__Modgud__Issuer` | Modgud realm host root, e.g. `https://auth.example.com` | | `Modgud.Audience` | `Shelf__Modgud__Audience` | Registered OAuth API name (default `shelf`) | | `Modgud.AuthBase` | `Shelf__Modgud__AuthBase` | Optional app subdomain for the OTP request. Unset = Issuer | | `Modgud.WebClientId` | `Shelf__Modgud__WebClientId` | Confidential OIDC client for the login broker | | `Modgud.WebClientSecret` | `Shelf__Modgud__WebClientSecret` | Client secret — set via environment, never in files | | `Modgud.AdminPermission` | `Shelf__Modgud__AdminPermission` | Permission that grants admin (default `shelf:admin`) | | `Modgud.Admins` | — | Email allowlist that is always admin (no-lockout floor) | In Modgud, register an Application `shelf` with a `shelf:admin` permission, an OAuth API + scope `shelf`, and a confidential client with the `authorization_code` grant, the `/signin-oidc` + `/signout-callback-oidc` redirect URIs, all six scopes and JWT access tokens. ### Who Is an Admin? A signed-in user is an administrator if any of: * their Modgud identity carries the `shelf:admin` permission (via Modgud roles/groups), or * their email is listed in `Modgud.Admins` — useful as a bootstrap before Modgud RBAC is set up, or * they belong to a Shelf permission group marked *admin group* (see [Access Control](./admin-ui.md#access-control)) The Admin UI is admin-only in practice: product management, groups, analytics and the Administration area all require admin. A signed-in non-admin has access only to restricted products they've been granted (see below) and their own profile. ## Access Control (Restricted Products) By default every product is public. A product can be marked **restricted** — it is then hidden from anyone without a read grant and returns `404` on unauthorized access. Access is granted to **principals** (permission groups and/or individual users) on the product's Access tab. Full details, including auto-membership scripting, are in [Admin UI → Access Control](./admin-ui.md#access-control). ## API Keys (CI/CD) Programmatic access uses `Authorization: Bearer `: ```bash curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/zip" \ --data-binary @docs.zip \ https://docs.example.com/_api/products/configuration/versions/v6 ``` Three kinds of keys are accepted, checked in this order: | Key | Scope | Managed via | |---|---|---| | **Per-product key** | One product only | Product form in the Admin UI (Tags & API tab) | | **Master key (UI-managed)** | All products | Administration → General | | **Master key (config/env)** | All products | `Shelf__ApiKey` environment variable | Per-product keys are the recommended way to give each CI pipeline exactly the access it needs — a leaked key can only deploy that one product. The config/env key stays valid alongside the UI-managed one, so a deployment always has a bootstrap key that survives database resets. Admins can view, generate, replace and remove keys in the Admin UI. Keys authorize the [Upload API](./upload-api.md) endpoints (product CRUD, version upload/delete) — they do not grant access to the Administration area. ## Auth Endpoints | Method | Path | Description | |--------|------|-------------| | `GET` | `/login` | Challenge Modgud (OIDC). Server route, not under `/_api` | | `GET` | `/logout` | End the Shelf cookie **and** the Modgud session | | `GET` | `/_api/auth/me` | Current auth status, admin flag and permissions | ## Protected Endpoints Product and version write endpoints accept an **admin** cookie session **or** a Bearer API key: | Method | Path | |--------|------| | `POST` | `/_api/products` | | `PUT` | `/_api/products/{product}` | | `DELETE` | `/_api/products/{product}` | | `POST` | `/_api/products/{product}/versions/{version}` | | `DELETE` | `/_api/products/{product}/versions/{version}` | ::: warning The cookie path requires **admin**. A non-admin signed-in session cannot write products; use a Bearer API key for CI/CD. (Before 2.1 any authenticated cookie could write — this is now gated.) ::: Admin-only endpoints (cookie session with admin rights required): | Method | Path | |--------|------| | `GET/PUT` | `/_api/settings/…` | | `GET` | `/_api/products/{product}/api-key` | | `GET/PUT/DELETE` | `/_api/users/…` | | `GET/POST` | `/_api/analytics/…` | | `GET/POST/PUT/DELETE` | `/_api/groups/…` | | `GET` | `/_api/principals` | ## Error Responses | Status | When | |--------|------| | `401` | Not signed in / missing or invalid API key | | `403` | Signed in, but not an administrator (admin-only endpoints) | --- --- url: /guide/product-registration.md description: >- Register products via Admin UI, API or JSON seed files: display metadata, tags, visibility, per-product API keys, and how the one-time JSON import into the database works. --- # Product Registration Before documentation can be deployed via the [Upload API](./upload-api.md), a product must be registered. Products can be managed through the [Admin UI](./admin-ui.md), the API, or by placing JSON config files in the config directory. ## Admin UI (Recommended) The easiest way to register products is through the [Admin UI](./admin-ui.md) at `/admin/`: 1. Sign in with your email (one-time code) 2. Navigate to the product management section 3. Click "Create Product" 4. Fill in the product details (name, display name, description, visibility) 5. Save See [Admin UI](./admin-ui.md) for the full workflow. ## API Products can be created, updated, and deleted via the API. All mutating endpoints require [authentication](./authentication.md). ### Create a Product ```bash curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "configuration", "displayName": "Cocoar.Configuration", "description": "Reactive configuration for .NET", "source": "upload", "visibility": "public", "tags": ["C#", ".NET"], "showWhenEmpty": false }' \ https://docs.cocoar.dev/_api/products ``` ### Update a Product ```bash curl -X PUT \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Cocoar.Configuration", "description": "Updated description", "visibility": "preview", "tags": ["C#", ".NET", "Configuration"], "showWhenEmpty": true }' \ https://docs.cocoar.dev/_api/products/configuration ``` ### Delete a Product ```bash # Remove registration only (docs files kept on disk) curl -X DELETE \ -H "Authorization: Bearer $SHELF_API_KEY" \ https://docs.cocoar.dev/_api/products/configuration # Remove registration AND all documentation files curl -X DELETE \ -H "Authorization: Bearer $SHELF_API_KEY" \ "https://docs.cocoar.dev/_api/products/configuration?deleteData=true" ``` ## JSON Config Files Products can also be registered by placing JSON config files in `{ConfigRoot}/products/`, one file per product: ``` /data/config/ └── products/ ├── configuration.json ├── capabilities.json └── filesystem.json ``` ### Config File Format Each JSON file describes one product: ```json { "name": "configuration", "displayName": "Cocoar.Configuration", "description": "Reactive configuration for .NET", "source": "upload", "visibility": "public", "tags": ["C#", ".NET"], "showWhenEmpty": false } ``` | Field | Required | Description | |-------|----------|--------------| | `name` | Yes | Product identifier, used in URLs and API calls | | `displayName` | No | Human-readable name for API responses and landing page | | `description` | No | Short description of the product | | `source` | No | Deployment source type. Default: `"upload"` | | `visibility` | No | `"public"` or `"preview"`. Default: `"public"` | | `openness` | No | `"OpenSource"`, `"Proprietary"` or `"Unspecified"`. Drives a badge + filter on the landing page (display-only). Default: `"Unspecified"` | | `repositoryUrl` | No | Optional source-repository link; when set, the landing card shows a "Source ↗" link | | `tags` | No | List of free-form labels (e.g. `["C#", "UI"]`). Used for filtering on the landing page | | `showWhenEmpty` | No | Show on landing page even without any deployed versions. Default: `false` | ::: tip The `name` field determines the URL path, not the filename. By convention, keep both in sync (e.g., `configuration.json` with `"name": "configuration"`). ::: ### Visibility The `visibility` field controls how a product appears on the landing page: | Value | Behavior | |-------|----------| | `"public"` | Shown on the landing page by default (if it has stable versions, or `showWhenEmpty` is set) | | `"preview"` | Hidden by default, shown when "Show preview" is toggled on | Preview visibility is useful for products that are in development or not yet ready for general use. The documentation is still accessible via direct URL regardless of visibility. ### Tags Tags are free-form labels you can attach to a product (e.g. `"C#"`, `"UI"`, `".NET"`, `"CLI"`). They appear as chips on product cards, and the landing page provides a tag filter bar so visitors can narrow down the list to products that interest them. Tags are stored as an array of strings. Shelf normalises them automatically: leading/trailing whitespace is trimmed, duplicates and empty values are removed, and the list is sorted alphabetically. ```json { "tags": ["C#", ".NET", "Configuration"] } ``` ### Openness & Repository Link The `openness` field marks whether a product's source is public — `"OpenSource"`, `"Proprietary"`, or `"Unspecified"` (the default). Marked products get a colored badge on the landing page and can be filtered by it, so open-source and proprietary documentation can be hosted side by side and told apart at a glance. It is a **display marker only** — it does not restrict access. To actually hide a product, mark it [restricted](./admin-ui.md#access-control) instead. Set `repositoryUrl` to show a discreet "Source ↗" link on the product card — typically for open-source products. Leave it empty and the card carries no repository link, which is the usual choice for proprietary products. ```json { "openness": "OpenSource", "repositoryUrl": "https://github.com/cocoar-dev/configuration" } ``` ### Show When Empty By default, a product only appears on the landing page once it has at least one deployed version. Set `showWhenEmpty: true` to display the product card immediately — even before any documentation is uploaded. The card is rendered in a distinct teaser style with a "Coming soon" indicator. This is useful for: * **Testing** — verify the product is registered and visible before pushing the first version * **Teasers** — announce upcoming documentation while it is still being written ```json { "showWhenEmpty": true } ``` ### Live Reload Config files are loaded at startup and monitored for changes. When you add, modify, or remove a config file, Shelf picks up the change automatically -- no restart needed. ## Why Registration? Product registration is required for the Upload API to prevent accidental creation of arbitrary products. Without registration, a typo in a CI pipeline (`configration` instead of `configuration`) would silently create a new product. Manual deployment (copying files directly into the docs volume) does **not** require registration -- Shelf serves any product directory it finds, regardless of whether a config file exists. ## API Integration Registered products are visible via the API: ```bash # List all registered products with their versions curl https://docs.cocoar.dev/_api/products ``` ```json [ { "name": "configuration", "displayName": "Cocoar.Configuration", "description": "Reactive configuration for .NET", "source": "upload", "visibility": "public", "tags": ["C#", ".NET"], "showWhenEmpty": false, "latest": "v5", "versions": ["v5", "v4"] } ] ``` --- --- url: /guide/upload-api.md description: >- Deploy documentation from CI/CD: the ZIP upload endpoint, API-key authentication, version format rules, and ready-to-use GitHub Actions examples. --- # Upload API Shelf provides an HTTP API for deploying documentation versions. This is designed for CI/CD pipelines -- analogous to `nuget push` or `docker push`. ## Prerequisites 1. The product must be [registered](./product-registration.md) (via Admin UI, API, or seed file) 2. An API key — either the product's own key, or a master key. See [Authentication](./authentication.md#api-keys-cicd) ::: tip Per-product keys Give each pipeline the per-product key from its product's **Tags & API** tab instead of the master key — a leaked key can then only deploy that one product. ::: ## Uploading a Version ```bash curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/zip" \ --data-binary @docs.zip \ https://docs.cocoar.dev/_api/products/configuration/versions/v6 ``` The ZIP file should contain the VitePress build output with `index.html` at the root: ``` docs.zip ├── index.html ├── assets/ │ ├── style.a1b2c3.css │ └── app.d4e5f6.js ├── guide/ │ └── getting-started.html ├── llms.txt └── llms-full.txt ``` ### What Happens 1. API key is validated (Bearer token or cookie session) 2. Product registration is checked (must exist) 3. Version format is validated against the [version pattern](./configuration.md#version-pattern) (supports SemVer: `v5`, `v5.2`, `v5.2.0`, `v5.2.0-beta.1`, `v1.0.0-vue-ui.10`) 4. ZIP is extracted to a temporary directory 5. Validation: `index.html` must exist at the root 6. Atomic move to `/data/docs/{product}/{version}/` 7. ManifestService detects the new version automatically 8. Response: `201 Created` Re-uploading an existing version replaces it atomically. ## Deleting a Version ```bash curl -X DELETE \ -H "Authorization: Bearer $SHELF_API_KEY" \ https://docs.cocoar.dev/_api/products/configuration/versions/v6 ``` The version directory is removed from disk. ManifestService detects the change automatically. ## CI/CD Integration ### Setup 1. Add `SHELF_API_KEY` as a repository secret in GitHub 2. Ensure the product is [registered](./product-registration.md) on the server 3. Add a deploy job to your workflow ### GitHub Actions with GitVersion ```yaml name: Deploy Docs on: push: branches: [main] jobs: deploy-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: GitVersion id: version uses: gittools/actions/gitversion/execute@v3 - uses: actions/setup-node@v4 with: node-version: 22 - name: Build working-directory: website run: npm ci && npx vitepress build - name: Package run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . - name: Deploy to Shelf env: # [!code highlight] KEY: ${{ secrets.SHELF_API_KEY }} # [!code highlight] VER: ${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }} # [!code highlight] run: | curl -f -X POST \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/zip" \ --data-binary @$GITHUB_WORKSPACE/docs.zip \ https://docs.cocoar.dev/_api/products/configuration/versions/v$VER ``` ::: tip Version Format Choose what makes sense for your docs. Use the GitVersion output variables in the upload URL: * **Major only** -- `v5` (use `outputs.major`) * **Major.Minor** -- `v5.2` (recommended, use `outputs.major` + `outputs.minor`) * **Full SemVer** -- `v5.2.0` (use `outputs.majorMinorPatch`) * **Pre-release** -- `v5.2.0-beta.1` (use full version for pre-release builds) ::: ### Manual Trigger Variant Replace the `on:` section to allow manual deploys with a version input: ```yaml on: workflow_dispatch: inputs: version: description: 'Version (e.g. v5.2)' required: true ``` Then use the `inputs.version` variable instead of the GitVersion output in the `env` section. ### Key Points * **ZIP from inside the dist directory** -- `cd .vitepress/dist && zip -r ... .` so `index.html` is at the root * **Use `curl -f`** -- fails the step on HTTP errors (4xx/5xx) * **Re-upload replaces atomically** -- safe to re-run the pipeline * **Pre-release versions are never "latest"** -- visitors are redirected to the highest stable version * **Node.js required** -- CI workflows need a Node.js setup step before building (for the Vue client) ## API Reference All API routes use the `/_api/` prefix. See [Authentication](./authentication.md) for details on auth methods. ### Products | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/_api/products` | No | List all registered products with version info | | `GET` | `/_api/products/{product}` | No | Get a single product with version info | | `POST` | `/_api/products` | Yes | Create a new product | | `PUT` | `/_api/products/{product}` | Yes | Update a product | | `DELETE` | `/_api/products/{product}` | Yes | Delete product config. Pass `?deleteData=true` to also delete docs from disk | ### Versions | Method | Path | Auth | Description | |--------|------|------|-------------| | `GET` | `/_api/products/{product}/versions` | No | List versions of a product | | `POST` | `/_api/products/{product}/versions/{version}` | Yes | Upload a ZIP as a new version | | `DELETE` | `/_api/products/{product}/versions/{version}` | Yes | Delete a version | ### Authentication See [Authentication](./authentication.md) — the Admin UI signs in via Modgud (email code); API access uses Bearer keys. ### List Products ``` GET /_api/products ``` Returns all registered products with their version information. No authentication required. ```json [ { "name": "configuration", "displayName": "Cocoar.Configuration", "description": "Reactive configuration for .NET", "source": "upload", "visibility": "public", "tags": ["C#", ".NET"], "showWhenEmpty": false, "latest": "v5.2.0", "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] } ] ``` ### List Versions ``` GET /_api/products/{product}/versions ``` Returns version information for a specific product. No authentication required. ```json { "name": "configuration", "latest": "v5.2.0", "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] } ``` ### Upload Version ``` POST /_api/products/{product}/versions/{version} ``` Uploads a ZIP file as a new documentation version. Requires [authentication](./authentication.md). ### Delete Version ``` DELETE /_api/products/{product}/versions/{version} ``` Deletes a documentation version. Requires [authentication](./authentication.md). ## Error Responses | Status | When | |--------|------| | `201` | Successfully deployed | | `400` | Invalid version format, corrupt ZIP, or missing `index.html` | | `401` | Missing or invalid authentication | | `404` | Product not registered | | `409` | Concurrent upload for the same product/version | | `413` | ZIP exceeds maximum upload size | ## Security * **Authentication**: Bearer token or cookie session checked against the configured API key. See [Authentication](./authentication.md) * **ZIP-Slip protection**: All extracted paths are validated to stay within the target directory * **Atomic deployment**: Files are extracted to a temp directory, validated, then moved -- Shelf never serves a half-extracted state * **Concurrent upload protection**: Simultaneous uploads for the same product/version return `409 Conflict` * **Size limit**: Configurable via `MaxUploadSizeBytes` (default: 100 MB) --- --- url: /guide/admin-ui.md description: >- The built-in web interface at /admin — manage products and versions, browse analytics and the access log, configure API keys and GeoIP. Desktop-only by design. --- # Admin UI Shelf includes a built-in web interface for managing products, documentation versions and the instance itself. The Admin UI is a Vue 3 single-page application served at `/admin/`, designed for desktop use. ## Accessing the Admin UI Navigate to `/admin/` (or `{PathBase}/admin/` if a [PathBase](./configuration.md#pathbase) is configured). Unauthenticated visits redirect to Modgud's login (OIDC); an existing Modgud session signs you in silently. See [Authentication](./authentication.md) for how the login works and how administrators are determined. The landing page and the admin UI share one shell — the same header (with a dark/light toggle) everywhere, and the admin sidebar shows for logged-in admins on every page. ## Dashboard The dashboard shows an overview of your Shelf instance: registered products, deployed version counts, and quick links into product management. ## Managing Products The products page is a data grid of all registered products. Everything is reachable from there: * **Create** — the "New Product" button opens the product dialog * **Edit** — double-click a row (or context menu → Edit) * **Open Docs** — context menu → opens the public documentation site * **Delete** — context menu → Delete Product (with confirmation) The product dialog is a modal with four tabs: ### General * **Name** — product identifier used in URLs (e.g. `configuration`). Cannot be changed after creation. * **Visibility** — `public` or `preview` * **Display Name / Description** — shown on the landing page * **Source** — mark the product *Open Source* or *Proprietary* (or leave *Unspecified*). This shows as a colored badge on the landing page so open-source and closed-source docs can sit side by side and be told apart at a glance. Display-only — it does not restrict access (use **Restricted** for that). * **Repository URL** — optional source-repository link. When set, the landing card shows a discreet "Source ↗" link; leave it empty and the product carries no repo link (typical for proprietary products). * **Show when empty** — display on the landing page before any version is deployed ("Coming soon" teaser) ### Access * **Restricted** — when on, the product is hidden from anyone without a read grant and returns `404` on unauthorized access. Orthogonal to visibility (a product can be preview + restricted). * **Who may read** — assign the principals that may read a restricted product: pick existing **groups or users**, or add a user by email (to stage access before their first login). See [Access Control](#access-control). ### Tags & API * **Tags** — free-form labels (e.g. `C#`, `UI`) that power the landing page tag filter * **API Key** — an optional per-product upload key for CI/CD, valid only for this product. Admins can view, generate, copy, replace and remove it. See [Authentication](./authentication.md#api-keys-cicd). ### Versions The complete version management for the product: * **Upload** — pick a version identifier (e.g. `v5.2.0`) and a ZIP with the VitePress build output ( `index.html` at the root) * **Existing versions** — every deployed version with its "latest" status, a direct link to the docs, and a delete action (with confirmation) Uploads and deletions take effect immediately — no save needed; the grid refreshes automatically. ### Visibility | Visibility | Landing Page | Direct URL | API | |------------|-------------|------------|-----| | `public` | Shown by default | Accessible | Listed | | `preview` | Hidden (shown with "Show preview" toggle) | Accessible | Listed | ## Analytics The Analytics page is a dashboard over the [access log](./configuration.md#access-log). All aggregation happens server-side; the page renders: * **KPI tiles** — total visits, unique visitors, countries, cities, top product. * **World map** — an interactive map with one bubble per visitor city, coloured by traffic volume (needs [GeoIP](#geoip) resolved). * **Breakdowns** — top countries and cities (with flags and full names), top products and pages, and browser / OS / language / referrer. * **Filters** — time range (7/30/90 days, all time), a product selector, and an IP-exclude field to hide specific IPs (e.g. your own) from every figure. The access log records one entry per documentation page view (HTML page loads — not assets, not HEAD requests). ## Access Control By default every product is **public**. Marking a product **restricted** (product dialog → Access tab) hides it from anyone without a read grant: it disappears from the landing page and the product API, and any direct URL returns `404` for unauthorized visitors (anonymous visitors are sent to login instead). Admins always see everything. Access is granted to **principals** — permission **groups** and/or individual **users** — assigned on the product's Access tab. Users are keyed by email, so you can grant access before someone's first login. **Groups** (Administration → Groups) are the reusable way to grant access: * **Members** — an explicit email list, and/or * **Auto-membership** — a JavaScript predicate over the signed-in user's claims (e.g. `user.email.endsWith('@cocoar.dev')` or `user.permissions.includes('shelf:internal')`). It is evaluated at each login and whenever the group is saved; a *Test against a user* dry-run is built in. Available: `user.email`, `user.permissions`, `user.claims`. * **Admin group** — members of a group marked *admin* are Shelf administrators. Only admins can read a restricted product's docs unless they (or a group they're in) are granted; groups themselves are managed only by admins. ## Administration The Administration area has five sections: * **General** — instance settings, currently the master API key: view, generate, replace or remove the UI-managed key; shows whether a config/env key is present. * **Users** — the local user list (thin mirrors of Modgud identities, created on first login). Deactivate a user to block sign-in, or delete the mirror (it is re-created on their next login). Shows each user's group memberships. Accounts and credentials themselves live in Modgud. * **Groups** — permission groups that grant read access to restricted products and, optionally, adminship (see [Access Control](#access-control)). * **Access Log** — the raw visit log with time, IP, product, version, path, geo data (country flag + city) and user agent. * **GeoIP** — download/update the free DB-IP Lite database that resolves visitor IPs to country and city for the access log and the analytics map. ## When to Use the Admin UI vs API | Task | Admin UI | API / CLI | |------|----------|-----------| | Initial setup / exploration | Recommended | -- | | One-off product management | Recommended | -- | | CI/CD deployment | -- | Recommended | | Automated workflows | -- | Recommended | The Admin UI and the API are backed by the same endpoints. Anything you do in the Admin UI can also be done via `curl` or any HTTP client. --- --- url: /guide/url-routing.md description: >- How URLs map to products and versions: routing rules, latest-version redirects, SemVer detection, and what non-doc paths fall through to. --- # URL Routing Shelf routes requests based on the URL structure. Every URL starts with a product name, optionally followed by a version. ## Routing Rules | URL | Behavior | |---|---| | `/configuration/` | **Redirect** to latest stable version (e.g., `/configuration/v5.2.0/`) | | `/configuration/v5.2.0/` | Serve `v5.2.0/index.html` | | `/configuration/v5.2.0/guide/getting-started.html` | Serve `v5.2.0/guide/getting-started.html` | | `/configuration/v5.2.0/llms.txt` | Serve `v5.2.0/llms.txt` | ## Latest Version Redirect When a URL has no explicit version (e.g., `/configuration/` or `/configuration/guide/getting-started.html`), Shelf **redirects** (HTTP 302) to the latest version URL. This redirect is necessary because VitePress's client-side router needs the URL to match the base path. A transparent proxy would cause the URL in the browser (`/configuration/`) to differ from the base path in the JavaScript (`/configuration/v5/`), breaking client-side navigation. ## How "Latest" Is Determined Shelf scans the product directory for subdirectories matching the [version pattern](./configuration.md#version-pattern) and picks the **highest stable version**. * Sorting is **numeric** by Major.Minor.Patch: `v5.2.0` > `v5.1.0` > `v5.0.0` * **Stable versions are preferred** over pre-releases: if both `v5.2.0` and `v6.0.0-beta.1` exist, the latest is `v5.2.0` * If only pre-release versions exist, the highest one is used ## Version Detection The first path segment after the product name is checked against the version pattern: * `/configuration/v5.2.0/...` — matches version pattern → explicit version, serve directly * `/configuration/guide/...` — does not match → redirect to latest ## VitePress Base Path VitePress bakes the `base` path into the build output at build time. Shelf handles this automatically through [Base Path Rewriting](./base-path-rewriting.md) — you do **not** need to set a specific `base` in your VitePress config. Just build your docs normally: ```bash npx vitepress build ``` Shelf detects the original base path and rewrites it to match the product/version URL when serving responses. --- --- url: /guide/base-path-rewriting.md description: >- Why VitePress sites built with base '/' work under /product/version/ URLs: the HTML/CSS/JS response rewriter and its placeholder mechanics. --- # Base Path Rewriting Shelf automatically rewrites the VitePress base path so that documentation sites built with the default `base: '/'` work correctly when served under a product/version URL like `/configuration/v5/`. ## Why This Is Needed VitePress bakes the `base` path into the build output at build time. A site built with `base: '/'` generates links like: ```html