# Den — project guide

Private, multi-user productivity app (projects, time-tracking/invoicing, habits/routines,
contacts, encrypted vault) at **den.fearthewild.com**, with a native Android app (Capacitor)
and an iOS app in progress. Brand: FearTheWild.

This file is the cross-machine source of truth — it travels with the repo (Claude Code's
per-machine memory does not). Keep it current when project state changes.

## Stack & layout
- `api/` — Fastify + better-sqlite3 + TypeBox (TypeScript). SQLite at `/var/lib/den/den.db` in prod.
- `web/` — Vue 3 `<script setup>` + Vite + TS SPA. Built to `web/dist`.
- `app/` — Capacitor native shell (Android live; iOS in progress) wrapping the web build.
  Plugins: local-notifications, preferences, native-biometric, social-login (Google + Apple), revenuecat.
  iOS uses Swift Package Manager (not CocoaPods) for plugins; `app/ios/` is committed.

## Hosting & deploy
- DigitalOcean droplet (Ubuntu), IP `167.99.82.211`, Apache reverse proxy, `den-api.service` (systemd).
- **Auto-deploy on push to `main`**: GitHub Actions (`.github/workflows/deploy.yml`) SSHes the droplet →
  `git reset --hard origin/main` + `scripts/deploy.sh` (npm ci + build `api`, restart `den-api`, npm ci +
  build `web`, health check). Takes ~1–2 min and self-restarts — don't poll health until it settles.
- Health/version check: `https://den.fearthewild.com/api/health` → `{version}` (the api version).
- Per-release version bumps (keep in sync): `api/package.json`, `api/src/server.ts` (`/health` version),
  `web/src/data/constants.ts` `APP_VERSION`, and `web/src/data/changelog.ts` (user-facing entry).
- After pushing, poll `/api/health` until the version flips (a brief 503 during restart is normal).

## Secrets / env (NEVER in the repo)
Set via systemd drop-ins in `/etc/systemd/system/den-api.service.d/*.conf` (`[Service]` +
`Environment="KEY=value"`), applied with `systemctl daemon-reload && systemctl restart den-api`:
- `google.conf` — Google OAuth (`GOOGLE_CLIENT_ID/SECRET`) + `GOOGLE_IOS_CLIENT_ID` (iOS sign-in audience)
- `apple.conf` — Sign in with Apple: `APPLE_BUNDLE_ID` (=`com.fearthewild.den`) + `APPLE_SERVICES_ID`
  (=`com.fearthewild.den.signin`). **LIVE** — Apple sign-in works on web + iOS. Apple Team ID `W858DLWZG5`.
- `stripe.conf` — `STRIPE_SECRET_KEY`, `STRIPE_PRICE_MONTHLY/ANNUAL`, `STRIPE_WEBHOOK_SECRET` (LIVE)
- `revenuecat.conf` — `REVENUECAT_SECRET_KEY` (RC v1 REST key), `REVENUECAT_WEBHOOK_AUTH` (shared header value)
**Public** client keys ARE committed (publishable) in `web/src/data/constants.ts`: RevenueCat Android +
iOS SDK keys `RC_ANDROID_KEY` / `RC_IOS_KEY`, and the Google iOS client id `GOOGLE_IOS_CLIENT_ID`.

**Apache CSP (NOT in the repo — on the droplet)**: `den.fearthewild.com-le-ssl.conf` sets a
`Content-Security-Policy`. Sign in with Apple's web popup needs `https://appleid.cdn-apple.com`
(script/style-src) + `https://appleid.apple.com` (connect/form-action/frame-src) — already added. If the
vhost is ever rebuilt, re-add those or web Apple sign-in breaks with a CSP `script-src` error.

## Building the Android app (Capacitor)
From `app/`:
1. `npm run sync` — runs `copy:web` (copies `web/dist` → `app/www`) THEN `cap sync`.
   **Do not** run bare `npx cap sync` — it skips the web copy and bundles stale assets.
   (So the full flow is: `cd web && npm run build` → `cd app && npm run sync`.)
2. Build the signed AAB from `app/android`:
   `JAVA_HOME=<JDK17+> ./gradlew bundleRelease`
   - Needs **JDK 17+**. On the dev PC that's Android Studio's bundled JBR
     (`C:\Program Files\Android\Android Studio\jbr`). On macOS use the JBR in
     `/Applications/Android Studio.app/Contents/jbr/Contents/Home` or any JDK 17+.
   - Output: `app/android/app/build/outputs/bundle/release/app-release.aab`
- App version lives in `app/android/app/build.gradle` (`versionCode` + `versionName`, e.g. 11 / "1.0.10").
- **Signing (gitignored — see "Working across machines")**: `app/android/key.properties` +
  `app/android/keystore/upload-keystore.jks`. Without these you can build but cannot sign a release.

