From 616a9b56ca047b4410522d40e140152bc0fab29a Mon Sep 17 00:00:00 2001 From: Vaibhav Dave <35380023+VaibhavDaveDev@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:09:21 +0530 Subject: [PATCH] Fix: harden billing engine, auth middleware, and request throttling - Billing Engine: Fix start-value resolution to correctly skip draft and empty periods using innerJoin - Auth & Access: Handle 403 Forbidden responses in api-client to redirect unverified users to /verify-email - Add middleware unit tests covering unverified-user redirects - Edit Requests: Check throttle limits before cancelling existing requests, allowing same-period overwrites without cross-period spam - Add strict Zod validation for proposedValues in approval and rejection flows - Bills: Fix the markPaidRoute race condition by reading tenancyId directly from the base bill - Validation & UI: Update parseDateInput to reject out-of-range calendar dates such as February 30 - Guard against invalid solarActivatedAt timestamps in PropertySettings.tsx - Documentation: Redesign the README.md Shield-style badges - Update DEPLOY.md with a deployment rollback guide - Update AGENTS.md DOX contracts to reflect the new architectural rules --- .gitignore | 1 - AGENTS.md | 2 +- DEPLOY.md | 579 +++++++++++++++++ README.md | 606 +++--------------- src/api/lib/AGENTS.md | 7 +- src/api/lib/billing-engine.ts | 38 +- src/api/routes/bills.test.ts | 109 +++- src/api/routes/bills.ts | 12 +- src/api/routes/edit-requests.ts | 73 ++- src/api/routes/meter-readings.ts | 45 +- .../properties/PropertySettings.tsx | 11 +- src/lib/AGENTS.md | 3 +- src/lib/api-client.test.ts | 134 +++- src/lib/api-client.ts | 39 +- src/lib/format.ts | 16 +- src/lib/pdf-extract-smoke.test.ts | 21 +- src/lib/pdf-extract.ts | 7 +- src/test/auth-middleware.test.ts | 67 ++ wrangler.jsonc.example | 2 +- 19 files changed, 1140 insertions(+), 632 deletions(-) create mode 100644 DEPLOY.md create mode 100644 src/test/auth-middleware.test.ts diff --git a/.gitignore b/.gitignore index 6501f3b..8071a79 100644 --- a/.gitignore +++ b/.gitignore @@ -92,5 +92,4 @@ fallow_review.txt otel-collector-config-local.yaml # Personal deployment guide -DEPLOY.md Plan.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c19a623..d58710c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ When the user requests a durable behavior change, record it here or in the relev - **Migration baseline:** All migrations were squashed into `src/db/migrations/0000_init.sql` (July 2026). This includes `rate_override` on `billing_periods` and all schema up to that date. Future migrations start from `0001`. The `meta/` folder contains only `0000_snapshot.json` and `_journal.json`. Stale snapshots and the two `0001_*` migration files were removed during the July 2026 squash. - **Local dev seed:** Run `pnpm seed:fresh` to reset the local D1 database and insert demo fixtures. Credentials: owner@demo.meterly.app / DemoOwner123, tenant@demo.meterly.app / DemoTenant123. Script is at `scripts/seed.ts`. Never commit real data to this script. **Password hashing in seed.ts must use `hashPassword` from `better-auth/crypto`** — not bcryptjs or any other library. Better Auth's verifier expects a `salt:hash` hex format (scrypt); bcrypt hashes (`$2b$...`) will throw `"Invalid password hash"` at login. -- **Edit request throttle after overwrite:** Edit request rate limits are applied per-tenant across all periods. However, when a tenant submits a new request for a period that _already_ has a pending request, the old request is cancelled _before_ the rate limit check. This intentional cancel-then-throttle logic allows same-period overwrites to succeed while still preventing cross-period spam. +- **Edit request throttle after overwrite:** Edit request rate limits are applied per-tenant per-property. The throttle check runs _first_ but subtracts any existing same-period pending request from the total count. Only after passing the throttle is the existing request cancelled and overwritten. This ensures strict upper bounds without penalizing same-period correction updates. ### CI / Developer Tooling diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..0106a70 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,579 @@ +# Meterly — Production Deployment Guide + +This guide covers a full first-time production deployment of Meterly on +Cloudflare Pages + Workers. It also covers ongoing operations: future deploys, +schema migrations, and rolling back a bad deploy. + +Prerequisites, secrets, and environment variables are listed upfront in the +checklist below. Complete each step in order — skipping steps leads to the +common errors documented in the Troubleshooting section. + +--- + +## Before You Start — Checklist + +- [ ] Cloudflare account created +- [ ] Wrangler CLI authenticated (`pnpm exec wrangler login`) +- [ ] Domain name decided (Cloudflare Pages gives you a `.pages.dev` free subdomain — you can use that) +- [ ] Google OAuth credentials ready (optional but recommended) +- [ ] GitHub OAuth credentials ready (optional but recommended) +- [ ] If using GitHub OAuth: note your `Client ID` (goes in `wrangler.jsonc`) and generate a `Client Secret` (goes in `wrangler secret put`) +- [ ] Email provider chosen: **Resend** (recommended — requires a verified domain) OR + **Atlas Mailer** (no domain needed, uses Gmail SMTP). See `.env.example` for details. +- [ ] If using Resend: Resend account created at https://resend.com and domain verified +- [ ] If using Atlas Mailer: Atlas Mailer Worker deployed and URL ready +- [ ] A strong random secret generated locally for `BETTER_AUTH_SECRET` (`openssl rand -hex 32`) + +--- + +## Step 1 — Authenticate with Cloudflare + +```bash +pnpm exec wrangler login +``` + +This opens a browser. Log in to your Cloudflare account. After authorising, Wrangler saves credentials locally. You only need to do this once per machine. + +Verify it worked: + +```bash +pnpm exec wrangler whoami +``` + +--- + +## Step 2 — Create the D1 Database + +```bash +pnpm exec wrangler d1 create meterly-db +``` + +You will see output like this: + +``` +Successfully created DB 'meterly-db' + +[[d1_databases]] +binding = "DB" +database_name = "meterly-db" +database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +``` + +**Copy the `database_id` UUID.** You will need it in Step 4. + +## Step 3 — Create the R2 Bucket + +```bash +# Production bucket: +pnpm exec wrangler r2 bucket create meterly-bills + +# Preview bucket (required for `wrangler dev --remote`): +pnpm exec wrangler r2 bucket create meterly-bills-preview +``` + +No ID to copy — the bucket names are already configured in `wrangler.jsonc`. + +--- + +## Step 4 — Update wrangler.jsonc + +Open `wrangler.jsonc` and fill in the D1 database ID you copied above. Replace the placeholder values: + +```jsonc +{ + "compatibility_date": "2024-09-23", + "compatibility_flags": ["nodejs_compat_v2"], + "vars": { + "MAX_SESSIONS_PER_USER": "3", + "MAX_UPLOADS_PER_DAY": "60", + "OBSERVABILITY_ENABLED": "false", + "ENVIRONMENT": "production", + "BETTER_AUTH_URL": "https://YOUR-PROJECT-NAME.pages.dev", + "PUBLIC_BETTER_AUTH_URL": "https://YOUR-PROJECT-NAME.pages.dev", + "EMAIL_PROVIDER": "resend", + "RESEND_FROM": "Meterly ", + // OR: if using Atlas Mailer instead: + // "EMAIL_PROVIDER": "atlas", + // "ATLAS_MAILER_URL": "https://your-atlas-mailer-worker.workers.dev", + "GOOGLE_CLIENT_ID": "your-google-client-id", + "GITHUB_CLIENT_ID": "your-github-client-id", + "PUBLIC_TURNSTILE_SITE_KEY": "your-turnstile-site-key", + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "meterly-db", + "database_id": "PASTE-YOUR-D1-ID-HERE", + "preview_database_id": "local", + "migrations_dir": "src/db/migrations", + }, + ], + "r2_buckets": [ + { + "binding": "BILL_PHOTOS", + "bucket_name": "meterly-bills", + // preview_bucket_name is used by `wrangler dev --remote`. A separate bucket + // keeps local/preview development from touching production bill photos. + "preview_bucket_name": "meterly-bills-preview", + }, + ], +} +``` + +**Replace:** + +- `PASTE-YOUR-D1-ID-HERE` with the UUID from Step 2 +- `YOUR-PROJECT-NAME` with what you want your Cloudflare Pages app to be called (becomes `https://your-project-name.pages.dev`) +- Other vars with their actual values (see Step 6 for how to get each one) + +> **💡 Important URL Note:** `YOUR-PROJECT-NAME` is just a placeholder in these instructions for the unique name you will choose. Cloudflare Pages URLs must be globally unique. If the name you want is already taken, Cloudflare will automatically append random characters during your first deploy in Step 7 (e.g., `meterly-a3f.pages.dev`). +> +> **If this happens, your authentication will break.** You will need to take the _actual_ assigned URL from the Step 7 success message, come back, update these variables and your OAuth callbacks, and deploy one more time. + +> If you are using a custom domain instead of `.pages.dev`, update `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL` to your custom domain. + +--- + +## Step 5 — Apply the Database Migration + +Run the init SQL against your **remote** (production) D1 database: + +```bash +pnpm exec wrangler d1 execute meterly-db --remote --file=./src/db/migrations/0000_init.sql +``` + +You will see output confirming each SQL statement was applied. + +> **Important:** You only run `0000_init.sql`. This is the complete squashed schema for the entire database — all tables, indexes, and constraints in one file. +> Do not run any `0001_*` files. Those were consolidated into `0000_init.sql` before release. +> Future schema changes will create new numbered migrations starting from `0001`. + +--- + +## Step 6 — Set Production Secrets + +Secrets are encrypted and stored in Cloudflare. They must never be committed to version control. `.dev.vars` is gitignored and safe for **local development only** — do not use it for production secrets. In production, always use `wrangler secret put`. + +Run each command below — it will prompt you to paste the value interactively: + +### 6a. BETTER_AUTH_SECRET + +```bash +pnpm exec wrangler secret put BETTER_AUTH_SECRET +``` + +Generate the value with: `openssl rand -hex 32` + +This is the master secret that signs all session cookies and auth tokens. Never share it. + +### 6b. TURNSTILE_SECRET_KEY + +```bash +pnpm exec wrangler secret put TURNSTILE_SECRET_KEY +``` + +**How to get it:** + +1. Go to https://dash.cloudflare.com +2. Left sidebar → Turnstile → Add site +3. Give it a name, enter your domain (e.g. `meterly.pages.dev` or your custom domain) +4. Copy the **Secret Key** and paste it here +5. Copy the **Site Key** and add it to `wrangler.jsonc` vars as `PUBLIC_TURNSTILE_SITE_KEY` + +### 6c. Email Provider Secret + +**Choose one based on your chosen provider:** + +#### Option A — Resend (recommended) + +```bash +pnpm exec wrangler secret put RESEND_API_KEY +``` + +Get your API key from https://resend.com/api-keys +Verify your sending domain first at https://resend.com/domains + +`RESEND_FROM` is a plain var (not a secret) — set it in `wrangler.jsonc` vars: +`"RESEND_FROM": "Meterly "` + +#### Option B — Atlas Mailer (no domain required) + +```bash +pnpm exec wrangler secret put ATLAS_MAILER_SECRET +``` + +This is the bearer token your Atlas Mailer Worker expects. +Also set `ATLAS_MAILER_URL` in `wrangler.jsonc` vars. + +### 6d. GOOGLE_CLIENT_SECRET (optional — skip if not using Google login) + +```bash +pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET +``` + +**How to get it:** + +1. Go to https://console.cloud.google.com +2. Create or select a project +3. APIs and Services → Credentials → Create Credentials → OAuth 2.0 Client ID +4. Application type: Web application +5. Authorised redirect URIs: `https://YOUR-PROJECT-NAME.pages.dev/api/auth/callback/google` + > _Note: Be sure to use your **actual** assigned Cloudflare URL here. If Cloudflare appends random characters to your project name during deploy, you must update this callback URL to match._ +6. Copy the **Client Secret** and paste it here as the secret +7. Copy the **Client ID** and add it to `wrangler.jsonc` vars as `GOOGLE_CLIENT_ID` + +### 6e. GITHUB_CLIENT_SECRET (optional — skip if not using GitHub login) + +```bash +pnpm exec wrangler secret put GITHUB_CLIENT_SECRET +``` + +**How to get it:** + +1. Go to https://github.com/settings/developers +2. Select **OAuth Apps** -> **New OAuth App** +3. Application name: Meterly (or your project name) +4. Homepage URL: `https://YOUR-PROJECT-NAME.pages.dev` +5. Authorization callback URL: `https://YOUR-PROJECT-NAME.pages.dev/api/auth/callback/github` + > _Note: Be sure to use your **actual** assigned Cloudflare URL here. If Cloudflare appends random characters to your project name during deploy, you must update this callback URL to match._ +6. Click **Register application** +7. Copy the **Client ID** and add it to `wrangler.jsonc` vars as `GITHUB_CLIENT_ID` +8. Click **Generate a new client secret**, copy it, and paste it here as the secret + +### 6f. CRON_SECRET + +```bash +pnpm exec wrangler secret put CRON_SECRET +``` + +Generate the value with: `openssl rand -hex 32` + +**Write this value down.** You will need it in Step 8 when setting up the cron scheduler. Without it, the reading reminder endpoint will return 401 Unauthorized to any caller. + +--- + +## Step 7 — First Deploy + +Build and deploy to Cloudflare Pages: + +```bash +pnpm run build +pnpm exec wrangler pages deploy dist --project-name=YOUR-PROJECT-NAME +``` + +The first time you run this, Wrangler will ask if you want to create a new Pages project. Type `y`. + +After a minute or two you will see: + +``` +Deployment complete! Take a peek over at https://YOUR-PROJECT-NAME.pages.dev +``` + +Future deploys are just: `pnpm run deploy` + +--- + +## Step 8 — Set Up the Daily Cron Job + +The cron endpoint `GET /api/cron/reading-reminders` needs to be called once per day by an external scheduler. It: + +1. Checks all properties where `reading_reminder_day` matches today's date +2. Finds the previous month's billing period if it is still in `draft` status +3. Creates in-app notifications for the property owner and active tenants + +It is protected by `Authorization: Bearer CRON_SECRET`. Without the correct header, it returns 401. + +### Option A — cron-job.org (Free, Recommended) + +1. Go to https://cron-job.org and create a free account +2. Dashboard → Create Cronjob +3. Fill in: + - **Title:** Meterly Reading Reminders + - **URL:** `https://YOUR-PROJECT-NAME.pages.dev/api/cron/reading-reminders` + - **Schedule:** Every day at 08:00 (or whatever time suits your users) + - **Request method:** GET +4. Expand the **Headers** section and add one header: + - Name: `Authorization` + - Value: `Bearer YOUR-CRON-SECRET-VALUE` (the exact value you set in Step 6f) +5. Save/Create + +The job will now run daily. You can check the execution log in cron-job.org to confirm it is hitting 200. + +### Option B — GitHub Actions (if repo is on GitHub) + +Create `.github/workflows/reading-reminders.yml` in your repository: + +```yaml +name: Daily Reading Reminders +on: + schedule: + # Runs at 03:00 UTC every day (08:30 IST) + - cron: "0 3 * * *" + workflow_dispatch: # allows manual trigger + +jobs: + trigger-reminders: + runs-on: ubuntu-latest + steps: + - name: Trigger reading reminders cron + run: | + curl --fail --silent --show-error \ + -X GET \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ + https://YOUR-PROJECT-NAME.pages.dev/api/cron/reading-reminders +``` + +Then add `CRON_SECRET` to your GitHub repository: + +1. GitHub → your repo → Settings → Secrets and variables → Actions +2. New repository secret → Name: `CRON_SECRET`, Value: (the same value you set in Step 6f) + +### Option C — Verify the Endpoint is Working + +Test it manually right now: + +```bash +# In Bash / Zsh (prompts silently to keep secrets out of shell history): +read -s -p "Enter CRON_SECRET: " CRON_SECRET; echo +curl -X GET \ + -H "Authorization: Bearer $CRON_SECRET" \ + https://YOUR-PROJECT-NAME.pages.dev/api/cron/reading-reminders +unset CRON_SECRET + +# Or in PowerShell: +$secret = Read-Host -AsSecureString "Enter CRON_SECRET" +$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) +$plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) +curl.exe -X GET -H "Authorization: Bearer $plain" https://YOUR-PROJECT-NAME.pages.dev/api/cron/reading-reminders +``` + +Expected response: + +```json +{ "success": true, "processed": 0, "notificationsSent": 0 } +``` + +Zero is correct if no billing periods happen to match today's reminder day. The endpoint is working correctly. + +--- + +## Step 9 — Verify the Full App + +1. Visit `https://YOUR-PROJECT-NAME.pages.dev` +2. Sign up with an email and password +3. Check your email inbox for the OTP (if Resend or Atlas Mailer is configured correctly) +4. Complete verification, create a property +5. Invite yourself as a tenant using a second email +6. Submit a meter reading, confirm a billing period, check bills + +--- + +## Step 10 — Custom Domain (Optional) + +1. Cloudflare Dashboard → Pages → your project → Custom domains → Set up a custom domain +2. Enter your domain (e.g. `app.meterly.com`) +3. Follow the DNS configuration instructions (if your domain is on Cloudflare, it is automatic) +4. After DNS propagates, update in `wrangler.jsonc`: + ```jsonc + "vars": { + "BETTER_AUTH_URL": "https://app.meterly.com", + "PUBLIC_BETTER_AUTH_URL": "https://app.meterly.com" + } + ``` +5. Redeploy: `pnpm run deploy` +6. Update the Google OAuth redirect URI to `https://app.meterly.com/api/auth/callback/google` +7. Update the GitHub OAuth callback URL to `https://app.meterly.com/api/auth/callback/github` +8. Update the Turnstile allowed domain to `app.meterly.com` + +--- + +## Rollback — Reverting a Bad Deploy + +Cloudflare Pages keeps a full deployment history. If a deploy breaks production: + +### Code rollback (instant) + +1. Cloudflare Dashboard → Pages → your project → Deployments +2. Find the last known-good deployment +3. Click the three-dot menu → **Rollback to this deployment** + +This takes about 30 seconds and requires no code changes. + +> **⚠️ Migration Compatibility Notice:** A Pages code rollback only reverts frontend and API worker code — it does **not** revert D1 database schema migrations or data. If the failing deployment included a database migration: +> +> - If the migration was **backward-compatible** (additive: added nullable columns, new tables), the rolled-back code will continue working without database changes. +> - If the migration was **breaking** (renamed/removed columns or tables), you must perform a database rollback (see below) or apply a forward corrective migration before or immediately after rolling back code. + +### Database rollback (manual) + +Cloudflare D1 supports **Time Travel** — point-in-time restore for up to **7 days** +on the Workers Free plan and up to **30 days** on the paid plan. + +#### Option A — D1 Time Travel (recommended) + +> **⚠️ Destructive Action:** Time Travel restore overwrites the database in place. Any writes made after the restore point will be lost. Always take a pre-restore export snapshot first. + +1. Find available bookmarks: + ```bash + pnpm exec wrangler d1 time-travel info meterly-db --remote + ``` +2. **Safety Step — Export current state to an external backup directory:** + + ```bash + mkdir -p ~/backups + pnpm exec wrangler d1 export meterly-db --remote --output=~/backups/meterly-pre-restore-$(date +%Y%m%d_%H%M%S).sql + chmod 600 ~/backups/meterly-pre-restore-*.sql + ``` + + > _Never save database dumps inside the project repository to prevent accidental commits of production data._ + +3. Restore to a specific timestamp or bookmark: + ```bash + pnpm exec wrangler d1 time-travel restore meterly-db --remote --timestamp= + # or by bookmark ID: + pnpm exec wrangler d1 time-travel restore meterly-db --remote --bookmark= + ``` +4. **Validate database integrity:** + ```bash + pnpm exec wrangler d1 execute meterly-db --remote --command="SELECT count(*) FROM user;" + ``` + +#### Option B — SQL dump restore (longer retention / manual backup) + +If the incident is older than the Time Travel retention window (7-30 days) and you need to restore from a previously exported `.sql` dump: + +1. **Create a fresh D1 database:** + ```bash + pnpm exec wrangler d1 create meterly-db-restored + ``` +2. **Import the SQL dump into the new database:** + ```bash + pnpm exec wrangler d1 execute meterly-db-restored --remote --file=~/backups/your-backup.sql + ``` +3. **Verify restored data integrity:** + ```bash + pnpm exec wrangler d1 execute meterly-db-restored --remote --command="SELECT count(*) FROM user;" + ``` +4. **Cut over to the restored database:** + Update `database_id` in `wrangler.jsonc` with the new database UUID from Step 1, then redeploy: + ```bash + pnpm run deploy + ``` +5. _If no backup exists:_ Create a forward corrective migration that reverses the bad schema change. Do not edit applied migration files. + +**Prevention:** Before applying any migration to production, always apply it to +local first (`--local`) and verify the app works. For destructive migrations, +export a snapshot first (supplements Time Travel for retention beyond 7 days): + +```bash +mkdir -p ~/backups +pnpm exec wrangler d1 export meterly-db --remote --output=~/backups/meterly-backup-$(date +%Y%m%d_%H%M%S).sql +chmod 600 ~/backups/meterly-backup-*.sql +``` + +--- + +## Ongoing — Future Deploys + +### Normal code change: + +```bash +pnpm run deploy +``` + +### New database migration: + +> ### Database Migration Best Practices: +> +> Always write **backward-compatible (expand-and-contract)** migrations: +> +> 1. **Expand**: Add new nullable columns or tables first. Deploy code that writes to both old and new columns. +> 2. **Contract**: In a subsequent release after all data is migrated, drop old columns/tables. +> This ensures that any sudden code rollback never encounters missing columns or schema incompatibilities. + +```bash +# 1. Generate migration from schema change +pnpm exec drizzle-kit generate + +# 2. Apply to local first and test +pnpm exec wrangler d1 execute meterly-db --local --file=./src/db/migrations/0001_your_migration.sql +pnpm run dev + +# 3. Apply to production +pnpm exec wrangler d1 execute meterly-db --remote --file=./src/db/migrations/0001_your_migration.sql + +# 4. Deploy new code +pnpm run deploy +``` + +Always run migrations before deploying new code that depends on them. + +--- + +## Environment Variables Reference + +| Variable | How to Set | Description | +| ----------------------------- | --------------------- | ------------------------------------------------------------------ | +| `BETTER_AUTH_SECRET` | `wrangler secret put` | Master signing secret — generate with `openssl rand -hex 32` | +| `TURNSTILE_SECRET_KEY` | `wrangler secret put` | From Cloudflare Turnstile dashboard → your site → Secret Key | +| `RESEND_API_KEY` | `wrangler secret put` | From https://resend.com/api-keys — only if `EMAIL_PROVIDER=resend` | +| `ATLAS_MAILER_SECRET` | `wrangler secret put` | Bearer token for your Atlas Mailer Worker | +| `GOOGLE_CLIENT_SECRET` | `wrangler secret put` | From Google Cloud Console OAuth credentials | +| `GITHUB_CLIENT_SECRET` | `wrangler secret put` | From GitHub Developer Settings OAuth Apps | +| `CRON_SECRET` | `wrangler secret put` | Random secret — you use this in your cron scheduler header | +| `RESEND_FROM` | `wrangler.jsonc` vars | Sender address, e.g. `Meterly ` | +| `ENVIRONMENT` | `wrangler.jsonc` vars | Set to `production` | +| `BETTER_AUTH_URL` | `wrangler.jsonc` vars | Your app's public URL (no trailing slash) | +| `PUBLIC_BETTER_AUTH_URL` | `wrangler.jsonc` vars | Same as `BETTER_AUTH_URL` | +| `ATLAS_MAILER_URL` | `wrangler.jsonc` vars | Your Atlas Mailer Worker URL | +| `GOOGLE_CLIENT_ID` | `wrangler.jsonc` vars | From Google Cloud Console OAuth credentials | +| `GITHUB_CLIENT_ID` | `wrangler.jsonc` vars | From GitHub Developer Settings OAuth Apps | +| `PUBLIC_TURNSTILE_SITE_KEY` | `wrangler.jsonc` vars | From Cloudflare Turnstile dashboard → your site → Site Key | +| `MAX_SESSIONS_PER_USER` | `wrangler.jsonc` vars | Max concurrent sessions (default: `3`) | +| `MAX_READINGS_PER_DAY` | `wrangler.jsonc` vars | Max meter reading submissions per user per day (default: `20`) | +| `MAX_UPLOADS_PER_DAY` | `wrangler.jsonc` vars | Max bill photo uploads per user per day (default: `60`) | +| `OBSERVABILITY_ENABLED` | `wrangler.jsonc` vars | Set to `true` to enable OpenTelemetry telemetry (default: `false`) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | `wrangler.jsonc` vars | OTLP HTTP collector endpoint (e.g. Grafana Cloud / local OTel) | +| `GRAFANA_CLOUD_INSTANCE_ID` | `wrangler.jsonc` vars | Instance ID for Grafana Cloud OTLP ingestion | +| `LOG_LEVEL` | `wrangler.jsonc` vars | Structured log verbosity: `debug`, `info`, `warn`, `error` | +| `GRAFANA_CLOUD_API_KEY` | `wrangler secret put` | Ingestion token for Grafana Cloud — store as encrypted secret | + +--- + +## Free Tier Headroom + +All Cloudflare services Meterly uses are free for reasonable usage: + +| Service | Free Limit | Notes | +| ------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Cloudflare Pages | Unlimited static asset requests | Static assets only | +| Pages Functions & Workers | **Shared** 100,000 requests/day (Workers Free tier) | API, SSR, and Worker requests all count against this single quota | +| D1 | 5M row reads/day, 100K writes/day, 500MB per database (5GB account total) | More than enough for hundreds of users | +| R2 | 10GB storage, 1M Class A ops/month | Bill photos are compressed to under 250KB each | +| Turnstile | Unlimited | Always free | + +--- + +## Troubleshooting + +**Deployment fails: "missing binding DB"** +Your `database_id` in `wrangler.jsonc` is still the placeholder. Run `pnpm exec wrangler d1 list` to find your real ID. + +**Email OTP never arrives in production** +`ENVIRONMENT` must be set to `production` in `wrangler.jsonc` vars. In development mode, OTPs print to the terminal and the mailer is not called. In production, the terminal print is suppressed and the mailer must work. + +**Turnstile: "invalid sitekey"** +The `PUBLIC_TURNSTILE_SITE_KEY` in `wrangler.jsonc` must match the site key from Cloudflare Turnstile. Make sure the Turnstile site allows your Pages domain. + +**Google login: redirect_uri_mismatch** +The redirect URI in Google Cloud Console must exactly match `https://YOUR-DOMAIN/api/auth/callback/google`. No trailing slash. Must be https. + +**Cron endpoint returns 401** +The `Authorization: Bearer` header value in your cron scheduler must exactly match what you set via `wrangler secret put CRON_SECRET`. Check for extra spaces or quote characters. + +**"table does not exist" error after deploy** +You need to apply the migration to the remote database. Run: + +```bash +pnpm exec wrangler d1 execute meterly-db --remote --file=./src/db/migrations/0000_init.sql +``` diff --git a/README.md b/README.md index 4d329da..47e088c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,58 @@

