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 0000000..40016e6 Binary files /dev/null and b/docs/images/directory.png differ 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(`