Commit 6472890 accidentally deleted the committed dist/.gitkeep: the
clean-state test ran 'rm -rf dist' (which contains the tracked .gitkeep)
and the following 'git add -A' recorded the deletion. Fresh clones then
had no dist/ and 'go build'/'just check' failed before the first shiroa
run ('pattern all:dist: no matching files found').
- Justfile: 'clean' now recreates dist/.gitkeep, so the tracked file
survives clean/build cycles.
- AGENT.md: clean-state procedure uses 'just clean' and requires a clean
'git status' after test cycles, with the reason documented.
- Verified: with dist/ holding only .gitkeep, 'go build' and 'go vet'
succeed (fresh-clone state).
Yggdrasil
A personal knowledge base written in Typst, compiled to a static site by shiroa, and served by a small Go binary that embeds the whole site and gates protected pages behind OIDC login (Authentik) with per-page group permissions.
No search, no database, no Node.js: one binary, one configuration.
How it works
flowchart TD
A["docs/*.typ (Typst sources)"] -->|"just build-docs (shiroa)"| B["dist/ (static site)"]
A -->|"just parse (cmd/parser, access-groups tags)"| C["acl.json"]
B -->|"go build (//go:embed)"| E["server binary"]
C --> E
E --> F{"request /page.html"}
F -->|"public page"| G["serve page"]
F -->|"protected page"| H{"session + groups ok?"}
H -->|"no session"| I["redirect to /login, then Authentik"]
H -->|"not authorized"| J["403"]
H -->|"authorized"| G
- Pages are written in Typst under
docs/and listed indocs/book.typ. - A page can declare who may read it with a tag:
#let access-groups = ("family", "admin"). Pages without a tag are public. - At build time,
cmd/parserscansdocs/, extracts the tags and generatesacl.jsonmapping final page URLs to allowed groups. - The Go server embeds both
dist/andacl.json. For every request it reduces the URL to a page key and looks it up in the ACL: public pages are served directly; protected pages require a session whose groups include at least one of the allowed groups. - Users authenticate with OIDC. The server reads the
groupsclaim of the verified ID token to build the session.
Repository layout
docs/ Typst sources (shiroa book)
book.typ book metadata + chapter list (the sidebar)
template.typ page layout (from the shiroa minimal template)
index.typ home page (public)
thermo/, security/, homelab/ example categories
cmd/parser/ generates acl.json from access-groups tags
cmd/server/ the web server
internal/acl/ ACL loading, page-key normalization, group checks
internal/oidc/ OIDC client (authorization code flow)
internal/middleware/ sessions, login/logout/callback, access gate
dist/ compiled site (generated, embedded at build time)
tools/ pinned shiroa binary (downloaded by `just tools`)
acl.json generated ACL (embedded at build time)
legacy_docs/ the old documentation base (kept for manual migration)
.gitea/workflows/ Gitea Actions CI
Quick start
Prerequisites: Go 1.22+ and just. The
pinned shiroa toolchain is downloaded automatically into tools/ on the
first build (needs curl + tar and network access); nothing is
installed globally.
just # show the available commands
just run # parse ACL, compile the book, build and start the server
Then open http://localhost:8080. The home page and public pages render;
protected pages (/thermo/fluids.html, /security/ssh.html,
/homelab/backup.html in the example book) return 503 until OIDC is
configured.
For pure writing previews without authentication, use just preview-docs.
Writing documentation
The book structure mirrors the URL structure: docs/<path>.typ is compiled
to /<path>.html. To add a page:
-
Create the file, e.g.
docs/security/wireguard.typ, starting with:#import "../template.typ": project #show: project = WireGuard Your content... -
Reference it in
docs/book.typ. A=heading creates a category (part) in the sidebar; each- #chapter(...)is a page:= Security - #chapter("security/ssh.typ", section: "4")[SSH hardening] - #chapter("security/wireguard.typ", section: "5")[WireGuard] -
Only pages listed in
book.typare built. Keepsection:numbers consistent (sub-chapters like"4.1"nest under"4").
To restrict a page, add the tag on the first line of its file:
// Only readable by members of these groups.
#let access-groups = ("family", "admin")
- The groups are compared against the
groupsclaim of the OIDC ID token (in Authentik: your user's group memberships). - A page without the tag is public.
- Everything served for a protected page is protected too: the shiroa
renderer fetches page content from
*.multi.sir.infiles, and those reduce to the same ACL entry as the page itself. - The search index (
searchindex.json) would also contain protected text, so the server never serves it.
Configuration
Environment variables (see .env.example):
| Variable | Default | Meaning |
|---|---|---|
LISTEN_ADDR |
:8080 |
listen address |
SESSION_TTL |
12h |
session lifetime |
COOKIE_SECURE |
true |
Secure cookie flag; use false only on plain-http local tests |
OIDC_ISSUER_URL |
(unset) | e.g. https://auth.example.com/application/o/yggdrasil/ |
OIDC_CLIENT_ID |
(unset) | OAuth2 client id |
OIDC_CLIENT_SECRET |
(unset) | OAuth2 client secret |
OIDC_REDIRECT_URL |
(unset) | e.g. https://docs.example.com/callback |
OIDC_SCOPES |
openid profile email groups |
requested scopes |
Without the OIDC_* variables the server runs in public-only mode:
public pages are served, protected pages answer 503. The server refuses to
start on a malformed acl.json or an unreachable OIDC issuer (fail closed).
Setting up Authentik
- Applications -> Providers -> Create: type OAuth2/OpenID Connect.
- Authorization flow: implicit/explicit consent (default is fine).
- Redirect URI: exactly
https://your-docs-host/callback. - Client type: Confidential; note the client id and secret.
- Scope mappings: make sure the issued ID token contains a
groupsclaim. Recent Authentik versions ship a scope mapping that addsgroups; otherwise create a Property Mapping (Scope) with e.g.return {"groups": [g.name for g in user.ak_groups.all()]}and add it to the provider. - Note the issuer URL shown on the provider page
(
.../application/o/<slug>/) and setOIDC_ISSUER_URLto it. - Create groups in Directory -> Groups (e.g.
family,admin) whose names match theaccess-groupsin your pages, and add users to them.
The redirect after login returns the user to the page they originally
requested. Logout is available at /logout.
Build and CI
Local commands (see Justfile): just build, just run, just parse,
just check, just clean, just preview-docs. Bare just prints the
command overview. The pinned shiroa lives in tools/ (gitignored) and is
downloaded on demand.
.gitea/workflows/build.yml (Gitea Actions, not GitLab CI) runs on push to
main and on pull requests: installs shiroa, regenerates acl.json,
compiles the book, runs go vet/go test, builds the static binary and
uploads it as an artifact.
A rootless Docker/Podman image is available:
just parse build-docs
docker build -t yggdrasil .
docker run --rm -p 8080:8080 --env-file .env yggdrasil
Note that the binary embeds the site at build time: publishing new or edited pages always requires rebuilding and redeploying the server.
Security notes and limitations
- Sessions live in memory: they vanish on restart and do not scale past one instance (users simply log in again).
- Page titles and URLs of protected pages are visible in the sidebar of public pages (that is inherent to a static site). Page content is not: neither the HTML nor the renderer data files can be fetched without authorization.
- Directory listing is enabled (file names only, e.g.
/thermo/). - Logout is a GET request (
/logout), like most documentation sites. - No search is implemented (
searchindex.jsonis explicitly blocked). - Static assets referenced by pages (theme CSS/JS, fonts, renderer wasm) are public; they contain no page content.
Legacy documentation
The previous documentation base lives in legacy_docs/ and is kept only
for reference; migrate pages into docs/ manually, then delete the folder.