- Meterly + + + Meterly +

-

Meterly

-

+ + GitHub Stars + + + License: AGPL-3.0 + + + CI + + + Last Commit + - Live Demo + Live Demo -

- -

- TypeScript - Cloudflare - Astro - React - Hono Donate using Liberapay

-Meterly is a transparent, multi-tenant utility billing platform designed to eliminate disputes between property owners and tenants. It specializes in handling complex utility scenarios, specifically properties equipped with grid-tied solar installations. +# Meterly -## Overview +Meterly is a transparent, multi-tenant utility billing platform designed to eliminate disputes between property owners and tenants. It handles complex utility scenarios, specifically properties equipped with grid-tied solar installations. -Traditional sub-metering often leads to friction due to opaque calculations. Meterly solves this by providing absolute mathematical transparency. When an owner generates a bill, tenants can view the exact formula used to calculate their share, including consumption rates, solar export refunds, and custom property charges. +Traditional sub-metering leads to friction because calculations are opaque. Meterly fixes this with absolute mathematical transparency — tenants see the exact formula behind every bill: consumption rates, solar export refunds, and custom charges. ## Key Features -- **Dual-Mode Billing Engine:** Supports both standard grid-only properties and complex solar-equipped properties with export tracking. -- **Absolute Transparency:** Tenants see the exact breakdown of their bill, down to the last unit and applied rate. -- **Audit Trails & Dispute Resolution:** Built-in workflow for tenants to request edits on meter readings. Owner approvals trigger an automated recalculation cascade. -- **Solo Mode:** Owners without tenants can track their own electricity bills using the same engine. -- **Tenant Lifecycle Management:** Invite, remove, and re-add tenants. Past tenants retain read access to their historical bills permanently. -- **AI-Native Architecture:** Codebase organized using the DOX framework, navigable by autonomous AI agents. - -## Session Management - -Meterly limits concurrent sessions per user to prevent token accumulation and enhance security. +- **Dual-Mode Billing Engine** — supports both standard grid-only properties and solar-equipped properties with full export tracking +- **Absolute Transparency** — tenants see the complete breakdown of their bill, down to the last unit and applied rate +- **Audit Trails & Dispute Resolution** — built-in workflow for tenants to request meter reading corrections; owner approvals trigger an automated recalculation cascade +- **Solo Mode** — owners without tenants can track their own electricity usage with the same engine +- **Tenant Lifecycle Management** — invite, remove, and re-add tenants; past tenants retain access to historical bills for transparency until property deletion and tenant archival +- **DOX Documentation Framework** — structured AGENTS.md contract hierarchy maintaining strict architectural boundaries and developer instructions -- **Max sessions per user:** 3 (configurable via `MAX_SESSIONS_PER_USER`) -- **Cleanup strategy:** FIFO (First In, First Out) -- **When triggered:** On every login - When a user logs in from a 4th device, the oldest active session is automatically deleted. +## Tech Stack & Architecture -## Tech Stack - -- **Frontend:** Astro (Islands architecture), React, Tailwind CSS, shadcn/ui, Recharts -- **Backend:** Hono, Drizzle ORM -- **Database:** Cloudflare D1 (SQLite) -- **Authentication:** Better Auth (Email + Password, Google OAuth, Email OTP for verification/reset) -- **Bot Protection:** Cloudflare Turnstile on auth routes -- **Deployment:** Cloudflare Pages & Workers - -### Architecture +| Layer | Technology | +| -------------- | --------------------------------------------------------- | +| Frontend | Astro (Islands), React, Tailwind CSS, shadcn/ui, Recharts | +| Backend | Hono, Drizzle ORM | +| Database | Cloudflare D1 (SQLite) | +| Auth | Better Auth — email/password, Google OAuth, email OTP | +| Bot Protection | Cloudflare Turnstile | +| Deployment | Cloudflare Pages & Workers | ```mermaid graph TD @@ -126,16 +124,13 @@ graph TD ## API Documentation -Meterly's API is documented with OpenAPI 3.0 via `@hono/zod-openapi`. - -- **Swagger UI:** Available at `/api/docs` (development and production) -- **Raw spec:** Available at `/api/docs/openapi.json` +Meterly's API is documented with OpenAPI 3.0 via `@hono/zod-openapi`. Every route registered with `createRoute()` appears automatically — no separate JSON file to maintain. -In local development: http://localhost:4321/api/docs +- **Swagger UI:** `/api/docs` (development and production) +- **Raw spec:** `/api/docs/openapi.json` +- **Local:** `http://localhost:4321/api/docs` -The spec is generated from code — every route registered with `createRoute()` appears automatically. No separate JSON file to maintain. - -### Bill Photo Upload Architecture +## Bill Photo Upload Architecture The upload flow keeps R2 costs minimal and gives users instant local access via IndexedDB. @@ -160,10 +155,10 @@ sequenceDiagram note over U,R2: Step 2 — Upload & rate-limit check U->>+W: POST /api/uploads/bill-photo (multipart WebP) W->>D1: SELECT count from bill_photos (today) - D1-->>W: count (0-10) - alt count >= 10 + D1-->>W: count (today) + alt count >= MAX_UPLOADS_PER_DAY (default: 60) W-->>U: 429 Rate Limited - else count < 10 + else count < MAX_UPLOADS_PER_DAY W->>R2: PUT {userId}/{periodId}/{ts}.webp R2-->>W: OK W->>D1: INSERT bill_photos record (auto-increments count) @@ -196,420 +191,15 @@ sequenceDiagram **Key design decisions:** - Compression happens entirely in the browser — zero server CPU cost. -- OCR (Tesseract.js) runs in a Web Worker so it never blocks the UI thread. It is **opt-in and off by default** — users toggle it per upload. This avoids the 2–5 second WASM load for users who just want to store a photo. +- OCR runs in a Web Worker so it never blocks the UI thread. It is opt-in and off by default. - R2 object keys are prefixed with `{userId}/` — the API enforces this prefix on every read, so cross-user access returns 403. -- On property delete, the API purges all R2 objects under `{userId}/{periodId}/` via `waitUntil` (non-blocking). -- Upload rate-limiting queries existing database records from D1 daily (start of UTC day), avoiding the Cloudflare KV write limit bottleneck entirely. - -## Project Structure (DOX Framework) - -This project strictly adheres to the DOX framework. Each domain directory contains an `AGENTS.md` file that acts as a binding contract defining the folder's purpose, ownership, and architectural rules. - -- `/src/api` — Hono backend routing and pure billing logic -- `/src/components` — React interactive islands organized by domain (`auth`, `dashboard`, `properties`, `ui`) -- `/src/db` — Drizzle ORM schemas and queries -- `/src/layouts` — Astro server-rendered layouts -- `/src/pages` — Astro server-rendered pages and API routing catch-alls - -## Getting Started - -### Prerequisites - -- Node.js (v18+) -- pnpm (v10+) -- Cloudflare account (free tier works) -- Cloudflare Wrangler CLI - -### Quick Start - -1. **Clone and Install** - - ```bash - git clone https://github.com/VaibhavDaveDev/Meterly.git - cd Meterly - pnpm install - ``` - -2. **Set Up Environment Variables** - - Copy `.env.example` to `.dev.vars`: - - ```bash - cp .env.example .dev.vars - ``` - - **For local development**, the default test values work out of the box: - - `BETTER_AUTH_SECRET`: Pre-filled with a dev secret (change in production!) - - `BETTER_AUTH_URL` and `PUBLIC_BETTER_AUTH_URL`: Set to `http://localhost:4321` - - `PUBLIC_TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY`: Cloudflare's always-pass test keys - - OAuth and mailer: Optional for local dev (email/password auth works without them) - -3. **Create Cloudflare Resources** - - You need to create a D1 database and update `wrangler.jsonc`: - - ```bash - # Create D1 database - pnpm exec wrangler d1 create meterly-db - # Copy the 'database_id' from the output - ``` - - Open `wrangler.jsonc` and replace the placeholder D1 ID: - - ```jsonc - { - "d1_databases": [ - { - "database_id": "paste-your-d1-id-here", // ← Replace this - }, - ], - } - ``` - -4. **Initialize Database Schema** - - **Important:** Apply the schema to your **local** D1 database: - - ```bash - pnpm exec wrangler d1 execute meterly-db --local --file=./src/db/migrations/0000_init.sql - ``` - - This creates all tables (users, properties, bills, etc.) in your local SQLite database. - -5. **Start Development Server** - - ```bash - pnpm run dev - ``` - - The app will be running at `http://localhost:4321` - -6. **Test the App** - - Visit `http://localhost:4321/signup` - - Sign up with any email and password - - The 6-digit OTP verification code will be printed in your terminal - - Copy the code and verify your email - - You're in! Create a property and start billing. - -7. **(Optional) Seed demo data** - - To get a fully populated dashboard without manual setup, reset the local database and load realistic demo fixtures: - - ```bash - pnpm seed:fresh - ``` - - This deletes any existing local data, re-applies the schema, and inserts: - - 1 property ("Sunshine Residency") with solar enabled - - 6 months of billing periods (Jan–Jun 2026) with meter readings and bills - - 2 demo accounts: - - | Role | Email | Password | - | ------ | ------------------------- | --------------- | - | Owner | `owner@demo.meterly.app` | `DemoOwner123` | - | Tenant | `tenant@demo.meterly.app` | `DemoTenant123` | - - To reseed without deleting `.wrangler/` entirely: - - ```bash - pnpm seed - ``` - - > Never commit real credentials or user data to `scripts/seed.ts`. - -### Common Issues - -**Q: Turnstile widget not showing?** -A: Check that `PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"` is uncommented in `.dev.vars`. If you want dev mode without Turnstile (faster), comment it out — forms will still work. - -**Q: Database errors or "table does not exist"?** -A: Make sure you ran the migration with the `--local` flag and that your `database_id` in `wrangler.jsonc` matches the one from `wrangler d1 create`: - -```bash -pnpm exec wrangler d1 execute meterly-db --local --file=./src/db/migrations/0000_init.sql -``` - -**Q: Email OTP not arriving?** -A: In development mode, OTPs are **printed to your terminal/console**. Check the logs where you ran `pnpm run dev`. You don't need a working mailer for local dev. - ---- - -## How D1 Database Works in Local Dev - -Meterly uses **Cloudflare D1** (SQLite) as its database. Here's how it works in local development: - -### Local D1 Mocking - -When you run `pnpm run dev`, **Wrangler automatically creates a local SQLite database** in the `.wrangler/state/v3/d1` directory. This is a real SQLite database file that persists between dev server restarts. - -**Key points:** - -- **No separate setup needed** — Wrangler handles it automatically -- **Database location:** `.wrangler/state/v3/d1/miniflare-D1DatabaseObject/` -- **Persistence:** Data survives dev server restarts (unless you delete `.wrangler/`) -- **Migrations:** You must manually apply schema changes using `wrangler d1 execute --local` - -### Local vs Remote D1 - -| Aspect | Local (`--local` flag) | Remote (production) | -| -------------------- | ----------------------------- | ------------------------------ | -| **Database** | SQLite file in `.wrangler/` | Cloudflare's distributed D1 | -| **Data persistence** | Survives restarts | Permanent | -| **Apply migrations** | `wrangler d1 execute --local` | `wrangler d1 execute --remote` | -| **When to use** | Development & testing | Production deployment | - -### Why You See `preview_database_id: "local"` in wrangler.jsonc - -This tells Wrangler to use the local SQLite file instead of hitting the remote D1 database during development. This is **intentional** and saves you from needing separate dev/prod databases. - ---- - -## How R2 Storage Works in Local Dev - -Meterly uses **Cloudflare R2** for storing meter bill photos. - -When you run `pnpm run dev`, **Wrangler automatically creates a local R2 bucket** in the `.wrangler/state/v3/r2` directory. You do not need to create a real bucket on Cloudflare for local development. - -**Key points:** - -- **No separate setup needed** — Wrangler handles it automatically via the `r2_buckets` configuration in `wrangler.jsonc`. -- **Database location:** `.wrangler/state/v3/r2/` -- **Persistence:** Files survive dev server restarts (unless you delete `.wrangler/`). -- **Production:** For production, you will need to create the real bucket using `pnpm exec wrangler r2 bucket create meterly-bills`. - ---- - -## Database Migrations - -### What Are Migrations? - -Migrations are **version-controlled SQL files** that describe every change made to the database schema over time. Meterly has a single squashed baseline: - -- `0000_init.sql` — The complete starting schema (all tables, indexes, and constraints in one file). - -Every change you make to `src/db/schema/` after this point becomes a new numbered migration file, starting from `0001`. These files live alongside your code in Git, which means the database history is auditable, reversible, and reproducible across environments. - -### Why Use Migrations Instead of `drizzle-kit push`? - -| Approach | Migrations (Recommended) | Direct Push (Avoid) | -| ---------------------- | ---------------------------------- | -------------------------------------- | -| **Version control** | Every change is tracked in Git | No history — impossible to audit | -| **Rollback** | Revert by reversing the migration | Very difficult — manual SQL required | -| **Team collaboration** | Everyone sees exactly what changed | Silent divergence between environments | -| **Production safety** | Review SQL before applying | Can break production mid-push | -| **Multi-environment** | Apply same file to dev and prod | Hard to keep environments consistent | - -### When to Create a New Migration - -Any time you edit a file inside `src/db/schema/`, you need a migration. The workflow is: - -```bash -# 1. Edit your schema file (add a column, add a table, add an index) - -# 2. Generate the migration SQL — Drizzle diffs your schema against the last snapshot -pnpm exec drizzle-kit generate -# A new file appears: src/db/migrations/0001_describe_change.sql - -# 3. Apply it to your local database and verify the app works -pnpm exec wrangler d1 execute meterly-db --local --file=./src/db/migrations/0001_describe_change.sql -pnpm run dev - -# 4. When you deploy, apply to production before pushing new code -pnpm exec wrangler d1 execute meterly-db --remote --file=./src/db/migrations/0001_describe_change.sql -pnpm run deploy -``` - -**The rule:** migrations go to production _before_ the code that depends on them. Never the other way around. - -### Never Edit an Applied Migration - -Once a migration file has been applied to any environment (local or remote), treat it as immutable. If you made a mistake, create a new migration to fix it — do not edit the existing file. Editing applied migrations breaks the diff chain that Drizzle uses to generate future migrations correctly. - -### Can You Use `drizzle-kit push` Instead? - -Yes, but you should not: - -1. No audit trail — you will not know what changed or when -2. No rollback — `push` is destructive and unrecoverable -3. Dangerous in production — a failed push leaves the database in an inconsistent state -4. Team collaboration breaks down — other developers cannot reproduce what you did - -Think of migrations as Git commits for your database. - ---- - -## Development Notes - -### Email OTP in Development - -Meterly uses email OTPs for sign-up verification and password reset. - -**In development, OTPs are always printed to the terminal:** - -``` -┌────────────────────────────────────────────────────┐ -│ [DEV] OTP — EMAIL VERIFICATION -│ Email: you@example.com -│ Code: 483920 -│ (This is only visible in dev — not in production) -└────────────────────────────────────────────────────┘ -``` - -**Three dev-mode behaviors depending on your `.dev.vars` configuration:** - -| Configuration | Behavior | -| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| No `EMAIL_PROVIDER` set | OTP prints to terminal only. No network call. Zero setup needed. | -| `EMAIL_PROVIDER=resend` + `RESEND_API_KEY` set | OTP prints to terminal **and** a test email is sent to `delivered@resend.dev`. Check the [Resend dashboard](https://resend.com/emails) to preview the template. | -| `EMAIL_PROVIDER=atlas` + `ATLAS_MAILER_URL` set | OTP prints to terminal **and** a real email is sent via [Atlas Mailer](https://github.com/VaibhavDaveDev/atlas-mailer.git). | - -**Recommended for first-time setup:** Leave `EMAIL_PROVIDER` unset. Just use the terminal OTP. - -**To test the full email template** in dev: Set `EMAIL_PROVIDER=resend` and your dev `RESEND_API_KEY`. -The email is redirected to Resend's `delivered@resend.dev` test address — no domain required, -no real email sent, but the Resend dashboard shows you the exact template that production will send. - -### Cloudflare Turnstile in Development - -Turnstile bot protection is applied to login, sign-up, and forgot-password forms. - -**Two modes:** - -1. **With Turnstile widget** (set `PUBLIC_TURNSTILE_SITE_KEY` in `.dev.vars`): - - Use test keys: `1x00000000000000000000AA` (always passes) - - Widget appears and works for UI testing -2. **Without Turnstile widget** (comment out `PUBLIC_TURNSTILE_SITE_KEY`): - - Widget doesn't render - - Forms still work (backend auto-mocks validation) - - Faster dev experience - -### Tenant Invite Flow - -When you invite a tenant: - -1. `POST /api/properties/:id/tenancies/invite` creates a tenancy with `status='invited'` and a 7-day expiry -2. Email sent with link to `/invite/[token]` -3. **In dev, the invite URL is also printed to the terminal** - -The tenant visits `/invite/[token]` and either: - -- **Accepts** via `POST /api/invites/:token/accept` — links tenancy to their account -- **Declines** via `POST /api/invites/:token/decline` — sets status to `'declined'` - -The owner can cancel via `DELETE /api/invites/:token/cancel`. - -**Key invite API routes:** - -``` -GET /api/invites/pending — tenant: list pending invites for their email -GET /api/invites/:token — public: resolve invite token -POST /api/invites/:token/accept — tenant: accept invite -POST /api/invites/:token/decline — tenant: decline invite -DELETE /api/invites/:token/cancel — owner: cancel invite -``` - ---- - -## Deployment - -Meterly is optimized for Cloudflare Pages & Workers. - -1. **Create production D1 database:** - - ```bash - # Apply the single baseline migration to production: - pnpm exec wrangler d1 execute meterly-db --remote --file=./src/db/migrations/0000_init.sql - ``` - -2. **Set production secrets:** - - ```bash - pnpm exec wrangler secret put BETTER_AUTH_SECRET - pnpm exec wrangler secret put TURNSTILE_SECRET_KEY - pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET - pnpm exec wrangler secret put CRON_SECRET - ``` - - **Email provider secrets** (set the one matching your `EMAIL_PROVIDER`): - - ```bash - # If EMAIL_PROVIDER=resend (recommended): - pnpm exec wrangler secret put RESEND_API_KEY - - # If EMAIL_PROVIDER=atlas (using [Atlas Mailer](https://github.com/VaibhavDaveDev/atlas-mailer.git)): - pnpm exec wrangler secret put ATLAS_MAILER_SECRET - ``` - - Also set these plain vars in `wrangler.jsonc` (not secrets — they're not sensitive): - - ```jsonc - "vars": { - "EMAIL_PROVIDER": "resend", - "RESEND_FROM": "Meterly " - } - ``` - - > **CRON_SECRET** protects the `/api/cron/reading-reminders` endpoint. - > Generate a strong random string: `openssl rand -hex 32` - > Call this endpoint daily via an external scheduler (e.g. [cron-job.org](https://cron-job.org)) - > with the header `Authorization: Bearer `. - > Without this secret set in production, the cron endpoint will return 401 for any caller. - -3. **Set production environment variables in `wrangler.jsonc`:** - - ```jsonc - { - "vars": { - "ENVIRONMENT": "production", - "BETTER_AUTH_URL": "https://your-domain.pages.dev", - "PUBLIC_BETTER_AUTH_URL": "https://your-domain.pages.dev", - }, - } - ``` - -4. **Deploy:** - ```bash - pnpm run deploy - ``` - ---- - -## Code Quality - -### Linting - -```bash -pnpm run lint -``` - -### Type Checking - -```bash -pnpm run typecheck -``` - -### Testing - -This project uses [Vitest](https://vitest.dev/) with in-memory SQLite (`better-sqlite3`) for fast, isolated tests: - -```bash -pnpm test -``` - ---- +- **Tenant billing history preservation & deferred sweep:** When an owner deletes a property, bill photos and billing records are **not** immediately deleted — they remain accessible as permanent tenant billing history. A deferred sweep (`sweepOrphanedPropertyData`) runs via `waitUntil` on owner delete and tenant archive routes. It permanently deletes R2 bill photos and associated DB history only after **both** conditions are met: the owner has deleted the property **and** every tenant has archived their tenancy. Until all parties have decommissioned their records, the sweep returns early with zero side effects. +- Upload rate-limiting queries existing database records from D1 daily, avoiding the Cloudflare KV write limit entirely. ## Observability Meterly uses `@microlabs/otel-cf-workers` for edge-native observability — traces, logs, and metrics exported as OTLP from inside the Cloudflare Worker runtime. -### Signal overview - -| Signal | Captured by | Backend | -| ------- | -------------------------------- | ---------- | -| Traces | @microlabs auto-instrumentation | Tempo | -| Logs | @microlabs console.log intercept | Loki | -| Metrics | @microlabs | Prometheus | - ```mermaid graph TD subgraph App [Meterly App] @@ -634,24 +224,20 @@ graph TD end ``` -### Local setup (Docker required) - -Start the observability stack in the background: +
+Local observability setup (Docker required) ```bash +# Start the observability stack docker compose -f docker-compose.observability.yml up -d -``` - -**Helpful Docker commands:** -```bash -# View collector logs to debug telemetry flow +# View collector logs docker compose -f docker-compose.observability.yml logs -f otel-collector -# Stop the observability stack (preserves data) +# Stop (preserves data) docker compose -f docker-compose.observability.yml stop -# Completely remove the stack and delete all stored telemetry data +# Remove stack + delete all stored telemetry docker compose -f docker-compose.observability.yml down -v ``` @@ -663,69 +249,63 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 LOG_LEVEL=info ``` -Open Grafana at http://localhost:3000 (admin / admin). -Loki, Tempo, and Prometheus datasources are pre-provisioned. - -### Production (Grafana Cloud) +Open Grafana at `http://localhost:3000` (admin / admin). Loki, Tempo, and Prometheus datasources are pre-provisioned. -Set in Cloudflare Workers → Settings → Variables: +For production (Grafana Cloud), set `OBSERVABILITY_ENABLED=true`, `OTEL_EXPORTER_OTLP_ENDPOINT`, and `GRAFANA_CLOUD_INSTANCE_ID` as plain Workers variables. Store `GRAFANA_CLOUD_API_KEY` as a Cloudflare **secret** (`wrangler secret put GRAFANA_CLOUD_API_KEY`) — never put API keys in plain Variables. -``` -OBSERVABILITY_ENABLED=true -OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp -GRAFANA_CLOUD_INSTANCE_ID= -GRAFANA_CLOUD_API_KEY= -LOG_LEVEL=warn -``` +
-Telemetry flushes via `ctx.waitUntil()` after each response — zero latency impact, free tier compatible. No Logpush or Tail Workers required. +## Security ---- +- **XSS Prevention** — React/Astro auto-escape output; no `dangerouslySetInnerHTML` usage +- **CSRF Protection** — Better Auth uses `SameSite=Lax/Strict` cookies +- **IDOR & Access Control** — every API route validates permissions via inline `isOwner` or `isTenant` checks (no CASL) +- **Rate Limiting** — auth endpoints rate-limited via Better Auth; bill photo uploads rate-limited per user per day +- **Bot Protection** — Cloudflare Turnstile on all auth routes -## Security Measures +### Session Security -Meterly implements robust defenses: +Meterly relies on Better Auth session cookies (`SameSite=Lax`, `HttpOnly`). -- **XSS Prevention:** React/Astro auto-escape output; no `dangerouslySetInnerHTML` usage -- **CSRF Protection:** Better Auth uses `SameSite=Lax/Strict` cookies -- **IDOR & Access Control:** Every API route validates `user.id` against resource ownership -- **Rate Limiting:** Auth endpoints rate-limited via Better Auth. Bill photo uploads are rate-limited per user to prevent abuse (default 10/day, configurable via \`MAX_UPLOADS_PER_DAY\` in \`wrangler.jsonc\`). -- **Bot Protection:** Cloudflare Turnstile on auth routes +**Cloudflare deployment:** Cloudflare provides `CF-Connecting-IP` as the authoritative client IP header. The application uses only `CF-Connecting-IP` and ignores `X-Forwarded-For`, which Cloudflare may preserve or append to rather than strip. IP-spoofing attacks that forge `X-Forwarded-For` have no effect when deployed on Cloudflare and the application reads only `CF-Connecting-IP`. -### Session Security & Cookie Theft Mitigation +**Rolling sessions:** Sessions auto-renew on activity. A session expires after 7 days of inactivity. -Meterly relies on Better Auth session cookies (`SameSite=Lax`, `HttpOnly`). +**Session limits:** Max 3 concurrent sessions per user (FIFO cleanup). A 4th login boots the oldest session. -**Cloudflare deployment:** Cloudflare Workers automatically strip spoofed IP headers -(`X-Forwarded-For` sent by clients) at the edge and replace them with `CF-Connecting-IP`. -This means IP-spoofing attacks cannot bypass IP checks when deployed on Cloudflare. +**If migrating away from Cloudflare:** configure your reverse proxy to set `X-Real-IP`, read only that header in the Hono middleware (never `X-Forwarded-For`), store the client IP at session creation, compare on each request, and revoke on mismatch. -**Rolling sessions:** Sessions auto-renew on activity (configured in `src/api/lib/auth.ts`). -A session expires after 7 days of inactivity. This limits the window where a stolen cookie -is usable. +## Project Structure -**Session limits:** Max 3 concurrent sessions per user (FIFO cleanup). A 4th login boots the -oldest session. +This project uses the **DOX framework** — every domain directory has an `AGENTS.md` file that acts as a binding contract defining purpose, ownership, and architectural rules. -**If migrating away from Cloudflare:** You MUST implement a trusted-IP middleware: +``` +src/ +├── api/ Hono backend routing and pure billing logic +├── components/ React interactive islands (auth, dashboard, properties, ui) +├── db/ Drizzle ORM schemas and migrations +├── layouts/ Astro server-rendered layouts +└── pages/ Astro SSR pages and API routing catch-alls +``` -1. Configure your reverse proxy (nginx/caddy) to set `X-Real-IP` from the actual client IP. -2. In the Hono middleware, read ONLY `X-Real-IP` (never `X-Forwarded-For` from clients). -3. At session creation, store the client IP. -4. On each request, compare `X-Real-IP` against the stored session IP. -5. On mismatch, revoke the session and return 401. - Note: This will cause false positives for mobile users switching between WiFi and cellular. - Consider subnet-level matching (`/24`) as a compromise, accepting that residential proxy - attacks from the same subnet can still succeed. +## Quick Start ---- +1. Clone and install: + ```bash + git clone https://github.com/VaibhavDaveDev/Meterly.git + cd Meterly && pnpm install + ``` +2. Copy environment file: `cp .env.example .dev.vars` +3. Create a D1 database: `pnpm exec wrangler d1 create meterly-db`, then paste the `database_id` into `wrangler.jsonc` +4. Apply schema: `pnpm exec wrangler d1 execute meterly-db --local --file=./src/db/migrations/0000_init.sql` +5. Start dev server: `pnpm run dev` — app is at `http://localhost:4321` -## Contributing +OTP codes for sign-up and password reset print directly to your terminal in development — no mailer needed. -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution guide — local setup, code standards, migration rules, testing, and the PR process. +For full local setup, migrations, seeding, and contributor workflow see [CONTRIBUTING.md](CONTRIBUTING.md). ---- +For production deployment see [DEPLOY.md](DEPLOY.md). ## License -AGPL-3.0. See `LICENSE` for details. +[AGPL-3.0](LICENSE) diff --git a/src/api/lib/AGENTS.md b/src/api/lib/AGENTS.md index c676fba..ed32cce 100644 --- a/src/api/lib/AGENTS.md +++ b/src/api/lib/AGENTS.md @@ -1,15 +1,19 @@ # Purpose + Core business logic and utility functions for the Meterly API. # Ownership + API and Backend developers. # Local Contracts + - All files in this directory must be pure functions or stateless helpers where possible. - Complex logic (like billing) must be accompanied by comprehensive unit tests. # Work Guidance -- `billing-engine.ts`: The source of truth for all meter-to-currency calculations. 100% unit test coverage required. + +- `billing-engine.ts`: The source of truth for all meter-to-currency calculations. 100% unit test coverage required. Start-value resolution resolves prior period readings using an `innerJoin` on `billingPeriods` (skipping periods with no submitted meter readings). - `auth.ts`: Better Auth instance — email+password primary, Google and GitHub OAuth secondary, emailOTP for password reset. Rolling sessions enabled (7-day expiry, 1-day roll window). Hooks: `onPasswordUpdate` sends security email; `after[/sign-up/email]` resolves avatar via `resolveAvatarAtSignup`. - `session-limit.ts`: FIFO session cleanup logic executed on login. - `email.ts`: Provider-agnostic email abstraction. Supports Resend (primary, `EMAIL_PROVIDER=resend`) and [Atlas Mailer](https://github.com/VaibhavDaveDev/atlas-mailer.git) (fallback, `EMAIL_PROVIDER=atlas`). `RESEND_FROM` configures the sender address. All logic gated by `EMAIL_PROVIDER` env var — no hardcoded provider. See `MAILER.md`. @@ -21,4 +25,5 @@ API and Backend developers. - Rate limits: `MAX_READINGS_PER_DAY` (default 20) and `MAX_UPLOADS_PER_DAY` (default 60) are read from env vars; max 3 photos per reading period per user. # Child DOX Index + - [MAILER.md](./MAILER.md): Email provider integration contract (Resend + [Atlas Mailer](https://github.com/VaibhavDaveDev/atlas-mailer.git)). diff --git a/src/api/lib/billing-engine.ts b/src/api/lib/billing-engine.ts index 31c2406..6a8fee7 100644 --- a/src/api/lib/billing-engine.ts +++ b/src/api/lib/billing-engine.ts @@ -133,9 +133,18 @@ export async function resolveAndValidateStartValues( period: BillingPeriod, data: ResolveAndValidateStartValuesInput ): Promise { - const [previousPeriod] = await db - .select() - .from(billingPeriods) + // Latest prior period that has a reading — skips draft/empty periods + const [prevReading] = await db + .select({ + solarGenerationEnd: meterReadings.solarGenerationEnd, + exportEnd: meterReadings.exportEnd, + importEnd: meterReadings.importEnd, + }) + .from(meterReadings) + .innerJoin( + billingPeriods, + eq(meterReadings.billingPeriodId, billingPeriods.id) + ) .where( and( eq(billingPeriods.propertyId, period.propertyId), @@ -145,26 +154,17 @@ export async function resolveAndValidateStartValues( .orderBy(desc(billingPeriods.periodMonth)) .limit(1); - let startValues = { - solarGenerationStart: property.solarGenInitial || 0, - exportStart: property.solarExportInitial || 0, - importStart: 0, - }; - - if (previousPeriod) { - const [prevReading] = await db - .select() - .from(meterReadings) - .where(eq(meterReadings.billingPeriodId, previousPeriod.id)) - .limit(1); - if (prevReading) { - startValues = { + const startValues = prevReading + ? { solarGenerationStart: prevReading.solarGenerationEnd, exportStart: prevReading.exportEnd, importStart: prevReading.importEnd, + } + : { + solarGenerationStart: property.solarGenInitial || 0, + exportStart: property.solarExportInitial || 0, + importStart: 0, }; - } - } if (!data.allowRollover) { if (data.importEnd < startValues.importStart) { diff --git a/src/api/routes/bills.test.ts b/src/api/routes/bills.test.ts index 46b3550..c576d1c 100644 --- a/src/api/routes/bills.test.ts +++ b/src/api/routes/bills.test.ts @@ -74,33 +74,27 @@ describe("Bills API", () => { await testDb .insert(properties) .values({ id: "p1", name: "Prop", ownerId: "owner-id", hasSolar: false }); - await testDb - .insert(billingPeriods) - .values({ - id: "bp1", - propertyId: "p1", - periodMonth: "2024-01", - calculationMode: "solar", - status: "draft", - }); - await testDb - .insert(tenancies) - .values({ - id: "ten1", - propertyId: "p1", - tenantId: "tenant-id", - status: "active", - }); - await testDb - .insert(meterReadings) - .values({ - id: "mr1", - billingPeriodId: "bp1", - submittedBy: "owner-id", - solarGenerationEnd: 100, - exportEnd: 0, - importEnd: 0, - }); + await testDb.insert(billingPeriods).values({ + id: "bp1", + propertyId: "p1", + periodMonth: "2024-01", + calculationMode: "solar", + status: "draft", + }); + await testDb.insert(tenancies).values({ + id: "ten1", + propertyId: "p1", + tenantId: "tenant-id", + status: "active", + }); + await testDb.insert(meterReadings).values({ + id: "mr1", + billingPeriodId: "bp1", + submittedBy: "owner-id", + solarGenerationEnd: 100, + exportEnd: 0, + importEnd: 0, + }); billId = "bill-1"; await testDb.insert(bills).values({ @@ -136,4 +130,65 @@ describe("Bills API", () => { const res = await app.request(`/bills/${billId}`, {}, mockEnv as never); expect(res.status).toBe(200); }); + + it("mark-paid: returns markedPaidAt and markedPaidBy on successful update", async () => { + // Tests the happy path where the post-update re-select succeeds. + currentUser = { id: "owner-id" }; + const mockCtx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + const res = await app.request( + `/bills/${billId}/mark-paid`, + { + method: "PATCH", + }, + mockEnv as never, + mockCtx as never + ); + + const json = (await res.json()) as { + data: { status: string; markedPaidAt: string; markedPaidBy: string }; + }; + expect(res.status).toBe(200); + expect(json.data.status).toBe("paid"); + expect(json.data.markedPaidAt).toBeTruthy(); + expect(json.data.markedPaidBy).toBe("owner-id"); + }); + + it("mark-paid: returns defensive fallback object if post-update re-select returns no row", async () => { + // Tests the fallback branch where the post-update query returns no row. + currentUser = { id: "owner-id" }; + const mockCtx = { waitUntil: vi.fn(), passThroughOnException: vi.fn() }; + + // We add a temporary trigger to delete the bill as soon as it is updated. + // This perfectly intercepts the post-update select and forces it to return empty. + const { sql } = await import("drizzle-orm"); + testDb.run(sql` + CREATE TRIGGER IF NOT EXISTS delete_bill_after_update + AFTER UPDATE ON bills + FOR EACH ROW + BEGIN + DELETE FROM bills WHERE id = NEW.id; + END; + `); + + try { + const res = await app.request( + `/bills/${billId}/mark-paid`, + { + method: "PATCH", + }, + mockEnv as never, + mockCtx as never + ); + + const json = (await res.json()) as { + data: { status: string; markedPaidAt: string; markedPaidBy: string }; + }; + expect(res.status).toBe(200); + expect(json.data.status).toBe("paid"); + expect(json.data.markedPaidAt).toBeTruthy(); + expect(json.data.markedPaidBy).toBe("owner-id"); + } finally { + testDb.run(sql`DROP TRIGGER IF EXISTS delete_bill_after_update;`); + } + }); }); diff --git a/src/api/routes/bills.ts b/src/api/routes/bills.ts index 4719224..2e17ae9 100644 --- a/src/api/routes/bills.ts +++ b/src/api/routes/bills.ts @@ -336,11 +336,12 @@ billsRouter.openapi(markPaidRoute, async (c) => { ); } + const markedPaidAt = new Date(); await db .update(bills) .set({ status: "paid", - markedPaidAt: new Date(), + markedPaidAt, markedPaidBy: user.id, }) .where(eq(bills.id, billId)); @@ -354,7 +355,7 @@ billsRouter.openapi(markPaidRoute, async (c) => { const [tenancy] = await db .select() .from(tenancies) - .where(eq(tenancies.id, updatedBill.tenancyId)) + .where(eq(tenancies.id, bill.tenancyId)) .limit(1); if (tenancy && tenancy.tenantId) { c.executionCtx.waitUntil( @@ -372,7 +373,12 @@ billsRouter.openapi(markPaidRoute, async (c) => { return c.json( { success: true as const, - data: updatedBill, + data: updatedBill ?? { + ...bill, + status: "paid" as const, + markedPaidAt, + markedPaidBy: user.id, + }, }, 200 ); diff --git a/src/api/routes/edit-requests.ts b/src/api/routes/edit-requests.ts index 3da98e9..9774b5a 100644 --- a/src/api/routes/edit-requests.ts +++ b/src/api/routes/edit-requests.ts @@ -21,6 +21,19 @@ import { IdParam, } from "../lib/openapi-schemas"; +// Proposed values schema — validated before any read/write +const proposedValuesSchema = z + .object({ + importEnd: z.number().optional(), + exportEnd: z.number().optional(), + solarGenerationEnd: z.number().optional(), + }) + .refine((values) => Object.values(values).some((v) => v !== undefined), { + message: "At least one proposed value is required", + }); + +type ProposedValues = z.infer; + async function loadRequestContext( db: Database, requests: (typeof editRequests.$inferSelect)[] @@ -135,11 +148,7 @@ const CreateEditRequestSchema = z.object({ "Please explain why this reading needs to be corrected (at least 10 characters). Your landlord needs to understand what went wrong. Example: 'I read the meter incorrectly when I submitted — it should be 1150, not 1200.'" ) .max(1000, "Reason too long (max 1000 characters)"), - proposedValues: z.object({ - solarGenerationEnd: z.number().optional(), - exportEnd: z.number().optional(), - importEnd: z.number().optional(), - }), + proposedValues: proposedValuesSchema, }); const createEditRequestRoute = createRoute({ @@ -345,13 +354,6 @@ requestsRouter.openapi(createEditRequestRoute, async (c) => { ) .limit(1); - if (existingRequest) { - await db - .update(editRequests) - .set({ status: "cancelled" }) - .where(eq(editRequests.id, existingRequest.id)); - } - // Throttle check — scoped to this property only const [pendingCountResult] = await db .select({ val: count() }) @@ -368,7 +370,10 @@ requestsRouter.openapi(createEditRequestRoute, async (c) => { ) ); - const pendingCount = pendingCountResult?.val || 0; + // Subtract the existing request that is being replaced — it shouldn't count + // against the limit since we are overwriting it, not adding a new one + const pendingCount = + (pendingCountResult?.val || 0) - (existingRequest ? 1 : 0); if ( property.maxPendingEditRequests !== 0 && pendingCount >= (property.maxPendingEditRequests || 3) @@ -386,6 +391,14 @@ requestsRouter.openapi(createEditRequestRoute, async (c) => { ); } + // Replace the tenant's previous pending request — only after throttle passes + if (existingRequest) { + await db + .update(editRequests) + .set({ status: "cancelled" }) + .where(eq(editRequests.id, existingRequest.id)); + } + const requestId = crypto.randomUUID(); await db.insert(editRequests).values({ id: requestId, @@ -722,7 +735,17 @@ requestsRouter.openapi(reviewRequestRoute, async (c) => { .where(eq(editRequests.id, requestId)); // ponytail: fetch proposed/current readings to construct rejection message with context - const proposedValues = JSON.parse(request.proposedValues || "{}"); + const proposedValuesRaw = (() => { + try { + return JSON.parse(request.proposedValues || "{}"); + } catch { + return {}; + } + })(); + const parsedProposed = proposedValuesSchema.safeParse(proposedValuesRaw); + const proposedValues: Partial = parsedProposed.success + ? parsedProposed.data + : {}; const [reading] = await db .select() .from(meterReadings) @@ -787,7 +810,27 @@ If you believe this is an error, you can submit a new correction request with ad } // Approval flow - const proposedValues = JSON.parse(request.proposedValues || "{}"); + const parsedProposed = (() => { + try { + return proposedValuesSchema.safeParse(JSON.parse(request.proposedValues)); + } catch { + return null; + } + })(); + if (!parsedProposed || !parsedProposed.success) { + return c.json( + { + success: false as const, + error: { + code: "INVALID_PROPOSED_VALUES", + message: + "Stored proposed values are malformed and cannot be applied.", + }, + }, + 400 + ); + } + const proposedValues: ProposedValues = parsedProposed.data; // Update the actual reading const [reading] = await db diff --git a/src/api/routes/meter-readings.ts b/src/api/routes/meter-readings.ts index 1cb0d81..a6eb0e2 100644 --- a/src/api/routes/meter-readings.ts +++ b/src/api/routes/meter-readings.ts @@ -131,16 +131,18 @@ readingsRouter.openapi(getPeriodContextRoute, async (c) => { ); } - // Determine start values - let startValues = { - solarGenerationStart: property.solarGenInitial || 0, - exportStart: property.solarExportInitial || 0, - importStart: 0, - }; - - const [previousPeriod] = await db - .select() - .from(billingPeriods) + // Latest prior period that has a reading — matches billing-engine.ts resolution + const [prevReadingCtx] = await db + .select({ + solarGenerationEnd: meterReadings.solarGenerationEnd, + exportEnd: meterReadings.exportEnd, + importEnd: meterReadings.importEnd, + }) + .from(meterReadings) + .innerJoin( + billingPeriods, + eq(meterReadings.billingPeriodId, billingPeriods.id) + ) .where( and( eq(billingPeriods.propertyId, period.propertyId), @@ -150,20 +152,17 @@ readingsRouter.openapi(getPeriodContextRoute, async (c) => { .orderBy(desc(billingPeriods.periodMonth)) .limit(1); - if (previousPeriod) { - const [prevReading] = await db - .select() - .from(meterReadings) - .where(eq(meterReadings.billingPeriodId, previousPeriod.id)) - .limit(1); - if (prevReading) { - startValues = { - solarGenerationStart: prevReading.solarGenerationEnd, - exportStart: prevReading.exportEnd, - importStart: prevReading.importEnd, + const startValues = prevReadingCtx + ? { + solarGenerationStart: prevReadingCtx.solarGenerationEnd, + exportStart: prevReadingCtx.exportEnd, + importStart: prevReadingCtx.importEnd, + } + : { + solarGenerationStart: property.solarGenInitial || 0, + exportStart: property.solarExportInitial || 0, + importStart: 0, }; - } - } // Determine current rates const [rate] = await db diff --git a/src/components/properties/PropertySettings.tsx b/src/components/properties/PropertySettings.tsx index bbc250d..3028263 100644 --- a/src/components/properties/PropertySettings.tsx +++ b/src/components/properties/PropertySettings.tsx @@ -1,6 +1,7 @@ import { ConfirmDialog } from "../common/ConfirmDialog"; import type { Property } from "../../types/db"; import { usePropertySettings } from "../../hooks/use-property-settings"; +import { parseDateInput } from "../../lib/format"; import { GeneralSettings, SolarSettings, @@ -76,11 +77,11 @@ export function PropertySettings({ /> { + if (!localProperty.solarActivatedAt) return null; + const { date } = parseDateInput(localProperty.solarActivatedAt); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + })()} solarGenInitial={localProperty.solarGenInitial} solarExportInitial={localProperty.solarExportInitial} isSolarLoading={isSolarLoading} diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md index 9476770..65a0505 100644 --- a/src/lib/AGENTS.md +++ b/src/lib/AGENTS.md @@ -9,7 +9,8 @@ Frontend and Fullstack developers. # Local Contracts - `auth-client.ts`: Better Auth client instance. -- `api-client.ts`: Generic wrapper for Meterly API calls. +- `api-client.ts`: Generic wrapper for Meterly API calls. Handles 401 (redirect to login) and 403 Forbidden (redirect to `/verify-email` for unverified users). +- `format.ts`: Date and currency formatting. `parseDateInput` explicitly rejects out-of-range calendar dates by validating parsed date components. - `utils.ts`: General-purpose frontend utility functions. - `image-compress.ts`: Browser Canvas-based image compression to WebP. - `image-cache.ts`: IndexedDB cache for meter bill photos. diff --git a/src/lib/api-client.test.ts b/src/lib/api-client.test.ts index 7464140..129dd84 100644 --- a/src/lib/api-client.test.ts +++ b/src/lib/api-client.test.ts @@ -1,4 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + afterEach, + vi, +} from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { apiClient } from "./api-client"; @@ -101,6 +109,79 @@ describe("apiClient", () => { }); }); + describe("apiFetch — 403 handling", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { pathname: "/dashboard", search: "", href: "/dashboard" }, + }); + server.use( + http.get("/api/forbidden-redirect", () => + HttpResponse.json( + { + success: false, + error: { code: "FORBIDDEN", message: "Email not verified" }, + }, + { status: 403 } + ) + ), + http.get("/api/forbidden-no-redirect", () => + HttpResponse.json( + { + success: false, + error: { + code: "OWNERSHIP_REQUIRED", + message: "You do not own this resource", + }, + }, + { status: 403 } + ) + ), + http.get("/api/forbidden-no-message", () => + HttpResponse.json( + { success: false, error: { code: "OWNERSHIP_REQUIRED" } }, + { status: 403 } + ) + ) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("redirects to /verify-email when 403 with FORBIDDEN code", async () => { + const result = await apiClient.get("/forbidden-redirect"); + expect(window.location.href).toBe("/verify-email"); + expect(result.data).toBeNull(); + expect(result.error?.message).toBe("Email verification required"); + }); + + it("does not redirect to /verify-email when already on /verify-email", async () => { + vi.stubGlobal("window", { + location: { + pathname: "/verify-email", + search: "", + href: "/verify-email", + }, + }); + const result = await apiClient.get("/forbidden-redirect"); + expect(window.location.href).toBe("/verify-email"); + expect(result.error?.message).toBe("Email verification required"); + }); + + it("returns generic error message when 403 is not FORBIDDEN code", async () => { + const result = await apiClient.get("/forbidden-no-redirect"); + expect(window.location.href).toBe("/dashboard"); + expect(result.data).toBeNull(); + expect(result.error?.message).toBe("You do not own this resource"); + }); + + it("returns fallback message when 403 body has no error message", async () => { + const result = await apiClient.get("/forbidden-no-message"); + expect(result.error?.message).toBe("Forbidden"); + }); + }); + it("does not expose the removed archiveTenancy/unarchiveTenancy helpers", () => { expect( (apiClient as unknown as Record).archiveTenancy @@ -109,4 +190,53 @@ describe("apiClient", () => { (apiClient as unknown as Record).unarchiveTenancy ).toBeUndefined(); }); -}); \ No newline at end of file + + describe("defensive parsing", () => { + it("handles non-JSON / HTML 403 response gracefully without throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 403, + ok: false, + json: () => + Promise.reject(new SyntaxError("Unexpected token < in JSON")), + }) + ); + const result = await apiClient.get("/protected"); + expect(result.data).toBeNull(); + expect(result.error?.message).toBe("Forbidden"); + vi.unstubAllGlobals(); + }); + + it("handles non-JSON / HTML 500 response gracefully without throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 500, + ok: false, + json: () => + Promise.reject(new SyntaxError("Unexpected token < in JSON")), + }) + ); + const result = await apiClient.get("/error-endpoint"); + expect(result.data).toBeNull(); + expect(result.error?.message).toBe("API Error: 500"); + vi.unstubAllGlobals(); + }); + + it("handles 200 OK with unparseable JSON body gracefully", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + ok: true, + json: () => Promise.reject(new SyntaxError("Unexpected token")), + }) + ); + const result = await apiClient.get("/corrupted-data"); + expect(result.data).toBeNull(); + expect(result.error?.message).toBe("An unknown error occurred"); + vi.unstubAllGlobals(); + }); + }); +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f72f065..aece2de 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -45,24 +45,53 @@ async function apiFetch( } return { data: null, error: { message: "Unauthorized" } }; } + if (response.status === 403) { + const errorData = (await response + .json() + .catch(() => null)) as ApiResponse | null; + + if (errorData?.error?.code === "FORBIDDEN") { + if ( + typeof window !== "undefined" && + window.location.pathname !== "/verify-email" + ) { + window.location.href = "/verify-email"; + } + return { + data: null, + error: { message: "Email verification required" }, + }; + } + + return { + data: null, + error: { + message: errorData?.error?.message || "Forbidden", + }, + }; + } if (!response.ok) { - const errorData = (await response.json()) as ApiResponse; + const errorData = (await response + .json() + .catch(() => null)) as ApiResponse | null; return { data: null, error: { - message: errorData.error?.message || `API Error: ${response.status}`, + message: errorData?.error?.message || `API Error: ${response.status}`, }, }; } - const responseData = (await response.json()) as ApiResponse; - if (responseData.success) { + const responseData = (await response + .json() + .catch(() => null)) as ApiResponse | null; + if (responseData?.success) { return { data: responseData.data, error: null }; } else { return { data: null, error: { - message: responseData.error?.message || "An unknown error occurred", + message: responseData?.error?.message || "An unknown error occurred", }, }; } diff --git a/src/lib/format.ts b/src/lib/format.ts index e2cedaa..b2e19ad 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -20,7 +20,7 @@ export function formatMeterReading( return Math.floor(num).toLocaleString("en-IN"); } -function parseDateInput(val: string | Date | null | undefined): { +export function parseDateInput(val: string | Date | null | undefined): { date: Date; isCalendarDate: boolean; } { @@ -31,10 +31,16 @@ function parseDateInput(val: string | Date | null | undefined): { const year = parseInt(match[1], 10); const month = parseInt(match[2], 10); const day = match[3] ? parseInt(match[3], 10) : 1; - return { - date: new Date(Date.UTC(year, month - 1, day)), - isCalendarDate: true, - }; + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + if ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ) { + return { date, isCalendarDate: true }; + } + return { date: new Date(NaN), isCalendarDate: true }; } } return { date: new Date(val), isCalendarDate: false }; diff --git a/src/lib/pdf-extract-smoke.test.ts b/src/lib/pdf-extract-smoke.test.ts index 499ab01..cbe3c1c 100644 --- a/src/lib/pdf-extract-smoke.test.ts +++ b/src/lib/pdf-extract-smoke.test.ts @@ -3,13 +3,12 @@ * * Environment notes: * - Vitest uses jsdom, which provides `document`, `File`, and `ArrayBuffer`. - * - pdfjs-dist requires a Web Worker for rendering (workerSrc). jsdom does not - * support real Web Workers, so the worker is set to an empty string to force - * pdfjs into a no-worker (synchronous) mode. This is the same approach used - * in browser testing environments. - * - The canvas API (used by the OCR fallback path) is not available in jsdom by - * default, so we only test the text-extraction path (extractFromText), plus - * the graceful error-handling paths of extractFromPdf for malformed inputs. + * - pdfjs-dist 6.x and the full OCR pipeline require `DOMMatrix`, `HTMLCanvasElement`, + * and `Worker` support, which are not fully implemented in jsdom. The `extractFromPdf` + * suite is skipped under headless/jsdom via the capability predicate below. + * - This file covers: (a) the pure regex logic via `extractFromText` (always + * runs), and (b) the real pdfjs extraction path via `extractFromPdf` (runs + * only in environments that provide DOMMatrix, Canvas, and Web Workers, e.g. a real browser). * * The minimal valid PDF used in these tests was hand-crafted following the * ISO 32000-1 specification. It contains a single page with one text object. @@ -85,7 +84,13 @@ describe("extractFromText (smoke -- unmocked)", () => { // without pdfjs-dist. Error-path coverage for extractFromPdf is provided by the // existing mocked suite in pdf-extract.test.ts. -describe.skipIf(typeof DOMMatrix === "undefined")( +// Full browser environment predicate: requires DOMMatrix, Canvas, and Web Worker +const hasFullBrowserPdfEnv = + typeof DOMMatrix !== "undefined" && + typeof HTMLCanvasElement !== "undefined" && + typeof Worker !== "undefined"; + +describe.skipIf(!hasFullBrowserPdfEnv)( "extractFromPdf (smoke — real pdfjs, no mock)", () => { it("handles a truly empty ArrayBuffer as a corrupt PDF (graceful error)", async () => { diff --git a/src/lib/pdf-extract.ts b/src/lib/pdf-extract.ts index 92f7cf7..eae8409 100644 --- a/src/lib/pdf-extract.ts +++ b/src/lib/pdf-extract.ts @@ -2,8 +2,11 @@ // Returns structured field values using regex patterns on the raw text. // Falls back gracefully if pdf.js is unavailable. // -// IMPORTANT: pdfjs-dist must be lazy-imported so it doesn't inflate -// the initial JS bundle for users who never upload a bill. +// IMPORTANT: pdfjs-dist must be lazy-imported (using `await import("pdfjs-dist")`) +// instead of a static top-level import. This serves two critical purposes: +// 1. It prevents inflating the initial JS bundle for users who never upload a bill. +// 2. It prevents crashes in the jsdom test runner. `pdfjs-dist` evaluates DOMMatrix +// at module evaluation time, which jsdom does not support, causing immediate test suite failures. export interface BillExtractResult { presentReadingImport: number | null; diff --git a/src/test/auth-middleware.test.ts b/src/test/auth-middleware.test.ts new file mode 100644 index 0000000..e89e974 --- /dev/null +++ b/src/test/auth-middleware.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { authMiddleware } from "../api/middleware/auth"; + +// Mock getAuth +vi.mock("../api/lib/auth", () => ({ + getAuth: vi.fn(), +})); + +import { getAuth } from "../api/lib/auth"; + +function makeApp() { + const app = new Hono<{ + Bindings: Record; + Variables: Record; + }>(); + app.use("/protected", authMiddleware); + app.get("/protected", (c) => c.json({ ok: true })); + return app; +} + +describe("authMiddleware", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 401 when no session exists", async () => { + (getAuth as ReturnType).mockReturnValue({ + api: { getSession: vi.fn().mockResolvedValue(null) }, + }); + const app = makeApp(); + const res = await app.request("/protected"); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("UNAUTHORIZED"); + }); + + it("returns 403 when session user email is not verified", async () => { + (getAuth as ReturnType).mockReturnValue({ + api: { + getSession: vi.fn().mockResolvedValue({ + user: { id: "u1", emailVerified: false }, + session: { id: "s1" }, + }), + }, + }); + const app = makeApp(); + const res = await app.request("/protected"); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("FORBIDDEN"); + }); + + it("continues to next handler when session is valid and email verified", async () => { + (getAuth as ReturnType).mockReturnValue({ + api: { + getSession: vi.fn().mockResolvedValue({ + user: { id: "u1", emailVerified: true }, + session: { id: "s1" }, + }), + }, + }); + const app = makeApp(); + const res = await app.request("/protected"); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); +}); diff --git a/wrangler.jsonc.example b/wrangler.jsonc.example index af8523a..23268be 100644 --- a/wrangler.jsonc.example +++ b/wrangler.jsonc.example @@ -21,7 +21,7 @@ { "binding": "BILL_PHOTOS", "bucket_name": "meterly-bills", - "preview_bucket_name": "meterly-bills" + "preview_bucket_name": "meterly-bills-preview" } ] }