From df2f042d18a4cb6cfaceb6de98c48b834c8df502 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 26 Oct 2026 10:19:56 -0700 Subject: [PATCH] docs: simplify household setup and configuration --- .changeset/README.md | 12 +- .dockerignore | 1 - .github/workflows/cli.yml | 2 - .github/workflows/config.yml | 2 - .gitignore | 4 +- .woodpecker/cli.yml | 2 - .woodpecker/config.yml | 2 - AGENTS.md | 28 +- README.md | 140 +--- apps/web/.env.example | 25 - apps/web/config/directory.example.yml | 6 - demo/README.md | 109 ---- demo/config/directory.yaml | 4 +- docs/compose.yaml | 52 ++ docs/config.md | 513 +++------------ docs/deployment.md | 316 +-------- docs/development.md | 885 +------------------------- docs/directory.example.yaml | 253 ++++++++ docs/images/directory.png | Bin 0 -> 50921 bytes docs/philosophy.md | 35 + packages/config/README.md | 52 +- packages/config/src/load.test.ts | 15 +- 22 files changed, 515 insertions(+), 1943 deletions(-) delete mode 100644 apps/web/.env.example delete mode 100644 apps/web/config/directory.example.yml delete mode 100644 demo/README.md create mode 100644 docs/compose.yaml create mode 100644 docs/directory.example.yaml create mode 100644 docs/images/directory.png create mode 100644 docs/philosophy.md diff --git a/.changeset/README.md b/.changeset/README.md index 81ecb90..38ed708 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,13 +1,5 @@ # Changesets -- Run `pnpm changeset` with a feature or fix to record its app impact for - `@directory/web` or `@directory/cli` (major, minor or patch). -- `@directory/config` is internal and ignored for independent releases. Record - behavior/schema changes in an explicit changeset for the consuming app(s). -- When ready, manually run `pnpm version:apps` to consume pending changesets, - synchronize the two private apps in lockstep and generate changelogs. Review - and commit the release changes only with owner authorization. -- Only after review and authorization, run `pnpm tag:apps` to create local Git - tags; pushing them is a separate, explicitly authorized step. +Run `pnpm changeset` for each change. -See the [manual release workflow](../docs/development.md#manual-app-releases). +Follow the bunny. diff --git a/.dockerignore b/.dockerignore index 0c4f2f5..554dee0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ **/e2e **/.env **/.env.* -!**/.env.example **/.npmrc **/*.local.toml **/docker-compose*.local.yml diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index b992568..8fe5623 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -12,7 +12,6 @@ on: - .woodpecker/** - apps/cli/** - packages/** - - apps/web/config/directory.example.yml pull_request: paths: - '*' @@ -22,7 +21,6 @@ on: - .woodpecker/** - apps/cli/** - packages/** - - apps/web/config/directory.example.yml permissions: contents: read diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index bed9067..3bac373 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -10,7 +10,6 @@ on: - .github/workflows/** - .woodpecker/** - packages/config/** - - apps/web/config/directory.example.yml pull_request: paths: - '*' @@ -19,7 +18,6 @@ on: - .github/workflows/** - .woodpecker/** - packages/config/** - - apps/web/config/directory.example.yml permissions: contents: read diff --git a/.gitignore b/.gitignore index a416ff6..958c236 100644 --- a/.gitignore +++ b/.gitignore @@ -11,13 +11,11 @@ test-results/ /.nono.json .env .env.* -!.env.example *.local.toml docker-compose*.local.yml docker-compose*.local.yaml /config/where.yaml -/apps/web/config/where.yaml +/apps/web/config /config/directory.yaml -/apps/web/config/directory.yaml /public/ /apps/web/public/ diff --git a/.woodpecker/cli.yml b/.woodpecker/cli.yml index c9a7ce4..ac818a8 100644 --- a/.woodpecker/cli.yml +++ b/.woodpecker/cli.yml @@ -14,7 +14,6 @@ when: - .woodpecker/** - apps/cli/** - packages/** - - apps/web/config/directory.example.yml - event: pull_request path: include: @@ -25,7 +24,6 @@ when: - .woodpecker/** - apps/cli/** - packages/** - - apps/web/config/directory.example.yml - event: tag ref: 'refs/tags/@directory/cli@*' diff --git a/.woodpecker/config.yml b/.woodpecker/config.yml index 25ed960..d7da672 100644 --- a/.woodpecker/config.yml +++ b/.woodpecker/config.yml @@ -13,7 +13,6 @@ when: - .github/workflows/** - .woodpecker/** - packages/config/** - - apps/web/config/directory.example.yml - event: pull_request path: include: @@ -23,7 +22,6 @@ when: - .github/workflows/** - .woodpecker/** - packages/config/** - - apps/web/config/directory.example.yml steps: prepare: diff --git a/AGENTS.md b/AGENTS.md index 26f8c87..a5442bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,3 @@ # Working on Directory -Start with [README.md](README.md) and [docs/development.md](docs/development.md). -Local planning notes, when present, live in `.plans/foundation/architecture.md` -and `.plans/foundation/implementation.md`. They are not build inputs. - -## Review and delivery - -- Work in small, meaningful commit-sized changes and reasonably small PRs. -- Complete agent code review before handing changes to the owner. -- When the owner requests staged review, stage only one coherent commit at a - time and wait for their review before preparing the next staged commit. -- Do not commit or push without the owner's manual review and explicit - authorization. Permission to stage is not permission to commit or push. -- At each handoff, report checks actually run, outstanding limitations, and useful - manual tests beyond code review. Never describe an unrun check as passing. -- Preserve operator configuration, prototype files, and unrelated changes. - -## Engineering - -- Use the pinned Node and pnpm versions. Keep dependency versions exact; shared - tools use the workspace catalog. -- Use strict TypeScript, ESLint with Svelte rules, and Prettier. -- Keep tests beside the code as `*.test.ts`; browser tests live in `apps/web/e2e`. -- Keep server code out of browser imports. Settings are configuration, not a - database or an admin editor. -- Keep comments for necessary context; do not narrate straightforward code. -- Do not add speculative abstractions, a worker, live service integrations, or - a second lint/format toolchain. +Read [README.md](README.md) and all the documentation in the docs folder. diff --git a/README.md b/README.md index 897cb87..5d5ad29 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,40 @@ # 🗂️ Directory -A minimal homelab directory for the family: learn what is available and open the -right link. The dashboard will be driven by one mounted YAML file. +A bare bones directory page for your homelab. -## Current state +![Directory showing household apps grouped by use](docs/images/directory.png) -The app includes a server-rendered directory, a compiled shared config -package, a standalone validator, and per-package CI checks. The shared loader reads YAML, applies -defaults, expands environment values, and validates the TypeBox contract. The -standalone CLI uses that same loader. It normalizes omitted site logo and favicon -paths to bundled `/favicon.svg`; explicit bundled selections are also valid. -The web server routes mounted site selections through public branding aliases. -The web server loads an immutable directory -before serving, polls for file changes, and keeps the last good snapshot after -invalid edits. Open pages with JavaScript check for accepted updates and offer -Refresh without automatically replacing what someone is reading. Compact icon-and-title tiles flow into a responsive grid with muted -group headings, plain shareable details and real external service links. -Header search loads on first focus/input and finds bookmarks with typo-tolerant, -word-order-independent matching across public bookmark metadata. Results rank by -relevance; clearing restores the configured groups and order. Enter opens details -for one ready result, or focuses the first when several match. Down enters results; -Tab and Shift+Tab visit every result, with arrow keys as an optional shortcut. -Escape returns to Search without losing the query. Escape in Search clears it. Search stays local -to the loaded directory and is disabled without JavaScript; the full directory -and real detail URLs still work. +Directory does one thing and nothing more. That's the point. Sort of. -Use `tag:family photos` for an exact, case-insensitive whole tag, or `access:login`, -`access:internal` and `access:admin` for enabled advisory flags, not access rules. -Selecting a tag or access chip in details closes details and fills Search with that -single query. Search is preserved in the URL's `q` parameter for sharing and reloads; -Clear removes it and restores grouped browsing. Without JavaScript, chips remain -informational and the full directory and plain details stay usable. +No plugins. No database. No baloney. -The header theme button cycles System → Dark → Light → System and saves the -choice in this browser. System follows OS changes; without JavaScript the OS -scheme applies and the button stays disabled. Existing -`appearance.primary_light_color` and `primary_dark_color` configure keyboard-focus -and Back-link underline accents on the directory, plus the filled sign-in action. -These two colors are public branding inputs. Sign-in automatically chooses a -readable foreground; choose colors with sufficient contrast for focus accents too. -Authentication is optional: shared credentials or OIDC protect the whole directory, -including data responses. Bookmark cards and details use server-prepared bookmark -logos when no logo path is configured. With JavaScript, a failed configured bookmark -logo switches once to its prepared logo; a failed fallback stops without retrying. -Without JavaScript, broken configured images stay broken, but text and links remain -usable. After repairing an image at the same URL, refresh to try it again. -The directory header shows `site.logo_path` at 48px beside its title and description, -using bundled `/favicon.svg` when omitted or once after a JavaScript image error. -Without JavaScript, a broken configured image stays broken. Sign-in shows the same -selected logo through public `/logo`, with the bundled 🗂️ mark as its default and -one-time JavaScript fallback. Selecting `site.logo_path` deliberately publishes -those bytes before login; the configured source URL stays private. -Configured images are now served only when -referenced by the captured configuration, with bounded 5 MiB reads and the same -authentication policy as the directory for mounted source URLs. Selecting -`site.favicon_path` publishes that image's bytes at the public `/favicon` alias, -including before sign-in; its source URI stays out of the shared layout. Omission -uses the bundled `/favicon.svg`; unavailable configured images return 404, not a -bundled fallback. Mounted files require Linux procfs; SVGs are trusted operator -content, not sanitized uploads. See [authentication setup](docs/config.md#authentication-shapes). -See the [configuration contract](docs/config.md) and -[production starter](apps/web/config/directory.example.yml). For the isolated, -local-only 32-bookmark review, see [the demo](demo/README.md). +## Get started -## Run with Docker +Let's assume you have docker compose (or you are getting it RIGHT NOW or you know +your way around the docker cli or you have your own tricks up your sleeve...) -Follow [local production review](docs/deployment.md#local-production-review) to -bootstrap the minimal, empty production starter without overwriting operator files. -It has open access and needs no session secret; enable authentication before -publishing. No host Node/pnpm is needed. For a populated local review instead, -follow [the isolated demo quickstart](demo/README.md). - -## Development with Node - -Use Node **26.5.0** and pnpm **11.9.0**, pinned in `mise.toml`: +Run the following commands in your favorite terminal: ```sh -pnpm install --frozen-lockfile -pnpm --filter @directory/web exec playwright install chromium -pnpm verify -CONFIG_PATH="$PWD/apps/web/config/directory.example.yml" pnpm dev +mkdir directory +cd directory +mkdir config public +curl -fsSLo compose.yaml https://git.eleith.com/eleith/directory/raw/branch/main/docs/compose.yaml +curl -fsSLo config/directory.yaml https://git.eleith.com/eleith/directory/raw/branch/main/docs/directory.example.yaml +docker compose up -d ``` -Run from the repository root and open `http://localhost:5173`. This starter is -empty and has no authentication. For the populated, authenticated preview that -leaves operator configuration and media untouched, use [demo/README.md](demo/README.md). -Groups, bookmark titles and `?link=` details also render without JavaScript. -The desktop dialog and phone sheet code loads with the page, so opening details -has no additional code-loading wait. Direct links animate in without a stretched -preview, and closing plays the matching exit. Plain details remain available without -JavaScript, with browser history and keyboard focus preserved after enhancement. -`/healthz` returns generic readiness. Invalid auth environment settings stop startup -with `auth_failed` and a fixed, value-free setting hint. Linux prerequisites, -reload checks and shutdown are documented in [development](docs/development.md). +Meditate for 5s or 1m depending on your internet connection, cpu speed or room +temperature. -Validate the production starter, isolated demo or your own configuration: +Open http://localhost:3000. -```sh -pnpm cli -- --help -pnpm cli -- validate apps/web/config/directory.example.yml -pnpm cli -- validate demo/config/directory.yaml -``` +Rejoice. -Validation returns 0 for a valid file and nonzero for unreadable or invalid input. -It checks environment references without printing configuration values. See -[configuration](docs/config.md) for paths, interpolation and safe errors, and -[agent instructions](AGENTS.md) for the review workflow. +Edit `config/directory.yaml` to start adding your links. -## Continuous integration +## Documentation -[Woodpecker](.woodpecker/) and [GitHub Actions](.github/workflows/) each have -`config.yml`, `cli.yml`, and `web.yml`, following `when`'s per-package pattern. -Package-scoped checks use frozen installs; shared-package and root-tooling -changes trigger dependent pipelines. Web E2E reuses the matching Playwright -images. `pnpm verify` remains a local all-workspace convenience, not a pipeline. -Main builds publish Linux amd64 `latest` images to the confirmed Gitea and GHCR -registries after web/E2E and schema/config/CLI gates. Qualified stable web tags -publish versioned images after the same gates; PRs do not publish. Automatic -deployment is not included. - -Each Woodpecker pipeline has one status-aware email step for main pushes, using `when`'s -notification secret names. Confirm those bindings, publishing authorization and Gitea CI-image pull access -on the actual provider; a Gitea PR does not itself trigger GitHub Actions. See -[development](docs/development.md) for routing, safe secret setup, first-run -checks, and scope limits. - -## Releases - -Changesets records app changes; the private web and CLI apps version together. -Versioning, release commits and tag pushes are manual and require owner review -and authorization. Only stable `@directory/web@` tags publish versioned -images; `latest` remains main-only. See the -[release workflow](docs/development.md#manual-app-releases). +- [Configuration](docs/config.md) +- [Deployment](docs/deployment.md) +- [Development](docs/development.md) diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 4da6642..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Copy to .env beside the Compose files. Never commit real secrets. -# No-auth configurations require no session secret. -# If auth is enabled, set your own random AUTH_SECRET of at least 32 characters; -# there is no shared fallback. Generate once with: openssl rand -base64 32 -# Save the result below in your ignored .env before starting Compose. -# AUTH_SECRET= -# For the isolated local review demo, see demo/README.md at the repository root. - -# For production behind your HTTPS proxy, set the public root origin. -# Leave unset for local review: Compose selects localhost:3000 or localhost:5173. -# Compose sets AUTH_URL to this same origin. -# ORIGIN=https://directory.example.com - -# Optional values for custom YAML that references them. -# DIRECTORY_PASSWORD= -# DIRECTORY_OIDC_CLIENT_SECRET= - -# Optional image source and host-port overrides. -# DIRECTORY_IMAGE=ghcr.io/eleith/directory:latest -# DIRECTORY_PORT=3000 -# DIRECTORY_BIND=127.0.0.1 - -# Development containers use your host identity; override if it is not 1000:1000. -# LOCAL_UID=1000 -# LOCAL_GID=1000 diff --git a/apps/web/config/directory.example.yml b/apps/web/config/directory.example.yml deleted file mode 100644 index feaee5d..0000000 --- a/apps/web/config/directory.example.yml +++ /dev/null @@ -1,6 +0,0 @@ -# yaml-language-server: $schema=../../../packages/config/src/config.schema.json -# Minimal production starter: open access, no credentials or session secret required. -# Configure authentication before publishing. See docs/config.md for the contract. -# For the isolated local review demo, see demo/README.md at the repository root. -version: 1 -links: {} diff --git a/demo/README.md b/demo/README.md deleted file mode 100644 index 7ddea78..0000000 --- a/demo/README.md +++ /dev/null @@ -1,109 +0,0 @@ -# Isolated local review demo - -This is a **manual, local-only** preview, not production configuration. Sign in -with **demo / directory-demo** using real Auth.js credentials authentication. -Never publish these known credentials. A unique generated `AUTH_SECRET` is still -required; there is no shared secret fallback. - -The 32 bookmarks span five uneven groups (10, 7, 6, 5 and 4 links), with short, -long and unbroken titles for layout review. All bookmarks use generated default -logos; the header, sign-in mark and tab icon use bundled default branding. No -image mounts or asset copies are needed. Existing SVGs in `public/demo` are -optional, unreferenced media, not preview requirements. Destinations are reserved -example.com placeholders, not live integrations. - -Check the overall centered canvas at desktop widths: header, group headings, -first grid column and footer share a left edge. Short groups and incomplete rows -start at that same column rather than centering separately. Check phone widths, -light/dark themes, long titles and search results too. All 32 cards and plain -`?link=` details should work with JavaScript on and off; generated bookmark -images must stay private after sign-out. The header title should wrap without -hiding Sign out. Dialog and form redesign are outside this page-layout review. - -## Preferred native preview - -With the [development prerequisites](../docs/development.md#setup-and-commands) -installed, run from the repository root: - -```sh -CONFIG_PATH="$PWD/demo/config/directory.yaml" \ - AUTH_SECRET="$(openssl rand -base64 32)" \ - ORIGIN=http://localhost:5173 AUTH_URL=http://localhost:5173 pnpm dev -``` - -Open **http://localhost:5173**, sign in, and stop with Ctrl-C when finished. This -command writes no operator configuration or media. Each run generates an ephemeral -secret and invalidates previous sessions; real deployments should keep their own -generated secret stable. Use localhost, not an HTTP IP address. If changing the -port, update both `ORIGIN` and `AUTH_URL` to match. - -For a production-server preview after `pnpm build`, from the repository root: - -```sh -CONFIG_PATH="$PWD/demo/config/directory.yaml" \ - AUTH_SECRET="$(openssl rand -base64 32)" \ - ORIGIN=http://localhost:3000 AUTH_URL=http://localhost:3000 \ - HOST=127.0.0.1 PORT=3000 node apps/web/build/index.js -``` - -Open **http://localhost:3000** with the same demo credentials. To test reloads, -copy `demo/config/directory.yaml` to a disposable directory and point `CONFIG_PATH` -to that absolute copy. Invalid edits keep the last good snapshot. Auth changes -require a process restart. - -Validate both the empty production starter and this demo manually from the repository root: - -```sh -pnpm cli -- validate apps/web/config/directory.example.yml -pnpm cli -- validate demo/config/directory.yaml -``` - -The CLI validates YAML/interpolation, not web auth prerequisites or image bytes. - -## Optional Docker review - -Use the existing Compose files; there is no demo-specific Compose configuration. -From `apps/web`, create `.env` only if absent: - -```sh -test -e .env || cp -n .env.example .env -openssl rand -base64 32 -``` - -Manually save that result as `AUTH_SECRET=` in your ignored `.env`, or -keep a valid unique secret already supplied by your environment/fnox. Do not -replace an existing stable secret just for setup. - -**Stop the server and back up any existing `config/directory.yaml` yourself -before explicitly choosing the following replacement.** This is a manual demo -opt-in, not an automatic bootstrap. The native preview above leaves active files -alone. Still in `apps/web`, copy only the configuration; no images are required: - -```sh -cp ../../demo/config/directory.yaml config/directory.yaml -ORIGIN=http://localhost:3000 docker compose up --build --pull never --force-recreate -``` - -Open **http://localhost:3000** and sign in with **demo / directory-demo**. The -explicit origin overrides any production HTTPS origin in `.env` for local review. -For the existing dev-container workflow instead, use: - -```sh -ORIGIN=http://localhost:5173 docker compose -f docker-compose.dev.yml up --build --force-recreate -``` - -With mise/fnox already configured, the equivalent dev shortcut from `apps/web` is -`ORIGIN=http://localhost:5173 mise docker up --build --force-recreate`; keep the -existing valid secret supply. Stop one Compose setup before starting the other. -See [deployment](../docs/deployment.md) for shutdown, mounts and HTTPS setup. -Leave existing operator images untouched. - -## Scope and automated-check exclusion - -`demo/` is tracked for manual local previews only. It has no package and is -outside the `apps/*` and `packages/*` workspaces. Automated tests use their own -fixtures, never this demo. It is not an automated test, build, lint, formatting or -package input, does not trigger CI, and is excluded from the Docker build context. -The minimal, credential-free production starter remains in `apps/web/config` for -operator bootstrap; it is not this demo. Enable your own authentication before -publishing. diff --git a/demo/config/directory.yaml b/demo/config/directory.yaml index c3117a1..e4db664 100644 --- a/demo/config/directory.yaml +++ b/demo/config/directory.yaml @@ -14,8 +14,8 @@ appearance: auth: credentials: - username: demo - password: directory-demo + username: admin + password: directory # Reserved example.com destinations are placeholders, not live integrations. # All bookmarks use generated logos; site branding uses the bundled default. diff --git a/docs/compose.yaml b/docs/compose.yaml new file mode 100644 index 0000000..23137a2 --- /dev/null +++ b/docs/compose.yaml @@ -0,0 +1,52 @@ +services: + directory-app: + # Replace latest with a published version or digest to pin updates. + image: ghcr.io/eleith/directory:latest + platform: linux/amd64 + # Optional values for ${VAR} references in directory.yaml. + env_file: + - path: .env + required: false + ports: + # Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall. + # Change the left port to use a different host port, e.g. '127.0.0.1:8080:3000'. + - '127.0.0.1:3000:3000' + volumes: + - ./config:/app/config:ro + - ./public:/app/public:ro + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped + healthcheck: + test: + - CMD + - node + - --eval + - fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + + directory-cli: + # Keep this version matched to the web image if you pin it. + image: ghcr.io/eleith/directory-cli:latest + platform: linux/amd64 + profiles: + - tools + env_file: + - path: .env + required: false + volumes: + - ./config:/app/config:ro + read_only: true + network_mode: none + cap_drop: + - ALL + security_opt: + - no-new-privileges:true diff --git a/docs/config.md b/docs/config.md index 946c1a5..2c5cbde 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,448 +1,137 @@ -# Configuration contract +# Configuration -The canonical [TypeBox schema](../packages/config/src/schema.ts) defines the -configuration and inferred TypeScript types. The generated -[editor JSON Schema](../packages/config/src/config.schema.json) describes authored -optionality: fields with defaults can be omitted, but link titles/URLs and auth -credentials remain required. +your `config/directory.yaml` controls the behavior and appearance of your +directory. craft it with care as yaml is powerful but unforgiving... -The shared loader and `directory validate` now read YAML, apply defaults, expand -environment values and validate the result. The web server loads a complete -immutable snapshot before serving, captures it once per request, and polls the -configuration path for changes. Groups and bookmark titles render immediately, -with plain `?link=` details and external service links. Cards and details use -prepared bookmark logos when no logo path is configured. Configured image files -are served only for exact references in the captured snapshot. Omit auth for open access, or -configure shared credentials or OIDC to protect the whole directory. Enabled authentication requires -`AUTH_SECRET` and matching `ORIGIN`/`AUTH_URL` before the web server can start. +## Config -## Migrating from Where +Start with the [example](directory.example.yaml). The tables below follow the +YAML headings; `credentials.username` means `username` inside `credentials`. +Defaults apply when you leave a field out. Unknown fields and `null` are errors. -Directory was previously named Where. The package namespace is now `@directory/*`, -the CLI executable is `directory`, the default filename is `config/directory.yaml`, -and the public-directory override is `DIRECTORY_PUBLIC_DIR`. An explicit -`CONFIG_PATH` can still point to an existing `config/where.yaml` file. Update -operator environments manually; there is no automatic fallback or migration. -The configuration format version and fields are unchanged. +[version](#version) · [site](#site) · [appearance](#appearance) · +[links](#links) · [auth](#auth) · [Images](#images) · [Environment values](#environment-values) -## Start with the production starter +### version -[directory.example.yml](../apps/web/config/directory.example.yml) is a minimal, -credential-free production starter. It has an empty directory and open access, -with no session secret required. **Configure authentication before publishing.** -Its schema comment points to the checked-in artifact relative to `apps/web/config`: +| Field | Value | Default | +| --------- | ----------------------------- | ------- | +| `version` | `1`. The file format version. | `1` | + +### site + +| Field | Value | Default | +| ----------------------- | ------------------------------------------------------------------- | -------------- | +| `title` | Nonblank text, e.g. `Home`. | `Directory` | +| `description` | Text below the title. | Empty | +| `footer` | Text at the bottom of the page. | Empty | +| `logo_path` | [Image path](#images), e.g. `/public/house.svg`, or `/favicon.svg`. | `/favicon.svg` | +| `favicon_path` | Browser tab icon. Same path options as `logo_path`. | `/favicon.svg` | +| `open_links_in_new_tab` | `true` or `false`. Applies to every destination. | `true` | + +### appearance + +| Field | Value | Default | +| --------------------- | ----------------------------------------------- | ----------- | +| `primary_light_color` | Quoted hex color, e.g. `'#087'` or `'#008877'`. | `'#0f766e'` | +| `primary_dark_color` | Quoted hex color. | `'#5eead4'` | + +Quote the `#`, or YAML will helpfully turn your color into a comment. + +### links + +Required. Use `links: {}` for an empty page, or give each link an id (using only +lowercase letters and hyphens if you must): ```yaml -# yaml-language-server: $schema=../../../packages/config/src/config.schema.json -version: 1 -links: {} +links: + lights: + title: Lights + url: http://homeassistant.local:8123 + group: Around the house + tags: [lights, home] ``` -There is no hosted schema URL. If copying the file elsewhere, update the schema -comment to the artifact's real location. The package also exports -`@directory/config/schema.json`; runtime consumers resolve compiled ESM, not TS source. +| Field | Value | Default | +| --------------- | --------------------------------------------------- | ------------------ | +| `title` | Nonblank name. | Required | +| `url` | Full `http://` or `https://` address. | Required | +| `description` | Plain-text notes; use YAML `\|` for multiple lines. | None | +| `group` | Nonblank heading, e.g. `Around the house`. | Ungrouped | +| `tags` | List of text labels, e.g. `[family, photos]`. | `[]` | +| `logo_path` | [Image path](#images), e.g. `/public/lights.png`. | Generated initials | +| `requires_auth` | `true` if the service needs sign-in. | `false` | +| `internal_only` | `true` if intended for LAN/VPN access. | `false` | +| `admin_only` | `true` if intended for administrators. | `false` | -For the separate populated, authenticated local review fixture and its image -setup, use [demo/README.md](../demo/README.md). The isolated `demo/` directory is -manual-only and excluded from builds and automated checks. It is never selected automatically at runtime. -Native preview can use it directly without changing operator files; Docker -review requires a deliberate manual copy. Back up existing active configuration -before replacing it. Auth changes require a process restart, not the config poll. +The three flags are labels only. They do not restrict access or hide links. -## Loading and validation +### auth -```sh -pnpm cli -- validate apps/web/config/directory.example.yml -pnpm cli -- validate demo/config/directory.yaml -``` +Omit this section to share your directory with the world. -The wrapper builds the shared package and standalone CLI first. The compiled -command is `node apps/cli/dist/index.js validate [path]`; its executable is named -`directory`. Gunshi defines commands and generates help, as in When. It accepts one -leading `--` for package-manager forwarding. Use `directory --help` or -`directory validate --help` for usage; help after a path also leaves the file unread. +Use `credentials` for a basic username and password or `oidc` to turn it up to +11 and wire into your authentication fiefdom. Choose one. -Path selection is explicit argument, then `CONFIG_PATH`, then -`config/directory.yaml`, relative to the working directory. There is no production- -specific path or search for nearby files. The web server pins the selected path -at startup. `pnpm dev` runs from `apps/web`, so its default is -`apps/web/config/directory.yaml`; an absolute `CONFIG_PATH` avoids working-directory -ambiguity. The deployment layout is: +| Field | Value | Default | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `origin` | Your directory's public origin, e.g. `https://home.example.com`. No credentials, path, query, fragment, or trailing slash. | Required when auth is enabled | +| `secret` | Optional random string of at least 32 characters. Keeps sessions across restarts. | Generated at each startup | +| `credentials.username` | Shared username, e.g. `family`. | Required for credentials | +| `credentials.password` | Password, or `'${DIRECTORY_PASSWORD}'`. | Required for credentials | +| `oidc.issuer` | Your provider’s full HTTP(S) issuer URL. | Required for OIDC | +| `oidc.client_id` | Client ID registered with your provider. | Required for OIDC | +| `oidc.client_secret` | Secret, or `'${DIRECTORY_OIDC_CLIENT_SECRET}'`. | Required for OIDC | -```text -deployment/ - config/directory.yaml - public/ -``` +Use HTTPS, except for local testing at `http://localhost:3000` (or your dev port). +OIDC's callback is your `origin` followed by `/auth/callback/oidc`. -The server selects the public directory once at startup through the shared -`resolvePublicDir(configPath, { env, cwd })` helper. By default it uses `public` -below the selected configuration path's grandparent, as in the layout above. -`DIRECTORY_PUBLIC_DIR` overrides this location; relative overrides resolve from -the working directory, not the YAML directory. The helper does not require the -directory to exist. Environment changes require a restart. -Validation checks image-path syntax only; neither loading nor snapshot preparation -inspects image files or contacts any destination. The resolved filesystem root -stays server-private. +By default, restarting Directory signs everyone out. Set `secret` if you'd rather +keep sessions; generate it once with `openssl rand -base64 32`. Changing it signs +everyone out again. Ordinary config reloads keep the current session secret. -Files use one YAML 1.2 core document. Mapping keys are read as strings; duplicate -keys, collection keys, unknown tags and other YAML versions are rejected. Merge -keys are disabled. Alias expansion is bounded and cycles are rejected. Non-plain -objects and unsafe keys are rejected before defaults can alter them. Arrays are -accepted only where the schema allows lists, such as bookmark tags. Explicit nulls -and wrongly typed values are errors, not requests for defaults. Unknown fields -remain errors. +Passwords and client secrets can go directly in YAML. Keep that file private. +The [example](directory.example.yaml) has commented setups for both auth methods. +Restart Directory after changing auth settings. -A valid file prints `Configuration is valid.` and exits 0. Invalid or unreadable -input exits 1. Errors contain fixed messages, codes and field paths such as -`/auth/credentials/password` or `/links/*/url`. Link IDs and unknown field names -are replaced with `*`; filenames, values, environment-variable names, parser -snippets and original exceptions are not printed. +## Images -### File changes and last-good state +Put `photos.png` in `public/` beside your Compose file and use +`/public/photos.png` in YAML. -The web server polls inode, modification time and size approximately once per -second. Mount the config directory rather than a single file so editor atomic -rename saves remain visible. Invalid edits and deletion keep the previous -snapshot; a later valid replacement recovers automatically. Ordinary requests do -not read YAML or prepare snapshots. Reloads with the same loaded values and -bookmark declaration order retain the existing snapshot and revision; comments -and formatting alone do not create an update. `/healthz` stays generic and ready -while a last-good snapshot is serving. - -Open pages with JavaScript check the accepted revision every five seconds while -visible, and when returning to the tab. A newer revision shows **Updates available** -in a sticky bar above the header, with a **Refresh** link. It does not replace -content or move focus automatically. -Refresh reloads the current browser URL, including its search, so document colors -and branding update together. Hidden/offline tabs pause checks, temporary failures -back off, and authentication denials stop checks without exposing revision data. -Without JavaScript, use the browser's normal refresh. Image-file byte changes alone -do not create a YAML revision or an update notice. - -An auth change rejects the entire replacement and reports `restart_required`, -including changes that enable or disable auth. It never partially accepts the -candidate's display changes or restarts the process automatically. Restore the -original auth to resume display reloads. Providers and session policy are captured -at startup; environment files are not polled. +Use SVG, PNG, JPEG, GIF, or WebP, at most **5 MiB** each. ## Environment values -Interpolation happens in string values **after YAML parsing**, not in keys. Values -from the environment are not parsed as YAML, expanded again, or converted to -booleans/numbers. Injecting an environment map through the loader API avoids -reading the process environment; the CLI normally uses its process environment. +The config supports interpolation so you can keep secrets out of your config or +just add a little dynamicness to your staticness. -| Syntax | Meaning | -| ------------------ | ------------------------------------------------------------------------------- | -| `${VAR}` | Use a set environment value; otherwise fail validation. | -| `${VAR:-fallback}` | Use the fallback only when the variable is unset. An empty value counts as set. | -| `$$` | A literal `$`; `$${VAR}` produces literal `${VAR}`. | +This is optional. Create `.env` beside your Compose file with only the values +you want to keep there: -Names use uppercase ASCII letters, digits and underscores, starting with a -letter or underscore. Fallbacks are literal text ending at the first `}`. Other -dollar text is unchanged. Quote templates in YAML when punctuation could affect -YAML parsing. - -For example: - -```yaml -site: - title: '${DIRECTORY_TITLE:-Home}' -links: - photos: - title: Photos - url: '${PHOTOS_URL:-https://photos.example.com}' - tags: ['${PHOTO_TAG:-family photos}', backup] +```dotenv +DIRECTORY_PASSWORD=replace-with-your-own-password ``` -The editor schema accepts supported templates in constrained string fields; -actual URLs, paths, colors and string choices are checked after expansion by the -loader. Required fields remain required. Booleans and numbers must be authored -with their actual YAML types. +Then use `password: '${DIRECTORY_PASSWORD}'` in your YAML auth block. +Compose passes the value to Directory. Keep `.env` private and out of Git. +After editing it, run `docker compose up -d --force-recreate` to apply the change. -**Public text stays public.** Using an environment variable in a title, -description, URL, tag or other display field does not make it secret. The future -dashboard will expose those values. Authentication configuration and session -secrets stay outside its display model. The validator prints neither kind. +| Syntax | Meaning | +| ------------------ | -------------------------------------------------- | +| `${VAR}` | Required value; missing is an error. | +| `${VAR:-fallback}` | Fallback when unset. An empty value counts as set. | +| `$$` | Literal `$`. `$${VAR}` displays `${VAR}`. | -## Fields and defaults +## Validation -| Field | Contract | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `version` | Only `1`; defaults to `1`. This is the file format, not the app release. | -| `site` | Optional object. Defaults: title `Directory`, description/footer empty. | -| `site.logo_path`, `site.favicon_path` | Independent site images, normalized by the shared loader to bundled `/favicon.svg` when omitted. Explicit `/favicon.svg` is also valid. Header and sign-in show the logo at 48px. Mounted selections publish bytes at `/logo` and `/favicon` independently. | -| `appearance` | Optional object. `primary_light_color` defaults to `#0f766e`, `primary_dark_color` to `#5eead4`. Colors use three or six hex digits. | -| `auth` | Omit for no auth; otherwise exactly one complete strategy below. No default strategy or credentials. | -| `links` | Required keyed map; `{}` is an explicitly empty directory. Each value requires `title` and `url`; `tags` defaults to `[]` and the three advisory booleans default to `false`. | +We all could use a little validation every now and then. -`site.open_links_in_new_tab` is a global boolean, defaulting to `true`. It controls -only the external **Open** links in bookmark details. Use `false` to open services -in the current tab: +We publish a json schema to give you little red squigglies but you can also use +our cli when you are in a pinch. -```yaml -site: - open_links_in_new_tab: false -``` - -There is no per-bookmark override. Bookmark tiles still open details; Back links, -search and other application navigation are unchanged. Native modifier-click, -middle-click and browser context-menu actions remain available. The setting works -with JavaScript enabled or disabled. - -Appearance colors are public branding: they accent keyboard focus, the directory's -Back-link underline and the filled sign-in action. Server-owned appearance emits -only these validated colors and derived readable foregrounds as semantic CSS in -the document head. The root layout sends only constant logo/favicon URIs. Page -surfaces stay unchanged. Sign-in foregrounds meet 4.5:1 contrast against -the configured primary; operators should still choose accessible focus accents. -Without JavaScript, colors follow the OS scheme. Refresh the document after -configuration reloads to update branding inputs. - -Site copy and optional link `description` are plain strings. Description supports -multiline guidance; there is no separate `good_to_know` field. Bookmarks have one -optional `logo_path`; the site logo and favicon remain independent. Unknown structural fields are errors, not -ignored extras; the old `site.group_by`, `sign_in` and `works_from` fields are not -supported. - -Link keys are lowercase letters/digits separated by single hyphens; a numeric-only -key is not a slug. `constructor`, `prototype` and `__proto__` are forbidden keys. - -### Tags, groups and service hints - -```yaml -tags: [photos, videos, backup] -group: Photos & files -requires_auth: true -internal_only: false -admin_only: false -``` - -`tags` is a YAML list of strings, in flow form as above or block-list form. It -defaults to `[]`. Scalar strings (including empty strings and CSV), maps, nulls -and non-string elements are invalid. The loaded configuration retains each -string's whitespace, case, duplicates and list order after environment expansion. -Snapshot preparation trims each string and drops blank labels for display/search; -it preserves order, duplicates and case. A comma inside an element is literal: -`tags: ['photos, videos', backup]` has two tags, not three. - -Environment interpolation is supported inside list elements. An environment value -containing commas remains one tag; `tags: '${TAGS}'` is invalid even if the value -looks like CSV or YAML. Environment values are never parsed into lists. - -To migrate older configurations, replace each comma-separated `tags` scalar with -an explicit YAML list, for example `tags: photos, videos, backup` becomes -`tags: [photos, videos, backup]`. Use `tags: []` or omit the field for no tags. -There is no automatic CSV compatibility or migration; update operator files -manually. - -`group` is an optional nonblank display label, independent of tags. If every link -omits it, the planned directory is flat. If any link has a group, ungrouped links -appear under Other. No category collection or global `group_by` selector is -needed. Groups follow their first appearance in the YAML, and links within each -group keep their declaration order. The ungrouped Other bucket stays last. A real -group named Other remains distinct from that missing-group bucket. There is no -sorting setting; remove `site.order` from older configurations. - -The three booleans are **informational service hints only** and default to false: - -- `requires_auth`: opening this service needs sign-in; not Directory's auth setting. -- `internal_only`: intended for LAN/VPN access; no network detection or enforcement. -- `admin_only`: intended for administrators; no role or visibility restriction. - -False means no advisory badge, not a verified claim about the service. All -visitors authorized to open Directory still see the same links. These flags neither -hide links nor change the directory's actual `auth` strategy. Grouping, -ordering and informational detail labels are rendered; search remains later work. -All advisory flag combinations preserve the same link inclusion. - -## Authentication shapes - -Declare one, never both: - -```yaml -auth: - credentials: - username: family - password: '${DIRECTORY_PASSWORD}' -``` - -```yaml -auth: - oidc: - issuer: https://identity.example.com - client_id: directory - client_secret: '${DIRECTORY_OIDC_CLIENT_SECRET}' -``` - -Username/password and all three OIDC fields are required and nonempty. Empty, -null, unknown or mixed auth blocks are invalid; disabling the other strategy with -`null` is invalid too. Omit `auth` entirely for no auth. Environment references are -expanded before validation, including in OIDC issuer URLs. Editors recognize -template syntax but cannot check the environment's values. Never put real -credentials into committed examples. - -### Sessions and deployment - -Auth is all-or-nothing: a valid session opens the same directory for everyone. -The service hints never become roles or per-link access rules. Sign-in and the -required OIDC callback, generic health status and bundled resources are public; -directory HTML, Kit data responses, generated bookmark logos and all mounted -source URLs require a session, including the selected favicon's `/public/...` -source. Image GET and HEAD requests return 401 before sign-in even with HTML -Accept, rather than redirecting to an HTML sign-in page. -The sign-in page exposes the site title, sign-in strategy, two primary colors, -derived button foregrounds and constant logo/favicon URIs, not links, descriptions, -sessions or private configuration. Dedicated public `/logo` and `/favicon` GET/HEAD -endpoints publish only the selected site's logo and favicon bytes respectively, -regardless of session. Selecting a bookmark/service image deliberately publishes -those same bytes through the selected alias; its raw source URL stays protected. -No configured source URI, filesystem path or image bytes enter public layout data. - -When auth is enabled, set these server environment variables: - -- `AUTH_SECRET`: a random string of at least 32 characters, generated with - `openssl rand -base64 32`. It protects encrypted, seven-day JWT session cookies. -- `ORIGIN`: the fixed public root origin, for example - `https://directory.example.com`, with no path, query, credentials or trailing slash. - Enabled auth uses HTTPS, with HTTP allowed only on `localhost` for local - development, such as `http://localhost:5173`. -- `AUTH_URL`: the same value as `ORIGIN`. This is Auth.js's standard URL setting; - callbacks do not depend on client-supplied Host or forwarded headers. The proxy - must still use the configured public origin. -- `NEXTAUTH_URL`: leave unset, or match `ORIGIN` exactly. -- `AUTH_REDIRECT_PROXY_URL`: unsupported; leave unset. - -Invalid auth environment settings fail startup with `auth_failed` and a fixed -hint naming the failing variable and its requirement. Diagnostics never include -its value or raw error details. The standalone CLI does not check these web -runtime settings. - -No-auth mode requires none of these variables. Production Node does not automatically -read `.env`; inject the environment through the process/container or Node's -`--env-file`. Cookies are HTTP-only and SameSite=Lax, and are Secure for HTTPS -origins. Protected/auth responses use private, no-store caching. - -Register `${ORIGIN}/auth/callback/oidc` with your OIDC provider. Admission belongs -to that provider's application assignments, not local accounts or role mappings. -The app uses state, PKCE and nonce checks; do not turn them off for a provider. -Shared credentials have no built-in rate limiting or lockout: use reverse-proxy -throttling on sign-in or prefer OIDC for an internet-facing deployment. - -Sign-out clears the browser's cookie. Restarting with the same `AUTH_SECRET` does -not revoke existing sessions; rotate it when changing access policy must end -existing access. No session database or revocation list is maintained. Loader/CLI -validation checks configuration shape and interpolated values, not provider -availability or these runtime environment prerequisites. - -## URLs and images - -Destinations and OIDC issuer URLs must be absolute HTTP(S), including local HTTP -hosts/ports when appropriate. Relative, protocol-relative, executable, malformed -or whitespace-containing URLs are rejected by the runtime schema. - -Mounted images are decoded `/public/...` paths with SVG/PNG/JPEG/GIF/WebP extensions. -Unicode/spaces and nested directories are allowed; empty/dot segments, percent -escapes, backslashes, queries, fragments and control characters are not. This is -syntactic validation, not filesystem access or MIME inspection. Site logo and favicon -fields also accept the exact bundled `/favicon.svg` URI. The shared loader supplies -that URI independently for omitted site fields; normalized site paths are required -strings for both web and CLI consumers. `links.*.logo_path` stays optional and -mounted-only, with no bundled URI selection. `links.*.favicon_path` is no longer -supported; remove it from older configurations. The site favicon is unchanged. - -`site.logo_path` renders a decorative, eager 48×48 image beside the directory title -and description. Omission uses bundled `/favicon.svg` directly in server-rendered -HTML, independently of `site.favicon_path`. With JavaScript, a failed configured -image switches once to the bundle, including failures before hydration; a failed -bundle stops without retrying. Without JavaScript, a broken configured image stays -broken while the title and navigation remain usable. Changing the source URL -allows a fresh primary attempt; repairing bytes at an unchanged URL requires a -refresh. With auth enabled, the configured source URL requires a session. -Sign-in uses public `/logo` for the same selection, with the same 48px/default/ -fallback behavior. The bundled mark is 🗂️ (card index dividers). `/logo` reads only -the captured mounted `site.logo_path`, ignoring query selectors. A bundled selection -(default or explicit) returns 404 before image I/O; missing or invalid mounted -bytes return generic 404. A full document -refresh accepts new selection/bytes. Raw sources and nonselected assets stay private. - -`site.favicon_path` selects a source image, not the public alias. The shared layout -maps validated mounted paths to `/favicon` and passes the resolved bundled URI -through unchanged; it does not choose defaults or send mounted source URIs or -private configuration. `/favicon` reads exactly the mounted source from the -request's captured snapshot using the same bounded reader and GET/HEAD response -headers below. A bundled selection (default or explicit) returns 404 before I/O. A missing, invalid or unavailable selected file also returns generic 404, -without redirecting or falling back to the bundle. Valid configuration reloads -change the selected bytes without changing the alias; invalid or auth-changing -candidates retain the last good selection. No auth hook exceptions are involved. - -The mounted-file endpoint serves only exact `assets.referencedPaths` from the request's -captured snapshot: mounted site logo/favicon and bookmark logo paths, not bundled URIs or an -arbitrary directory listing or static file tree. GET and HEAD use private, -no-store and nosniff headers; HEAD has the same status/headers without a body. -There are no range or conditional-cache responses. Missing, unreferenced, -escaping, unreadable, unstable, oversized or unrecognized files receive a generic -404 without filesystem details. A missing public directory is valid at startup. -Accepted YAML reloads replace references; invalid or auth-changing candidates -retain the last good set. Image bytes are read on request, so missing files and -atomic image replacements recover without a YAML change. - -Reads are bounded to 5 MiB per file. The reader checks canonical containment, -regular-file type, the opened descriptor's Linux `/proc/self/fd` location and file -stability. Symlinks resolving inside the selected root are allowed; escapes are -not. File signatures must match the supported extension; this is not a full image -decoder or XML validator. SVGs must be UTF-8 with an SVG root and no DTD/entity -markup. They are **trusted operator content**, not sanitized uploads or a safe -active-content boundary. Use only trusted files and do not expose the mounted -directory directly through a proxy. Mounted-file serving needs Linux with procfs; -without descriptor lookup it fails closed with 404, including in native Node. - -### Prepared bookmark logos - -The logo API at `/bookmarks//logo` currently returns a generated image, prepared -server-side with the snapshot, not during page or image requests. Display links -carry one resolved `logo_path`: the configured path when provided, otherwise this -logo API URL. Cards and details render that value in the initial HTML. With -JavaScript, a failed configured bookmark logo switches once to the same private -logo API, including when the image failed before hydration. A failed fallback -stops; an omitted primary already uses the API and is not retried on failure. -Without JavaScript, valid configured and prepared logos still render, but a broken -configured image cannot switch; text and navigation remain usable. Changing the -primary URL or selected link allows a fresh primary attempt in the existing image. -Repairing file bytes at the same URL does not trigger a browser retry: refresh the -document to try again. No second image field or client-side generator is involved. - -The current internal renderer uses DiceBear core 10.4.0 and a matching local -Initials definition; no remote image service or browser generator is involved. -Letters come from the title, falling back to its slug when the title has no usable -letters. A stable group/link palette keeps the 64×64 images readable. The renderer -can change without changing display fields or logo API URLs. - -Bookmark logos use the directory's authentication policy, including GET and HEAD. -A missing or removed bookmark gets a generic 404; responses are private/no-store -and nosniff. Only the local URL is page data, never SVG bytes or private config. - -## Maintaining the contract +From the folder containing `compose.yaml`: ```sh -pnpm schema:generate -pnpm schema:check -pnpm verify +docker compose run --rm directory-cli config validate ``` - -The generator compiles fresh canonical TS using the pinned compiler into a -clean, disposable directory, then emits deterministic JSON with local `$defs` -references. It walks maps/unions/nested schemas and removes only defaulted -properties from `required`; it does not alter runtime types or literal annotation -data. No generated TypeScript types or second runtime validator are maintained. - -`schema:check` compares bytes without writing. It works before any build and is -the first gate in local `verify` and both providers' config pipeline. Build does -not regenerate the artifact or mask drift. Regenerate intentionally after schema -changes, review the JSON diff, and update examples/tests together. - -Browser-facing code may import **types only** from `@directory/config/types`. Its JS -entry is empty and does not import the schema, format registry or loader. -The canonical schema's TypeBox format registry entry is namespaced -`directory-http-url`; the editor copy uses standard `uri` plus the HTTP(S) pattern. diff --git a/docs/deployment.md b/docs/deployment.md index 39b431d..a191ad9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,319 +1,43 @@ -# Docker and Compose +# Deployment -Directory has one web container, following When's separate development and -production Compose pattern. No host Node/pnpm installation is needed. Published -images target Linux amd64; there is no worker, database or writable data volume. -The UI renders bookmark groups and plain details with service links and prepared -bookmark logos when no logo path is configured. With JavaScript, failed configured -bookmark logos on cards and details switch once to their private prepared logo; -failed fallbacks stop without retrying. Without JavaScript, broken configured -images stay broken while text and links remain usable. After repairing bytes at -the same URL, refresh the document to retry the primary image. -The directory header renders `site.logo_path` at 48px, or bundled `/favicon.svg` -when omitted. JavaScript switches a failed configured header image to the bundle -once; without JavaScript, a broken configured image stays broken. Sign-in shows -that selection through public `/logo`, with bundled 🗂️ as default/JavaScript -fallback. Selecting `site.logo_path` deliberately publishes its bytes before login, -even if also used by a bookmark. Its raw source URL stays private and is absent -from public HTML and shared layout data. Server-generated head CSS uses only the -two configured primary colors and derived readable button foregrounds. Root data -contains only constant logo/favicon URIs; descriptions, links, auth/session data -and filesystem paths stay out. -Configured image files are now -served only for exact snapshot references, with bounded 5 MiB reads and the -directory's authentication policy for all mounted source URLs. Selecting -`site.favicon_path` deliberately publishes that image's bytes at public `/favicon`, -including before sign-in, even when the source is also a bookmark or site logo. Its raw -`/public/...` URL stays protected. The shared layout sends only the constant alias, -not the source URI. Omission uses bundled `/favicon.svg`; a missing or invalid -selected file returns 404, without a bundled fallback. +use docker compose with our [compose](compose.yaml) -## Local production review +i mean, there are many other ways, but i'm not going to talk about them. -From the repository root, prepare files in the web directory so Compose reads -the adjacent `.env`. These commands create only missing config/env files and -preserve any existing operator configuration: +Fill out `config/directory.yaml`. The [example](directory.example.yaml) has comments +for sign-in and SSO. See [configuration](config.md) for all the juicy options. + +## start up ```sh -cd apps/web -test -e .env || cp -n .env.example .env -test -e config/directory.yaml || cp -n config/directory.example.yml config/directory.yaml -mkdir -p public +docker compose up -d ``` -The production starter is empty and has **open access**, with no session secret -required. Enable your own [authentication](#authentication-and-https) before -publishing. Existing active YAML remains unchanged and may have different auth -requirements. If auth is enabled, set a unique random `AUTH_SECRET` of at least -32 characters and a matching origin before starting; there is no fallback. +you can edit most configurations without having to restart -For the populated local-only review instead, follow [demo/README.md](../demo/README.md). -It documents explicit configuration replacement after backup, non-overwriting -image copies into the runtime mount, and the required secret. Its preferred -native preview leaves operator configuration and media untouched. Demo files -are excluded from the Docker build context, not copied into the image. +## inspect logs -Once the configuration choice and any auth settings are ready, still in `apps/web`: +not sure what is going on? check out the logs ```sh -docker compose up --build --pull never +docker compose logs ``` -Open **http://localhost:3000**, not an HTTP IP address when auth is enabled. -`/healthz` returns generic readiness. Stop with Ctrl-C; `docker compose down` -removes the container, not your mounted files. Add `-d` to run in the background -and use `docker compose logs -f` for diagnostics. +and visit `http://localhost:3000/healthz` to see what is up. -Edit your ignored `config/directory.yaml`, never the tracked starter or demo, to try -site-copy reloads. The **directory** mount keeps atomic rename saves visible. -Invalid later YAML keeps the last valid snapshot; invalid initial YAML prevents -startup. Auth changes require a process restart, not just a polling interval. -See the [configuration contract](config.md). +or down. -The production service runs as UID/GID **1000:1000**, with a read-only root -filesystem, temporary `/tmp`, and read-only config/public mounts. Ensure this -identity can read the mounted files and traverse their directories. Do not fix -permissions with a recursive world-writable change. Nothing writes to your -configuration or assets. The `public` mount supplies the protected image endpoint; -**do not serve it directly through a proxy**. Only trusted operator SVGs belong -here: image recognition is not sanitization or a full decoder. Missing directories -or files do not prevent startup; unavailable images return generic 404s. - -Mounted-file reads require Linux `/proc/self/fd` access to validate the opened -inode. This applies to the container and native Node; missing procfs fails closed -with 404. The root is resolved once at boot, by default as `public` below the -selected config path's grandparent (`/app/public` in production Compose). -`DIRECTORY_PUBLIC_DIR` can override it; relative values use the process working -directory. This selects the root, not permission to serve arbitrary files under -it. See [image behavior](config.md#urls-and-images). - -## Authentication and HTTPS - -The production starter has no auth. Before publishing, configure your own -[credentials or OIDC strategy](config.md#authentication-shapes) in the mounted YAML; -never use the isolated demo's known credentials for a deployment. -Set a unique generated session secret and any custom YAML environment references in -`.env`; generate `AUTH_SECRET` with: +## restart ```sh -openssl rand -base64 32 +docker compose restart ``` -For example, shared credentials use: +## update -```yaml -auth: - credentials: - username: family - password: '${DIRECTORY_PASSWORD}' -``` - -This custom YAML needs `DIRECTORY_PASSWORD` in `.env`, in addition to `AUTH_SECRET`. -Only YAML that references it needs that variable. Do not put real credentials -in the image, build arguments, tracked examples or shell history. Compose injects -`.env` at runtime; YAML references are expanded by Directory, not Compose. - -For your production HTTPS proxy, also set the canonical public root origin: - -```dotenv -ORIGIN=https://directory.example.com -``` - -Compose sets **both `ORIGIN` and `AUTH_URL`** to this value. Do not include a -trailing slash or path. Without an override, local production review uses -`http://localhost:3000`. If you change `DIRECTORY_PORT`, set `ORIGIN` to the URL -you actually open. Production Node trusts that fixed origin rather than -client-supplied forwarding headers. Invalid auth environment settings stop -startup with `auth_failed` and a fixed, value-free hint naming the setting to fix. - -The host binding defaults to `127.0.0.1`. A host reverse proxy can reach port -3000; a containerized proxy needs an operator-configured shared network, or an -appropriately restricted host binding through `DIRECTORY_BIND`. Keep direct -access restricted to your proxy/firewall. The image itself listens on -`0.0.0.0:3000` and does not terminate TLS. Root-path hosting only; subpaths are -not supported. Shared credentials need proxy throttling. OIDC also needs the -registered `${ORIGIN}/auth/callback/oidc` callback and provider admission rules. - -After changing `.env`, recreate rather than merely restart the existing container: +if a new version is released, update your compose with the latest version and +then ```sh -docker compose up -d --no-build --force-recreate +docker compose up -d --force-recreate ``` - -For YAML-only auth changes, `docker compose restart` reloads the boot policy. -Keep `AUTH_SECRET` stable to preserve sessions; rotate it to revoke them. Actual -HTTPS/cookie/proxy and external-provider behavior still need deployment testing. - -## Development container - -First complete the configuration choice and, if enabling auth, the stable -`AUTH_SECRET` setup above. Use the same `.env`, YAML and public directory. If your host identity is not -1000:1000, set `LOCAL_UID`/`LOCAL_GID` in `.env` using `id -u` and `id -g`. -These settings control both image-generated directory ownership and the running -user. Rebuild after changing them. - -```sh -ORIGIN=http://localhost:5173 docker compose -f docker-compose.dev.yml up --build -``` - -With mise and fnox installed, `ORIGIN=http://localhost:5173 mise docker up --build` -is an optional shortcut from this directory. It uses `docker-compose.dev.yml`. -The `*.local.toml` files stay outside Git, formatting and the Docker build context. - -The shared fnox config has no session-secret default. If auth is enabled, supply -a random `AUTH_SECRET` through your local fnox provider, shell or `.env`. No-auth -configurations need no secret. If your existing `mise docker` workflow supplies -that secret through fnox, keep using it. Compose passes that value into the container; the app still -rejects missing or short secrets when auth is enabled. Other YAML environment -references remain configured in `.env`. - -Open **http://localhost:5173**. The explicit origin in the direct command above -overrides a production HTTPS origin in `.env` for this local dev session. Web source/static/config binds are -read-only to the container; Vite caches and generated files stay inside it. -Host source edits trigger Vite reloads. Config YAML is polled as in production. -Shared config TypeScript is compiled at container startup; restart the dev -container after changing that source. Dependency/lockfile changes require a -rebuild. There is no separate compiler watcher or source-export alias. - -Validate the mounted YAML with the existing CLI inside the dev image: - -```sh -docker compose -f docker-compose.dev.yml run --build --rm directory-app \ - node /m/apps/cli/dist/index.js validate /m/apps/web/config/directory.yaml -``` - -The dev image includes the standalone CLI; the production image does not. It -checks YAML/interpolation, not web runtime prerequisites or provider reachability. To stop this setup: - -```sh -docker compose -f docker-compose.dev.yml down -``` - -Dev and production use the same Compose project/service name; stop one before -switching to the other. Dev is a local source-preview server, not the recommended -public HTTPS deployment. - -## Published production images - -Successful main builds publish `latest` to: - -- `git.eleith.com/eleith.private/directory:latest` -- `ghcr.io/eleith/directory:latest` - -Manually authorized stable web tags publish versioned images to the same two -repositories. For example, `@directory/web@1.2.3` creates `:1.2.3`, `:1.2` and -`:1` aliases, never `:latest`. The tag must be a complete stable version with no -leading zeroes and exactly match the web manifest; validation happens before -registry login/publication. CLI tags do not publish another image. See the -[manual release workflow](development.md#manual-app-releases) for Changesets, -version review and separately authorized tag pushes. - -Image availability and registry read permissions are operator setup. Before the -first successful publishing run, build locally as above. Afterwards, with `.env` -and mounts already prepared: - -```sh -docker login git.eleith.com -docker compose pull -docker compose up -d --no-build -``` - -To choose GHCR, set `DIRECTORY_IMAGE=ghcr.io/eleith/directory:latest` in `.env` -and authenticate there if required. To select a published release instead, set -`DIRECTORY_IMAGE=git.eleith.com/eleith.private/directory:1.2.3` (or the GHCR -equivalent); use a real published version, not this illustrative value. Minor -and major aliases move with releases; use a full version or digest for pinning. -Pulling does not apply an update until you recreate the container. There is no -automatic homelab deployment or separately published CLI image. - -Both providers keep PRs non-publishing. Main and stable web-tag publication wait -for web checks/E2E and explicit schema/config/CLI gates. On Woodpecker, authorize -`gitea_docker_registry` server-side for `push` and `tag` events and the exact -`woodpeckerci/plugin-docker-buildx:6.0.3` image, never PRs. There is no native -branch filter for secrets; workflow conditions do not replace trusting push -writers or stronger secret-backend policy. SMTP stays main-push-only, unchanged -by release tags. Confirm provider permissions and actual runs as described in -[development](development.md#main-and-release-image-publication). - -The Docker build copies compiled output and pruned production dependencies into -the non-root runtime image. Operator -YAML, secrets, mounted public files and the isolated `demo/` directory are excluded -from the build context. -The runtime contains no application source, build toolchain or browser installation. -Final container behavior, mounts and image size are owner-run manual checks. - -## Owner Docker review checklist - -These checks require your Docker installation; source/Node checks alone are not -a Docker pass. Use disposable copies of any real configuration. - -1. Run `docker compose config --quiet` and the equivalent with - `-f docker-compose.dev.yml`. Build/start each setup above; confirm health, - configured copy, logs, Ctrl-C and restart. Try source reloads in dev and ensure - no root-owned files appear in the host checkout. -2. After the production build, check the **actual image**, not compressed download - size (adjust the image name if overridden): - - ```sh - docker image inspect git.eleith.com/eleith.private/directory:latest \ - --format '{{.Size}} bytes; user={{.Config.User}}; arch={{.Architecture}}' - docker compose exec directory-app id - ``` - - Require **less than 400000000 bytes**, non-root UID/GID 1000 and amd64 before - approving publication. Inspect that the final image contains only runtime - output/dependencies, not source, compiler, browser, fixtures or operator files. - Check the effective root filesystem and bind-mount settings too: - - ```sh - docker inspect "$(docker compose ps -q directory-app)" \ - --format 'read-only-root={{.HostConfig.ReadonlyRootfs}}{{range .Mounts}}; {{.Destination}} writable={{.RW}}{{end}}' - docker compose exec directory-app node --eval \ - "require('node:fs').openSync('/app/config/directory.yaml','r+')" - ``` - - Require `read-only-root=true` and `writable=false` for `/app/config` and - `/app/public`. The write-open command must fail; it requests write access - without changing file bytes. Only `/tmp` is intended to be writable. - -3. Edit site copy, save by atomic rename, then try invalid YAML and deletion/ - restoration. Last-good copy and generic health must survive later failures. - With the container stopped, invalid YAML or enabled auth with a missing secret - or invalid origin must fail closed on startup. Restore valid settings afterwards. -4. In credentials mode, try wrong/right passwords, JavaScript disabled, sign-out, - and a private `__data.json` URL without a session. Only the signed-in session - should reach bookmark cards, details and directory data. Click a card to its - real `?link=` details, use the separate new-tab service link and return with - Back to directory. Try JavaScript disabled. Changing auth must require a - restart, not silently alter a running session policy. -5. In a disposable public mount, configure a bookmark's `logo_path` to a small - trusted SVG. With JavaScript disabled, check its card and details render after - sign-in. Without a session, GET/HEAD requests to both mounted files and - `/bookmarks//logo` must return 401 even with `Accept: text/html`; configured - favicon source URLs stay private too. Before sign-in, check the tab uses public - `/favicon` for a selected site favicon. Selected `site.logo_path` bytes are also - deliberately public through `/logo`, never through the raw source URL. Check - sign-in's 48px logo/default 🗂️ and one-time JavaScript fallback; omission or - unavailable selections return generic 404 from `/logo`. - Omit the selection to use bundled `/favicon.svg` with a 404 at `/favicon`. - Missing or invalid selected files must return 404 rather than a static fallback. - Check private/no-store and nosniff headers, - bodyless HEAD, unreferenced and escaping-symlink 404s, missing-file recovery, - atomic image replacement, and valid/invalid YAML reference reloads. Omit - `logo_path` to check the prepared logo remains usable. With JavaScript enabled, - block a configured bookmark image in devtools and check cards/details use the - prepared logo once; block the fallback too and check there is no retry loop. - Restore access and refresh to retry the primary. Verify the actual container's - procfs descriptor lookup as its non-root runtime user: - - ```sh - docker compose exec directory-app node --input-type=module --eval \ - "import {open,realpath} from 'node:fs/promises'; const f=await open('/app/config/directory.yaml'); try { await realpath('/proc/self/fd/'+f.fd); console.log('procfs descriptor lookup available'); } finally { await f.close(); }" - ``` - - Do not replace this owner-run image/mount check with a host-only Node result. - -6. In the normal HTTPS/proxy setup, verify redirects, Secure cookies, CSRF, - configured OIDC callback and proxy throttling. Rotate the session secret and - confirm old sessions stop working. Do not treat local HTTP smoke or a fake - issuer as a verified external-provider deployment. diff --git a/docs/development.md b/docs/development.md index 383846f..0e59f43 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,885 +1,24 @@ # Development -## Current scope +Read the [philosophy](philosophy.md). -The bootstrap contains all three workspaces, local quality gates, and per-package -Woodpecker/GitHub workflow definitions. CLI help and the SSR directory read the -same supported configuration format version. The TypeBox schema, inferred types -and deterministic editor-schema generation are implemented, along with YAML -loading, environment interpolation and standalone CLI validation; see -[configuration](config.md). The web server now loads before serving, polls the -configuration path, and captures one immutable snapshot per request. The shell -renders configured groups, bookmark titles and SSR details with external service -links. Details progressively enhance into a responsive dialog. Cards and details use server-prepared bookmark logos when no logo path is -configured. Configured image files now serve through exact captured snapshot -references, not an arbitrary static directory. Optional credentials or OIDC authentication gates the entire directory; -no-auth mode stays open and requires no session secret. [Docker and Compose](deployment.md) -provide dev/production containers, main `latest` images and manually tagged -stable releases. +Then resist the urge to add a plugin system. -## Toolchain - -`mise.toml` pins Node **26.5.0** and pnpm **11.9.0**. The shared catalog pins direct -dependencies exactly, including: - -| Tool | Version | -| --------------------------------- | --------------- | -| SvelteKit / Svelte | 2.70.1 / 5.56.0 | -| adapter-node / Svelte Vite plugin | 5.5.4 / 7.1.2 | -| Vite / TypeScript | 8.0.16 / 6.0.3 | -| ESLint / typescript-eslint | 10.4.1 / 8.60.0 | -| eslint-plugin-svelte | 3.18.0 | -| Prettier / prettier-plugin-svelte | 3.8.3 / 3.5.2 | -| Vitest / coverage-v8 | 4.1.7 / 4.1.7 | -| Playwright | 1.61.1 | -| Bits UI | 2.18.1 | -| Fuse.js (full build) | 7.5.0 | -| unplugin-icons / Phosphor JSON | 23.0.1 / 1.2.2 | -| Changesets CLI | 3.0.1 | - -Published peer ranges support this combination. `@types/node` 24.12.4 satisfies -Vite/Vitest's peer ranges; it does not describe every Node 26 API. The base ESLint -configuration now loads Svelte template rules and the actual web configuration. -Do not use SvelteKit 3 configuration APIs with this pinned Kit 2 stack. -UI glyphs use When's `virtual:icons/ph/*` imports with unplugin-icons' Svelte -compiler. Only selected SVGs are compiled into the app; there is no browser icon -API, font or runtime collection. Branding and bookmark/service images remain separate. - -## Setup and commands +With Docker, mise, and fnox installed, run from `apps/web`: ```sh -pnpm install --frozen-lockfile -pnpm --filter @directory/web exec playwright install chromium -pnpm verify +mkdir -p config +cp -n ../../docs/directory.example.yaml config/directory.yaml +mise docker up ``` -On Linux, Playwright may also need system libraries. On a machine where you -control system package installation, its `install --with-deps chromium` command -installs them; do not use it to bypass sandbox restrictions. Install the Chromium -revision matching the pinned Playwright version after changing that dependency. +if it works. celebrate. -| Command | What it does | -| -------------------------------------------- | ------------------------------------------------------------------------------ | -| `pnpm build` | Builds config, then CLI and the adapter-node production server. | -| `pnpm check` | Checks config/CLI TypeScript plus web Svelte/TypeScript diagnostics. | -| `pnpm lint` / `pnpm format` | Checks or writes root/workspace Prettier formatting; lint also runs ESLint. | -| `pnpm test` / `pnpm test:coverage` | Runs all workspace unit/artifact tests, with diagnostic coverage. | -| `pnpm test:e2e` | Runs Chromium against the existing production build. | -| `pnpm schema:generate` / `pnpm schema:check` | Generates editor JSON / checks drift without writing, before any build. | -| `pnpm verify` | Checks schema drift, builds, checks types/lint, runs coverage tests, then E2E. | -| `pnpm dev` | Builds config, then starts Vite on `127.0.0.1:5173`. | +when you come back, in a browser, navigate to http://localhost:5173 -`pnpm build:packages` rebuilds just shared output. Package exports deliberately -resolve `dist/index.js` and `dist/index.d.ts`, with no source aliases or custom -loader. `@directory/config/types` is a type-only entry; the schema JSON is exported -separately. Schema generation compiles fresh source into disposable output and -never relies on an existing `dist`. Build before independent workspace -checks/tests; `pnpm verify` does this in order. -After editing config source during development, rebuild shared output to make it -visible to Vite; there is no automatic config-source compiler/watch process. -The config package pins YAML **2.9.1**. The CLI uses **Gunshi 0.35.1** for -commands, arguments and generated help, matching When. It uses catalog-pinned esbuild -**0.28.2** for its Node bundle; dependency install scripts are disabled for -esbuild because the platform binary ships as an optional dependency. +## write code -The web server needs configuration at runtime, not at build time. From the -repository root, preview the isolated demo without creating or modifying -operator configuration or copying images: +the app is a svelte app and in development it is hot reloading. code away and +watch the magic work (or break). -```sh -CONFIG_PATH="$PWD/demo/config/directory.yaml" \ - AUTH_SECRET="$(openssl rand -base64 32)" \ - ORIGIN=http://localhost:5173 AUTH_URL=http://localhost:5173 pnpm dev -``` - -Open **http://localhost:5173**. See [the demo quickstart](../demo/README.md) for -local-only credentials, manual preview scope and production preview. Its 32 bookmarks -span five uneven groups and use generated default logos; site branding uses the -bundled default, so no image mounts or asset copies are needed. This command uses -real authentication with a fresh ephemeral secret, not an auth bypass. -The separate `apps/web/config/directory.example.yml` starter is empty and open, -requires no session secret, and must have authentication added before publishing. - -`pnpm dev` runs Vite from `apps/web`, so its default path is -`apps/web/config/directory.yaml`. Absolute `CONFIG_PATH` and `DIRECTORY_PUBLIC_DIR` -values avoid working-directory ambiguity. An existing active configuration stays -unchanged with the command above. Back it up yourself before explicitly choosing -to replace it with a demo copy; setup must never automatically overwrite it. -Auth changes require a process restart, not just waiting for the file poll. -Use HTTPS or `http://localhost:5173`, never an HTTP IP address with auth enabled; -update both `ORIGIN` and `AUTH_URL` if the port changes. See -[authentication setup](config.md#authentication-shapes) and -[Compose setup](deployment.md#local-production-review) for stable secrets and -manual, non-overwriting asset copying. - -### CLI validation - -```sh -pnpm cli -- --help -pnpm cli -- validate apps/web/config/directory.example.yml -pnpm cli -- validate demo/config/directory.yaml -``` - -The root command builds shared config and the CLI, then runs its compiled Node -entrypoint. The wrapper also emits build logs; the following stream guarantees -refer to the compiled CLI itself: - -- Help returns 0 on stdout without loading configuration. -- `directory validate [path]` returns 0 on stdout for a valid file, otherwise 1 on - stderr. It checks the actual environment, with no weaker structure-only mode. -- Missing commands, unknown options and extra paths return 1 on stderr. Exactly - one leading `--` is accepted for package-manager forwarding; repeated or - mid-command separators are rejected. Gunshi handles help in its normal flag - positions, including after a path, without loading configuration. -- Output contains no configuration values, filenames or parser snippets. See - [configuration](config.md) for paths, interpolation and safe error codes. - -The package's build command runs TypeScript and esbuild directly, following -When's pattern. It cleans `dist`, emits declarations, and bundles `index.js` and -the side-effect-free `cli.js`. Only Node built-ins remain external. The -executable keeps its shebang and executable mode; it needs no source, workspace -resolution or `node_modules` at runtime. `runCli` is async and accepts injected -output sinks and an optional environment map. - -Artifact tests copy only those two bundles outside the workspace, run from that -temporary directory, and compare acceptance/errors with the shared loader. They -cover configuration paths, auth/environment failures, secret sentinels, help, -arguments and API import without executing main. Config compiled-module and CLI -subprocess tests do not contribute to source coverage; passing them is not a -claim of full source coverage. - -### Prepared configuration snapshots - -`prepareSnapshot` in `apps/web/src/lib/server/config/snapshot.ts` clones the -validated private configuration, creates the display model and exact referenced -asset lists and bookmark logos, then freezes the complete snapshot. -`getBookmarkLogos` in `bookmark-logos.ts` currently renders SVGs with the internal -Initials style. SVG strings stay in the private, frozen `assets.bookmarkLogos` map -for every link, including those with configured logos. Display links carry one -required `logo_path`, resolved to the configured path or `/bookmarks//logo`. -This logo API remains available for every bookmark. `BookmarkLogo.svelte` is a -thin domain wrapper: it passes the resolved primary and derives the same private -API fallback for the shared `Logo.svelte`, at 64px on cards and details. The -protected header also uses `Logo`, at 48px with eager loading, `site.logo_path` -and bundled `/favicon.svg` as fallback. The shared config loader resolves omitted -site logo/favicon paths to `/favicon.svg` for both web and CLI; normalized site and -display paths are required strings. `getSite` selects them directly, without -choosing defaults. Bundled paths are excluded from mounted asset references. -Sign-in uses the selected bytes through public `/logo` at 48px, with bundled 🗂️ as -default and one-time JavaScript fallback. No configured source URI enters public -HTML or shared layout data. The site favicon remains independent. - -`Logo` makes one JavaScript fallback attempt. Its mount check catches image failures -before hydration; fallback failures stop, including when primary and fallback -are the same. Changing the source/fallback pair resets the attempt in place, -including revisiting a previously failed source. The bookmark fallback encodes -link identity, so switching links also resets the attempt. Repairing bytes at an -unchanged URL requires a document refresh, not automatic polling. Without -JavaScript, broken configured images stay broken; text and links remain usable. -Configured paths still reach the mounted-image endpoint. -The directory page sends only the snapshot's `display` member; the root separately -publishes allowlisted branding inputs described below. The client-facing interfaces -in `src/lib/display/types.ts` contain no server imports. Snapshot IDs are fresh -random UUIDs, not hashes of configuration or credentials. - -`prepareDirectory` builds the browser data through `getSite(config)` and -`getGroups(config)`. These helpers select public fields, trim each YAML-list tag -and drop blank labels, and group links in YAML declaration order. Tag order, -duplicates and case are preserved; commas inside a tag remain literal. Named -groups follow their first appearance, with ungrouped links last. Group identities -keep a configured Other separate from the missing-group bucket. - -`loadDirectory(path?, { env, report }?)` in -`apps/web/src/lib/server/config/load-directory.ts` loads and prepares the first -snapshot before returning a directory object: - -```ts -const directory = await loadDirectory(path, { report }); -const captured = directory.currentSnapshot(); -await directory.reload(); -directory.stop(); -``` - -- `currentSnapshot()` returns a complete immutable snapshot. Replacements change - one private reference; earlier captures keep their complete version. -- `reload()` runs one attempt at a time. Requests during loading or preparation - coalesce into one follow-up. Identical loaded values and bookmark declaration - order keep the current snapshot and revision without preparing logos again. - Load/preparation failures retain the current - snapshot and report a safe issue. Auth differences from the initial snapshot - report `restart_required` and reject the entire candidate, including display - edits. Restoring the original auth permits later display reloads. -- `stop()` is terminal. It clears pending work and prevents later publication or - reporting, without claiming to cancel I/O already in progress. - -Startup failure rejects with a redacted `DirectoryError`; no directory object is -returned. There is no separate `start()` or public publisher. The config package -only loads and validates configuration. - -### Web startup and file polling - -The Kit `init` hook skips build analysis, then chooses separate production or -development startup functions. Both use the shared `bootApp` function; production -startup finishes before adapter-node opens a socket. Compilation needs no mounted -YAML or credentials. Startup errors stay fatal and are reported without raw error -values. Enabled auth requires a random 32+ character `AUTH_SECRET` and matching -root `ORIGIN`/`AUTH_URL` with no trailing slash. Authenticated deployments use -HTTPS; HTTP is supported only on `localhost` for local development. Missing or inconsistent auth settings -report `auth_failed` followed by a fixed hint naming the failing environment -variable and its requirement, without values or raw error details. Unknown auth -initialization failures report only `auth_failed`. No mode falls back to open -access after an auth failure. - -Only a valid startup installs `watchConfig`, a non-persistent one-second path -poll. Inode, modification-time or size changes request a serialized reload; -editor atomic rename saves and deletion/recreation keep working. Boot requests -one additional reload after watcher installation to catch saves during setup. -Invalid later edits keep the last good snapshot and generic health readiness. - -The request hook captures `directory.currentSnapshot()` once into -`event.locals.snapshot`; nested loaders use that reference even if a reload -publishes meanwhile. Boot resolves the public root once through the existing -`resolvePublicDir` helper, using the same selected config path and environment as -configuration loading. The application captures it as `event.locals.publicDir`; -it never enters page data. Missing roots or files do not prevent startup. The page returns only the captured display model, a validated selected-link -identity and a signed-in boolean after the access gate. Private configuration, -assets, sessions and filesystem paths are not page data. -`/healthz` returns only `{ "status": "ok" }` after initialization. Requests do not -load YAML or regenerate bookmark logos. Shutdown stops the watcher and blocks pending -publication. Polling/config preparation are not browser code. - -Production lifecycle handling uses adapter-node's `sveltekit:shutdown` event. -Development lifecycle handling separately owns replacement of previous instances, -pending-startup cleanup, and the small serve-only Vite close bridge. Kit's pinned -SSR loader does not run `import.meta.hot.dispose`, so that bridge remains -application-specific. Development cleanup happens at the next initialization or -server close, not immediately when source is invalidated. - -### Configured image files - -`/public/[...file]` checks exact membership in the captured -`assets.referencedPaths` before any image I/O. The server-only `assets.ts` reader -uses bounded 5 MiB reads, canonical containment, regular-file and stability checks, -and the same opened descriptor's `/proc/self/fd` location. This requires Linux -procfs even in native Node; unavailable descriptor lookup fails closed. Symlinks -inside the root work, escapes do not. Extension/signature checks recognize -PNG/JPEG/GIF/WebP and UTF-8 SVG without DTD/entity markup; this is neither a full -codec/XML decoder nor an SVG sanitizer. SVG files are trusted operator content. - -The route shares only response formatting with `/bookmarks/[id]/logo`; the -snapshot renderer is unchanged. GET and HEAD return private/no-store, nosniff and -byte lengths, with no HEAD body or range/conditional caching. Failures are generic -404s. Config loads/preparation do not inspect images. References update only with -accepted snapshots; image bytes update independently on subsequent requests. -All mounted source URLs, including the selected site favicon's source, remain -private when auth is enabled. HTML Accept does not turn image requests into -sign-in redirects. The public group's `/favicon` GET/HEAD route reads only -`locals.snapshot.configuration.site.favicon_path` through the existing reader and -response helper. Selecting a bookmark image publishes its bytes through this alias -as well, without making its raw URL public. A bundled selection (default or -explicit) returns 404 before I/O; unavailable mounted bytes return generic 404, -not a bundled fallback. - -The public group's `/logo` GET/HEAD route likewise reads only the captured -`site.logo_path`, with the same reader, headers, generic 404s and no query selectors. -A bundled selection (default or explicit) returns 404 before I/O. Selecting an image also referenced by a bookmark -publishes those bytes only through the alias, not its raw source URL. - -The shared server layout maps resolved `/public/` site paths to constant -`faviconPath` and `logoPath` aliases (`/favicon` and `/logo`), passing bundled -`/favicon.svg` selections through unchanged. Defaults belong to shared config -loading, not the layout. Its single head -icon link has no fixed MIME type. No configured source URI, title, description, -image bytes, session or private configuration enters root data; the existing public -sign-in title remains page-owned. The server `themeStyleTag` helper selects the -captured display's two validated primary colors and derives readable button -foregrounds. A native Kit `transformPageChunk` inserts these as `--color-primary` -and `--color-on-primary` on `:root` in the HTML head, not a layout data payload or -wrapper. Shared `lib/styles/theme.css` owns static palettes, spacing, error colors, -radii, `--outline-focus` and manual color schemes; page CSS owns geometry. Native `light-dark()` uses the OS scheme unless the root's -`data-theme` selects light or dark (modern browser support required). Body palettes -stay unchanged; the two primary colors and selected branding bytes are deliberately -public before login. Theme inputs follow the existing full-document refresh -contract after configuration reloads; query-only navigation retains the document theme. - -With JavaScript, the protected header theme button cycles System → Dark → Light -→ System. `directory.theme` in localStorage accepts only `system`, `dark` or -`light`; invalid or unreadable values start in System. Blocked writes do not -prevent toggling for the current document. The static nonce-bearing script in -`app.html` restores the preference before head styles and hydration, including on -public sign-in. The root records both `data-theme-preference` and the applied -`data-theme`; OS changes update the latter only in System. There is no theme cookie -or server-side preference. Without JavaScript, CSS follows the OS and the button -stays disabled. - -### Shared controls - -Visible controls live in `apps/web/src/lib/components/ui`: `Button`, `ButtonLink`, -`Input` and `Chip`, with appearance and interaction states owned by `controls.css`. -Feature components own behavior, labels, icons and placement—not duplicate hover, -focus, pressed, disabled or field styling. Root semantic theme colors remain the -palette source; no separate UI theme is introduced. - -Scoped styles belong with the component that renders the affected DOM. Use native -layout wrappers for placement and animation, explicit presentation props for -context-specific spacing, and forwarded headless-library attributes for semantics -instead of reaching into child classes with `:global`. The class-prefixed shared -UI stylesheet is intentionally shared; it does not style unrelated native elements. - -`Button` forwards native button attributes/events and defaults to `type="button"`; -submit controls must opt in. `ButtonLink` retains a real caller-provided href, -target and rel. Both expose a native `element` binding, including forwarded Bits -Close attachments. Primary actions keep their validated foreground/background -colors through all states; quiet/icon and chip controls use restrained fills. - -`Input` wraps its native input with optional leading/trailing snippets, forwarding -id/name/ARIA/autocomplete/required/readonly/disabled and native events. Its `value` -and `element` bindings refer to the actual string value and HTML input. The `class` -prop belongs to the shell for layout hooks. Decorative leading content is an -associated native label, so clicking it focuses the input without another handler. -Only input focus highlights the shell; a trailing Clear button gets its own focus -indicator, not a second field ring. -`Chip` delegates interactive labels to Button and keeps informational labels as -spans without scripting. Hidden form metadata remains a native hidden input. - -### Progressive details - -Every tile retains a real `/?link=` URL with speculative preloading disabled; -a current search is preserved as `q`. Dialog code is statically imported with the -page, not fetched on first activation. JavaScript-enabled direct entries show the -directory before the dialog's entrance, rather than a wide detail preview. Plain -details and Back live in native `noscript` content and remain usable when JavaScript -is disabled. The server-prepared global `site.open_links_in_new_tab` preference -reaches both render paths: Open uses native `_blank` by default, or `_self` when -false, with matching accessible text. Both retain `noopener noreferrer`; no click -handler replaces browser navigation. - -Kit 2 `pushState`/`replaceState` and typed `page.state` carry the selected ID, -grid-entry marker and current query. Local opens, closes and Back/Forward use the -loaded snapshot, without a data request. A real navigation or refresh accepts a -fresh server snapshot. Grid close goes back once; direct close removes `link` while -preserving `q`. Bits owns trapping, dismissal, scroll lock and animation presence. -The mounted dialog retains its last link/group through the exit, then returns -focus after native animation completion. Backdrop fades take 150ms, phone slides -200ms, and desktop fades 150ms in both directions; reduced motion disables them. -Ordinary dismissal returns to the activating tile or heading. Chip selection -instead applies its query to the destination directory entry and focuses Search -after exit, not to the detail entry that is being popped. The body portal inherits -the configured accents naturally from the document root. - -### Configuration update notice - -The protected `/config/version` endpoint returns only the accepted snapshot ID -with private/no-store caching. It follows the existing endpoint authentication -boundary; rejected configuration details remain in operator diagnostics. - -`ConfigUpdateNotice` lives in the root layout, observing the optional authorized -page display through typed `App.PageData`. It captures the document's first display -revision once, so later SPA data updates do not incorrectly mark old document CSS -as current. It owns one request/timer, visibility/focus/online listeners, abort -cleanup and bounded failure backoff. No checks run on public pages without display -data, and no-JavaScript pages keep native refresh behavior. The full-width status -bar sits above the header in document flow, stays sticky while scrolling, and -remains below the modal overlay. It never takes focus. Its native link tracks the -actual browser URL when Kit navigation state changes, including shallow query -changes, and opts out of Kit navigation for a full document load. No invalidation -or client theme bridge is needed. - -### Local search - -The header search shell is server-rendered but disabled until hydration. Its first -focus or input dynamically imports `lib/search/engine.ts`, the only runtime Fuse -import. Empty focus quietly warms the engine without moving the full directory; -refocusing an unchanged query retains its results and images. Nonempty query -replacements publish results without an intervening grouped directory. Tile-only -visits do not load search unless the URL already contains a nonempty query. Normal -text queries use Fuse.js 7.5.0's full-build token -search with all query words required, independent of word order or field. The -fixed threshold is 0.35 with location ignored; weights are title 4, prepared tags -3, description 2 and remaining link-local public text 1. Unit fixtures cover -ranking, typos, fragments, long queries and metadata boundaries. - -A leading `tag:` matches the entire trimmed remainder against one whole prepared -tag, using `toLowerCase()` on both sides. Spaces, punctuation and further colons -are literal. A leading `access:` accepts only `login`, `internal` or `admin`, -matching `requires_auth`, `internal_only` or `admin_only` strictly equal to `true`. -Both prefixes and access values are case-insensitive. Empty known-prefix values -and invalid access values return zero results, never fuzzy fallback. Other prefixes -and non-leading prefix text remain normal Fuse queries. There are no quote escapes, -operators, stacked filters or free-text suffixes: each query selects one mode. -Exact modes filter the existing allowlisted documents in prepared display order -across groups, preserving original link references without mutating field data. - -The index includes only explicit bookmark text: title, prepared tags, description, -ID, URL, group label and enabled advisory labels. Image references, private config -and common site copy are not search content. One successful index is retained for -the loaded snapshot ID; real navigation accepting a different snapshot rebuilds -it. Search never requests new configuration. The visible query is represented by -`q` in the URL: typing replaces the current entry, Clear removes it, and shared or -reloaded queries activate search after hydration without taking focus. Typed -`page.state.directoryQuery` carries the same query through Kit shallow history, -whose APIs do not update `page.url`; real navigation falls back to the URL value. -No local storage or server search is involved. - -Nonempty queries show flat tiles with group captions, ranked for normal text and -in prepared display order for exact prefixes. Selecting a detail chip closes details, -replaces the current query with its `tag:` or `access:` query and focuses Search. -Without JavaScript, chips remain informational. Empty/whitespace queries restore -the original groups and order. Clear returns focus to the input; -result counts, loading, no-results and failure messages are announced. Positive -counts and keyboard help are screen-reader-only; loading, empty and failure -messages remain visible without reserving a success-feedback gap. A failed -import leaves the full grid usable and suggests reloading, without an automatic -retry or reload loop. Without JavaScript the full HTML and detail URLs still work. -Search remains a labelled native search field, not a selection popup. Enter opens -details when exactly one result is ready, focuses the first of several, and does -nothing while loading or with no matches. Down focuses the first ready result. -Filtered results form a normal list of real detail links. Tab and Shift-Tab visit -every result in document order. Optional arrows follow the visual CSS rows without -wrapping at boundaries; Home/End move to the first/last result in the current row. -CSS handles responsive wrapping without recreating links or their images. Escape -from a result focuses Search and preserves the query; Escape in Search or Clear -restores grouped browsing. IME composition and modified keys do not trigger these -commands. Detail activation is synchronous and uses the already-loaded dialog. -Search is discovery, not access control: details always resolve against the full -loaded directory. Shallow dialog history preserves query/results and returns -focus to the activating tile, falling back to the directory heading when that result -is no longer present. Chip selection instead returns focus to Search with the new -query. Access flags remain advisory; filtering does not enforce service access. - -### Navigation error recovery - -Kit navigation errors use a centered, themed page with the HTTP status, a fixed -heading and a native Back to home link. That link requests a full page load at `/`, -so recovery does not depend on a failed client route module. -The page does not render raw error messages, stacks or requested URLs, and makes -no automatic retry. Authentication redirects and plain API denials remain owned -by the access gate; this page does not bypass or replace them. - -### Authentication boundary - -The running application owns its directory and one boot-created Auth.js instance -when auth is configured. Providers and seven-day JWT session policy do not change -on display reloads. The request hook captures the application and one snapshot, -then runs Auth.js and the whole-directory access checks. Only a signed-in boolean is -added to the directory page data; sessions, provider configuration and Auth.js handles -remain server-side. - -Route folders declare access ownership; SvelteKit groups do not change URLs: - -```text -routes/ - +layout.server.ts - +layout.svelte - (public)/ - favicon/ - logo/ - signin/ - healthz/ - (protected)/ - +page.server.ts - +page.svelte - (endpoints)/ - bookmarks/[id]/logo/ - public/[...file]/ -``` - -With authentication enabled, the hook bypasses the session gate only for matched -`(public)` route IDs. All other application routes, including unmatched requests, -require a session by default. Anonymous HTML GET page navigation redirects to -sign-in; data, HEAD and non-navigation requests return 401. The nested `(endpoints)` -group always returns 401 to anonymous requests, even with HTML Accept, and is also -where future private APIs belong. Route handlers and Kit reject unsupported verbs; -public ownership does not permit arbitrary writes. Configured image references do -not create access exceptions. Bundled static files are served by adapter-node or -Vite before application hooks, not by a public URL-prefix rule in the gate. -No-auth mode, origin/CSRF checks and the native Auth.js sequence are unchanged. - -Sign-in uses native SvelteKit form actions, as in When, with a server-selected -provider and an allowlist of submitted fields. Return URLs are reduced to safe -local paths. Sign-out is the root page's named POST action. Login forms work -without JavaScript. Desktop fine-pointer devices at least 48rem wide focus the -username or SSO button on mount; phones and no-JavaScript pages do not autofocus. -Sign-in uses a centered desktop card and a cardless phone layout with 48px controls, -fixed generic error banners and reduced-motion-aware states. Auth.js owns session -encryption/cookies and OIDC callbacks; -Directory owns default-deny routing, safe diagnostics and fixed-origin policy. -Auth.js is bundled server-side; its build-time dependency classification keeps -framework/compiler peers out of the pruned container runtime. - -SvelteKit's CSRF protection stays enabled, with an additional exact-origin check -on state-changing requests independent of content type. Auth.js uses its standard -`AUTH_URL` setting for callback/action URLs. Session handling uses the native -SvelteKit event and cookie context; only submitted form fields are allowlisted. Native CSP protects scripts and framing; -it does not restrict form redirects to self, because Chromium would block the -OIDC redirect to the identity provider. Responses returned through the outer -application hook receive `nosniff` and `strict-origin-when-cross-origin` headers in -both authenticated and open-access modes, including auth redirects and denials. -Kit-generated responses outside that hook, such as slash normalization, early CSRF -rejections and conditional 304s, are not covered by this policy. Referrer policy -preserves form Origin headers without leaking directory paths to another origin. -This baseline does not change authentication's private/no-store caching or -adapter-served static asset caching. Cookie security uses the native Auth.js and -SvelteKit defaults for HTTPS and HTTP localhost. - -Server diagnostics use **tslog 5.2.0**: JSON in production and pretty output in -development, with call-site stack capture disabled. Reporting passes only fixed -messages, issue codes and safe field paths to the logger, never raw errors or -configuration values. Library formatting is not a substitute for that boundary. - -### Browser checks - -Browser tests launch `node build/index.js` from the web workspace on -`127.0.0.1:4183`. They refuse to reuse an existing server and stop their server -afterwards. Build before invoking E2E independently. Playwright explicitly sets -`CONFIG_PATH` to the checked-in empty-directory fixture under `e2e/fixtures`, not -the process's operator path. Tests use no personal configuration, credentials, -or development server. Unit discovery is separate from browser specs. -The existing web `check` command also type-checks `playwright.config.ts` and all -`e2e/**/*.ts` files, including support helpers. Its tsconfig restates Kit's generated -includes before adding browser tests; keep that block aligned when upgrading Kit. -Application tests use only their own fixtures and disposable data, never the -manual `demo/` configuration or images. The demo is not an automated test, build, -lint, formatting or package input and does not trigger CI. - -The co-located process tests launch the real adapter-node build with disposable -YAML and OS-assigned ports. They check fatal invalid configuration or auth -prerequisites, atomic replacement, invalid/deleted file recovery, safe logs/health and graceful -shutdown. These tests require the production build, just like the CLI artifact -tests; they are not browser tests. Coverage measures TypeScript modules, not Svelte markup; -there is no arbitrary percentage gate or claim of full UI coverage. The config -smoke test imports built output, so its pass does not imply source instrumentation -coverage of `src/index.ts`. - -Authentication browser/HTTP tests use separate disposable production servers and -a test-only local OIDC issuer built from Node HTTP/crypto. They exercise real -Auth.js flows, discovery, PKCE/state/nonce, invalid claims/outages, cookie expiry/rotation, -private data routes and cross-origin/redirect attacks. Configured-image tests use -disposable mounts for real production HTTP authentication, exact references, -symlinks, MIME, HEAD/cache headers, reloads and byte recovery, plus the existing -SSR logo consumer with JavaScript disabled. Reader unit tests cover bounded I/O, -ordinary races and descriptor cleanup. These are not Docker/procfs deployment -checks. No live IdP or operator -config is used; real HTTPS/proxy and provider registration remain manual checks. - -Root commands cover all three workspaces, ordered by their declared package -dependencies. Each workspace's lint/format commands explicitly share the root -ignore file, so formatting after a build/test run must not rewrite generated -output. Build output, coverage, operator configuration, local plans, manual demo -data, and prototypes remain outside formatting; ESLint also ignores `demo/`. - -## Manual app releases - -Directory follows When's thin Changesets CLI pattern, with no release bot, -prerelease flow or automatic commit, tag push or deployment. The three root -commands are `pnpm changeset`, `pnpm version:apps` and `pnpm tag:apps`; root -quality commands still cover all workspaces, not just web. - -The fixed group contains only `@directory/web` and `@directory/cli`. Both are -private, but `privatePackages.version` and `privatePackages.tag` are enabled so -Changesets can version and tag them together. `commit: false` keeps commits -manual; `baseBranch: main` is the comparison base. `@directory/config` is internal -and ignored for independent versioning, **not ignored for release impact**: -record behavior/schema changes in an explicit changeset for each affected -consuming app. Its checks remain required. Tooling/docs-only setup needs no -retroactive app changeset or version bump. - -1. With each feature or fix, run `pnpm changeset`. Select the affected app(s), - choose major/minor/patch by consumer impact and write a useful release note. - Review the generated `.changeset/*.md` with the feature. The fixed group - synchronizes app versions at release time, even for a web-only change. -2. When preparing a release on reviewed main, inspect pending changesets with - `pnpm changeset status`, then manually run `pnpm version:apps`. This consumes - pending entries, updates app manifests and generates app changelogs. Review - the complete diff, including any lockfile changes, and verify the intended - versions and notes. Do not hand-bump versions or run this in CI. -3. Run the frozen-install and all-workspace quality gates (`pnpm verify`), then - obtain owner review and explicit authorization before committing the release - manifests, changelogs, consumed changesets and any lockfile changes. Do not - mix unrelated working changes into the release commit. -4. Only after the release commit is approved, and tagging is explicitly - authorized, run `pnpm tag:apps`. It creates local qualified tags such as - `@directory/web@1.2.3` and `@directory/cli@1.2.3`; it does not push them. - Inspect tags and their target commit. Push the approved release commit and - only the intended release tags when separately authorized; do not blindly - push every local tag. -5. Inspect both providers' runs and registry outputs. Only the qualified web tag - publishes the web image; the accompanying CLI tag must not publish a second - image. There is no npm publication or separately published CLI image. - -The publisher accepts only stable `@directory/web@..` tags -with no leading zeroes, matching `apps/web/package.json` exactly. Partial, -prerelease, build-metadata, malformed and mismatched tags are rejected before -registry login/publication. After web checks/E2E and schema/config/CLI gates, -`@directory/web@1.2.3` publishes `1.2.3`, `1.2` and `1` image aliases to both -`git.eleith.com/eleith.private/directory` and `ghcr.io/eleith/directory`. Tags -never move `latest`; only successful main pushes do. Minor/major aliases move -when a release is published; select a full version or digest for deployment -pinning. See [published images](deployment.md#published-production-images). - -Rehearse authoring, versioning and tagging only in an independent disposable Git -repository, never the working repository or a worktree that shares its refs. -Before a real release, check private-app lockstep, ignored-config app impact, -consumed notes/changelogs and qualified tags there. Hosted publication and -registry authorization still require actual authorized provider runs. - -## Continuous integration - -Each provider has separate `config.yml`, `cli.yml`, and `web.yml` pipelines, -matching `when`: [.github/workflows](../.github/workflows/) and -[.woodpecker](../.woodpecker/). `pnpm verify` remains the local all-workspace -aggregate, not a pipeline. Every selected pipeline uses a frozen full-workspace -install, then package-scoped build, type checks, lint, and coverage tests. Config -exports and CLI executables are built before their artifact tests. Both config -pipelines run `pnpm schema:check` after frozen installation and before building; -regeneration is never an automatic part of build. Web alone has -production browser tests, with an explicit production build before Playwright. -Each pipeline also runs root `pnpm lint` so global formatting/ESLint remains -covered; this intentionally overlaps workspace lint. - -Path filters include dependencies and shared tooling, not just app directories: - -- `packages/config/**` and the shared `apps/web/config/directory.example.yml` - starter run config, CLI, and web checks. The manual-only `demo/` does not trigger - pipelines or supply automated test fixtures. -- `apps/cli/**` or `apps/web/**` selects that application's pipeline. -- Root files (`'*'`, including dotfiles), `.changeset/**`, `docs/**`, and both - workflow directories run all three. This covers the lockfile, catalog, - package manager/Node pins, base TypeScript, ESLint/Prettier settings, docs, and - workflow changes. - -Both providers check main pushes and pull requests. The web pipeline also -selects qualified web-tag candidates and runs all workspace gates before image -publication, even when the other package pipelines do not select tag events. -Keep these filters and the package gates current when adding shared tools or -commands. - -- GitHub Actions uses `ubuntu-24.04`, Node 26.5.0, pnpm from the `packageManager` - pin, and a pnpm store cache. Job names are distinct: `Config checks`, `CLI -checks`, `Web checks`, and `Web E2E`. Only web has a dependent E2E job, using - `ghcr.io/eleith/containers-playwright:1.61.1-node26` pinned to its verified - manifest digest. That separate checkout gets another frozen install and builds - only web and its shared dependencies before browser tests. -- Each Woodpecker pipeline shares `/workspace/src` across ordered preparation, - package build/check/lint/test steps, and web E2E where applicable. Plain Node - steps use `node:26.5.0-bookworm`, pinned Corepack 0.36.0, and pnpm 11.9.0. - Corepack's shared cache is under the ignored `.pnpm-store/corepack`; the pnpm - store is local too. Later steps reuse dependencies and output. Web E2E uses - `git.eleith.com/eleith.private/playwright:1.61.1-node26` without overriding its - baked Corepack cache settings. - -The browser images contain Playwright 1.61.1's browsers and Ubuntu 24.04 system -libraries; CI does not download browsers or run APT for each application check. -Keep both image references (and the GitHub digest) paired with the catalog's -Playwright version. A `node26` image tag alone does not pin a Node patch: browser -steps explicitly require Node 26.5.0 and pnpm 11.9.0. The public GHCR image's -contents were inspected; inspect runner logs after image changes to confirm the -separately built Gitea image too. A mismatched image fails rather than silently -using another toolchain. Local development still needs its own matching browser -installation as described above. - -These workflows do not inject operator YAML/application credentials, write package -versions or deploy. Release versioning and tag pushes remain manual. PR jobs -retain `contents: read`; the public GHCR browser image requires no registry -login. The Woodpecker runner -needs pull access to the existing Gitea CI image, as for `when`. Configure read -access at the CI platform, not by exposing publishing credentials to application -commands or pull requests. - -### Main and release image publication - -Main pushes and validated stable web tags add image publication only after web -checks and production E2E. -Both providers also run explicit schema/config/CLI gates before publishing, so -parallel package pipelines cannot allow a failing dependency check to be bypassed. -The Docker build copies pruned production dependencies and compiled output into -the non-root image. It needs no operator YAML or auth secret at build time. See -[Docker and Compose](deployment.md) for local use and the owner's container, -bind-mount and image-size checks. - -- Woodpecker uses `woodpeckerci/plugin-docker-buildx:6.0.3` to publish - `git.eleith.com/eleith.private/directory`, matching When's plugin, registry and - `gitea_docker_registry` secret. Main publishes `latest`; stable web tags use - the validated `.tags` file for full/minor/major aliases. Allow this plugin in - the runner's privileged-plugin configuration. Restrict the secret - **server-side** to `push` and `tag` events and this exact plugin; no PR access, - including higher-priority secret overrides. Woodpecker has no native branch - filter for secrets: workflow branch/tag conditions are not a credential - boundary. Trust everyone who can push workflow changes, or enforce stronger - policy through your secret backend. - Registry credentials belong only in plugin settings, never application commands - or build arguments. -- GitHub's `Publish web image` job depends on checks/E2E and runs only for main - pushes or qualified stable web tags. Only that job receives `packages: write`; - it authenticates to GHCR with `GITHUB_TOKEN` and publishes - `ghcr.io/eleith/directory`. `docker/metadata-action@v6` disables automatic - `latest`, selecting it explicitly for main and full/minor/major aliases for - validated web tags. Confirm the repository/package link and write - authorization at the provider. Normal PR - jobs do not run registry login or receive publication credentials. - -Both publish the `prod` target for `linux/amd64`. CLI tags do not publish images; -release tags never update main's `latest`. Registry availability, token scope -and runner capability must be confirmed on the real -providers; static validation is not a successful image build or publication. - -### Woodpecker email notifications - -Every package pipeline has one `notify` step using -`deblan/woodpecker-email:3.3.0`, with `status: [failure, success]`. It runs only on -main pushes, never PRs. The plugin infers the result from the CI status, so no -explicit `level` setting or duplicate steps are needed. Configure or confirm these existing secret names -in the effective repository/organization/global secret scope: - -- `woodpecker_email_dsn` -- `woodpecker_email_from_address` -- `woodpecker_email_from_name` -- `woodpecker_email_recipient` - -Restrict all four secrets server-side to the `push` event and the email plugin -image/tag (`deblan/woodpecker-email:3.3.0`). Ensure the allowlist includes this -version; retain tags still required by other repositories sharing these secrets. -Do not enable PR access: YAML conditions alone cannot protect secrets -from a PR that changes the workflow. Bind secrets only in notification settings, -not application commands. Missing SMTP settings/delivery remain operator setup, -not a passing notification test. Common-path changes can produce three package -emails rather than one aggregate message. - -Enable the repository in Woodpecker with a Linux amd64 container-backed runner -that can pull the CI images, and place/enable the workflow in the intended GitHub -repository or mirror. A Gitea pull request does not automatically create a GitHub -pull request or trigger that provider's PR event. Check actual runs on each -provider separately; YAML checks, image inspection, and local `pnpm verify` are -not successful hosted CI runs. Review branch-protection/required-check settings -before enabling path filters: GitHub can leave a required check pending when its -entire workflow is skipped. Do not blindly require every selectively triggered -package check. Distinct job names avoid ambiguous statuses but do not solve that -provider policy issue. - -Schema generation, YAML loading, CLI validation and Compose commands are available. -Release commands are manual as described above. Docker execution and final -image checks, axe and PageSpeed remain owner-run manual checks, not -agent-verified gates. - -## Manual checks - -For presentation review, use disposable fixture data with at least nine bookmarks, -spaced and long unbroken titles, named groups and unassigned links. Check 360, 768 -and 1280px widths in both OS themes, with and without JavaScript: centered 6rem -tiles, wrapped titles, no horizontal overflow, and distinct group/detail/footer -spacing. Tab through tiles, Back and Open to inspect the configured accent -and visible focus. Capture phone/desktop light/dark screenshots outside the repo. -Also review flat and empty directories; the empty state should explain how to add links. -Use Tab, Enter and Space on the theme button, including at 360px beside Sign out. -Check the next-action label and visible focus, cycle all three modes, change the -OS scheme in System and manual modes, then refresh and navigate details/Back. -Throttle hydration to check restoration before paint; block localStorage to check -usable in-memory toggling. Sign out with a saved manual theme and confirm public -sign-in keeps that scheme and approved public branding, with no private site data -or CSP script errors. Disable JavaScript to check OS colors and native navigation. -Tab first to Search, then Theme and Sign out; check search focus and Clear at -360/1280px in both schemes. Try typos, reversed multiword queries, tags and deep -description text; clear or enter whitespace to restore configured grouping. -Inspect the network before focus and after tile-only activation: no search/Fuse -chunk should load. Throttle search loading, change/clear the query, and block the -chunk to check announcements and the usable full-grid fallback. Open a result, -Close/Back/Forward, and confirm the query and return focus survive. A native -navigation or refresh should accept replacement metadata, while local search and -dialog actions keep their loaded snapshot. - -1. Follow the clean setup in an isolated export of the staged commit. Check root - verification, CLI help and validation of both the production starter and demo without - personal config. Use a disposable file with a missing environment reference - or malformed YAML and check the failure output stays redacted. In a YAML-aware - editor, check template syntax and required-field diagnostics. -2. After changes are authorized and pushed, inspect a Woodpecker PR - run and a GitHub PR/main run on the configured providers. Confirm package - routing: config/root/tooling/docs/workflow changes fan out; application-only - changes select their own pipeline. Check pinned tools, frozen install, package - gates, and web's preinstalled browsers/production E2E. Review required-check - policy against skipped workflows. An absent runner or GitHub mirror remains - an outstanding operator setup task, not a passing check. -3. Review events and permissions: PR checks must not receive SMTP or publishing/ - deployment secrets and must not send email or push images. Confirm SMTP event/ - plugin restrictions at the server, including any higher-priority secret - overrides. On an authorized main run, check package notifications actually - arrive. Verify failure delivery in a controlled runner test, not by breaking - main. Do not inject operator YAML or credentials into application checks. -4. Make a disposable copy to try startup and file changes without editing the - checked-in demo or operator configuration: - - ```sh - preview=$(mktemp -d) - cp demo/config/directory.yaml "$preview/directory.yaml" - CONFIG_PATH="$preview/directory.yaml" \ - AUTH_SECRET="$(openssl rand -base64 32)" \ - ORIGIN=http://localhost:5173 AUTH_URL=http://localhost:5173 pnpm dev - ``` - - Run from the repository root; open `http://localhost:5173` and use - **demo / directory-demo**. This generates a unique demo-only secret per run. - Check desktop/narrow widths, OS light/dark and JavaScript disabled. Confirm - all 32 generated default logos load on cards and details across five uneven - groups; site branding uses the bundled default and service URLs are placeholders. - No image mounts or asset copies are needed for this manual demo. Edit the - copied site's title/description and refresh after approximately one second. - Try an atomic rename save, invalid YAML, deletion and restoration: the last - good copy should remain until a valid file returns. `/healthz` exposes only - readiness, not configuration names or paths. Configured authentication must - require sign-in before site copy/data are returned; missing secret/origin must - fail startup. Try a wrong password, sign-out and a direct `__data.json` request. - Follow a bookmark to its `?link=` details and close the dialog. Without - JavaScript, return with Back to directory. Check Tab/Shift-Tab trapping, Escape, - outside dismissal and return focus, then browser Back/Forward and a direct URL. - Throttle or block page hydration scripts to review usable server-rendered details. - Check that a generated logo renders without JavaScript and remains protected before sign-in, - including HEAD and `/bookmarks//logo` opened as HTML. For a configured logo, - use a disposable public directory and set `DIRECTORY_PUBLIC_DIR` to its absolute - path before starting. Check mounted logos on cards/details without JavaScript, - generic 404s for unreferenced or missing files, then atomic file replacement - and recovery. To exercise browser fallback manually, block a configured bookmark - image in browser devtools or choose a missing image path in the disposable YAML. - With JavaScript, cards and details should switch once to their prepared logo; - blocking that endpoint too must stop without a retry loop. Disable JavaScript - to check broken images leave the text and links usable. Unblock or repair the - primary and refresh to try the unchanged URL again. In the disposable config, - set `site.logo_path` to check a 48px header logo beside wrapped title/description - at 360px, with Sign out still visible. Omit it to check the bundled default; - break it to check the one-time JavaScript fallback and no-JavaScript limitation. - Before sign-in, check selected logo bytes through `/logo`, independent of the - tab's `/favicon`; configured source URIs must not appear in public HTML/data. - Check configured primary/button foreground contrast, the centered desktop card, - cardless phone, generic error banner and native POST. Desktop fine-pointer - autofocus must not open a phone keyboard; no-JavaScript first Tab is Username. - Leave the curated demo files untouched. - Authenticated mounted GET/HEAD work; unauthenticated requests - return 401 even with HTML Accept, including the selected favicon's raw source. - Before sign-in, check the tab icon and `/favicon` GET/HEAD: only the selected - bytes are public, with private/no-store, nosniff and bodyless HEAD. Change the - selected source and check the same alias returns new bytes after polling; - invalid YAML and auth-changing edits must keep the last good selection. Omit - the field to check the head uses `/favicon.svg` and the alias returns 404. - Missing or invalid selected files must return 404, not the bundled icon. Verify - Linux procfs availability. - See the [owner Docker checklist](deployment.md#owner-docker-review-checklist) - for the separate real-image/mount/procfs review. Stop the server when finished. - For production output, after `pnpm build`, run: - - ```sh - CONFIG_PATH="$PWD/demo/config/directory.yaml" \ - AUTH_SECRET="$(openssl rand -base64 32)" \ - ORIGIN=http://localhost:3000 AUTH_URL=http://localhost:3000 \ - HOST=127.0.0.1 PORT=3000 node apps/web/build/index.js - ``` - - Run from the repository root. Open `http://localhost:3000`, sign in with - **demo / directory-demo**, then stop the server when finished. This production - preview also generates a demo-only secret per run. The - directory uses prepared bookmark logos for omitted logo paths and serves exact - configured image references. Review local search with the checks above. Run - manual axe/PageSpeed on this visible baseline when available; this slice is not - final UI acceptance. - -Complete agent review before staged owner review. Stage only one coherent commit -at a time when requested, and wait for manual review and explicit permission -before committing or pushing. A successful local check is not a hosted CI run. +keep changes small, well tested, well linted and well. diff --git a/docs/directory.example.yaml b/docs/directory.example.yaml new file mode 100644 index 0000000..506a8bf --- /dev/null +++ b/docs/directory.example.yaml @@ -0,0 +1,253 @@ +# Replace the placeholder addresses with your own. +# Sign-in is disabled. Uncomment one auth example below to enable it. +version: 1 +site: + title: Home Server + description: Useful things running at home. + footer: Something not working? Ask your household maintainer. + +appearance: + primary_light_color: '#0f766e' + primary_dark_color: '#5eead4' + +# Shared sign-in. Use your directory's HTTPS address, with no trailing slash. +# HTTP localhost is allowed for local testing. Restart after changing auth. +# auth: +# origin: https://directory.example.com +# credentials: +# username: admin +# password: replace-with-your-own-password + +# Or SSO. Register this callback with your provider: +# https://directory.example.com/auth/callback/oidc +# auth: +# origin: https://directory.example.com +# oidc: +# issuer: https://identity.example.com +# client_id: directory +# client_secret: replace-with-your-client-secret + +# Restarts sign everyone out unless you set auth.secret. To keep sessions, +# generate a value once with: openssl rand -base64 32 +# Add it inside your chosen auth block as: secret: 'your-generated-value' +# Passwords and secrets can also use '${VAR}' from an optional .env file. +# Keep files containing secrets private. + +# Reserved example.com destinations are placeholders, not live integrations. +# All bookmarks use generated logos; site branding uses the bundled default. +links: + photos: + title: Photos + url: https://photos.example.com + description: | + Family photos and videos, all in one place. + Install your photo service's mobile app for automatic uploads. + tags: [photos, videos, memories] + group: Photos & files + requires_auth: true + files: + title: Files + url: https://files.example.com + description: Shared documents, school projects and household paperwork. + tags: [documents, sharing, storage] + group: Photos & files + backups: + title: Backups + url: https://backups.example.com + description: | + Check the latest backup status before you need a restore. + Ask your household maintainer for help recovering a file. + tags: [backup, recovery, storage] + group: Photos & files + scans: + title: Paperwork inbox + url: https://scans.example.com + description: Sort recent scans into searchable bills, receipts and warranties. + tags: [scans, documents, receipts] + group: Photos & files + archive: + title: Family history archive + url: https://archive.example.com + description: Old letters, digitized albums and notes about the family tree. + tags: [history, memories, documents] + group: Photos & files + albums: + title: Shared albums + url: https://albums.example.com + description: Choose a photo collection to share with visiting relatives. + tags: [photos, albums, sharing] + group: Photos & files + transfers: + title: File drop + url: https://transfers.example.com + description: Temporary space for large files moving between household devices. + tags: [transfer, sharing, storage] + group: Photos & files + internal_only: true + notebooks: + title: Household notebooks + url: https://notebooks.example.com + description: Shared notes for school, travel plans and everyday reference. + tags: [notes, writing, planning] + group: Photos & files + sync: + title: Device sync status + url: https://sync.example.com + description: Check whether laptop folders have reached the home server. + tags: [sync, devices, storage] + group: Photos & files + internal_only: true + keepsakes: + title: DigitalKeepsakesAndSchoolProjectCollections + url: https://keepsakes.example.com + description: Finished school projects and small creations worth keeping. + tags: [school, archive, memories] + group: Photos & files + music: + title: Music + url: https://music.example.com + description: Albums, playlists and a soundtrack for dinner. + tags: [music, audio, playlists] + group: Entertainment + movies: + title: Movies + url: https://movies.example.com + description: Browse the family movie and television library. + tags: [movies, television, streaming] + group: Entertainment + books: + title: Books + url: https://books.example.com + description: Find your next ebook or audiobook for the commute. + tags: [books, reading, audiobooks] + group: Entertainment + radio: + title: Radio + url: https://radio.example.com + description: Saved station links and recordings from the community radio club. + tags: [radio, audio, recordings] + group: Entertainment + podcasts: + title: Podcast queue + url: https://podcasts.example.com + description: Episodes downloaded for the next long drive. + tags: [podcasts, audio, travel] + group: Entertainment + reading: + title: Weekend reading list + url: https://reading.example.com + description: Saved articles and reading suggestions from around the household. + tags: [reading, articles, bookmarks] + group: Entertainment + requests: + title: Library requests + url: https://requests.example.com + description: Suggest something for the household media collection. + tags: [library, requests, media] + group: Entertainment + requires_auth: true + home-automation: + title: Home automation + url: https://home.example.com + description: | + Lights, room temperatures and everyday routines. + Connect to the home network or VPN before opening this service. + tags: [home, lights, routines] + group: Everyday tools + internal_only: true + recipes: + title: Recipes + url: https://recipes.example.com + description: Favorite meals, shopping lists and this week's dinner plan. + tags: [cooking, meals, shopping] + group: Everyday tools + calendar: + title: Family calendar + url: https://calendar.example.com + description: Appointments, school events and reminders for bin collection. + tags: [calendar, reminders, planning] + group: Everyday tools + groceries: + title: Shopping list + url: https://groceries.example.com + description: Add groceries before the next trip to the shops. + tags: [shopping, groceries, pantry] + group: Everyday tools + weather: + title: Garden weather station + url: https://weather.example.com + description: Local rainfall and temperature readings from the garden sensors. + tags: [weather, garden, sensors] + group: Everyday tools + energy: + title: Energy + url: https://energy.example.com + description: Household electricity trends and daily solar production. + tags: [energy, solar, sensors] + group: Everyday tools + internal_only: true + guides: + title: Workshop guides + url: https://guides.example.com + description: Repair notes and instructions for frequently used tools. + tags: [workshop, repair, guides] + group: Workshop + printer: + title: 3D printer + url: https://printer.example.com + description: Check the current print before starting another job. + tags: [printing, workshop, projects] + group: Workshop + internal_only: true + inventory: + title: Parts inventory + url: https://inventory.example.com + description: Find spare cables, screws and electronic components. + tags: [parts, storage, workshop] + group: Workshop + projects: + title: Works in progress + url: https://projects.example.com + description: Sketches, task lists and progress photos for household projects. + tags: [projects, planning, workshop] + group: Workshop + manuals: + title: Appliance manuals and warranty notes + url: https://manuals.example.com + description: Setup instructions and service records for household appliances. + tags: [manuals, warranties, repair] + group: Workshop + router: + title: Router + url: https://router.example.com + description: Manage the home network; leave changes to your household maintainer. + tags: [network, router, maintenance] + group: Behind the scenes + requires_auth: true + internal_only: true + admin_only: true + monitoring: + title: Server health + url: https://monitoring.example.com + description: Storage capacity, temperatures and service availability. + tags: [monitoring, maintenance, server] + group: Behind the scenes + internal_only: true + admin_only: true + containers: + title: Container console + url: https://containers.example.com + description: Maintenance console for the services running on the home server. + tags: [containers, maintenance, server] + group: Behind the scenes + requires_auth: true + internal_only: true + admin_only: true + dns: + title: Local name service + url: https://dns.example.com + description: Household DNS records and local network filtering settings. + tags: [network, dns, maintenance] + group: Behind the scenes + internal_only: true + admin_only: true diff --git a/docs/images/directory.png b/docs/images/directory.png new file mode 100644 index 0000000000000000000000000000000000000000..40016e62111a2a95d8bf3172acfaefaa006785c0 GIT binary patch literal 50921 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 literal 0 HcmV?d00001 diff --git a/docs/philosophy.md b/docs/philosophy.md new file mode 100644 index 0000000..461e94d --- /dev/null +++ b/docs/philosophy.md @@ -0,0 +1,35 @@ +# Philosophy + +do one thing. only. +do it well. maybe. + +this is a highly opinionated app prioritizing the following principles + +1. simplicity (user experience and developer experience) +2. performance (fast enough, but not the fastest) +3. low maintenance (this is just 1, but repeated in new words) + +## the one thing + +Directory is a sitemap for the apps you use at home. A browser start page for you, +your household, or whoever keeps asking where the photos went. + +## a text file + +Configuration lives in a text file. Edit it, back it up, reverse it and put it +in git. + +## no user management + +Everyone sees the same page. Shared sign-in and SSO can control who gets in. + +Keep. It. Simple. + +## not enough? + +you've got options. more features come with more things to configure and maintain. +decide how much you want to take on. + +- [dashy](https://dashy.to/) — widgets, themes, and service status checks +- [homepage](https://gethomepage.dev/) — service integrations and widgets +- your own page — hand code it, vibe code it, hard code and maintain it all diff --git a/packages/config/README.md b/packages/config/README.md index 6a7dcc4..edb7bd6 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,52 +1,6 @@ # @directory/config -Shared configuration for Directory. The [contract](../../docs/config.md) lists fields, -defaults, paths and environment syntax. +Shared server-side YAML loading, validation and path resolution for Directory. -```ts -import { loadConfigFile } from '@directory/config'; - -const config = await loadConfigFile('config/directory.yaml', { env: process.env }); -``` - -`loadConfigFile(path?, { env }?)` reads YAML and calls -`validateConfig(raw, { env }?)`. Both apply defaults, interpolate string values, -and check the canonical TypeBox schema. Loaded `site.logo_path` and -`site.favicon_path` are required strings, defaulting independently to bundled -`/favicon.svg`. Site images accept that explicit bundle or a strict mounted -`/public/` image path; bookmark image fields stay optional and mounted-only. -Omitting `env` uses `process.env`; an -injected map is used on its own. Neither function watches files or publishes -state. The loader returns a fresh private configuration without changing the -input. Preparing the browser's directory data and immutable snapshots belongs -to the web server. - -Bookmark `tags` accepts only a YAML list of strings and defaults to `[]`, never a -CSV scalar. Loaded strings retain their whitespace, case, duplicates and order -after environment interpolation. Interpolation works inside list elements, not -as a way to turn a scalar into a list; commas inside an element stay literal. -The web display trims each tag and drops blank labels without deduplicating, -changing case or splitting commas. Migrate old CSV tags to explicit lists manually. - -Input is checked before defaulting: nulls, non-plain or cyclic objects, accessors, -hidden/symbol properties and unsafe keys are rejected. Arrays are accepted only -where the schema allows lists. This prevents TypeBox object defaults from changing -invalid input into valid objects. Fields are never silently removed. - -Failures throw `ConfigError` with a safe message and readonly `issues` containing -`code`, `path` and `message`. Values, filenames, variable names and original -exceptions are excluded. Known field names remain visible; link IDs and unknown -names become `*`. Catch unexpected errors without printing their raw contents. - -The package also exports `resolveConfigPath`, `resolveDeploymentRoot` and -`resolvePublicDir`. These resolve operational paths without reading assets. -The default layout has sibling `config/` and `public/` directories. - -Directory state, snapshot preparation and reload policy belong to the web server. -The shared loader keeps no cached configuration. See the web server's -[directory API](../../docs/development.md#prepared-configuration-snapshots). - -Browser code must use **type-only** imports from `@directory/config/types`; the main -entry imports filesystem and validation code. `@directory/config/schema.json` is the -generated text-editor schema. See [development](../../docs/development.md) for -build, schema-drift and test commands. +- [Configuration](../../docs/config.md): fields, defaults and safe errors. +- [Schema](src/config.schema.json). diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 0687719..0f995a5 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -401,11 +401,8 @@ describe('YAML file loader', () => { } ); - it('validates the minimal production starter with defaults and no auth', async () => { - const path = fileURLToPath( - new URL('../../../apps/web/config/directory.example.yml', import.meta.url) - ); - const config = await loadConfigFile(path, { env: {} }); + it('loads minimal YAML with defaults and no auth', async () => { + const config = await loadYaml('links: {}\n'); expect(config.version).toBe(1); expect(config.links).toEqual({}); expect(config).not.toHaveProperty('auth'); @@ -423,6 +420,14 @@ describe('YAML file loader', () => { }); }); + it('validates the downloadable starter without auth or environment values', async () => { + const path = fileURLToPath(new URL('../../../docs/directory.example.yaml', import.meta.url)); + const config = await loadConfigFile(path, { env: {} }); + expect(config.version).toBe(1); + expect(Object.keys(config.links).length).toBeGreaterThan(0); + expect(config).not.toHaveProperty('auth'); + }); + it('loads an empty directory, flat links, multiline descriptions and native booleans', async () => { expect((await loadYaml('links: {}')).links).toEqual({}); const config = await loadYaml(` -- 2.54.0