Simplify docs and define configuration-first setup #51

Merged
eleith merged 1 commits from docs/household-first-guides into main 2026-10-26 17:51:46 +00:00
22 changed files with 515 additions and 1943 deletions

No files matched your search

+2 -10
View File
@@ -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.
-1
View File
@@ -17,7 +17,6 @@
**/e2e
**/.env
**/.env.*
!**/.env.example
**/.npmrc
**/*.local.toml
**/docker-compose*.local.yml
-2
View File
@@ -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
-2
View File
@@ -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
+1 -3
View File
@@ -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/
-2
View File
@@ -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@*'
-2
View File
@@ -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:
+1 -27
View File
@@ -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.
+23 -117
View File
@@ -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=<id>` 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@<version>` 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)
-25
View File
@@ -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
-6
View File
@@ -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: {}
-109
View File
@@ -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=<id>` 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=<result>` 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.
+2 -2
View File
@@ -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.
+52
View File
@@ -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
+101 -412
View File
@@ -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=<id>` 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/<id>/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.
+20 -296
View File
@@ -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=<id>` 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/<id>/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.
+12 -873
View File
@@ -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/<id>/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=<id>` 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@<major>.<minor>.<patch>` 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=<id>` 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/<id>/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.
+253
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+35
View File
@@ -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
+3 -49
View File
@@ -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).
+10 -5
View File
@@ -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(`