mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-18 08:35:22 +00:00
a1716d8170
* 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
205 lines
9.7 KiB
Markdown
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.
|