A verified, budget-aware marketplace for in-home care and household help for older adults.
CareMatch connects older adults (and their families) with verified in-home helpers across a spectrum of services — cleaning and errands, companionship, personal care, and eventually skilled care — matched on care needs, personality, and budget, with an identity-verification trust layer as the headline differentiator.
The product is governed by five design principles: senior sovereignty,
trust made visible, budget as a first-class input, one seam-free
spectrum, and radical accessibility. See
docs/BLUEPRINT.md for the full product & build blueprint
and docs/ARCHITECTURE.md for the system design.
This repo implements the defensible core of the platform end to end:
- Matching Engine and Budget Optimizer (blueprint §4) — the blueprint is explicit that "your defensibility lives entirely in matching quality, trust UX, and accessibility."
- Care-plan lifecycle, scheduling, and the visit-event stream (§6) with visit-time identity verification (§3, stage 5) — turning a matched, optimized plan into approved, scheduled, verifiable visits.
- Funding Navigator (§4.4) — a decision-tree screener for VA Aid & Attendance, Medicaid HCBS waivers, LTC insurance and Medicare Advantage that re-runs the optimizer against an adjusted effective budget.
- Notifications (§2, §5) — turns the visit-event stream into audience-targeted alerts: family reassurance on verified arrival, urgent ops escalation on a "not good" review or a failed identity check.
- Real authentication and a real database — role-based accounts (senior/family/provider/ops) with hashed passwords and JWTs, and full persistence in Postgres. No in-memory shortcuts, no runtime mock data.
- Consent / permissions (§5.2) — senior-granted, revocable family access at
view/book/financiallevels, enforced by the authorization layer. - Senior web app (§5.1) — an accessibility-first PWA served by the same server: login → home → one-question-per-screen booking → match card-stack with visible verification → "here is your care plan." This is where "trust made visible" and "radical accessibility" stop being JSON and become a UI.
- Family dashboard (§5.2) — a responsive, information-dense web app served
at
/family: a reassurance strip (last / next visit + verification), visit detail with the event timeline, the shared care plan, and budget — scoped strictly to what the senior granted. The senior invites family by email. - Billing & payments — subscription plans, invoices with line items, the
marketplace take-rate, and provider payouts, over a
PaymentGatewayboundary (in-house ledger locally; swap for Stripe Connect in prod). Money is in cents. - Audit log & ops console — an append-only audit trail of sensitive actions
and an ops-only console at
/console(platform metrics, an incident queue for failed verifications and bad reviews, and the audit trail). Ops accounts are provisioned from config, never self-registered. - Account lifecycle & compliance — password reset and email verification (hashed, single-use, expiring tokens over a transactional email boundary), and the CCPA/GDPR right to data export and account deletion (scrubs PII and health data, de-identifies retained financial records).
- Provider verification & trust (§3) — the moat, made real: a verification
record pipeline (identity → background → credential → continuous monitoring)
whose results derive a provider's
verified_tierand active/suspended status (never self-declared). Matching only surfaces active providers; an adverse result suspends them instantly. The senior app's "Verified helper" badge shows each provider's real, dated stages. Provider profiles are claimed safely (one account per unclaimed profile), andVerificationRecords are recorded via an ops endpoint that stands in for a Persona/Checkr webhook.
It is a running application: every request is authenticated at the gateway, all state lives in the database, authorization enforces senior sovereignty, and a real senior-facing client drives the whole loop.
web/ Senior PWA — index.html, styles.css (design system), app.js
web/family/ Family dashboard — index.html, family.css, family.js
scripts/ Playwright end-to-ends: e2eSenior.ts, e2eFamily.ts
src/
config.ts Env-driven runtime config (db, jwt, port)
main.ts Entrypoint: open DB → migrate + seed → start HTTP server
domain/ Core model — tiers, tasks, entities, geo, booking, user, funding
matching/ Rules-based v1 matching engine (hard filters + weighted scoring)
optimizer/ Budget optimizer — the "here is your care plan" signature feature
funding/ Funding Navigator — screens for VA/Medicaid/LTC/MA, adjusts budget
booking/ Care-plan lifecycle, visit scheduling, visit-event state machine
notifications/ Turns visit events into audience-targeted alerts + dispatcher
auth/ scrypt password hashing, HS256 JWT, register/login/authenticate
db/ PGlite/Postgres client, migrations, seed, and repositories
api/ App wiring, HTTP server, auth middleware + authorization
data/ Provider seed fixtures + a representative intake profile
test/ Node built-in test runner suites (65 tests, real DB + auth)
docs/ Blueprint, architecture, data model
Stack. Node ≥ 22.6 (TypeScript run directly via --experimental-strip-types).
The database is PostgreSQL: PGlite — real Postgres
compiled to WASM, running in-process — locally and in tests, and managed Postgres
in production against the identical schema (DATABASE_URL). Auth uses only Node
built-ins (scrypt + HMAC-SHA256 JWT). The one runtime dependency is the database
driver; the rest is standard library.
The blueprint describes eleven backend components, three client apps, and a phone
concierge. Deferred (and why): identity/verification vendor onboarding, payments,
messaging, the PII vault, and the three client apps — all either depend on
third-party vendors (Persona/Checkr, Stripe Connect, Twilio) or build on the core
contracts and API defined here. See docs/ARCHITECTURE.md.
cp .env.example .env # set JWT_SECRET; PGLITE_PATH=./.pgdata to persist to disk
npm install # installs the DB driver (+ typescript/@types/node for dev)
npm run typecheck # tsc --noEmit, strict
npm test # 65 tests against a real in-memory Postgres
npm run db:setup # run migrations + seed the provider roster
npm start # migrate + seed + serve the API on http://localhost:3000Then open http://localhost:3000/app — the senior web app. Register as a senior, tap "Get help with something," and walk the booking flow; the plan you confirm is scheduled and shows up on your home screen.
Pure-logic demos that need no server or DB:
npm run demo # a full match + care-plan run for the sample profile
npm run demo:booking # propose → approve → schedule → run a visit + notificationsThe senior flow is covered by a Playwright end-to-end (opt-in, not part of
npm test): npm i -D playwright-core && npm run test:e2e.
Every route except /health and /auth/* requires a bearer token.
npm start
# 1) Register a senior — returns { token, user }
TOKEN=$(curl -s -X POST localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"rosa@example.com","password":"password123","role":"senior","displayName":"Rosa"}' \
| node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
# 2) Propose a care plan (seekerId is taken from the token, not the body)
curl -s -X POST localhost:3000/care-plans \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"needs": [
{ "task": "bathing", "hoursPerWeek": 3, "preferredDays": ["mon","wed","fri"] },
{ "task": "companionship", "hoursPerWeek": 4, "preferredDays": ["tue","thu"] },
{ "task": "housekeeping", "hoursPerWeek": 3, "preferredDays": ["wed"] }
],
"location": { "lat": 37.7793, "lng": -122.4193 },
"monthlyBudget": 2800, "budgetHardness": "firm",
"preferences": {
"languagesRequired": ["Spanish"], "petsInHome": true, "smokingOk": false,
"personality": { "chatty": 0.7, "active": 0.5, "structured": 0.8, "playful": 0.6, "nurturing": 0.9 }
},
"previousProviderIds": ["prov_maria"], "planKey": "smart_mix"
}'POST /match returns ranked, explainable candidates and 2–3 named
care-plan options (full_coverage, smart_mix, essentials), each priced
against real providers with a recommendedPlanKey.
An accessibility-first PWA served by the same Node server at /app — no build
step, no framework, one runtime dependency total (still just the DB driver). The
design system (web/styles.css) treats WCAG 2.2 AA as the floor: 20px base type,
≥56px touch targets, ≥7:1 contrast, always-visible focus, no gesture-only or
timed interactions, honoured prefers-reduced-motion, and a persistent "Call
us" button in the same corner of every screen.
The screens implement the blueprint's §5.1 senior experience:
- Home leads with the next-visit card (who is coming, when) and a tappable Verified helper badge that opens "how we verify every helper" — trust made an observable thing, not a claim. Then one big "Get help with something," then "My people" rebook tiles.
- Booking is one question per screen with progress dots and a back button always in the same place: what you need (plain-language tiles) → which days → budget.
- Match results are a card stack (one helper at a time, ≤5), each with a face, the verification badge, and "why this match" reasons.
- "Here is your care plan" shows the recommended plan as a monthly cost against a budget bar; confirming proposes, approves, and schedules four weeks of visits, which then surface on Home.
Verified end-to-end with Playwright (scripts/e2eSenior.ts).
The second client — information-dense where the senior app is sparse (§5.2). A family member signs in and sees only the seniors who have granted them access, at the level granted. The Overview leads with the reassurance strip that answers "is Mom okay and is this working" — last visit (with the verified-arrival time), next visit, and recent activity. Visits drills into the event timeline (en-route → verified arrival → checked out → note → review). Care plan shows the shared weekly plan; Budget shows spend against the plan.
The senior grants access from their app's Share with family screen by email
and level; the whole handoff is covered by scripts/e2eFamily.ts
(npm run test:e2e:family).
The API gateway is production-shaped:
- Health & readiness:
GET /health(liveness) andGET /ready(checks the database). Metrics:GET /metricsin Prometheus text format. - Security headers on every response (CSP,
X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy, HSTS in production), configurable CORS allowlist, and anX-Request-Idechoed for tracing. - Rate limiting per client (tighter on
/auth/*) →429withRetry-After. - Structured JSON logs (one line per request: method, path, status, duration, request id) suitable for any log aggregator.
- Graceful shutdown on SIGINT/SIGTERM.
- CI (
.github/workflows/ci.yml) runs typecheck + tests on Node 22. - Docker:
docker build -t carematch . && docker run -p 3000:3000 -e JWT_SECRET=… carematch(no build step — TypeScript runs via type stripping; the image ships source + runtime deps and has aHEALTHCHECK). - API description:
docs/openapi.yaml(OpenAPI 3.1).
- Accounts have a role:
senior,family,provider, orops. Passwords are hashed with scrypt; login/registration issue an HS256 JWT (Authorization: Bearer <token>). - Senior sovereignty is enforced in code: only the senior (or ops acting on their behalf) can approve a care plan or schedule visits; a family member can propose but not approve. A senior only ever sees their own plans and visits.
- Provider-scoped actions: only the assigned provider can mark en-route,
check in, check out, or note a visit. Only the senior can review it; only ops
can mark a no-show. The
seekerIdon any request is derived from the token, never trusted from the body. - Consent is senior-granted and granular (§5.2). A family account has no
access to a senior until the senior grants a consent at
view(see plans, visits, notifications),book(also propose/schedule), orfinancial(also the funding navigator's money detail). Higher levels include the lower ones; consent is revocable (POST/GET/DELETE /consents), and family can never approve a plan regardless of level.
- Hard filters remove non-candidates: tier eligibility (a provider's verified tier must cover the highest tier the needs require), service radius, required languages, availability overlap, pets, and smoking.
- Transparent weighted-linear scoring ranks survivors over five factors —
schedule fit (0.25), budget fit (0.20), personality alignment (0.20), quality
signals (0.20), and a continuity bonus (0.15). The weights are config, not
code (
src/matching/weights.ts) so ops can tune them weekly. - Every candidate carries plain-language reasons ("Within budget · Speaks Spanish · You have worked together before") — explainability is trust for this audience. ML enters only at v3, as a re-ranker.
Given a needs profile and budget, it decomposes needs into task blocks (each with a minimum required tier from the task catalog) and produces named plans:
- Full coverage — everything requested, staffed by the fewest, highest-tier providers. Max continuity, max cost; shown honestly even when over budget.
- Smart mix — every block assigned to the cheapest tier that safely covers it. Lower-tier hours are billed at that tier's market rate, so a personal-care aide doing housekeeping costs household money — the mechanism that makes this plan meaningfully cheaper than paying skilled rates for every hour.
- Essentials — only safety-critical blocks, sized to come in under budget, with a clear list of what was deferred.
This reframes the product from "search for a caregiver" to "here is your care plan."
A guided, content-first eligibility screener (no payer integrations) for VA Aid &
Attendance, Medicaid HCBS waivers, long-term care insurance, and Medicare
Advantage supplemental benefits. It rates each source likely / possible /
unlikely with an indicative monthly benefit and concrete next steps, sums the
likely benefits into an estimated funding figure, and re-runs the optimizer at
the raised effective budget — so the difference the funding makes shows up in
real plans, side by side (assessAndReplan, POST /funding/plan). Every figure
is flagged as an estimate, not an eligibility determination.
Once a plan is chosen, it moves through a lifecycle that encodes senior sovereignty and visible trust:
- Care plan (
carePlanService.ts) — the optimizer option is promoted to a versionedCarePlanin theproposedstate. Families may propose; only the senior approves, which activates it. Visits can only be scheduled from an active plan. - Scheduling (
schedulingService.ts) — an active plan expands into concreteVisits over a date range. Same-day, same-provider blocks are co-located into one visit; weekly hours spread evenly across scheduled days. - Visit lifecycle (
visitLifecycle.ts) — a strict state machine over the VisitEvent stream:scheduled → en_route → verified_arrival → checked_out → noted → reviewed, withcancelled/no_showoff-ramps. Check-in requires both a biometric selfie match and a GPS geofence pass — a failed attempt throws rather than advancing, catching the wrong-person / account-sharing case. A "not good" review flags the visit for a same-day human call-back.
Every visit transition is turned into audience-targeted notifications by a pure
rules function (notificationRules.ts), then delivered through a swappable
NotificationSink (console for the demo/server, a recording sink for tests; one
sink per real channel — Twilio, push, email — in production). Highlights:
- Verified arrival → family reassurance ("✓ Maria verified on arrival, 9:02 AM") — the observable trust event made visible.
- "Not good" review → urgent ops phone-call escalation, plus a family heads-up — the review doubles as incident detection (§5.1).
- Failed identity check → urgent ops alert naming the reason (low selfie match / out of geofence). This isn't a visit event — the check-in is rejected — but it's a possible wrong-person signal, so it's surfaced immediately.
BookingService fans these out and persists them as each visit progresses;
GET /seekers/:id/notifications returns a senior's inbox.
| Method & path | Purpose |
|---|---|
POST /auth/register |
Create an account, returns { token, user } (public) |
POST /auth/login |
Exchange credentials for a token (public) |
GET /health |
Liveness check (public) |
POST /consents |
Senior grants a family member access (body: { granteeId | granteeEmail, level }) |
GET /seekers/:id/visits |
A senior's visits (view access) |
GET /seekers/:id/care-plans |
A senior's care plans (view access) |
GET /billing/plans · GET|POST /billing/subscription |
Membership plans & subscription |
POST /billing/invoices · GET /billing/invoices |
Generate/list invoices (financial access) |
POST /billing/invoices/:id/pay |
Pay an invoice; settles provider payouts |
GET /billing/payouts |
A provider's payouts |
GET /admin/metrics · /admin/incidents |
Platform metrics, incident queue (ops) |
GET /admin/audit · /admin/users |
Audit trail, user lookup (ops) |
POST /auth/password-reset/request · /confirm |
Password reset (public, token) |
POST /auth/verify-email/confirm · POST /account/verify-email/request |
Email verification |
GET /account/export · DELETE /account |
Data export & account deletion (self) |
GET /providers/:id/verification |
A provider's trust badge (stages + dates) |
POST /admin/providers/:id/verification |
Record a verification result (ops) |
GET /consents |
Senior lists grants made; family lists grants held |
DELETE /consents/:id |
Senior revokes a grant |
POST /match |
Ranked candidates + care-plan options |
POST /funding/plan |
Funding screen + plans at both out-of-pocket and funded budgets |
POST /care-plans |
Propose a plan (body: needs profile, optional planKey) |
GET /care-plans/:id |
Fetch a plan |
POST /care-plans/:id/approve |
Senior approves (activates) a plan |
POST /care-plans/:id/visits |
Schedule visits (body: { weeks, startDate? }) |
GET /care-plans/:id/visits |
List a plan's visits |
GET /visits/:id |
Fetch a visit |
POST /visits/:id/actions |
Apply a lifecycle action (body: { kind, … }) |
GET /seekers/:id/notifications |
Notifications generated for a senior |
All rows below /health require Authorization: Bearer <token>. Visit action
kinds: en_route, check_in (selfieMatchScore, distanceMeters),
check_out, note (mealPrepared, medsReminded, mood), review
(rating, voiceComment), cancel (reason), no_show.