## Building the iOS app (Capacitor) — Mac only
Requires **full Xcode** (not just Command Line Tools) + CocoaPods (`brew install cocoapods`). Cap 8 uses
SwiftPM for plugins. Bundle id `com.fearthewild.den`; min iOS 15.
1. `cd web && npm run build` → `cd app && npm run sync` (copies `web/dist` → `app/www`, runs `cap sync`).
2. Simulator: `npx cap run ios --target=<udid>` (`xcrun simctl list devices` for the UDID). Or
   `npx cap open ios` and press Run in Xcode.
3. Version: `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` in `app/ios/App/App.xcodeproj`. iOS ships on
   its own App Store track — start **1.0.0 / build 1** (cross-platform parity is via the shared server,
   not matching version numbers).
- **Signing**: via the Apple Developer account in Xcode (Signing & Capabilities) — NO local keystore file
  like Android. Needs the membership active.
- iOS-only web tweaks are gated behind `html.cap-ios` (set in `web/src/main.ts`) so Android is never
  affected — e.g. the safe-area handling (iOS needs `env(safe-area-inset-*)` republished at `:root`
  because env() collapses to 0 inside our backdrop-filter shells; Android gets Capacitor's injected vars).
- The simulator software-renders WebGL, so the ambient shader feels laggy there; smooth on a real device.

## Working across machines (PC ⇄ Mac)
The repo is the source of truth, EXCEPT these gitignored local-only files needed to **sign Android**:
- `app/android/key.properties`
- `app/android/keystore/upload-keystore.jks`  ← the Android **upload key**; irreplaceable. Back it up.
Transfer them out-of-band (AirDrop/USB/password-manager), never via git/chat. Not needed for iOS work.
iOS needs **no** transferable signing files — signing is via the Apple Developer account in Xcode. A fresh
**Mac** checkout for iOS also needs full Xcode + `brew install cocoapods`.
Server secrets stay on the droplet (the app talks to the live server) — nothing to transfer there.
Run `npm install` in `api/`, `web/`, and `app/` on a fresh checkout.

### Fixing a bug across Web / Android / iOS
Most bugs are in the **shared web code** (`web/src`) — fix it **once** and all three platforms get it; there
is NO per-platform re-coding. After a shared fix the flow is:
1. Push → web **auto-deploys**.
2. **Android**: `cd web && npm run build` → `cd app && npm run copy:web && npx cap sync android` →
   `JAVA_HOME=<JDK17+> ./gradlew bundleRelease` (from `app/android`) → upload the AAB.
   (On the PC use `npx cap sync android`, not bare `cap sync` — the iOS platform isn't installed there.)
3. **iOS (Mac only)**: `cd web && npm run build` → `cd app && npx cap sync ios` → archive + upload in Xcode.
Only genuinely platform-specific bugs (native plugins, iOS safe-area gated behind `html.cap-ios`, signing)
need platform-specific edits. So: fix shared code → each platform picks it up on its next build.

## Billing (tier is the single source of truth)
- `users.tier` (`free`/`pro`/`epic`) gates features (`web/src/data/tiers.ts`). Admins get full access.
- **Web**: Stripe Checkout (£5/mo, £45/yr). Routes in `api/src/routes/billing.ts`.
- **Android**: in-app subscriptions via **RevenueCat** → Google Play (£5/£45 to match web).
  App flow in `web/src/revenuecat.ts` (lazy-init; entitlement id `pro`; offering `default`;
  products `pro_monthly`/`pro_annual`). Server verifies via `/billing/play/sync` + `/billing/revenuecat`
  webhook. RevenueCat appUserID = the numeric Den user id.
- **Reconciliation**: Pro if EITHER `stripe_active` OR `play_active` (per-source flags, migration 14).
  Neither webhook downgrades a user still active on the other source.
- **iOS**: in-app subscriptions via **RevenueCat** → App Store (same `pro` entitlement). RevenueCat app
  "Den (App Store)" + `RC_IOS_KEY`; App Store Connect products `pro_monthly`/`pro_annual` in the "Den Pro"
  subscription group; both attached to the `pro` entitlement + `default` offering. The app picks the SDK
  key by platform (`revenuecat.ts`). No server change — RevenueCat's webhook unlocks `pro` for any store
  (sets `play_active`). Verified with a sandbox purchase.

## DB migrations
Numbered, additive, gated by `_schema_version` in `api/src/db.ts` (currently 14). Beta testers have
live data — never destructive. Bump `SCHEMA_VERSION` and append a migration fn.

## Current status (2026-08-23)
- **Web**: live at **0.14.10** — the quota fix (see below). **Android**: v1.0.12 (**code 13**) built on
  the PC 2026-08-23 and **submitted to Play review for Production** — carries the quota fix. Previously:
  v1.0.11 (code 12) → Production.
  (Note: the earlier v1.0.10/code 11 AAB was built but never uploaded — the Play library topped out at
  code 10/1.0.9, so the closed test ran on 1.0.9. Code 12 was the first build carrying all fixes to prod.)
  Android AABs can only be built on the **PC** (the Mac has no JDK, no Android Studio and no keystore).
  On the PC use `npm run copy:web` + `npx cap sync android` — bare `npm run sync` dies on the iOS
  platform, which isn't installed there (the Android copy still completes, but don't rely on it).
- **iOS v1.0.2 (build 4) SUBMITTED to App Review 2026-08-14** (carries the quota fix). Archived +
  exported + uploaded from the Mac; build processed `VALID`, version created and submitted by the user.
  **Release is MANUAL** — click "Release this version" in ASC after approval. Prior versions **1.0 and
  1.0.1 are both `READY_FOR_SALE`** (live; 1.0.1 shipped the login/signup scroll fix on 2026-07-08).
- **iOS uploads now use an App Store Connect API key** instead of an Apple ID + app-specific password:
  `xcrun altool --upload-app -f App.ipa -t ios --apiKey <keyId> --apiIssuer <issuerId>`. The `.p8` lives
  at `~/.appstoreconnect/private_keys/` on the **Mac only** (chmod 600, gitignored by virtue of being
  outside the repo — it is a real credential, never commit it); the key/issuer ids are recorded in
  Claude's local project memory, and the key is revocable at ASC → Users and Access → Integrations.
  The same key drives the ASC REST API — mint an ES256 JWT with Node's built-in `crypto`
  (`dsaEncoding:'ieee-p1363'`, aud `appstoreconnect-v1`); no JWT library is installed on the Mac.
  App id `6783219995` ("Den: Projects & Habits").
- **0.14.10 quota fix**: completed one-off tasks (which share the `routines` table with repeating habits)
  counted against the habit limit forever, and are invisible in the UI once done — the admin account hit
  300/300 with only 12 real habits. Quota now counts habits + work still on your plate
  (`countsTowardQuota` in `web/src/utils/recurrence.ts`, `liveRoutineCount` in `api/src/routes/routines.ts`;
  the server rule is deliberately **looser** so it can never reject a create the client thought was fine).
  Archived projects likewise no longer consume project quota. Paid caps raised to 999 projects /
  99,999 habits; admins uncapped client-side. **No data was deleted.**
- **Play Billing**: **LIVE in Production** — merchant profile verified and the closed-test window cleared;
  real users can subscribe on Android. (RevenueCat → Google Play, entitlement `pro`, £5/£45 matching web.)
- **iOS (on the Mac) — Apple Developer account ACTIVE (Team `W858DLWZG5`). Working in the simulator:**
  - `app/ios/` (Cap 8 / SwiftPM); builds + runs; iOS-only safe-area/layout fixes scoped to `html.cap-ios`.
  - **Google sign-in**: live (iOS OAuth client, reversed-id URL scheme, server `GOOGLE_IOS_CLIENT_ID`).
  - **Sign in with Apple**: LIVE on web + iOS (App ID capability + Services ID `com.fearthewild.den.signin`
    + Xcode entitlement + `apple.conf` env). Web needed the Apache CSP opened for Apple's domains (above).
  - **In-app purchases**: LIVE — RevenueCat App Store app + products + entitlement/offering; sandbox
    purchase verified in the simulator (sign the sim into a Sandbox Account: Settings → App Store).
  - **Icon + launch screen**: generated from the bear vector via `app/scripts/gen-ios-assets.mjs` + `cap assets`.
- **iOS v1.0 APPROVED by App Review** (submitted 2026-06-23 as v1.0; approved after the IAP 2.1b fix,
  build 2). The 1.0 train is now **closed** to new builds — ship further fixes as a **new version** (1.0.1+).
  Release of 1.0 is **manual** — check its state (may be *Pending Developer Release*). **v1.0.1 (3)**
  uploaded + **SUBMITTED to App Review 2026-07-08** (Waiting for Review) — login/signup scroll fix.
  **Universal (iPhone + iPad)**.
  Listing kit `app/store-assets/appstore-listing.md`; screenshots in `app/store-assets/ios-screenshots/`
  (`6.5/`, `ipad-13/`). Demo login for the reviewer: `test@example.com` / `testaccount123` (keep it active).
  - **Signing (no physical device)**: automatic signing can't archive without a registered device, so the App
    target's **Release** config uses **manual** signing with a manually-created **"Den App Store"** App Store
    profile + an **Apple Distribution** cert (App Store profiles need no devices). `ITSAppUsesNonExemptEncryption=false`.
    Build: `xcodebuild … archive` → `-exportArchive` (method app-store-connect, manual, profile "Den App Store")
    → `xcrun altool --upload-app -u <appleID> -p <app-specific-password>`. Debug stays automatic for the simulator.
  - **Release is MANUAL** — after approval, click "Release this version" in App Store Connect to go live.
- Cross-platform requirement: Web, Android, iOS must stay **in sync** — shared web codebase + shared
  server; keep `tier` server-authoritative so a subscription on any platform unlocks Pro everywhere.

## Conventions
- Match surrounding code style; comments explain *why*. Don't deploy without the user's go-ahead.
- Native auth uses a Bearer token (`X-Client-Mode: native`); web uses an httpOnly cookie.
- The Vault and project "secrets" are zero-knowledge — server only stores client-encrypted blobs.
