* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) `npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory exists" and "the advisory service was unreachable". The Web job ran it before Build / Type check / vitest under `bash -e`, so a registry timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each produced a red row with every frontend verification step SKIPPED — a lane that read like a failure and had asked nothing. scripts/ci-audit.mjs runs the audit in --json mode and decides from the report: metadata.vulnerabilities present → fail iff high+critical > 0, naming the advisories; an error envelope or unparseable output → a GitHub warning annotation saying the gate did not run, exit 0. The step moves to the end of the job so the frontend's own verdict always exists whatever the audit does. Verified locally against five report shapes (transport timeout envelope, E503 envelope, one high advisory, clean, garbage) and two live runs (the real registry: clean; a dead registry: warning, exit 0). `--input <file>` is the seam those checks use. Fixes BUG-2881 * ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title Codex round 1 on #1247: the first draft warned and exited 0 when the advisory service could not be asked, which made the only supply-chain gate pass exactly when it had not run. A gate that passes when it cannot run is not a gate. Now: up to three attempts with backoff (registry blips are usually seconds long), then `::error title=npm audit did not run` and exit 1. The title is distinct from `::error title=npm audit` (a real advisory) so the checks tab tells the two apart without opening the log; re-running is the remedy for the first and never for the second. Because the step runs last, Build / Type check / vitest have already produced their result either way — the original blindness is gone regardless of which way this step fails. Verified against the same five saved shapes (transport and E503 envelopes and garbage now exit 1 under the did-not-run title; a high advisory exits 1 under the advisory title; clean exits 0) and two live runs (real registry: clean; dead registry: three attempts logged, exit 1). Refs BUG-2881 * ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for presence, not for shape: Number("x") + Number(null) > 0 is false, so a malformed count read as a clean audit — a second fail-open, one layer deeper than round 1's. high/critical must now be non-negative integers or the report is unreadable, which is the fail-closed path. (2) The two env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked Atomics.wait forever; both now fall back to the default with a line saying so. Refs BUG-2881 * build: the local preflight runs the same audit gate CI does, and runs it last Codex round 3 on #1247 (blast radius): `make web-check` still chained bare `npm audit && npm run check`, so a registry blip stopped svelte-check locally exactly as it had in CI, and CONTRIBUTING documented the bare command as the way to reproduce the gate. New `web-audit` target runs `npm run audit:ci`; `check` runs it after web-check and web-test, mirroring the Web job's order. CONTRIBUTING and docs/architecture.md say so. Refs BUG-2881 * build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice (`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not list as reaching `npm ci`. `npm audit` reads the lockfile and needs neither node_modules nor a build — verified by running it with node_modules removed — so the prerequisite goes; CLAUDE.md's safe list gains `web-audit`. Refs BUG-2881
9.7 KiB
Architecture
High-level map of the Pad codebase for contributors. CLAUDE.md covers the same ground but is written for AI agents working in the repo — this doc is the human-readable companion.
Shape
Pad ships as one Go binary. The SvelteKit web UI is built into static
assets and embedded into the binary at compile time via go:embed, so a
deployed Pad has exactly one moving piece on the filesystem. SQLite is the
default backing store; PostgreSQL + Redis is the alternate mode for
multi-node production use.
┌────────────┐ ┌────────────┐ ┌───────────────┐
│ CLI (pad) │ │ Web UI │ │ AI agents │
│ │ │ (Svelte 5)│ │ via /pad │
└─────┬──────┘ └──────┬─────┘ └───────┬───────┘
│ HTTP │ HTTP/SSE │ HTTP
▼ ▼ ▼
┌─────────────────────────────┐
│ Pad HTTP server │
│ (internal/server/*.go) │
└─┬────────┬───────────────┬──┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌─────────┐ ┌────────────┐
│ SQLite / │ │ EventBus│ │ Webhooks + │
│ Postgres │ │ (SSE) │ │ Email │
└──────────┘ └─────────┘ └────────────┘
Backend (Go)
cmd/pad/main.go— Cobra CLI entry point. Everypad <command>is registered here. The same binary also serves as the daemon (pad server start) and as the CLI client that talks to it.internal/server/— HTTP router (chi), middleware, handlers, SSE hub.server.gois the main router;handlers_*.gogroup endpoints by resource.internal/store/— SQL abstractions and migrations. Each resource (workspaces, items, users, webhooks, sessions, …) has a<resource>.gofile;migrations/is the golang-migrate style migration sources. Backed by SQLite by default, PostgreSQL whenPAD_DB_DRIVER=postgres.internal/models/— shared Go structs that flow through the API response boundary (Collection,Item,User,View, etc.).internal/items/— field-schema validation: collection schemas declare typed fields (select, text, date, number, …) and this package validates item fields against them.internal/collections/— default collection definitions per template (startup,scrum,hiring,interviewing,product) and workspace bootstrap logic.internal/cli/— HTTP client used by the CLI to talk to the local daemon, plus formatting helpers for terminal output.internal/events/— in-process EventBus that fans SSE updates to connected clients. In Redis mode, a pub/sub bridge replaces the in-memory bus so multiple Pad replicas stay in sync.internal/webhooks/— outbound webhook dispatcher with HMAC signing, retries, and delivery log.internal/email/— transactional email via Maileroo. Used for workspace invitations and password resets; nil-safe if unconfigured.internal/diff/— per-item version history storage + diff rendering.internal/links/— resolver for[[wiki-link]]syntax across items.internal/config/— workspace detection,.pad.tomlparsing, environment-variable loading.
Request flow
- CLI or web UI sends an HTTP request to
/api/v1/…. - Middleware chain in
internal/server/middleware_*.gohandles auth, rate limiting, metrics, audit logging. - The handler in
internal/server/handlers_*.goparses the request, calls one or moreinternal/store/*methods, and writes the JSON response. - If the mutation is observable (item change, comment, etc.), the
handler publishes an event to
internal/eventswhich fans out to connected SSE clients at/api/v1/events.
Two SSE streams, one connection budget
Pad serves two Server-Sent Events endpoints over two separate buses, and the distinction matters for anything touching either:
/api/v1/events |
/api/v1/events/stream |
|
|---|---|---|
| Scope | one workspace | the caller's user, across every workspace |
| Bus | internal/events |
internal/watchevents |
| Carries | activity events (item changed, comment added) | watch notifications and pushes addressed to the caller |
| Consumers | the web UI | pad watch --stream, the agent monitor |
| Auth | a resolved user, a legacy workspace token, or the fresh-install window | a resolved user, always |
They cost the same process resources — a goroutine and a bus subscription
each, plus (with Redis) a session-presence registration for the watch
stream, which is the only one that registers — so they share ONE
admission budget, enforced per instance by
internal/server/stream_admission.go before either subscribes:
PAD_SSE_MAX_CONNECTIONS and PAD_SSE_MAX_PER_USER cover both,
PAD_SSE_MAX_PER_WORKSPACE covers only the workspace-scoped one. A
refusal is 429 sse_limit_exceeded with Retry-After.
With PAD_REDIS_URL set, both buses and the presence registry cross
instance boundaries together, under one key namespace
(internal/redisns). Without it, all three are in-process and a
multi-replica deployment would not work.
Frontend (SvelteKit + Svelte 5)
web/src/routes/— page routes. File-based: a folder corresponds to a URL segment.[username]/[workspace]/...is the main workspace-scoped tree;console/is the server-admin UI.web/src/lib/components/— reusable UI (BottomSheet, FieldEditor, ReactionPicker, NestedChildren, etc.).web/src/lib/stores/— Svelte 5 rune-based stores for cross-route state (current workspace, page title, current user).web/src/lib/api/client.ts— typed HTTP client. Every REST endpoint the backend exposes has a method here; adding an endpoint means adding a client method too.web/src/lib/types/index.ts— mirrorsinternal/models/as TypeScript types.
The web UI is built with npm run build (static adapter) and the
build/ output is embedded into the Go binary via //go:embed in
internal/server/embed.go. npm run dev runs a Vite dev server on
:5173 that proxies API requests to the running Pad daemon on :7777
— fast iteration without rebuilding the binary.
Data model
Workspaces
└── Collections (typed by a JSON schema)
└── Items (structured fields + optional markdown content)
├── parent/child links
├── blocks / blocked-by dependency links
└── comments, reactions, tags
- Collections have a
fieldsJSON schema that declares field keys (e.g.status,priority,due_date) with types and options. - Items have structured
fieldsJSON validated against the collection's schema, plus optional rich Markdowncontent. - Parent/child links power progress tracking and burndown; any item type can be a parent of any item type.
[[wiki-link]]syntax resolves across all items in a workspace and renders as clickable links in the UI.
CLI ↔ daemon model
There is only one binary, pad. Some commands run purely client-side
(pad item show REF), but most go through the daemon:
pad server start— run the daemon foreground (normal dev mode).pad auth configure— first-run credential setup, auto-starts the local daemon on first use.- All other
pad <verb>commands are CLI → HTTP → daemon → SQLite.
The CLI discovers the daemon via ~/.pad/credentials.json (see
internal/cli/client.go). In Remote or Cloud mode, the same client
targets a network-served Pad instance instead of a local daemon.
Agent integration
skills/pad/SKILL.md ships inside the binary and gets installed into
an AI agent's configuration by pad agent install. The skill is
natural-language — it documents the CLI well enough that any
Claude/Cursor/Copilot-style agent can drive Pad via terminal calls.
Testing
- Go:
go test ./...covers the backend;internal/store/tests run against real SQLite by default and against PostgreSQL whenPAD_TEST_POSTGRES_URLis set (seeMakefiletargetstestandtest-pg). - Web:
cd web && npm run buildto catch type / build errors;npm run checkruns svelte-check. - CI:
.github/workflows/ci.ymlruns the full matrix — Go (SQLite + PostgreSQL + race), govulncheck, golangci-lint (new-issues mode), web build, svelte-check, vitest, npm audit (last).
Build and install
See CLAUDE.md for the day-to-day commands (make install is the one
you'll run most). The short version:
make build— build web UI + Go binary to./pad.make install— build, kill running daemon, install to$HOME/.local/bin/pad, restart. Heads-up:make installrunskillall -9 padsystem-wide, so any otherpadprocess on the host (including other users' daemons) gets killed.make dev-web— SvelteKit hot-reload dev server.
Further reading
CLAUDE.md— agent-focused development guide (identical scope, different audience).docs/deployment.md— full environment-variable reference, production deployment shapes.docs/backup.md— backup and restore procedures.SECURITY.md— reporting a vulnerability, threat model, hardening tips.