Files
pad/docs/architecture.md
T
xarmian a1716d8170 ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* 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
2026-09-04 10:45:21 -04:00

205 lines
9.7 KiB
Markdown

# 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. Every `pad <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.go` is the main router; `handlers_*.go` group endpoints
by resource.
- **`internal/store/`** — SQL abstractions and migrations. Each resource
(workspaces, items, users, webhooks, sessions, …) has a `<resource>.go`
file; `migrations/` is the golang-migrate style migration sources.
Backed by SQLite by default, PostgreSQL when
`PAD_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.toml` parsing,
environment-variable loading.
### Request flow
1. CLI or web UI sends an HTTP request to `/api/v1/…`.
2. Middleware chain in `internal/server/middleware_*.go` handles auth,
rate limiting, metrics, audit logging.
3. The handler in `internal/server/handlers_*.go` parses the request,
calls one or more `internal/store/*` methods, and writes the JSON
response.
4. If the mutation is observable (item change, comment, etc.), the
handler publishes an event to `internal/events` which 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`** — mirrors `internal/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 `fields` JSON schema that declares field keys
(e.g. `status`, `priority`, `due_date`) with types and options.
- **Items** have structured `fields` JSON validated against the
collection's schema, plus optional rich Markdown `content`.
- **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 when
`PAD_TEST_POSTGRES_URL` is set (see `Makefile` targets `test` and
`test-pg`).
- **Web:** `cd web && npm run build` to catch type / build errors;
`npm run check` runs svelte-check.
- **CI:** `.github/workflows/ci.yml` runs 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 install` runs
`killall -9 pad` system-wide, so any other `pad` process on the host
(including other users' daemons) gets killed.
- `make dev-web` — SvelteKit hot-reload dev server.
## Further reading
- [`CLAUDE.md`](../CLAUDE.md) — agent-focused development guide
(identical scope, different audience).
- [`docs/deployment.md`](deployment.md) — full environment-variable
reference, production deployment shapes.
- [`docs/backup.md`](backup.md) — backup and restore procedures.
- [`SECURITY.md`](../SECURITY.md) — reporting a vulnerability, threat
model, hardening tips.