Skip to content

Feature map ​

Every user-facing capability of the portal, with the code that implements it and the checks that cover it. The IDs F01-F17 are fixed: the same IDs appear in the end-to-end runner (steps named E2E-F03 ...), in the Testing page and in review notes. For the rules behind these features see Product primitives.

How to read the columns:

  • Unit/integration tests: automated tests that exist today, in test/*.ts (Vitest with the Workers pool), tests/cloud/*.py and tests/skills/test_securus_emessage_skill.py (pytest). none means no automated test; that feature is checked by the end-to-end run or by hand.
  • E2E: the step id the end-to-end runner uses for the feature. A step may report SKIP with a reason (for example F05 needs a real browser).
  • Status: result of the verification run on 2026-10-31; details in each feature's Verification: line.
IDFeatureSurfaceUnit/integration testsE2EStatus
F01Landing page/noneE2E-F01Verified
F02Legal & error pages/terms/, /privacy/, 404noneE2E-F02Verified
F03Sign up/signup/, POST /api/auth/signuptest/api.test.ts (2)E2E-F03Verified
F04Log in, log out, sessions/login/, /api/auth/*, /api/metest/api.test.ts (4)E2E-F04Verified
F05Safe post-login redirectpublic/console/auth.jsnoneE2E-F05Verified
F06Console overview & setup stepper/console/#overview/api/me shape only (test/api.test.ts)E2E-F06Verified
F07Inmate management#inmate, /api/inmatetest/api.test.ts (1)E2E-F07Verified
F08Plan catalogGET /api/plans, landing, #billingtest/api.test.ts (1)E2E-F08Verified
F09CheckoutPOST /api/billing/checkouttest/api.test.ts (2)E2E-F09Partly verified
F10Billing portalPOST /api/billing/portaltest/api.test.ts (1)E2E-F10Partly verified
F11Stripe webhook syncPOST /api/stripe/webhooktest/stripe_webhook.test.ts (6)E2E-F11Verified (simulated events)
F12Monthly allowance window & usage viewsrc/period.ts, GET /api/usage, #usagetest/period.test.ts (5), test/api.test.ts (2)E2E-F12Verified
F13Hermes link API/api/hermes/*test/stripe_webhook.test.ts (3), test/api.test.ts (1)E2E-F13Verified
F14Hermes bridge cronhermes_bridge/portal_sync.pytests/cloud/test_reform_assistant_portal_sync.py (22)E2E-F14Verified (stub Securus)
F15Securus portal-mode contact addsecurus_poc_contacts.pytests/skills/test_securus_emessage_skill.py (5)E2E-F15Verified (stub + unit)
F16Developer docs sitedocs/ (VitePress)none (npm run docs:build)E2E-F16Verified
F17Cloudflare deploymentwrangler.jsonc, docsnonenpm run e2e against productionVerified

F01 Landing page ​

  • User story: As a family member, I see what the service does, what it costs, and how to start, on one responsive page.
  • Entry points: UI /. Header anchors /#how, /#pricing, /#faq; "Log in" goes to /login/; "Get started" goes to /signup/.
  • Code files: public/index.html (sections: hero, "What they get", "How it works", "Pricing", "FAQ", closing call to action), public/assets/site.css, public/assets/site.js, public/assets/hero.svg, public/favicon.svg.
  • Automated tests: none. Pricing numbers are static HTML and must equal GET /api/plans (checked by E2E-F08).
  • E2E steps: E2E-F01.

Verification: Verified 2026-10-31: npm run e2e against wrangler dev (3 consecutive runs, 15 pass / 0 fail) (E2E-F01); browser QA at 1440px and 390px — See pricing anchor, pricing Choose links carry ?plan=monthly|quarterly|annual, mobile menu opens and shows Pricing.

  • User story: As a visitor, I can read the terms and privacy pages, and a wrong URL shows a friendly 404 with a way home.
  • Entry points: UI /terms/, /privacy/; any unknown path (Workers Static Assets not_found_handling: "404-page"). Unknown /api/... paths return JSON 404 not_found, not the HTML page.
  • Code files: public/terms/index.html, public/privacy/index.html, public/404.html, wrangler.jsonc (assets), src/index.ts (routeApi).
  • Automated tests: test/api.test.ts "reports usage and returns correct route errors" covers the JSON 404 not_found and 405 method_not_allowed for API paths. The HTML pages have none.
  • E2E steps: E2E-F02.

Verification: Verified 2026-10-31: E2E-F02 (terms, privacy, 404 headings); browser check of the 404 page.

F03 Sign up ​

  • User story: As a family member, I create an account with email, name and password and land logged in.
  • Entry points: UI /signup/; API POST /api/auth/signup (201 {user}, sets ra_session; 400 invalid_field, 409 email_taken).
  • Code files: public/signup/index.html, public/console/auth.js, src/index.ts (signup), src/auth.ts (createPassword, createSession), src/http.ts.
  • Automated tests: test/api.test.ts: "signs up, returns the current account, logs in, and logs out"; "rejects duplicate normalized email addresses".
  • E2E steps: E2E-F03.

Verification: Verified 2026-10-31: test/api.test.ts; E2E-F03 (201 + cookie, 409 duplicate, 400 short password); browser QA — signup with ?plan=quarterly lands on /console/#billing, without a plan on /console/.

F04 Log in, log out, sessions ​

  • User story: As a returning user I log in, stay logged in for 30 days, and can log out; a wrong password gives the same answer as an unknown email.
  • Entry points: UI /login/; API POST /api/auth/login, POST /api/auth/logout, GET /api/me.
  • Code files: public/login/index.html, public/console/auth.js, src/index.ts (login, logout, me), src/auth.ts (getSessionUser, requireUser, clearSession, expiry cleanup in createSession), src/http.ts (sessionCookie).
  • Automated tests: test/api.test.ts: "signs up, returns the current account, logs in, and logs out"; "sets Secure only for HTTPS session requests, independent of APP_URL"; "removes expired sessions for the logging-in user and opportunistically for other users"; "reports usage and returns correct route errors" (401 without cookie).
  • E2E steps: E2E-F04.

Verification: Verified 2026-10-31: test/api.test.ts; E2E-F04 (HttpOnly, SameSite=Lax, no Secure on http, generic 401, logout 204 then 401); browser QA — Log out returns to /login/, /console/ without a session redirects to /login/?next=%2Fconsole%2F, wrong password shows an error.

F05 Safe post-login redirect ​

  • User story: As a user sent to log in from a deep link, I return to where I was, but a crafted link can never send me to another site; a plan chosen on the landing page carries through signup.
  • Entry points: /login/?next=<path>, /signup/?next=<path>&plan=<id> (plan makes signup finish at /console/#billing; next is carried to the sibling page).
  • Code files: public/console/auth.js (safeNext: only same-origin paths survive; anything resolving to another origin gives null, falling back to /console/).
  • Automated tests: none (client-side script).
  • E2E steps: E2E-F05 (browser behavior; may report SKIP).

Verification: Verified 2026-10-31 (browser QA): next=/console/#usage honoured; //evil.example, https://evil.example/, javascript:alert(1) fall back to /console/; /%09/evil.example stays on this origin (it lands on the site's own 404 page, not an external site).

F06 Console overview & setup stepper ​

  • User story: As a signed-in user I see how far along setup is (add inmate, choose plan, connected) and what to do next.
  • Entry points: UI /console/#overview (also the fallback hash); API GET /api/me (setup.inmate_added, setup.plan_chosen, setup.connected).
  • Code files: public/console/index.html, public/console/app.js (overview view, hash router), public/console/console.css, src/index.ts (me).
  • Automated tests: test/api.test.ts "signs up, returns the current account, logs in, and logs out" checks the /api/me account data; the stepper UI has none.
  • E2E steps: E2E-F06.

Verification: Verified 2026-10-31 (browser QA): new account shows "0 of 3 complete", "1 of 3" after adding an inmate; connected account shows the connected banner and "3 of 3 complete".

F07 Inmate management ​

  • User story: As a user I add, edit or remove my one inmate; invalid input is explained; the identity is frozen while a plan is active.
  • Entry points: UI /console/#inmate; API GET, PUT, DELETE /api/inmate (400 invalid_field, 409 inmate_locked).
  • Code files: src/inmate.ts, src/index.ts (inmateRoute), public/console/app.js (inmate view), migrations/0001_init.sql (inmates).
  • Automated tests: test/api.test.ts "validates inmate fields and locks identity changes while a subscription is active".
  • E2E steps: E2E-F07.

Verification: Verified 2026-10-31: test/api.test.ts (validation, inmate-number format, lock while active and past_due); E2E-F07; browser QA — save, field error. Found and fixed during verification: inmate numbers were only checked for non-empty (now ^[A-Za-z0-9-]{3,20}$, fields at most 80 characters), and the lock now also covers past_due.

F08 Plan catalog ​

  • User story: As a visitor or user I see the same three plans and prices everywhere.
  • Entry points: API GET /api/plans (public); UI landing #pricing, console #billing.
  • Code files: src/plans.ts, src/index.ts (route table), public/index.html, public/console/app.js (plan grid).
  • Automated tests: test/api.test.ts "returns public plans and enforces checkout prerequisites and missing Stripe configuration" (plan ids, prices, months, savings, 40 messages).
  • E2E steps: E2E-F08.

Verification: Verified 2026-10-31: test/api.test.ts; E2E-F08 (800/2160/8160 cents, months 1/3/12, 40 messages); landing and #billing show the same prices.

F09 Checkout ​

  • User story: As a user with an inmate I pay for a plan in Stripe Checkout; I cannot double-subscribe or take an inmate someone else pays for.
  • Entry points: UI #billing Choose; API POST /api/billing/checkout {plan_id} returns {url} (400 invalid_field, 400 inmate_required, 409 already_subscribed, 409 inmate_taken, 503 billing_not_configured, 502 stripe_error). Returning from Stripe lands on #billing?checkout=success|cancel.
  • Code files: src/billing.ts (createCheckout, previous open session expiry), src/env.ts, public/console/app.js.
  • Automated tests: test/api.test.ts: "returns public plans and enforces checkout prerequisites and missing Stripe configuration"; "rejects checkout for a live account subscription and an inmate already subscribed elsewhere". Expiring a previously open session has no automated test.
  • E2E steps: E2E-F09.

Verification: Partly verified 2026-10-31: test/api.test.ts and E2E-F09 cover 400 inmate_required and 503 billing_not_configured; browser QA shows the "Payments are not switched on yet" banner. Not verified: a real Stripe Checkout session, open-session expiry, 409 already_subscribed/inmate_taken against Stripe; needs the Stripe sandbox account (see Testing payments).

F10 Billing portal ​

  • User story: As a subscriber I manage payment method, cancellation and plan changes in Stripe's billing portal.
  • Entry points: UI #billing Manage billing; API POST /api/billing/portal returns {url} (404 no_customer, 503 billing_not_configured).
  • Code files: src/billing.ts (createPortal), public/console/app.js.
  • Automated tests: test/api.test.ts "returns public plans and enforces checkout prerequisites and missing Stripe configuration" (portal: 401 without a session, 503 billing_not_configured).
  • E2E steps: E2E-F10.

Verification: Partly verified 2026-10-31: test/api.test.ts covers 401 without a session and 503 billing_not_configured. Not verified: a real portal session; needs the Stripe sandbox account.

F11 Stripe webhook sync ​

  • User story: As the operator I trust that payment events keep each account's plan, status, billing period and connection correct, exactly once, and that I can replay any event from R2.
  • Entry points: API POST /api/stripe/webhook (Stripe-Signature; 400 invalid_signature, 400 invalid_event, 503 billing_not_configured). Events handled: checkout.session.completed, checkout.session.expired, customer.subscription.created|updated|deleted.
  • Code files: src/stripe_webhook.ts (signature check, stripe_events idempotency, R2 archive stripe/events/<id>.json, subscription re-fetch, stale and duplicate handling to stripe/duplicates/<id>.json), migrations/0001_init.sql, migrations/0002_checkout_sessions.sql.
  • Automated tests: test/stripe_webhook.test.ts: "rejects bad signatures and stale Stripe timestamps"; "accepts signed subscription events, archives and deduplicates replay"; "uses subscription item periods when Stripe omits top-level periods"; "keeps connected renewals and ignores stale events for replaced subscriptions"; "records a cross-account live inmate subscription as a duplicate"; "updates the plan from a changed subscription price even with stale metadata".
  • E2E steps: E2E-F11.

Verification: Verified 2026-10-31 with locally signed events: test/stripe_webhook.test.ts; E2E-F11 (create, replay, bad signature, renewal keeps connected, deletion cancels). Not verified: events sent by real Stripe and the re-fetch/cancel calls, which need STRIPE_SECRET_KEY.

F12 Monthly allowance window & usage view ​

  • User story: As a subscriber I see how many of this month's 40 messages are used and left, even on a quarterly or annual plan.
  • Entry points: UI /console/#usage; API GET /api/usage; library monthlyWindow in src/period.ts.
  • Code files: src/period.ts, src/usage.ts (getUsage), public/console/app.js (usage view).
  • Automated tests: test/period.test.ts: "clamps a January 31 anchor to February's final day"; "steps through quarterly billing months and clamps at billing end"; "selects the twelfth annual month and clamps to annual period end"; "uses one complete window for a February-to-March monthly period"; "uses exactly three windows for an April-to-July quarterly period". test/api.test.ts: "returns only the current monthly window usage for quarterly subscribers"; "reports usage and returns correct route errors" (zeros without a subscription). test/stripe_webhook.test.ts "uses subscription item periods when Stripe omits top-level periods" also checks the usage window.
  • E2E steps: E2E-F12.

Verification: Verified 2026-10-31: test/period.test.ts, test/api.test.ts; E2E-F12 (window contains now and ends at or before billing end, allowance 40, used 1); browser QA of the Usage view.

  • User story: As the Hermes host I fetch pending connections, acknowledge them, report usage once per message and read each inmate's allowance, authenticated by one bearer token.
  • Entry points: API GET /api/hermes/connections?status=pending, POST /api/hermes/connections/<id>/ack, POST /api/hermes/usage, GET /api/hermes/allowances; all 401 unauthorized without Authorization: Bearer <HERMES_LINK_TOKEN>.
  • Code files: src/hermes.ts, src/usage.ts (recordUsage), src/period.ts, src/index.ts (routeApi).
  • Automated tests: test/stripe_webhook.test.ts: "authorizes Hermes connections, acknowledges status, and deduplicates usage"; "returns a quarterly Hermes connection with the current monthly and billing windows"; "returns the current monthly window for quarterly Hermes allowances". test/api.test.ts: "records Hermes usage only for the inmate with a live subscription".
  • E2E steps: E2E-F13.

Verification: Verified 2026-10-31: test/stripe_webhook.test.ts, test/api.test.ts; E2E-F13 (401 without token, pending connection with monthly window, allowances).

F14 Hermes bridge cron ​

  • User story: As the operator I run one cron script that connects paid inmates in Securus, refills allowances monthly, revokes when a subscription ends, and reports usage back, safely re-runnable.
  • Entry points: script python hermes_bridge/portal_sync.py [--token-file PATH] (env REFORM_PORTAL_URL), run as a Hermes --no-agent cron job; prints a JSON summary and exits non-zero if anything failed.
  • Code files: hermes_bridge/portal_sync.py, hermes_bridge/README.md, state file $HERMES_HOME/securus/portal_sync_state.json.
  • Automated tests: tests/cloud/test_reform_assistant_portal_sync.py: test_run_cli_is_bounded_noninteractive_utf8; test_pending_connection_adds_contact_grants_allowance_and_acks_connected; test_already_added_contact_is_acknowledged_connected; test_connection_uses_portal_contact_mode_and_grants_plan_allowance; test_timed_out_contact_setup_is_acknowledged_as_failed; test_connection_with_null_period_is_failed_without_being_stored; test_null_allowance_period_is_not_saved_and_later_window_refills; test_period_comparison_recovers_from_invalid_previous_and_rejects_invalid_candidate; test_reconnect_grants_only_unused_allowance_from_matching_record; test_quarterly_reconnect_tracks_monthly_window_and_refills_next_month; test_reconnect_never_grants_negative_unused_allowance; test_reconnect_without_matching_used_value_grants_full_plan_allowance; test_failed_allowance_reactivation_keeps_revoked_marker_until_retry_succeeds; test_refill_occurs_once_for_each_monthly_window; test_missing_allowance_revokes_remaining_stamps_once; test_usage_idempotency_key_includes_ledger_entry_for_repeated_reply_id; test_usage_without_reply_ids_still_posts_once; test_main_exits_nonzero_after_processing_an_operation_failure; test_cli_failure_is_acknowledged_and_later_connections_continue; test_refill_occurs_only_once_for_each_period; test_usage_posts_each_message_once_across_runs; test_token_is_not_printed_by_cron_entry_point.
  • E2E steps: E2E-F14.

Verification: Verified 2026-10-31: tests/cloud/test_reform_assistant_portal_sync.py; E2E-F14 runs the real portal_sync.py against the live Worker with a stub Securus skill: connects once, usage posted once across reruns, revokes after cancellation. Not verified against the live Securus account.

F15 Securus portal-mode contact add ​

  • User story: As a paying family, my loved one is added to the Securus service with the plan's allowance already in place when the welcome message arrives, without bothering the service admin.
  • Entry points: script securus_poc.py add_contact ... --portal-allowance N (called by the bridge).
  • Code files: skills/communication/securus-emessage/scripts/securus_poc_contacts.py (cmd_add_contact, _set_portal_allowance), skills/communication/securus-emessage/scripts/securus_poc.py.
  • Automated tests: tests/skills/test_securus_emessage_skill.py: test_portal_add_uses_plan_allowance_without_admin_message_or_reply_mark; test_portal_already_added_enabled_contact_is_success_without_second_welcome. For contrast, the admin-driven path: test_add_contact_command_welcomes_the_new_member; test_admin_notice_is_only_the_first_name_and_allowance; test_a_contact_added_outside_the_agent_gets_the_default_allowance.
  • E2E steps: E2E-F15.

Verification: Verified 2026-10-31: tests/skills/test_securus_emessage_skill.py (portal mode: no admin notice, allowance before welcome, already-added is success, admin path unchanged); E2E-F15 asserts one portal-mode add_contact with allowance 40. Not yet deployed to the Mac.

F16 Developer docs site ​

  • User story: As a developer I can read and build the full documentation, including the product primitives, this feature map, the Stripe section and the reference.
  • Entry points: npm run docs:dev, npm run docs:build, npm run docs:preview from cloud/reform-assistant/.
  • Code files: docs/.vitepress/config.mts (nav and sidebar), docs/index.md, docs/product/, docs/guide/, docs/stripe/, docs/reference/, docs/operations/.
  • Automated tests: none; npm run docs:build fails on broken links and is the check.
  • E2E steps: E2E-F16.

Verification: Verified 2026-10-31: npm run docs:build completes with no dead links; served at the Tailscale preview.

F17 Cloudflare deployment ​

  • User story: As the operator I deploy the Worker with its D1 database, R2 bucket, secrets and domain by following the guide.
  • Entry points: npx wrangler d1 create, npx wrangler r2 bucket create, npx wrangler d1 migrations apply reform-assistant --remote, npx wrangler secret put <NAME>, npm run deploy; guide Cloudflare deployment.
  • Code files: wrangler.jsonc, migrations/, .dev.vars.example, docs/guide/cloudflare.md, docs/operations/runbook.md.
  • Automated tests: none; test/apply-migrations.ts applies the migrations in the Vitest pool, which exercises the schema but not a deployment.
  • E2E steps: E2E-F17.

Verification: Verified 2026-11-01: deployed to https://reform-assistant.phucdoan.workers.dev (D1, R2, secrets, APP_URL) and docs to https://reform-assistant-docs.pages.dev; npm run e2e against production passed 3 consecutive runs (15 pass, 0 fail); browser check on the live site. Found and fixed during deployment: Cloudflare blocks Python's default urllib User-Agent (403, error 1010), so the Hermes bridge now sends its own. Custom domain not set up yet (see Cloudflare deployment).