allowance tracker for kids https://vppillai.github.io/passbook/
  • Go 46.4%
  • JavaScript 30.7%
  • Shell 16.4%
  • CSS 4.3%
  • HTML 2.2%
Find a file
Vysakh P Pillai 8282f65678
Merge pull request #57 from vppillai/feat/lock-screen
feat: thumb-first lock screen with auto-submit
2026-08-04 10:13:13 -07:00
.github test(scripts): add a test harness for add-data.sh, and fix what it found 2026-07-29 08:36:36 -07:00
backend feat(auth): strengthen Argon2id and upgrade existing hashes in place 2026-07-29 08:41:29 -07:00
config/instances fix: honor carry_over_balance config, round to cents, show carried balance 2026-06-10 15:31:00 -07:00
docs/superpowers docs: record the visual verification and what it found 2026-07-31 08:44:58 -07:00
frontend fix(ui): three lock screen defects found by rendering it 2026-07-31 08:44:40 -07:00
infrastructure feat: return total_expenses in the months list; per-instance biometric name 2026-07-27 11:27:26 -07:00
scripts test(scripts): add a test harness for add-data.sh, and fix what it found 2026-07-29 08:36:36 -07:00
.gitignore docs: design for the lock screen rework 2026-07-29 11:00:57 -07:00
LICENSE Add MIT license file 2026-02-12 21:46:39 -08:00
README.md feat(auth): strengthen Argon2id and upgrade existing hashes in place 2026-07-29 08:41:29 -07:00

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:


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

  1. Create config/instances/<name>.yaml. Minimum required fields:
    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 set
    
    Optional blocks:
    allow_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
    
  2. (Optional) Add a custom PWA icon at frontend/assets/icons/<name>.svg. If not present, the instance uses the default frontend/assets/icon.svg.
  3. Commit and push to main.
  4. CI discovers the file and deploys the passbook-<name>-prod stack, then publishes the frontend at https://<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

  1. Keep PIN private - The 4-6 digit PIN is the primary authentication
  2. Use unique PIN - Don't reuse PINs from other services
  3. Monitor CloudWatch - Review logs periodically for anomalies
  4. 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:

  1. Fork this repository to your own GitHub account/org.
  2. Log in to AWS with admin-capable credentials so aws sts get-caller-identity works (aws configure or aws sso login).
  3. Log in to GitHub CLI: gh auth login.
  4. Run the setup script from inside your clone:
    ./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 nothing
    
    It deploys the bootstrap stack (parameterized to your GitHub owner/repo), sets the AWS_ACCOUNT_ID repo secret, creates the production environment (required by the OIDC trust condition), and enables GitHub Pages with "GitHub Actions" as the build source. It is idempotent — safe to re-run.
  5. Add an instance at config/instances/<name>.yaml (copy kids.yaml), then git push origin main.
  6. 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 by scripts/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:

  1. Build and test Go backend (tests, go vet, and gofmt -l — a gofmt-violating push fails here just as PRs do)
  2. Deploy a CloudFormation stack per instance (Lambda code is referenced via the per-commit S3 key — CloudFormation is the single source of truth)
  3. 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 $1520/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:

  1. each month's total_expenses equals the sum of its actual EXP# rows
  2. starting_balance equals the previous month's ending_balance (when carry_over_balance is on; with it off, every month starts at 0)
  3. ending_balance equals starting_balance + allowance_added - total_expenses
  4. the global BALANCE row equals sum(allowance_added - total_expenses) — the formula that holds in both carry modes
  5. every MONTHLIST mirror 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

  1. Export each instance's data: ./scripts/add-data.sh --instance <name> export backup-<name>.json
  2. Run the full teardown above in the old account.
  3. Configure AWS CLI for the new account: aws configure
  4. Re-run ./scripts/setup.sh (deploys the bootstrap stack, updates the AWS_ACCOUNT_ID secret, re-creates the production environment, 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
  5. If you ran the manual deploy, update the AWS_ACCOUNT_ID GitHub repository secret (Settings → Secrets and variables → Actions → Secrets).
  6. Push to trigger backend + frontend deploys.
  7. 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 sessionStorage token) 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:

  1. Apply the updated bootstrap.yaml first using admin credentials: aws cloudformation deploy --template-file infrastructure/bootstrap.yaml --stack-name passbook-bootstrap --capabilities CAPABILITY_NAMED_IAM --region us-west-2
  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
  3. 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


  1. 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-artifacts CI job keeps only the 2 newest Lambda zips (current + previous, the latter needed for CloudFormation rollback) and the bucket's 1-day NoncurrentVersionExpiration rule erases the rest. ↩︎