Development guide
Work on PurrOS itself: local setup, repository layout, the rules every endpoint follows, and adding a feature module.
How to work on PurrOS itself. Read DESIGN.md first for the architecture and the reasons behind it.
PurrOS is a monorepo:
| Directory | What | Language |
|---|---|---|
api/ | The API server, background worker and CLI: the purros binary | Go |
web/ | The web app: dashboard, Employee Area, kiosk, team displays (planned) | Next.js, TypeScript, Tailwind CSS |
packages/sdk/ | @purros/sdk, the typed API client (planned) | TypeScript |
docs/ | This documentation | Markdown |
config/ | Deployment configuration: purros.env and postgres.env (git-ignored; only the *.example files are committed) | dotenv |
state/ | Runtime state for local runs: uploaded files and backups (git-ignored) |
API: local setup
Requirements: Go 1.26+ and PostgreSQL 16 (Docker is the easiest way to get one).
git clone https://github.com/selectdev/PurrOS.git
cd PurrOS
docker compose -f docker-compose.dev.yml up -d # PostgreSQL on localhost:5432 (user/password: purros)
cd api
export DATABASE_URL="postgres://purros:purros@localhost:5432/purros?sslmode=disable"
export PURROS_SECRET="$(openssl rand -base64 32)"
export PURROS_URL="http://localhost:8080"
export PURROS_STATE_DIR=../state # uploaded files and backups go to the repo's state/ (git-ignored)
go run ./cmd/purros setup --company "Dev Co" --owner-email [email protected] --timezone America/Chicago
go run ./cmd/purros locations create --name "Store 101" --external-id 101 --timezone America/Chicago --cutoff 04:00
go run ./cmd/purros integrations register --manifest ../examples/dev-manifest.json # prints an API key: export it as KEY
go run ./cmd/purros serve # http://localhost:8080examples/dev-manifest.json is a minimal manifest for local work:
{
"name": "dev",
"scopes": ["organization:read", "people:read", "people:write", "time:read", "time:write",
"sales:read", "sales:write", "inventory:read", "inventory:write"]
}Try it:
curl -s localhost:8080/api/v1/me -H "Authorization: Bearer $KEY"
curl -s localhost:8080/api/v1/openapi.json | headCommands
Run these in api/:
| Command | What it does |
|---|---|
go run ./cmd/purros serve | Run the API and worker |
go run ./cmd/purros help | List all CLI commands |
gofmt -l . | List badly formatted files (should print nothing) |
go vet ./... | Static checks |
go test ./... | Unit tests |
PURROS_TEST_DATABASE_URL=postgres://purros:purros@localhost:5432/postgres?sslmode=disable go test -race ./... | Unit and API integration tests (each test creates and drops its own database) |
go build -o purros ./cmd/purros | Build the binary |
Before opening a pull request, gofmt -l . must print nothing and go vet and the full test run (with PURROS_TEST_DATABASE_URL) must pass. CI runs the same checks.
API code layout
api/
cmd/purros/ main: hands off to internal/cli
internal/
cli/ every `purros` subcommand (see docs/operations/cli.md)
config/ environment variables, config and state directories
db/ connection pool, transactions, advisory locks, migrations/ (SQL, embedded)
httpx/ the HTTP framework: router, auth, reach checks, errors, validation,
pagination, idempotency, rate limits, batch helpers, OpenAPI generation
features/ feature registry and switches
catalog/ permissions, scopes and webhook events (each tied to a feature),
and the route → permission and reach tables
events/ audit log and transactional outbox
webhooks/ outbox dispatcher and signed delivery worker
crud/ generic list/get/create/update/archive resources
ingest/, refs/ batch ingestion; references by ID, SKU, barcode or external ID
auth/, secure/ Argon2id and TOTP; encryption and signing with PURROS_SECRET
mail/, storage/ email queue and SMTP; local and S3 file storage
backup/, pdf/, ids/ backups and restore; invoice PDFs; prefixed IDs
modules/<area>/ one package per area, exporting Routes()
server/ wiring, health checks, API integration tests
testutil/ fresh-database test harnessRuntime files stay out of the source tree: configuration lives in config/ and uploaded files and backups in state/ (both git-ignored). With the exports above, local runs write to the repository's state/.
Rules to follow
- Declare, don't hand-wire. Every endpoint is a
httpx.Routewith itsFeature,Scope,Body/Responsetypes andHandler. The router enforces auth, the feature switch, the scope and rate limits, and generates OpenAPI from the same declaration. - Every feature is optional. Give routes the right
Feature. For sub-features checked inside a handler, callc.RequireFeature("time.kiosk"). Never assume another feature is on. - Record every change. Inside the transaction that makes a change, call
c.Record(tx, httpx.Change{...}). That writes the audit entry and the webhook event together. - Batch endpoints never fail the whole batch for one bad record. Validate each record with
httpx.ValidateItem, run it inhttpx.Savepoint, and return anhttpx.BatchResult. - Idempotent ingestion. Ingested records are unique on
(source, external_id), and re-sending updates them. - No breaking API changes in v1. Add fields and endpoints, don't rename or remove them. Clients must ignore unknown fields.
- Money and quantities are decimals (
decimal.Decimal,numericcolumns, strings in JSON), never floats. - Nothing vendor-specific in core. Connections to specific products belong in integrations.
- Errors are Problems. Return
httpx.Validation(...),httpx.NotFound(...)and similar. Any other error becomes a logged500with a request ID.
Adding a feature module
- Add the feature (and any sub-features) to
internal/features/features.go, and its permissions, scopes and events tointernal/catalog/catalog.go. TheTestCatalogsReferenceRealFeaturestest keeps these consistent. - Add a migration in
internal/db/migrations/(000NN_name.sqlwith-- +goose Up/-- +goose Down). Follow the conventions: prefixed text IDs,external_idwhere syncable,created_at/updated_at,numericfor money and quantities, and aversioncolumn on mutable records. - Create
internal/modules/<feature>/with the types, SQL andRoutes(), and register it ininternal/server/server.go. - Map every new route to a permission in
internal/catalog/routes.go(and, for reach-limited permissions, how to find its location or employee ininternal/catalog/reach.go). The server refuses to start while a route is unmapped. - Add a prefix to
internal/idsfor new entity types. - Write integration tests in
internal/server/usingtestutil.New. Include a test showing the feature is unreachable when disabled. - Document it: the guide in
docs/guides/, endpoints indocs/api/endpoints.md, and events indocs/api/webhooks.md.
Documentation
Docs live in docs/ as Markdown. Update them in the same pull request as the change. Write for the reader of each section (employees, managers, admins or integrators), use plain language, and mark planned behavior as planned.
Commit and PR conventions
- Small, focused pull requests that explain what changed and why.
- Link the issue being fixed.
- Include screenshots for UI changes (once
web/exists). - Contributions are accepted under the project's AGPL-3.0 license.