Petty
Everything you keep, in its place. Petty is a private record of what you keep and where: cash in a tin, tools in the basement, passports in the safe, the drill you lent out. Each drawer sits in a place, holds items — money, counted things or notes — and is shared only with the people you choose. Everything is encrypted on your device before it is sent.
Try it at petty.kropka.studio — nothing to install, it works in your browser. Or run your own.
What it does
- Money, things and notes. Cash in any currency, things counted by the piece, and single items with a note: a passport, a drill, a spare key. Tag items and filter by tag.
- Drawers in places. Home › Bedroom › Wardrobe › Safe. A drawer lives in a place; moving a room moves its drawers. Search finds an item by its name, note or tag.
- Shared, with roles. A drawer is shared only with the people you pick. Writers add entries; readers only look. The server enforces this.
- Count, check, never erase. Mark a drawer as checked after a real count. Mistakes are reversed, not deleted.
- Works with AI apps. Claude Desktop (a one-click add-on), Claude Code, Cursor, VS Code or any
MCP app can read and update your drawers. It decrypts on your computer; the server still sees only
ciphertext. A Claude skill teaches it the good habits (one total per currency, ask before it
writes), and the add-on is listed in the MCP Registry as
io.github.WawRepo/petty. See docs/agent.md. - A command line.
pettysigns in through your browser, likegh auth login, then reads and writes drawers from a terminal, scripts or AI agents, with--jsonand Tab completion. See docs/cli.md. - Passkey first, works offline. Face ID, Touch ID or Windows Hello opens your vault. It installs like an app and keeps working without a network.
- English, Polish, German, Spanish and French — the app, its emails and the sign-in pages.
Made for
- Cash at home — every tin and envelope in its room, counted and checked. Where Petty started.
- The workshop — the zip ties are in Home › Basement › Workshop, in the desk drawer.
- A trip kitty — one drawer per trip, one item per person; nobody can quietly change what they paid.
- Yearly accounts — a drawer per account, checked once a year, the date kept with it.
- Lent out — who has your drill, and since when.
- The family safe — where the passports, keys and papers are, shared read-only with the people who may one day need it.
Documentation
| For | Read |
|---|---|
| Users | this README, the in-app privacy page, docs/agent.md (use Petty from Claude Desktop or another AI app), docs/cli.md (the petty command line) |
| Operators (self-hosting) | docs/deploy.md · docs/monitoring.md · docs/auth-clerk.md |
| Contributors | CONTRIBUTING.md · docs/decisions.md · CLAUDE.md — the engineering rules; plain text, no AI tool needed to read or follow them |
| Security reviewers | SECURITY.md · docs/threat-model.md · docs/security-review-2026-09.md · docs/README.md (how keys and tokens work, with diagrams) |
| History, not maintained | petty-app-spec.md (frozen 2026-09-25), petty-spec-review.md, SPEC-ISSUES.md — the original specification, its review and the reasoning behind the first decisions · petty.html — the pre-rewrite prototype |
Ticket IDs such as PETTY-123 in docs and commit messages refer to the maintainers' internal tracker.
The public trail for changes is this repository's issues, pull requests and CHANGELOG.md.
Security in five lines
- Drawer content is encrypted in the browser (AES-256-GCM). The server stores that content only as ciphertext — it still sees account details (email, display name) and activity metadata (who wrote which entry, when); the in-app privacy page lists exactly what.
- Each drawer has its own key, wrapped for each member with ECDH P-256. Your private keys never leave your device unencrypted.
- Your keys sit in a vault opened by a passkey or a passphrase (Argon2id), with a recovery code.
- Every ciphertext is bound to its row, drawer, line and author, and entries form a signed hash chain, so a server cannot move, re-attribute or silently drop them.
- Permissions and append-only history are enforced by the server and the database, not by the UI.
The threat model is docs/threat-model.md; the settled design decisions are docs/decisions.md. The latest security review is
docs/security-review-2026-09.md. Report problems privately as described in SECURITY.md.
Run your own
cp deploy/compose/.env.example deploy/compose/.env # fill in the values
docker compose -f deploy/compose/docker-compose.yml --env-file deploy/compose/.env up -d
Make the database passwords letters and digits only (openssl rand -hex 32). Then put an HTTPS
reverse proxy in front of 127.0.0.1:3000. docs/deploy.md has the details,
and docs/monitoring.md covers metrics, logs and traces.
License
Copyright (C) 2026 Petty contributors. Petty is free software under the GNU Affero General Public
License v3.0 (LICENSE, and NOTICE). If you run a changed version for other people, you must
offer them its source. Contributions are welcome; see CONTRIBUTING.md and CODE_OF_CONDUCT.md.
Development setup
This part covers running Petty locally. Nothing here needs a cloud account or network access
after pnpm install.
Try it with a seeded household (Docker only)
make demo # pulls the published image (PETTY_TAG=<tag> for another), migrates, seeds two people and six drawers
make demo-local # same, but builds the image from your working tree (no CI, no registry)
make demo-down # stop it
make demo-reset # stop it and drop the data (shares the local dev database volume)
Open http://localhost:3300 (mail at http://localhost:8025). Logins are printed by the
seed: [email protected] / password-ania / vault passphrase vault ania 2026 drawer;
[email protected] / password-bartek / vault bartek 2026 drawer.
Settings: one .env at the root
Copy .env.example to .env (gitignored). The API dev server, the tests, Playwright and
docker compose all read that one file; the web app needs none (it asks the API). Anything
already in the environment wins over the file. The production image carries no file: a
deployment passes the same names as environment variables or secrets.
Requirements (clean machine)
- Docker Desktop (or any Docker with Compose v2)
- Node 24 (LTS;
.node-versionsays 24 — the image and CI use it too) - pnpm 11:
corepack enable && corepack prepare [email protected] --activate(the version inpackage.json) - GNU make (preinstalled on macOS;
apt install makeon Debian/Ubuntu)
First run
pnpm install
make dev
make dev starts Postgres 16 and Mailpit in Docker, waits for the database, runs the
migrations, then starts the API on http://127.0.0.1:3000 and the web app on
http://localhost:5173. Stop everything with Ctrl-C and make stop.
Then, in a second terminal:
make seed # three test users
curl localhost:3000/health # {"ok":true,"db":"up"}
Test users
| Login password | Vault passphrase (from Phase 2 on) | |
|---|---|---|
| [email protected] | password-alice | vault alice 2026 drawer |
| [email protected] | password-bob | vault bob 2026 drawer |
| [email protected] | password-carol | vault carol 2026 drawer |
Mailpit catches every email the app sends (join links, invitations, removals, ownership
offers): http://localhost:8025. Set SMTP_HOST/SMTP_PORT/MAIL_FROM/APP_URL for a real provider.
Commands
| Command | Does |
|---|---|
make dev | db + mailpit + migrate + api + web |
make seed | three users + a shared "Kitchen" drawer with real ciphertext (skips if alice exists; make reset first to reseed) |
make test | every vitest suite (API tests need the db) |
make lint | typecheck + eslint |
make reset | drop the database volume, migrate again |
pnpm --filter @petty/api migrate:create <name> | new empty SQL migration |
Offline
The web app is an installable PWA. The service worker caches the app shell only; API responses are never HTTP-cached. Ciphertext (vault blob, bootstrap) and non-extractable key handles live in IndexedDB, so unlock and reading work offline. Entries and document edits made offline wait in an encrypted outbox and are sent on reconnect (idempotent by client id); the home screen then reports what changed while you were away and which queued changes were refused.
pnpm e2e runs two Playwright projects: dev (Vite dev server) and prod
(the API serving the production build on :3100 under the strict security headers;
fails on any CSP violation; service worker active). A third, clerk-prod, runs the Clerk sign-in
against a Clerk development instance, only with CLERK_E2E=1 and its keys.
Layout
apps/api Fastify API. All writes go through here. Connects as petty_api;
petty_maint only inside rotation and deletion.
apps/web Vite + React SPA. Holds keys in memory; never writes plaintext to disk.
packages/crypto Envelope encryption, AAD binding, signatures, compatibility corpus.
packages/ledger Pure domain: balance fold, chain verification, money formatting,
line extras (icon, tags, counted) and place paths.
packages/protocol zod schemas shared by api and web.
packages/agent The drawer tools shared by the MCP server and the command line.
apps/mcp The MCP server for AI apps (petty-mcp.mjs, the petty.mcpb add-on).
apps/cli petty, the command line (petty.mjs).
apps/api/migrations Version-controlled SQL migrations (node-pg-migrate).
tests/integration The production image under deploy/compose, over real HTTP.
deploy/compose The production compose file; deploy/backup the pg_dump + age image.
docker/postgres/init.sql Dev-only: creates petty_api / petty_maint with LOGIN.
Places, icons and tags
A drawer sits in one place of a tree (Home › Kitchen › shelf): the path of
names is stored in the drawer's encrypted document, the tree itself in the
person's encrypted user document (apps/web/src/lib/places.ts, edited on
/places). Renaming or moving a place rewrites the path on the drawers you can
write. Drawers and lines carry an optional Lucide icon slug; lines carry tags
and a "part of the total" flag, all inside the document. Details and the
reasoning: docs/decisions.md (rows Places, Icons, Line tags); background in the frozen petty-app-spec.md, section "Places, icons and line tags".
API (apps/api)
All bodies are JSON; bytes travel as base64; the server never opens ciphertext.
Session = httpOnly cookie petty_session (__Host-petty_session over HTTPS, as in the image).
In the image every route sits under /api (/api/me). Access tokens and Clerk session tokens come
as Authorization: Bearer …. Non-members get 404, never 403.
| Route | Who | Does |
|---|---|---|
GET /config GET /health GET /health/live | – | sign-in mode, contact address, open sign-up, version; health with the database; the process is up |
POST /auth/signup POST /auth/login POST /auth/logout | – | invite-only signup with vault blobs and public keys; login; logout |
POST /auth/forgot POST /auth/reset | – | local mode: a one-hour reset link by email; a new login password (the vault is untouched) |
POST /auth/provision | Clerk session | Clerk mode: the first sign-in creates the Petty account with its vault blobs |
POST /join-links GET /join-links/:token | user | create / inspect a join link (7 days) |
GET /me PATCH /me GET /users/lookup?email= GET /users/:id/keys | user | profile, locale, exact-email lookup for inviting, key history |
GET /me/doc PUT /me/doc | user | the user's own encrypted document (key pins), version-checked |
GET /bootstrap | user | all drawers, documents, my wraps, members, entries since each line's last Adjust, invitations, transfers |
POST /drawers GET /drawers/:id DELETE /drawers/:id | user / read / owner | create; read; permanent delete via delete_drawer() |
PUT /drawers/:id/document | write | optimistic lock on base_version → 409 VersionConflict; verification: true stamps last_verified_at |
GET/PUT/DELETE /drawers/:id/photo | read / write | own encrypted row |
POST /drawers/:id/lines/:lineId/delete | write | new document + entries removed via delete_line() |
POST /drawers/:id/entries | write | append; idempotent on client id; Adjust needs expected_head_seq → 409 RecountRequired; Reverse rules → 409 ReverseRefused |
GET /drawers/:id/lines/:lineId/entries?before=&limit= | read | older pages |
GET /drawers/:id/members POST /drawers/:id/invitations GET /invitations `POST /invitations/:id/accept | decline DELETE /invitations/:id` | read / owner / invitee |
DELETE /drawers/:id/members/:userId POST /drawers/:id/leave | owner / member | removes wraps, sets rotation_needed |
POST /drawers/:id/transfer POST /drawers/:id/transfer/accept DELETE /drawers/:id/transfer | owner / target | ownership moves only on acceptance |
POST /drawers/:id/rotation PUT /drawers/:id/rotation/batch GET /drawers/:id/rotation | write | publish new key + wraps first; re-seal in batches (petty_maint); status |
GET /drawers/:id/export | owner, write | everything for the drawer; read members get 403 |
GET /me/delete POST /me/delete | user | preview; delete with per-drawer decisions (hand over / delete), password re-entered |
GET /me/recovery-vault PUT /me/recovery-vault PUT /me/vault | user | recovery-code copy; a new recovery code; replace the passphrase wrap of the same keys |
POST /me/custody-challenge | user | a fresh challenge for a custody proof (docs/threat-model.md) |
POST /me/passkeys DELETE /me/passkeys/:id | user | add / remove a passkey that opens the vault |
GET /me/storage | user | storage used, against STORAGE_QUOTA_MB |
GET /drawers/:id/document/history POST /drawers/:id/document/restore | owner | the drawer document's 30-day history; restore an earlier version |
GET /me/tokens POST /me/tokens DELETE /me/tokens/:id GET/POST /me/tokens/:id/keys | user | access tokens: list, make (with a custody proof), revoke, their key bundle |
GET /me/token GET /me/token/bootstrap GET /me/token/keys DELETE /me/token | token | a token about itself, its drawers and its key bundle; a token ends itself |
GET /users/:id/delegations | user | the tokens a person's key vouched for, to check their signatures |
POST /device/code POST /device/token | – | device login: a tool asks for a code, then polls for its sealed token |
GET /device/:code POST /device/:code/approve POST /device/:code/deny | user | the /device page (signed in, never a token): see the request, allow it (the page seals a new token) or deny it |
GET /admin/users POST /admin/users/:id/block /unblock /revoke-sessions /restore-vault /admin | admin | accounts: list, block, unblock, end sessions, restore a vault from its history, make or remove an admin |
Database roles
| Role | Used by | May |
|---|---|---|
petty | migrations, seed | everything (owner) |
petty_api | every API request | SELECT/INSERT on entries; no UPDATE/DELETE there |
petty_maint | rotation, line/drawer/account deletion | column-restricted UPDATE, DELETE via functions |
Connection strings live in .env.example; copy to .env to override.
Troubleshooting
pnpm installsays "Ignored build scripts: esbuild" →pnpm-workspace.yamlalready allows it; runpnpm installagain.- Port 5432 busy → stop the local Postgres (
brew services stop postgresql) or change the port indocker-compose.ymland.env.