- Go 46.4%
- JavaScript 30.7%
- Shell 16.4%
- CSS 4.3%
- HTML 2.2%
|
|
||
|---|---|---|
| .github | ||
| backend | ||
| config/instances | ||
| docs/superpowers | ||
| frontend | ||
| infrastructure | ||
| scripts | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
Passbook
A simple, secure budget-tracker app. One codebase, multiple independent deployments (e.g., a child's allowance, a household eat-out budget). Each deployment has its own data, login PIN, branding, and URL — but shares CI and code.
Live apps:
- Kids: https://vppillai.github.io/passbook/kids/
- Eat-Out: https://vppillai.github.io/passbook/eatout/
Features
- Multi-instance deployment from a single codebase
- Per-instance PWA (name, icon, theme color, start URL)
- Per-instance UI color theming via CSS variables
- Per-instance UI labels (e.g., "Allowance" vs "Budget")
- Dynamic CI matrix: drop a YAML file to add a new instance
- Monthly budget tracking (configurable amount per instance)
- Expense tracking with descriptions (add, edit, delete)
- Running balance calculation (monthly and total)
- Monthly history view with pagination
- Month management from the UI (create months, add funds, delete an empty month)
- PIN-protected access (4-6 digits)
- Optional biometric unlock (Face ID / Touch ID / Windows Hello), toggleable from the menu
- Mobile-first responsive design with bottom-sheet dialogs
- Automatic dark mode (follows system preference)
- Carried-over balance surfaced as its own dashboard chip (deficits stay visible)
- Automatic session expiry (24h)
Architecture
GitHub Pages
┌──────────────────────────────────┐
│ /passbook/ (landing page) │
│ /passbook/kids/ (kids SPA) │
│ /passbook/eatout/ (eatout SPA) │
└──────────┬───────────┬───────────┘
│ │ HTTPS
┌──────────────▼─┐ ┌─────▼───────────┐
│ API Gateway │ │ API Gateway │
│ passbook-kids- │ │ passbook-eatout-│
│ prod │ │ prod │
└────────┬───────┘ └────────┬────────┘
│ AWS_PROXY │
┌────────▼───────┐ ┌───────▼────────┐
│ Lambda (Go) │ │ Lambda (Go) │
│ ARM64, 256MB │ │ ARM64, 256MB │
└────────┬───────┘ └───────┬────────┘
│ │
┌────────▼───────┐ ┌───────▼────────┐
│ DynamoDB │ │ DynamoDB │
│ passbook-kids- │ │ passbook-eatout│
│ prod │ │ -prod │
└────────────────┘ └────────────────┘
Shared infrastructure (deployed once via bootstrap.yaml):
• S3 bucket — Lambda deployment artifacts
• IAM role — passbook-github-actions (assumed via OIDC)
• OIDC provider — trusts environment:production only
Data Model (Single-Table Design)
| PK | SK | Purpose |
|---|---|---|
CONFIG |
CONFIG |
PIN hash (Argon2id), settings |
BALANCE |
BALANCE |
Total accumulated balance |
MONTH#2026-02 |
SUMMARY |
Month starting/ending balance, totals |
MONTH#2026-02 |
EXP#<ts>#<id> |
Individual expense |
SESSION#<token> |
SESSION#<token> |
Auth session (24h TTL) |
RATELIMIT#<ip> |
RATELIMIT |
Failed PIN attempts for one source IP (15m TTL) |
RATELIMIT#@global |
RATELIMIT |
Account-wide failed-PIN counter (15m TTL). @ cannot occur in an API Gateway source IP, so it cannot collide with a real one |
MONTHLIST |
<yyyy-mm> |
Mirror of each month's summary, in one partition. Lets "which months exist / come after this one?" be a sorted Query instead of a full-table Scan |
WACHAL#<challenge_id> |
WACHAL#<challenge_id> |
In-flight WebAuthn ceremony session (short TTL, single use) |
WACRED#<cred_id> |
WACRED#<cred_id> |
Enrolled WebAuthn credential (public key + sign count) |
WACREDLIST |
WACRED#<cred_id> |
Enumeration partition for the credentials above |
API Endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/health |
No | Health check |
| GET | /api/auth/status |
No | Check if PIN is configured |
| POST | /api/auth/setup |
No | First-time PIN setup |
| POST | /api/auth/verify |
No | Verify PIN, receive session token |
| POST | /api/auth/change |
Yes | Change PIN (requires current PIN; rate limited on the same budget as /api/auth/verify, 429 when exhausted) |
| POST | /api/auth/logout |
Yes | Invalidate session |
| POST | /api/auth/webauthn/login/options |
No | Begin biometric unlock — returns the assertion challenge |
| POST | /api/auth/webauthn/login |
No | Finish biometric unlock — verifies the assertion, returns a session token (same body as PIN verify) |
| POST | /api/auth/webauthn/register/options |
Yes | Begin biometric enrollment — returns the attestation challenge |
| POST | /api/auth/webauthn/register |
Yes | Finish biometric enrollment — stores the credential |
| DELETE | /api/auth/webauthn |
Yes | Disable biometric unlock (removes every enrolled credential) |
| GET | /api/balance |
Yes | Get total balance |
| GET | /api/months?limit=50&cursor= |
Yes | List months with balances (paginated) |
| GET | /api/month/{yyyy-mm}?limit=50&cursor= |
Yes | Get month summary + expenses (paginated) |
| POST | /api/month |
Yes | Create a new month with allowance |
| POST | /api/month/{yyyy-mm}/funds |
Yes | Add funds to an existing month |
| DELETE | /api/month/{yyyy-mm} |
Yes | Delete an empty month (409 if it still has expenses; reverses its allowance) |
| POST | /api/expense |
Yes | Add new expense |
| PUT | /api/expense/{month}/{id} |
Yes | Edit expense amount and/or description |
| DELETE | /api/expense/{month}/{id} |
Yes | Delete expense (refunds balance) |
The two webauthn/login* endpoints answer 401 for a failed assertion, which
means "biometric unlock failed", not "your session is dead". They are therefore
listed in AUTH_ENDPOINTS in frontend/js/api.js, which suppresses the
automatic session-clear a 401 normally triggers. The three session-gated
WebAuthn endpoints are deliberately NOT on that list: a 401 from those really
does mean the session expired.
Deleting a month returns 409 in two distinct cases — it still has expenses, or its allowance changed between the read and the delete (the client should refresh and retry). The messages differ so the second is not reported as the first.
Multi-Instance
Each deployment ("instance") is fully isolated — its own DynamoDB table, Lambda function, API Gateway, and frontend subpath. Instances share: codebase, CloudFormation template, CI workflows, bootstrap stack, and S3 deployment bucket.
How per-instance customization works
Each instance's config/instances/<name>.yaml drives three layers of customization:
| Block | Drives | Mechanism |
|---|---|---|
pwa: |
PWA install (name, icon, theme color, start URL) | CI writes build/<instance>/manifest.json; meta tags rewritten in index.html |
colors: |
In-app accent / background colors | CI writes build/<instance>/css/theme.css (linked after styles.css); cascade overrides CSS custom properties |
labels: |
Divergent UI strings ("Allowance" vs "Budget") | CI bakes window.PASSBOOK_LABELS into build/<instance>/js/config.js; applyLabels() runs at page init |
format: |
Currency and locale for every rendered amount and time | CI bakes window.PASSBOOK_FORMAT into the same config.js; ui.formatCurrency/formatTime read it (default en-US / USD) |
webauthn_display_name: |
Name the OS shows in the Face ID / Touch ID / Windows Hello prompt | Passed to CloudFormation as WebAuthnDisplayName → the Lambda's WEBAUTHN_RP_DISPLAY_NAME (falls back to display_name, then the instance name) |
allow_overspending: |
Whether a balance may go negative (default false) |
Passed to CloudFormation as AllowOverspending → the Lambda's ALLOW_OVERSPENDING. When false the server refuses any write that would take a balance below zero — across the whole carry chain, not just the month being written, so a back-dated expense cannot push a later month negative. Also gates whether CI emits --negative-color into theme.css |
carry_over_balance: |
Whether a month's ending balance becomes the next month's starting balance (default true) |
Passed to CloudFormation as CarryOverBalance → the Lambda's CARRY_OVER_BALANCE. With it on, editing any month ripples through every later month's starting/ending balance; with it off each month stands alone and starts from zero |
Every key in frontend/js/labels.js can be overridden by listing it under
labels:; anything omitted falls back to the default English string, so a
partial labels: block is fine.
The CSS theming is a separate stylesheet (not an inline <style> block) because the app's Content-Security-Policy uses style-src 'self', which blocks inline styles. External same-origin stylesheets are allowed.
To customize the PWA icon for an instance, drop a square SVG at frontend/assets/icons/<name>.svg. If absent, the instance uses the default frontend/assets/icon.svg.
Adding a new instance
- Create
config/instances/<name>.yaml. Minimum required fields:
Optional blocks:name: <name> display_name: Human Readable Name monthly_amount: 200 pwa: name: App Display Name short_name: ShortName description: Brief description theme_color: "#5B7FD9" background_color: "#F5F7FB" colors: primary: "#5B7FD9" primary_dark: "#4263B3" background: "#F5F7FB" labels: app_title: My App # ... see config/instances/kids.yaml for the full label setallow_overspending: true # defaults to false (balances cannot go negative) carry_over_balance: false # defaults to true (surplus/deficit rolls forward) format: # defaults to en-US / USD locale: en-GB currency: GBP webauthn_display_name: My App # defaults to display_name colors: negative: "#991B1B" # only emitted when allow_overspending is true - (Optional) Add a custom PWA icon at
frontend/assets/icons/<name>.svg. If not present, the instance uses the defaultfrontend/assets/icon.svg. - Commit and push to
main. - CI discovers the file and deploys the
passbook-<name>-prodstack, then publishes the frontend athttps://<owner>.github.io/<repo>/<name>/.
No other code changes are required — the workflow's dynamic matrix expands automatically.
Backend-then-frontend ordering (new-instance race fix): a single push that
adds config/instances/<name>.yaml triggers both deploy workflows in parallel.
The frontend build now fails fast if an instance has no resolvable
ApiEndpoint (rather than silently baking an empty API URL), because on that
first push the new backend stack doesn't exist yet. Once "Deploy Backend to AWS"
finishes successfully, a workflow_run trigger re-runs the frontend workflow
automatically — this time the endpoint resolves and the build publishes. So the
first frontend run for a brand-new instance is expected to fail; the automatic
re-run is the one that ships it. No manual action is needed.
Security Review
This app is hosted in a public GitHub repository. Below is a comprehensive security analysis.
What's Public vs. Private
| Data | Location | Visibility |
|---|---|---|
| Source code | GitHub | Public |
| Infrastructure templates | GitHub | Public |
| PIN hash | DynamoDB | Private (AWS account only) |
| Session tokens | DynamoDB | Private |
| Expense data | DynamoDB | Private |
| AWS credentials | GitHub OIDC | Never stored |
Authentication Security
| Control | Implementation | Notes |
|---|---|---|
| PIN Hashing | Argon2id (64 MiB, 1 iteration, 1 thread) | Memory-hard, resistant to GPU attacks. Existing hashes carry their own parameters and are upgraded in place on the next successful unlock |
| Salt | 16 bytes random per PIN | Unique salt prevents rainbow tables |
| Session Tokens | UUID v4 (122 bits of randomness) | Cryptographically secure |
| Session Storage | Server-side in DynamoDB; client token in sessionStorage |
Token is cleared when the tab closes (matches the "Lock" UX) |
| Session Expiry | 24-hour TTL | Auto-deleted by DynamoDB |
Brute Force Protection
| Control | Value | Purpose |
|---|---|---|
| Per-IP attempt limit | 5 failed attempts per 15-minute window | Scoped per source IP so one attacker cannot lock the household out |
| Account-wide attempt limit | 50 failed attempts per 15-minute window | Bounds distributed guessing, which the per-IP limit does nothing about |
| Counter storage | DynamoDB row with 15-minute TTL | Auto-expires, no manual cleanup |
| Argon2id cost | 64 MiB / 1 iteration | Each verification is deliberately slow. Memory is the dimension that resists GPUs — it caps how many guesses fit on a card — so the parameters favour memory over iterations at equal total work |
| API rate limit | 5 req/sec, 10 burst | API Gateway level |
Why both limits. A 4-digit PIN is 10,000 combinations. With only a per-IP cap, 5 free guesses per address means roughly 2,000 addresses exhaust the entire keyspace inside one window, leaving Argon2's cost as the only obstacle. The account-wide counter bounds total wrong guesses no matter how many addresses are used.
The account-wide limit carries a deliberate trade-off: to prevent a guess being evaluated, the counter is checked before the hash comparison, so while it is tripped the correct PIN is refused too — an attacker can deny PIN login for up to 15 minutes. Two things bound that cost:
- 50 is far above believable legitimate use, so it is only reached under attack.
- Biometric unlock is exempt, because a WebAuthn credential is not guessable. An enrolled user keeps a way in during an attack.
- The counter's increment is conditional on being below the cap, so once tripped the row's TTL stops being refreshed. A sustained attack therefore cannot extend the window beyond 15 minutes from the attempt that reached it.
If you want a larger keyspace, use a 6-digit PIN — the app accepts 4-6.
Network Security
| Control | Implementation | Purpose |
|---|---|---|
| CORS | Access-Control-Allow-Origin: https://vppillai.github.io |
Only allow requests from app |
| Origin / Referer enforcement | At least one of Origin or Referer must match the allowed origin (browser-style requests). Requests with neither header are rejected. |
Blocks direct API access from non-browser clients |
| HTTPS | Enforced by API Gateway + GitHub Pages | Encryption in transit |
| Security headers | X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Cache-Control: no-store |
MIME sniffing, clickjacking, referrer, caching protection |
| Content Security Policy | CSP meta tag: default-src 'none' with minimal allowances |
Restricts resource loading to same origin |
| Request body limit | 32 KB max in Lambda handler | Prevents oversized payload abuse |
Data Protection
| Control | Implementation |
|---|---|
| Encryption at rest | DynamoDB SSE (AES-256) |
| Encryption in transit | TLS 1.2+ (API Gateway) |
| No PII in logs | Lambda doesn't log sensitive data |
| PIN never stored | Only Argon2id hash |
Infrastructure Security
| Control | Implementation |
|---|---|
| No stored credentials | GitHub OIDC for AWS access |
| OIDC scope | Trust restricted to environment:production — only production deployments can assume role |
| Least privilege | Lambda role limited to specific table; CI role scoped to passbook-* resources |
| Reserved concurrency | Lambda capped at 5 concurrent executions |
| Private S3 | Lambda artifacts not publicly accessible |
| Resource isolation | All resources prefixed passbook-* |
Potential Attack Vectors & Mitigations
| Vector | Risk | Mitigation |
|---|---|---|
| PIN brute force | Low | Per-IP limit (5 / 15 min), account-wide limit (50 / 15 min) bounding distributed guessing, Argon2id slowness |
| Session hijacking | Low | HTTPS only, short TTL, no persistent storage |
| XSS | Low | No user-generated HTML, minimal DOM manipulation |
| CSRF | Low | Origin validation, no cookies used |
| Code injection | Low | Parameterized DynamoDB queries |
| Direct API access | None | Requires matching Origin or Referer header; non-browser clients with neither are rejected |
| Credential exposure | None | No credentials in code, OIDC used |
Security Recommendations
- Keep PIN private - The 4-6 digit PIN is the primary authentication
- Use unique PIN - Don't reuse PINs from other services
- Monitor CloudWatch - Review logs periodically for anomalies
- Update dependencies - Keep Go modules updated for security patches
Deployment
Quick start (deploy your own)
Forking your own copy? The whole setup is one script. Manual prerequisites:
- Fork this repository to your own GitHub account/org.
- Log in to AWS with admin-capable credentials so
aws sts get-caller-identityworks (aws configureoraws sso login). - Log in to GitHub CLI:
gh auth login. - Run the setup script from inside your clone:
It deploys the bootstrap stack (parameterized to your GitHub owner/repo), sets the./scripts/setup.sh # prompts for region (default us-west-2) ./scripts/setup.sh --region us-east-1 # or pass it directly ./scripts/setup.sh --dry-run # preview every action, change nothingAWS_ACCOUNT_IDrepo secret, creates theproductionenvironment (required by the OIDC trust condition), and enables GitHub Pages with "GitHub Actions" as the build source. It is idempotent — safe to re-run. - Add an instance at
config/instances/<name>.yaml(copykids.yaml), thengit push origin main. - Your app goes live at
https://<your-owner>.github.io/<your-repo>/<name>/.
The origin and Pages base path are derived from your GitHub owner and repo
name automatically (no hardcoded vppillai/passbook), so a fork works
without editing any workflow.
The manual steps below document what setup.sh automates, for operators who
prefer to run each piece by hand or are rehoming an existing deployment.
Prerequisites
- AWS CLI configured with admin access
- GitHub CLI (
gh) authenticated (used byscripts/setup.sh) - GitHub repository with Pages enabled
- Region:
us-west-2(configurable via--region/ templates)
Step 1: Bootstrap (One-Time)
scripts/setup.sh does this for you. To run it by hand, pass your GitHub
owner and repo so the OIDC trust condition matches your fork (defaults are
vppillai/passbook — change them to your own):
aws cloudformation deploy \
--template-file infrastructure/bootstrap.yaml \
--stack-name passbook-bootstrap \
--parameter-overrides GitHubOrg=<your-owner> GitHubRepo=<your-repo> \
--capabilities CAPABILITY_NAMED_IAM \
--region us-west-2
Step 2: GitHub Configuration
Add these repository settings (Settings → Secrets and variables → Actions):
Secrets:
AWS_ACCOUNT_ID: Your 12-digit AWS account ID
The frontend workflow fetches each instance's API endpoint directly from CloudFormation outputs — no manual variable needed.
Pages:
- Settings → Pages → Source: "GitHub Actions"
Step 3: Deploy
Push to main to trigger automatic deployment:
git push origin main
CI runs a dynamic matrix across all instances defined in config/instances/. Each instance gets its own backend stack and frontend build. Workflows:
- Build and test Go backend (tests,
go vet, andgofmt -l— a gofmt-violating push fails here just as PRs do) - Deploy a CloudFormation stack per instance (Lambda code is referenced via the per-commit S3 key — CloudFormation is the single source of truth)
- Build and deploy all instance frontends to GitHub Pages
For a brand-new instance the frontend build is sequenced after the backend via
a workflow_run trigger — see "Adding a new instance" above. The
prune-artifacts job then trims the S3 bucket to the 2 newest Lambda zips.
Project Structure
passbook/
├── config/
│ └── instances/ # One YAML per deployed instance
│ ├── kids.yaml
│ └── eatout.yaml
├── .github/workflows/
│ ├── deploy-frontend.yaml # Per-instance build, GH Pages deploy
│ ├── deploy-backend.yaml # Matrix over instances → N CF stacks
│ └── test.yaml # PR validation
├── frontend/
│ ├── index.html # Same SPA per instance (labels swapped at init)
│ ├── manifest.json # Default PWA manifest (CI rewrites per instance)
│ ├── assets/
│ │ ├── icon.svg # Default icon (kids)
│ │ └── icons/
│ │ └── eatout.svg # Per-instance icon overrides
│ ├── css/styles.css
│ └── js/
│ ├── app.js # Calls applyLabels() on init
│ ├── api.js
│ ├── auth.js
│ ├── labels.js # Default English strings + override merging
│ └── ui.js
├── backend/
│ └── ... (Go Lambda handler, shared across instances)
├── infrastructure/
│ ├── bootstrap.yaml # Shared across instances (manually deployed)
│ └── template.yaml # Parameterized by InstanceName
└── scripts/
├── setup.sh # One-shot fork onboarding (bootstrap + GH config)
├── admin.sh # All take --instance <name>
├── add-data.sh
├── cleanup-aws.sh # Remove a single instance
├── teardown.sh # Remove ALL instances + shared resources
├── migrate-instance.sh # Cross-stack data migration tool
└── bootstrap.sh # Manual bootstrap-stack deploy (setup.sh wraps this)
Cost Estimate
Per instance (typical household-scale usage):
| Service | Expected Usage | Monthly Cost |
|---|---|---|
| Lambda | ~1,000 invocations | $0.00 |
| API Gateway 1 | ~1,000 requests | ~$0.00 |
| DynamoDB (incl. PITR) 1 | <1 MB, minimal reads/writes | ~$0.00 |
| CloudWatch logs | Basic logs, 14-day retention | $0.00 |
| Per-instance subtotal | ~$0.00/month |
Shared (one-time across all instances):
| Service | Monthly Cost |
|---|---|
| S3 (Lambda artifacts) 1 | ~$0.00 |
All services stay within AWS Free Tier for typical multi-instance household usage.
No CloudWatch alarms by design. The infrastructure intentionally creates no CloudWatch alarms or SNS topics — alarms cross the 10-alarm free-tier cliff at instance #6 (+$0.20/mo each) and email-into-the-void if no subscription is confirmed. The cost guard is instead a single account-level AWS Budget (set one up in the Billing console with a notification email). The tight API Gateway throttle (5 req/s, burst 10) plus Lambda reserved concurrency of 5 caps worst-case abuse at roughly $15–20/month/instance.
Admin Tools
Scripts for managing data directly in DynamoDB.
Prerequisites
The admin scripts preflight for aws, jq, and awk and abort with a clear
message if any is missing.
| Tool | Purpose | Installation |
|---|---|---|
| AWS CLI v2 | DynamoDB access | Install guide |
| jq | JSON parsing | sudo apt install jq / brew install jq |
| awk | Decimal/cent arithmetic | POSIX; pre-installed on macOS and Linux |
| xxd | Random ID generation | Pre-installed with vim; not preflighted, so install vim if xxd: command not found |
AWS CLI Configuration:
# Configure credentials (one-time setup)
aws configure
# Enter: AWS Access Key ID, Secret Access Key, Region (us-west-2)
# Verify access to a DynamoDB table (substitute your instance name)
aws dynamodb describe-table --table-name passbook-kids-prod --region us-west-2
Interactive TUI
./scripts/admin.sh --instance kids
Provides a menu-driven interface:
╔════════════════════════════════════════════╗
║ Passbook Admin Console ║
╚════════════════════════════════════════════╝
Total Balance: $148
Monthly History:
Month │ Starting │ Allowance │ Expenses │ Ending │ Saved
───────────┼────────────┼────────────┼────────────┼────────────┼────────────
2026-02 │ $0 │ +$100 │ -$1 │ $99 │ $99
2026-01 │ $0 │ +$50 │ -$1 │ $49 │ $49
Actions:
─────────────────────────────────────────────
1) Add/Update month 6) Set total balance
2) Add expense 7) View month expenses
3) Add funds 8) Export data
4) Remove funds 9) Import data
5) Delete month r) Recalculate balance
0) Admin (PIN/Sessions) q) Quit
CLI Commands
For scripting or batch operations:
# View all data in DynamoDB
./scripts/add-data.sh --instance kids show
# Export all data to JSON backup
./scripts/add-data.sh --instance eatout export backups/eatout-$(date +%Y%m%d).json
# Add a month summary (starting balance auto-calculated from previous month)
./scripts/add-data.sh --instance kids month 2026-01 100 30
# ^ ^ ^
# | | └── expenses
# | └────── allowance
# └──────────── YYYY-MM
# Add a historic expense (auto-creates month if needed)
./scripts/add-data.sh --instance kids expense 2026-01 15 "Book purchase"
# Add extra funds to a month
./scripts/add-data.sh --instance kids funds 2026-02 50
# Remove funds from a month
./scripts/add-data.sh --instance kids rmfunds 2026-02 20
# Delete a month and ALL its expenses
./scripts/add-data.sh --instance kids rmmonth 2026-01
# Set total balance directly
./scripts/add-data.sh --instance kids balance 170
# Recalculate total balance from all months
./scripts/add-data.sh --instance kids recalc
# Import data from JSON backup
./scripts/add-data.sh --instance kids import mybackup.json
Checking ledger consistency
audit is read-only and exits non-zero when it finds a problem, so it also
works as a scheduled check:
./scripts/add-data.sh --instance kids audit
It verifies five invariants, in the order a discrepancy propagates:
- each month's
total_expensesequals the sum of its actualEXP#rows starting_balanceequals the previous month'sending_balance(whencarry_over_balanceis on; with it off, every month starts at 0)ending_balanceequalsstarting_balance + allowance_added - total_expenses- the global
BALANCErow equalssum(allowance_added - total_expenses)— the formula that holds in both carry modes - every
MONTHLISTmirror agrees with its canonical row, with no missing and no orphaned mirrors
Why it exists: app versions before the carry-propagation fix could move a
month's balance and the global balance without shifting the later months that
carry from it, so a table can hold drift that no longer reproduces. audit
tells you whether yours does.
If it reports problems, back up and then repair:
./scripts/add-data.sh --instance kids export before-repair.json
./scripts/add-data.sh --instance kids repair
./scripts/add-data.sh --instance kids audit # confirm
repair runs three stages in dependency order — total_expenses from the
expense rows, then the carry chain, then the global balance — because each is
derived from the one before it. The stages are also available individually as
fixexpenses, fixchain <YYYY-MM> and recalc; run them in that order if you
prefer to go step by step. allowance_added is never recomputed: it is the
record of money granted and cannot be derived from anything else.
Development
Backend
cd backend
go mod tidy
go test ./...
go build -o bootstrap cmd/api/main.go
Frontend
Open frontend/index.html directly in browser. API calls will fail without backend, but UI can be tested.
Environment Variables (Lambda)
| Variable | Default | Description |
|---|---|---|
TABLE_NAME |
Required | DynamoDB table name |
ALLOWED_ORIGIN |
Required | CORS allowed origin (e.g. https://vppillai.github.io) |
MONTHLY_ALLOWANCE |
100 |
Allowance granted when a month is created. Non-numeric, non-finite (NaN, Inf) and negative values are rejected with a warning and fall back to the default |
ALLOW_OVERSPENDING |
false |
true lets balances go negative; otherwise the server refuses any write that would take one below zero |
CARRY_OVER_BALANCE |
true |
false makes each month start from zero instead of the previous month's ending balance |
ENVIRONMENT |
prod |
Deployment environment name, used in log context |
WEBAUTHN_RP_DISPLAY_NAME |
Instance name | Name shown in the OS biometric prompt |
These are set automatically by the CloudFormation template per instance. See infrastructure/template.yaml for the parameter wiring.
Cleanup
Remove one instance
./scripts/cleanup-aws.sh --instance <name>
Deletes the instance's CloudFormation stack, DynamoDB table, and log group. Does not touch the shared bootstrap stack or S3 deployment bucket.
Full teardown (all instances + shared resources)
Use the one-shot script. It deletes every per-instance stack, the retained
DynamoDB tables, and the bootstrap stack — and crucially empties the
versioned S3 bucket (all object versions + delete markers), which a plain
aws s3 rm --recursive cannot do, so aws s3 rb would otherwise fail:
./scripts/teardown.sh --dry-run # preview everything that would be deleted
./scripts/teardown.sh # delete (prompts: type "DELETE EVERYTHING")
DynamoDB PITR snapshots survive table deletion for 35 days and can be restored manually from the console if needed.
Rehoming to another AWS account
- Export each instance's data:
./scripts/add-data.sh --instance <name> export backup-<name>.json - Run the full teardown above in the old account.
- Configure AWS CLI for the new account:
aws configure - Re-run
./scripts/setup.sh(deploys the bootstrap stack, updates theAWS_ACCOUNT_IDsecret, re-creates theproductionenvironment, re-enables Pages — all idempotent). Or do step 4 by hand:aws cloudformation deploy --template-file infrastructure/bootstrap.yaml --stack-name passbook-bootstrap --parameter-overrides GitHubOrg=<your-owner> GitHubRepo=<your-repo> --capabilities CAPABILITY_NAMED_IAM --region us-west-2 - If you ran the manual deploy, update the
AWS_ACCOUNT_IDGitHub repository secret (Settings → Secrets and variables → Actions → Secrets). - Push to trigger backend + frontend deploys.
- Import data back:
./scripts/add-data.sh --instance <name> import backup-<name>.json
Troubleshooting
PIN Setup Fails
- Check CloudWatch logs:
/aws/lambda/passbook-api-<instance>-prod(e.g.,/aws/lambda/passbook-api-kids-prod) - Verify DynamoDB table exists and Lambda has permissions
401 Unauthorized
- Session expired (24h limit)
- Close the tab (clears the
sessionStoragetoken) and re-authenticate
403 Forbidden
- Request origin doesn't match allowed origin
- Direct API access attempted (not from app)
429 Too Many Requests
- API rate limit exceeded (5 req/sec)
- Wait and retry
CloudFormation stack stuck in UPDATE_ROLLBACK_FAILED
If a deployment adds new IAM permissions to the GitHub Actions role (e.g. new Lambda or CloudWatch actions) and the stack update fails mid-rollback:
- Apply the updated
bootstrap.yamlfirst using admin credentials:aws cloudformation deploy --template-file infrastructure/bootstrap.yaml --stack-name passbook-bootstrap --capabilities CAPABILITY_NAMED_IAM --region us-west-2 - Resume the stuck rollback (substitute your instance's stack name):
aws cloudformation continue-update-rollback --stack-name passbook-kids-prod --region us-west-2 - Wait for
UPDATE_ROLLBACK_COMPLETE, then re-trigger the CI deployment
bootstrap.yaml changes don't take effect automatically
bootstrap.yaml is a manually managed stack (it creates the CI/CD role itself, so it can't bootstrap itself via CI). Any changes to bootstrap.yaml must be deployed manually with admin credentials before the CI pipeline will have the new permissions.
License
MIT
Built with Claude Code
-
API Gateway (HTTP API v2), S3, and DynamoDB Point-in-Time Recovery are billable-but-negligible SKUs — they sit outside the perpetual-free tiers but cost effectively nothing at household scale (PITR on a few-KB table is ~$0.000002/month). S3 artifact growth is bounded: the
prune-artifactsCI job keeps only the 2 newest Lambda zips (current + previous, the latter needed for CloudFormation rollback) and the bucket's 1-dayNoncurrentVersionExpirationrule erases the rest. ↩︎