THIS FILE is the portable breakdown β the one that travels. It is what docs.appolis.app renders (the hub renders markdown at build time, so a published page can no longer lag the doc), what git diffs, and what anyone without an app login can read. Keep it current: it is the only version of this document the outside world ever sees.
/breakdownin the running app is a LIVE TWIN, not a mirror β it cannot be published. It fetches/api/config,/api/stats,/api/productsand/api/dealersat runtime and its theme + term-rule editors write back (config/app-config.json/ theterm_rulestable), so it is always true inside the app and worthless outside it: hosted anywhere else those relative fetches hit the wrong origin and the page renders as an empty shell whose write-back buttons POST into the void. That is exactly what happened when it was copied to docs.appolis.app; the published copy has been pulled anddocs-hub/build.jsnow carries a β DELIBERATELY NOT COPIED note next to it. Two ways back onto the hub, both fine: (a) this markdown in the hub'srenderslist β the chosen route, and the prerequisite is met; or (b) a static snapshot export that bakes the API values in at build time (not built; see Β§9). Do not addweb/breakdown.htmlto the hub'scopieslist.
Same rule as Flip CMS: this document is updated on every app change.
What it is: customer-service command center for AWS Motor Club (AWS = Addendum & Warranty Solutions) vehicle protection products: TireGuard β tire repair/replacement program; GlassGuard β windshield protection; ScreenGuard β a touchscreen-radio screen protector; more products to come. β
The old GlassGuard naming exception is resolved (v0.10.0): at Mercedes-Benz of South Orlando an employee had sold the touchscreen protector under the GlassGuard name, so every one of that dealership's GlassGuard registrations was really ScreenGuard. The owner decided (in writing) to rename them all, and the owner-clicked fix in #/imports βΊ Data fixes moves them (dry run first, an audit row, a stored list of exactly which rows moved, and an Undo button). From then on that dealership's "GG" statements file as ScreenGuard; everywhere else GlassGuard means windshield and "GG" still means GlassGuard. Product copy, decks and new dealer setups keep the two names apart. Replaces the Google AppSheet + spreadsheet-pipeline setup: instead of dealership sheets being copy-pasted through staging spreadsheets into a master sheet, dealership data goes straight into a real database and every derived number (term, expiry, dealer profit) is computed by code.
Status: v0.18.0 β LIVE ON CLOUDFLARE at aws-motor-club.pages.dev (deployed 2026-08-17, migration first, then code). Real data: 8,136 customers, 10,733 registrations β 10,717 live plus 16 recorded as cancelled β after the Dec-2025 β Jul-2026 statement backfill (1,141 added, 144 repaired) and the 2026-08-18 AppSheet enrichment: 1,331 VIN-less legacy rows matched back to their AppSheet source (999 by contract number), every recovered VIN decoded to year/make/model, December-2025 cleared of its 123 double-counted placeholders, and 570 duplicate customer records merged so each person holds ALL their products on one profile. Remaining known specks: 8 rows whose AppSheet match was ambiguous, 4 VINs NHTSA cannot decode, 44 vehicles whose two products carry different surnames (family members or resales β left for human review). The live app is behind per-person login (Β§1.5): approved people only, each with their own password. Default theme is the β¨ New look (gloss-black shield, v0.5.0); β© Classic is one click away in the sidebar foot. Every registration carries a decoded year/make/model (v0.5.3). 30 claims ($9,831.10 paid β the AppSheet claims history landed 2026-08-18: 11 new claims imported, 12 legacy claims corrected for a doubling bug in the old master workbooks where the same amount sat in two columns, and 12 claims now correctly showing in progress per the dealer), 1 dealership (Mercedes-Benz of South Orlando), 14 F&I managers, 19 note activities carried over from AppSheet.
Version is single-sourced here.APP_VERSIONinweb/index.html(the badge + refresh prompt),package.json, this Status line, the top of Β§10, the deck (web/features.html), and the Program Numbers deck (web/present.html) must all read the same number. They drifted five ways once (badge v0.5.2 / Status v0.5.0 / changelog v0.5.3 / package.json 0.4.0 / deck v0.4.1) β if you bump one, bump all five. Two things only land after a command: the badge reaches users afternpm run deploy, and the hub copy of the doc + deck only refreshes afternode build.js && npx wrangler deployinF:\Claude Code\docs-hub.
Brand palette (from the AWS logo): gloss black #101214/#17191c, silver #9ba1a6, bright green #63b32e (accents/active states), white cards.
cd "F:\Claude Code\AWS Motor Club"; npm start β http://localhost:8788 (zero deps, needs Node β₯22.5 for node:sqlite; Node 24.18 at C:\Program Files\nodejs\node.exe, not on PATH in some shells β use full path)npm test (87 tests across five files, node:test β node --test --test-reporter=tap test/<file> per file gives the pass/fail line)data/aws-motor-club.db (WAL). AWS_DB env overrides; seed only loads into an empty DB, so real data is never overwritten.aws-app in F:\Claude Code\.claude\launch.json (port 8788; Flip CMS owns 8787).server/lib/dbwrap.js (wrapNodeSqlite locally / wrapD1 in prod); server/api.js is runtime-agnostic (no node imports) and shared by server/server.js (node) and server/worker.js (fetch handler + APP_KEY cookie gate; app config lives in the app_config D1 table there, in config/app-config.json locally β injected via handleApi hooks).npm run deploy (gate on green npm test first) = esbuild-bundle server/worker.js β web/_worker.js (Pages advanced mode; excluded from static upload) + wrangler pages deploy --branch main. Bindings (D1 + APP_KEY) come from wrangler.jsonc (pages_build_output_dir form)./ or /breakdown paths in worker.js β Pages pretty-URLs 308-redirect .html paths, which loops against rewrites; env.ASSETS.fetch(req) pass-through is correct.aws-motor-club id 4b687ecd-7c0b-41f1-94ca-ec7719d8381d (ENAM), account tyler@spartanstudios.com (shared account is fine; only the URL needed separating). Data refresh: npm run export-data β npx wrangler d1 execute aws-motor-club --remote --file deploy/out/schema.sql -y then --file deploy/out/data.sql -y (data.sql is idempotent: defers FKs, wipes childrenβparents, re-inserts).npx needs node on PATH in this shell: $env:Path = "C:\Program Files\nodejs;$env:Path".The app used to sit behind ONE shared device key: anyone holding it was in, and the app could never tell who did what. That is gone. Access is now per person, and the account list is a closed allowlist β there is no self-registration anywhere in the app, so a stranger with a valid-looking email cannot create anything.
users table, seeded from ALLOWLIST in server/db.js: tyler@spartanstudios.com (Tyler Bradford) and matt@awsmotorclub.com (Matt Dunkle), both role admin. Adding a third person = add a row there AND run the same INSERT ... ON CONFLICT against prod D1. Seeding never touches an existing password_hash, so re-running it cannot wipe someone's password.password_hash = NULL ("unclaimed"); the first successful login for that email stores whatever password was typed, permanently. Nobody β including us β sets it for them.MAX_WORKERS_PBKDF2_ITERATIONS in server/lib/auth.js pins it and a regression test asserts stored iterations never exceed it. More work factor cannot come from raising this number.Password1 while blocking strong passphrases.HttpOnly; SameSite=Lax; Secure cookie (aws_sess), 30 days. Only the SHA-256 of the token is stored, so a database leak cannot be replayed as a login. Signing out deletes the row; changing your password signs out every OTHER device.isOpenPath in server/lib/auth.js) leaves exactly the login surface open β /login, /api/auth/login, /api/auth/me, the icon and the manifest. Everything else, including /breakdown, /features and every /api/* route, needs a session: HTML redirects to /login, API returns 401.admin and sees exactly the same app (Tyler's call, 2026-07-26). The role column exists for a future ladder, but nothing reads it today except the reset permission.#/account, reached by clicking your name in the sidebar) does two things: change your own password (needs the current one; signs out every other device) and reset a teammate's when they're locked out. A reset clears their password and kills their sessions, so their next sign-in runs the same claim flow they used originally. You cannot reset yourself β a hijacked session could otherwise lock the real owner out β and a reset can never create an account.npx wrangler d1 execute aws-motor-club --remote -y --command "UPDATE users SET password_hash=NULL WHERE email='someone@example.com'". There is deliberately no standing bypass key.ID_SECRET and GOOGLE_PLACES_KEY are encrypted Pages secrets; APP_KEY and ACCESS_KEY.txt were deleted outright. Set or rotate one from bash (never PowerShell β it appends CRLF and the value silently never matches): printf 'value' | npx wrangler pages secret put NAME --project-name aws-motor-club.npm start binds to 127.0.0.1 only and runs the same login gate. It previously listened on every interface with no auth at all, publishing the whole member database to the LAN. HOST=0.0.0.0 still allows a deliberate device test and prints a warning.Term length comes from what the product cost the DEALER (term_rules table):
| Product | Dealer cost | Term |
|---|---|---|
| TireGuard | $299 | 3 years |
| TireGuard | $199 | 1 year |
| GlassGuard | any | 1 year (product default, no price rules) |
| ScreenGuard | any | 1 year (product default, no price rules β the same rule as GlassGuard, so a relabelled row's expires_on never changes) |
expires_on = deal_date + term_years. Coverage is active through the expiry date itself, expired the day after. Feb-29 deals roll to Mar-1.term_source: rule (price matched), default (product has no price rules β normal for GlassGuard), unmatched (product HAS rules but the price matched none β UI shows a "verify" badge).dealer_products table, editable on the dealer page): what we charge that dealer per unit. The dealer sells at whatever price they choose; dealer profit = sale price β dealer cost. New registrations default their dealer cost from dealer_products when left blank (and term follows from it). Import backfilled MBSO: TireGuard $299, GlassGuard $185 (dominant observed costs) β after the v0.10.0 relabel MBSO's $185 is its ScreenGuard price (the GlassGuard price row stays, for any windshield sold later).dealer_product_aliases says what a sheet's "GG"/"TG" means AT a given dealer (effective dates honoured); the importer asks it through resolveProductCode() in server/lib/imports.js, so a dealer with no alias row behaves exactly as before. The relabel never changes money: dealer profit, sale price, dates and expires_on of every moved row are identical before and after (pinned by sum, per product and in total).fixDateSwap() in deploy/lib/xlsx-lite.js corrects them at import; zero future deal dates remain.server/db.js)products (code, name, default_term_years) + term_rules (product, dealer_cost_cents β term_years) β new products/prices are rows, not code.dealerships (name, city/state, phone, contact_name) + v0.9.0 slug (unique, the URL name), link_key (32-char private key printed on the dealer's QR sticker; Rotate key retires every old sticker), address/zip/lat/lng (geocoded through Nominatim), glass_program (1 = on the GlassGuard drive; defaults 0 for every dealer, an admin flags them on the dealer page), glass_contact_email, active.customers (first/last name, phone, email, address/city/state/zip, lat/lng) β dealership registration often arrives with LAST NAME + VIN only; full contact info is usually captured at claim time. UI treats missing fields as normal.registrations β customer Γ product Γ vehicle: deal_date, vin, year/make/model, fi_manager, dealer_cost_cents, sale_price_cents, dealer_profit_cents (derived), term_years / term_source / expires_on (derived via insertRegistration()).claims β registration_id, status pipeline new β in_progress β awaiting_customer β approved β closed_paid | closed_denied, issue, resolution, opened_at/closed_at. Status changes auto-log to activity.claim_items β the "what was actually done" ledger: action (repair/replace/roadside/reimburse/other), description, tire_position (LF/RF/LR/RR/SPARE), amount_cents, vendor, date. Claim payout = SUM(items).activity β timestamped notes/calls/texts/emails per customer and/or claim (replaces the AppSheet notes blob).roadside_vendors β vendor directory: phones, city/state/zip, lat/lng, coverage radius, services, our 1β5 rating, times_used, notes, and category: 'ranger' (on-location operator who goes TO the customer β Tyler's definition of a Road Ranger) vs 'shop' (fixed drop-off location). The two categories are separate sections in the UI.media β metadata of record for every stored photo/sketch/licence image (kind, mime, bytes, sha256, sensitive, purge_after, who/what it belongs to); the BYTES live in R2 (MEDIA binding, bucket aws-motor-club-media) in production and in data/media/ locally. media_access β one row per admin view of a sensitive (licence) image.outbox β every email/SMS the app wants to send: recipient, subject, text + HTML body, attachments (media ids), status queued | held | sent | sent_by_hand | failed, provider fields for when a delivery provider exists.public_hits (rate-limit counters, hashed IPs only) Β· vin_cache (one NHTSA vPIC lookup per VIN, ever).glass_inspections (a glass pro's drive-by inspection) Β· glass_leads (a customer's own claim start) Β· appointments Β· dealer_product_aliases (what a statement code like "GG" means AT a given dealer) Β· customer_sessions (the customer-portal cookie, filled since v0.13.0). Empty until P1βP4 (P1 / v0.10.0 puts the MBSO "GG" β ScreenGuard row in dealer_product_aliases).claims.source / lead_id / inspection_id, import_batches.rollback / external_source. New product row SCREENGUARD (ScreenGuard, 1-year default) β the touchscreen protector MBSO's sheets call "GG"; v0.10.0 moved MBSO's former GlassGuard registrations onto it (see Β§2).glass_inspections is now written by POST /api/public/glass (one row per submission: dealership, pro, customer, VIN/vehicle, damage_types = JSON list of the six fixed codes four_plus_chips Β· line_of_sight Β· through_and_through Β· delamination Β· frit_band Β· failed_prior_repair, sketch_media_id, photo_media_ids, service repair|replacement, walk_in always 1 today, oem Y|N filled from At Home's returned sheet, registration_id + claim_id only when GlassGuard coverage matched). media rows of kind sketch, photo (ref_kind glass_inspection, purge 400 days) and csv (the weekly sheet, ref_kind glass_week); outbox rows with ref_kind glass_inspection / glass_week; claims.source = 'glass_pro' with inspection_id; a matched inspection adds ONE claim_items row (action = repair for Repair, replace for Replacement, amount 0, item_date today).claim_items.action = 'repair' on claims whose registration has the same VIN and a GLASSGUARD product, item_date inside the current CALENDAR year, skipping claims in status closed_denied; must_replace when the count is 2 or more.glass_leads is now written by POST /api/public/claim (token = a 32-character random link key for /appt; dealership_id = the dealership the customer bought from, NULL for "Other / not sure"; registration_id only when GlassGuard coverage matched the VIN AND last name; damage_media_ids = JSON list of 1β3 photo ids; licence_media_id; nearest_dealership_id + nearest_miles; repair_count_year + must_replace as they stood at submission; status runs new β hot_lead_queued β called β appointment_set β closed | rejected; closed_at is stamped when a request is closed or rejected and cleared if it is reopened). media rows of kind photo (ref_kind glass_lead, purge 400 days) and licence (sensitive = 1, purge_after NULL while the request is open, closed_at + 90 days once it is closed or rejected); outbox rows with ref_kind = 'glass_lead' (the HOT LEAD to config.atHomeInsideSalesEmail, the customer's "we got your request", the customer's appointment confirmation); claims.source = 'funnel' with lead_id; appointments rows (set_by = the signed-in email, or "At Home (appointment link)"). The GlassGuard network is the dealerships with glass_program = 1, active, and coordinates β none are flagged yet (the owner supplies the list), so no nearest dealer is offered today. New config key glassRerouteMiles (default 25; POST /api/config accepts 1β500).customer_sessions is now written by POST /api/public/portal/login (token_hash = SHA-256 of the random cookie token, never the token; customer_id = the ONE customer the sign-in matched; expires_at = 7 days; rows past their week are deleted on every sign-in). The portal also reads registrations.contract_no (the number printed on the customer's AWS paperwork, e.g. MBSO-TG2022-0002) as a sign-in fact. claims.source may carry portal in future; nothing writes it yet.feed_contracts (id, source, external_id, registration_id, created_at, UNIQUE(source, external_id)) β v0.14.0: the idempotency key of the dealer-feed landing; one (sending system, its contract id) maps to exactly one registration, so a re-send can never file a second one. import_batches gains rows with mode='feed' (external_source = the sending system; rollback = JSON lists of the registration / customer / feed_contract ids it added and the ids it updated).server/api.js)GET /api/search?q= β one box searches first/last name, phone, email, VIN, address/city/state/zip, dealership name, make, model, year, contract #.GET /api/customers?q&sort&dir&coverage&product&claims&page&per β the full list: server-side sort (name/city/regs/last_deal/next_expiry/open_claims/ coverage), filters, pagination, totals.GET/POST/PATCH /api/customers/:id, POST /api/customers β card = customer + registrations (+coverage) + claims (+items, payouts) + activity + totals.POST /api/registrations β derives term/expiry/profit on insert; dealer cost defaults from dealer_products when omitted.GET /api/dealers/:id/report?group=month|quarter|year|manager|product|manager_product &from&to&products&managers β the report engine behind the dealer drill-down.POST /api/dealers/:id/pricing β upsert per-product dealer cost.GET /api/roadside/discover?customer_id|city,state,zip|lat,lng[&radius][&kind=shops|mobile] β auto-discovery: Nominatim geocode (customer coords persisted back) + distance-ranked results with in-directory flags. kind=shops = tire/repair shops (Overpass tags). kind=mobile = Road Rangers, TWO engines merged: (a) Overpass tag pull (tyres/car_repair/towing tag + craft/car_parts/ truck_repair/office=company for mobile) filtered by MOBILE_RE in code β Overpass case-insensitive name regexes are unusably slow, don't reinstate; (b) bounded Nominatim free-text ("mobile tire", "roadside assistance", "towing", "mobile mechanic", sequential w/ 400ms gaps per usage policy), gated by MOBILE_STRICT_RE to keep fuzzy junk out. Duplicate names merge, filling missing phone/website across sources. Default radius is 20 mi for BOTH kinds (user report 2026-07-31; was 60 mobile / 40 shops β a wide default buried the closest help, which is the whole point when someone is stranded). The picker still offers 20/40/60/100 for rural gaps. kumi.systems Overpass mirror fallback. GET/POST /api/roadside take category (ranger|shop); near filters by it.GET /api/comms β recent conversation threads. POST /api/activity takes direction (in/out).GET /api/claims[?open=1], POST /api/claims, PATCH /api/claims/:id (status/issue/resolution), POST /api/claims/:id/items.POST /api/activity β log note/call/text/email.GET /api/dealers, POST /api/dealerships, GET /api/dealers/:id/report?granularity=month|year β Program Overview data.GET/POST/PATCH /api/roadside, GET /api/roadside/near?customer_id=|city=&state=&zip= β haversine when both sides have lat/lng, else stateβcityβzip-closeness fallback (server/lib/geo.js).GET /api/products, GET /api/stats (dashboard; by_product now has three rows).POST /api/maintenance/relabel-screenguard {dry_run, dealership_id, cutover} (admin only) β dry_run:true (the default) returns {registrations, batches, dealer_products, first_deal, last_deal, profit_cents, active, unwound, already_applied} and writes nothing; dry_run:false moves exactly those registrations GlassGuard β ScreenGuard, moves that dealer's statement batches, copies its GlassGuard price to ScreenGuard, adds the alias, and files one import_batches row mode='relabel' whose rollback JSON holds the exact id lists β all in one all-or-nothing db.batch (D1 batch() in production, BEGINβ¦COMMIT locally); a second commit is 409 already applied. cutover:"YYYY-MM-DD" (null = every row) limits it to earlier deals and ends the alias the day before. POST /api/maintenance/relabel-screenguard/rollback {batch_id} puts the stored ids back and refuses (409, naming the ids) if any was changed to something else since. Id lists travel as one JSON parameter read with json_each(), so the move is five statements however many rows. server/lib/relabel.js.d slug + k key; a wrong pair is 403, and a dealership does NOT need glass_program = 1 β the key is the authority): GET /api/public/dealer?d=&k= β {name} only Β· POST /api/public/coverage {d,k,vin,last_name} β {found, product, repairs_this_year, must_replace} and nothing else (found only when the VIN AND last name match a live GlassGuard registration; ScreenGuard, expired and unwound contracts are "not found"; rides the vin rate counter and spends no Turnstile token) Β· POST /api/public/glass β 201 {reference:"GI-<id>", coverage, claim_id?} β validates everything first (VIN rules, β€ 6 distinct damage types from the fixed six, sketch PNG/JPEG β€ 300 KB, 1β6 photos each β€ 1.5 MB, repair|replacement, consent Y/N with phone + email required on Y), refuses Repair when must_replace, then writes media + inspection + claim + the two outbox rows in one request; any failure undoes that request's own rows and fails the request (500), a refusal is a 4xx sentence naming the field.GET /api/glass/inspections (newest 300 + the current week's walk-in count and last-queued time) Β· GET /api/glass/inspections/:id (all fields, damage labels, 1-day signed links for the sketch and photos, the claim, the registration, the two outbox rows) Β· PATCH /api/glass/inspections/:id {oem:"Y"|"N"|null} Β· GET /api/glass/walkins?from&to (default: this MondayβSunday) β {rows, count, csv, last_queued} with the CSV header Inspection,Date,Dealership,Pro,VIN,Year,Make,Model,Service,OEM (Y/N) (replacement + walk-in inspections only; text cells starting = + - @ are defused) Β· POST /api/glass/walkins/queue {from?,to?} β stores the CSV as media kind csv, queues an Outbox email to atHomeInsideSalesEmail with the CSV as a 30-day signed link (400 when the week is empty)./glass (the form), /glass/manifest.webmanifest and /glass/sw.js (the installable app β the service worker is served with Service-Worker-Allowed: /glass, added by server/lib/pwa.js in both server.js and worker.js, and caches only the page shell, never API traffic), plus the PNG home-screen icons under /glass/.GET /api/public/dealers β {dealers:[{id,name}]} β active dealerships by name only, for the /claim and /appt dropdowns (never slugs, keys or addresses) Β· POST /api/public/claim {dealership_id|'other', vin, first_name, last_name, phone, email, zip?, consent:true, damage_photos:[1β3], licence_photo} β 201 {reference:"GL-<id>", coverage:{found, repairs_this_year, must_replace}, nearest:{name, miles}|null} β validates everything first (VIN rules, phone of at least 10 digits, email, 5-digit ZIP, the consent box, 1β3 damage photos and exactly one license photo, each β€ 1.5 MB JPEG/PNG), then writes the lead + media (the license sensitive = 1) + the claim when covered + the HOT LEAD and customer outbox rows in one request; any failure undoes that request's own rows (I6). The ZIP is geocoded server-side (Nominatim) only when the network has a dealer; a failed lookup is not a failed submit β the HOT LEAD email says the nearest dealer "could not be worked out" Β· GET /api/public/appt?t=<token> β first name, vehicle, purchasing dealer, coverage + counter, nearest dealer, used, and the dealer dropdown β never phone, email, VIN or license; a bad token is 404 Β· POST /api/public/appt {t, kind:'repair'|'replace', location_kind:'dealer'|'mobile', dealership_id | mobile_address, scheduled_for:'YYYY-MM-DDTHH:MM', notes?} β 201 (appointment + the claim item dated that day + the customer's confirmation in the outbox + the request moves to appointment_set); bad token 404, an appointment already set (or the request closed) 409, repair when must_replace 400 with the reason, a date in the past 400. The token is the credential, so this route spends no Turnstile token (it is still rate-limited).GET /api/leads Β· GET /api/leads/:id (fields, live coverage, nearest dealer, 1-day signed damage-photo links, the license as an id only β NO link β, the claim, the appointment, the outbox rows and the appointment link) Β· PATCH /api/leads/:id {status} (any stage except appointment_set, which only an appointment reaches; closed / rejected stamp closed_at and set the license purge_after 90 days out, reopening clears both) Β· POST /api/leads/:id/appointment (the same write as /appt, signed in, set_by = your email). The license photo is read only through GET /api/media/:id (admin session; one media_access row per view)./claim (Start a claim) and /appt?t=<token> (set the appointment) β plain pages, not installable apps. /claims and /api/claims stay closed (pinned).aws_cust, a SEPARATE trust boundary from the admin aws_sess: sessionUser() never reads it and the portal never reads aws_sess; pinned in test/portal.test.js): POST /api/public/portal/login {vin, last_name, contract_no | phone_last4 | zip | proof} β the VIN, the last name AND one more fact: the contract number on one of that customer's registrations for that VIN (compared ignoring case, spaces and dashes), the last 4 digits of the phone on file, or the first five digits of the ZIP on file (proof is the page's single box and may be any one of the three; a wrong extra fact fails the whole sign-in). Every miss β unknown VIN, wrong name, wrong fact, or a customer with nothing on file to check against β is the SAME 401 {error:"We could not confirm those details β call AWS or start a claim."}; missing pieces are 400; unwound registrations never match. Rate limits: portal_login 8 per 10 min per connection (the 9th is 429) plus a ceiling of 20 attempts per VIN per hour across every connection; honeypot β silent 204; Turnstile is required when TURNSTILE_SECRET is set. Success sets aws_cust (random token, only its SHA-256 stored, 7 days, HttpOnly, SameSite=Lax, Path=/, Secure over https). config.portalAuth = 'knowledge' (default) or 'otp': with 'otp' and no text/email provider the sign-in answers 503 naming todo_684 β it never falls back to the weaker road (any other value is 503 too). POST /api/public/portal/logout deletes the row and clears the cookie. GET /api/public/portal/me β ONLY that customer: customer, products (product, VIN, vehicle, dealership name, deal date, expires_on, coverage active | expiring (inside 60 days of the end) | expired | unknown, contract number, and on GlassGuard products repairs_this_year / repair_limit 2 / must_replace), claims (number, status, issue, product, VIN and items with action, date and β unless config.portalShowAmounts === false β amount_cents), start_claims / start_claim (the prefill for /claim: VIN, name, phone, email, ZIP, dealership id; GlassGuard vehicles only, live contracts first) and contact. Unwound registrations and their claims are left out; no row id of anyone else's is ever present. New config keys portalAuth and portalShowAmounts (defaults 'knowledge' and true)./portal (the customer's page); /claim?prefill=1 reads the portal session (GET /api/public/portal/me) to fill its own form β no personal detail ever goes into a URL.POST /api/contracts/import β v0.14.0 dealer-feed landing. Auth: an admin session OR header x-feed-key = DMS_FEED_KEY (constant-time compare; key unset β 503; wrong/missing β 401). The session gate has exactly one exception: a POST of this exact path carrying a non-empty x-feed-key header is let through to the handler (any other method/path/no header is gated as always). Body {source, adapter, dry_run, contracts[]} (β€ 500 contracts, β€ 8 MB β 413); only an explicit dry_run:false writes. Response {ok, dry_run, source, adapter, plan:{inserts, updates, unwinds, skipped_duplicates, unmatched_terms}, errors:[{index, external_id, field, path, message}], rows, batch_id}. Idempotent on (source, external_id), then (vin, product, deal_date). Adapters in server/lib/feed.js: generic-json live; pen, fi-menu β 501. Spec: FEED-CONTRACT.md.GET /api/feeds/status (signed in) β {endpoint, key_set, max_contracts, adapters} β never the key itself. POST /api/contracts/rate and /api/contracts/void β signed-in only, 501 "not built yet" (PEN-shaped slots).GET /api/outbox[?status=] Β· GET /api/outbox/summary (queued/held/failed counts, turnstile and provider flags β feeds the sidebar badge and the amber line) Β· GET /api/outbox/:id (full row + signed attachment links) Β· PATCH /api/outbox/:id {status:"sent_by_hand"} Β· POST /api/outbox/:id/retry (503 "no delivery provider is configured β todo_684" until an adapter exists; never a silent queue) Β· POST /api/maintenance/purge-media (admin; deletes media past purge_after, returns the list; runs when #/outbox opens).POST /api/dealerships now takes address, zip, glass_program, glass_contact_email, geocodes, and issues slug + link_key; PATCH /api/dealerships/:id edits them ({rotate_key:true} issues a new key, {geocode:true} re-runs the lookup); GET /api/dealers/:id and the report carry slug, link_key, glass_url (/glass?d=<slug>&k=<key>, relative)./api/public/, /glass, /claim, /portal, /appt and the signed media links /m/<id>/<expiry>.<sig> (/api/stats, /api/media/, /api/outbox, /api/maintenance/, /claims and /api/claims stay closed; pinned in test/foundation.test.js). Every public POST is honeypot-checked (field website β silent 204), rate-limited per connection (submit 6 per 10 min; vin 12; portal_login 8; IPs stored only as keyed hashes), Turnstile-verified when TURNSTILE_SECRET is set (fail-visible amber line in #/outbox when it is not), and capped at 8 MB (413). GET /api/public/vin/:vin β year/make/model (cached, check-digit validated), GET /api/public/config β the Turnstile site key. GET /api/media/:id (admin) serves stored images; a sensitive one needs an admin and writes a media_access row first. Config adds opsEmail and atHomeInsideSalesEmail (both default info@awsmotorclub.com) and turnstileSiteKey, editable on /breakdown.web/index.html, vanilla JS, hash-routed)data-tab="feeds"): the endpoint URL, "Feed key: set / not set" (never the key), a paste-JSON "Check this payload" box (dry run only β it cannot save), and a "Contracts received" table of feed batches.web/lib/qr.js, a zero-dependency encoder), the link in a copyable box, π Rotate key (the confirm says every printed sticker stops working) and π¨ Print sticker sheet (six stickers, "GlassGuard β scan to start a repair or replacement"). /glass is a placeholder page until P2.mobile mode (towing/mobile/roadside operators). OSM under-maps one-man mobile outfits, so Google Maps hunt links + Tyler's phone contacts are first-class sources here.shops mode) + its own directory (category='shop'). Cross-links between the two sections preserve the customer/location being searched.&view=map).hooks.mailAdapter in server/server.js / server/worker.js β an adapter is one file in server/lib/providers/ (contract in its README), written when todo_684 is decided; until then Retry says so instead of pretending.server/lib/places.js: Text Search (New) with rankPreference: DISTANCE (surfaces small independents over prominent chains) + per-kind query sets (rangers: mobile tire repair / towing service / roadside assistance / mobile mechanic; shops: tire shop / used tires / tire repair shop). Returns verified phone, website, hours, rating + review count (Enterprise SKU fields β ~1k free calls/mo, then ~3.5Β’/call; ~4-7 calls per discovery click). Key lookup (lazy, no restart needed): local = GOOGLE_PLACES_KEY env or config/secrets.json.googlePlacesKey; cloud = Pages secret GOOGLE_PLACES_KEY (npx wrangler pages secret put GOOGLE_PLACES_KEY --project-name aws-motor-club). Without a key everything falls back to OSM and the UI says so. GOTCHA: pipe the secret via bash printf (PowerShell piping appends CRLF β "API key not valid" 400s); worker also trims the value defensively.rangerAccept / shopAccept (places.js): NEGATIVE_RE kills computer/phone/appliance/car-wash/mobile-home/etc. (Tyler saw a mobile computer repair shop in Road Rangers); rangers additionally require a towing type/tag or an on-location vehicle-service name; shops require tire/auto signals. Gate cases are unit-tested β extend the tests when adding patterns.deploy/decode-vins.js backfills year/make/model from the VIN via the free NHTSA vPIC batch API (50 VINs per POST, polite gaps). It only touches rows missing BOTH year and make, so re-runs never clobber hand-entered vehicles, and one decode fans out to every registration sharing that VIN. All 9,715 registrations now carry a vehicle.Both master lists (intake/*.xlsx, sheet "MASTER For APP") imported by deploy/import-masters.js using deploy/lib/xlsx-lite.js (zero-dep xlsx reader; needs the file expanded as a zip first). Columns are located by header title, not position. Actual layout (identical in both files): Dealership Β· Deal Date Β· Deal Number Β· VIN Β· F&I Manager Β· Customer Name (one full-name column, ALL CAPS, sometimes company or bare last name) Β· Dealer Cost Β· Sale Price Β· Dealer Profit Β· Term Β· "TG Customer #" (β contract_no) Β· Status Β· Date of Last Claim or Service Β· Customer Email/Phone/Address Β· Notes Β· 1stβ5th Service or Claim Amount Β· Total Amount Paid Β· Claim In Progress (Β· Front/Rear Tires, TireGuard only, empty).
Import rules that matter:
server/lib/names.js (companies kept verbatim).term_source='sheet').claim_items; "Claim In Progress" β status in_progress (3 open). Notes β activity.node deploy/import-masters.js --tire <dir> --glass <dir> --wipe (refuses to touch a non-empty db without --wipe). node deploy/check-import.js prints a sanity report.Known source-data quirks (imported faithfully, worth cleaning over time): junk contact cells (e.g. an email of "51"), a handful of future deal dates (e.g. 2026-12-01), GlassGuard dealer costs vary ($185 dominant, $399/$599 strays β all 1-yr by product default, so harmless).
Done items are deleted, not left sitting here looking open. Shipped and gone: real-data import (v0.3), the Cloudflare Pages + D1 port (v0.4/v0.4.1), geocoding (v0.4/v0.4.3), VIN decode and the AppSheet notes import (v0.5.3), and the styled breakdown mirror β superseded by the live /breakdown twin.
POST /api/auth/password endpoint is built and tested but has no UI yet), and per-role permissions β both accounts are admin today, so the role column exists but nothing reads it. Also: this app still has no Appolis ID identity, so it cannot join the suite's signed identity envelope until it does./api/config, /api/stats, /api/products and /api/dealers once and bakes the values into a flat HTML file, so the rendered breakdown could be published without the live twin's runtime fetches. Not built β the hub renders this markdown instead, which covers the need. Only worth building if a stakeholder needs the twin's exact rendered look outside the app.Newest first. Every entry is ONE source line on purpose β the hub's renderer is line-based and a wrapped bullet shreds the list (see the note at the top of this file).
Renumbering note (reconciled 2026-07-26, todo_1086): two releases had been given numbers that were already taken.v0.5(2026-07-04, mobile build-out) collided withv0.5.0(2026-07-17, the New Look), andv0.5.1was held by both the 2026-07-05 Google Places release and the 2026-07-17 bug-button sweep. The 2026-07-17 numbers are baked into shipped code (web/index.htmlcomments read "NEW LOOK (v0.5.0, todo_682)") and into board todos 682/727/731, so they kept their numbers; the two earlier releases moved down into the v0.4.x line, where they also sit correctly by date.v0.5βv0.4.4and Google Placesv0.5.1βv0.4.5. No release was deleted and none changed content.
POST /api/contracts/import, with a private access key instead of a login; every contract is checked first (VIN, dealer, product, money in whole cents, dates), a dry run shows exactly what would happen, a real send files each good contract once (sending the same one again adds nothing), bad contracts are skipped and listed by field, and each send leaves an audit row on Import statements β Dealer feeds. Nothing is wired to a real system yet: the generic-json format works today; pen and fi-menu are documented empty slots (they answer "not built yet") until PEN's spec is in hand. Production needs one new table (deploy/migrate-v0.14.0.sql, safe before or after the deploy) and the DMS_FEED_KEY Pages secret (until it is set, key callers get 503 and admins can still test).portalAuth) switches to texted one-time codes once a text provider is chosen (todo_684); until then that setting refuses rather than quietly falling back. Nothing is emailed or texted yet. 115 tests.#/imports screen: pick the .xlsx the dealer sends, see exactly what it will do to every row, then commit. Nothing is written by the preview, and the commit re-reads the file server-side rather than trusting anything the browser reports. Built on a new zero-dependency .xlsx reader (server/lib/xlsx.js) that runs in the Worker as well as locally β the existing deploy/lib/xlsx-lite.js needs a pre-expanded archive on disk and can only ever serve the import scripts. Eight months of real statements (Dec-2025 β Jul-2026, 1,921 rows) drove every rule: day-first dates repaired (workbooks saved under a day-first locale turned days 1-12 into real date cells with month and day transposed while days 13-31 stayed text β the December and January sheets are both affected and were silently wrong); cancelled deals recognised in all five shapes the dealers use (a FLAT CXL banner, an UNWIND/CANCEL heading, a per-row note column, a negative cost, and an accounting $(185.00)) and stored as registrations.unwound so they exist as history but are excluded from every count, report and coverage total; columns matched by header name because the layout moves month to month (June drops FI-WIP, July's GlassGuard sheet carries a spare column) and because the June GlassGuard sheet is headed TGRGCOST/TGRGSALE β trusting that would file a month of GlassGuard as TireGuard. The dealer's own cheque total is used as an independent audit: our arithmetic must reproduce it to the penny (live cost above the line, minus refunds above it) and the import refuses to write when it does not. That check earned its place immediately β it caught a live March deal being deleted because the customer's surname is CANCELLIERI and the cancellation pattern was unanchored. Re-uploading a statement is a no-op, a corrected sheet shows its changes for approval rather than duplicating, a blank F&I manager never erases one already recorded, and a re-bought vehicle gets a second registration instead of overwriting the first (deal number is the identity β a VIN can carry several deals over time). 68/68 tests, 18 of them new. Two further defects were caught by generating the production SQL and reading it before running it, rather than importing blind: a statement row only has to carry what the dealer printed, and a December refund line names just a VIN and a clawback amount β writing its blanks over the matched registration would have erased that deal's sold date, sale price and expiry, so every field a statement may omit is now COALESCEd and only the cancellation flag is written unconditionally; and dealer_profit_cents / expires_on, although derived, are now COMPARED, because production held rows whose profit was never computed (NULL, so February and March reported $0.00 profit against real six-figure sales) and a re-upload previously skipped them as "unchanged". Deployed 2026-08-17 in the required order β deploy/migrate-v0.7.0.sql against D1 first, then the code β followed by the Dec-2025 β Jul-2026 backfill: 1,141 registrations added, 144 repaired, 502 customers created, 16 cancellations recorded, all eight months reconciling to the figure the importer predicted, with 0 unmatched terms and 0 future-dated deals. 69/69 tests.error code: 1101 while every invalid one returned a clean 400. Node has no such cap, so all 49 tests passed. The verification gap was mine: to avoid claiming Tyler's account I had only ever tested prod with deliberately-invalid passwords, which never reach the hashing call. Fixes: iterations pinned to 100k behind MAX_WORKERS_PBKDF2_ITERATIONS with a regression test asserting stored iterations never exceed the ceiling; the login page now distinguishes a server fault from a bad password instead of blaming your connection; and password rules dropped composition requirements for length-only per NIST SP 800-63B (the "must contain a number" rule was also rejecting reasonable passphrases). New verification standard: prove the real hashing path in prod with a TEMPORARY account, then delete it β done here, then removed, leaving both real accounts untouched and unclaimed. 50/50 tests.#/account (click your name in the sidebar): change your own password (requires the current one, signs out every other device) and β since no email provider exists yet for a "forgot password" link β any admin can re-arm a locked-out teammate so their next sign-in sets a new password. Guards: reset needs a session, refuses self-targeting, rejects unknown emails, and can never create an account; the team list is session-gated and never ships salts or hashes to the browser. Also added the house-standard styled askConfirm() (this app was still missing it) so the reset confirmation is not a bare browser dialog. Tyler confirmed Matt has the same access level as Tyler β both admin, same app. 49/49 tests.APP_KEY + ID_SECRET came out of wrangler.jsonc (ID_SECRET is an encrypted Pages secret, APP_KEY and ACCESS_KEY.txt deleted as dead); and wrangler.jsonc left .gitignore since it no longer holds credentials. Sidebar shows who is signed in with a sign-out. 46/46 tests (12 new auth tests incl. the allowlist being un-registerable and a weak claim leaving the account unclaimed). Verified on prod: old key cookie now 401, both accounts still unclaimed and waiting for their owners.intake/AWS MANUAL INPUT LIST FOR APP.xlsx, tab "MASTER For APP" β 10,135 rows, combined TG+GG with a product column); new deploy/import-appsheet-notes.js matches notes by contract # β VIN and dedupes by body, so re-runs are safe. Result: the AppSheet universe holds 17 notes total β all 17 matched, 6 were new (11 already imported from the masters), 0 unmatched; prod D1 synced idempotently (19 note activities live). No photos are embedded in the export. The "missing notes" perception was mostly the missing VEHICLES (fixed in this same release β item 1 below) β the notes data was always this small. (1) VEHICLES EXIST NOW: every one of the 9,715 imported registrations lacked year/make/model (the sheets only carried VINs) β new deploy/decode-vins.js backfills them via the free NHTSA vPIC batch API (50 VINs/POST, polite gaps, only touches rows missing BOTH year+make so re-runs never clobber hand-entered vehicles; one decode fans out to every registration sharing the VIN). vehicleStr() lights up across the app automatically. (2) junk contact cells: 17 junk emails (the "29"/"51" artifacts) nulled in data, and the UI now renders phone/email only when they look real (okPhone/okEmail β customers list, card header, comms; the edit form still shows raw values so junk stays correctable). (3) NOTES ROOT-CAUSED (the "notes not imported" half of todo_682): the import was NOT at fault β both master workbooks contain only ~12 notes across every tab, and all imported. The real per-customer notes/photos live ONLY in AppSheet's backing data β Tyler exports it (board todo_685), then a matcher-importer lands them as activity. Stale finding for ops: 2 of the 3 imported "in progress" claims date to 2022/2023 β close or resolve in-app. 34/34 tests.server/lib/places.js: Text Search (New), DISTANCE-ranked, per-kind queries incl "used tires" for indie shops; results carry rating/review count (shown in list + map popups). rangerAccept/shopAccept gate ALL engines β mobile computer repair etc. can no longer appear (Tyler's report). Key plumbing local (env/secrets.json) + cloud (Pages secret), lazy-read, graceful OSM-only fallback with "not configured" surfaced in UI. Key landed the same day and was verified live: rangers came back 40/40 with a phone number, and indie tire shops (Sandlake Tire Shop 4.8β
, 1.1 mi) now surface ahead of the chains. 34/34 tests. (Renumbered from v0.5.1 β see the note above.).hide-sm (customers/claims/comms/dealers/vendors/discovery keep the essentials; anything still wide scrolls inside .panel { overflow-x:auto }); 16px inputs (kills iOS focus-zoom), β₯40px touch targets, 2-col stat cards, 62vh map, scrollable dialogs, safe-area insets. PWA basics: manifest + SVG icon + theme-color β "Add to Home Screen" runs standalone. Discovered phones are now tel: links. Desktop layout untouched (all scoped to the media query). Verified at 375Γ812: zero horizontal overflow on every page, drawer + map + card OK. (Renumbered from v0.5 β see the note above.)kind=mobile: towing tag + mobile/roadside-named, filtered in code β Overpass ,i name regexes are unusably slow, don't reinstate them); new Tire Shops tab holds the fixed drop-off shops. roadside_vendors.category (ranger|shop) separates the directories (remote D1 ALTERed). Overpass mirror fallback added. 32/32 tests.dealer_products per-dealership pricing (backfilled MBSO TG $299 / GG $185; registration default + editable panel). Date-swap fix: future deal dates were month/day transpositions β corrected at import (0 remain). 31/31 tests.registrations.contract_no (searchable) + deal_number, activity.direction; idempotent addColumn migrations; nullable deal_date. 27/27 tests. Verified live on real data in preview.F:\Claude Code\AWS Motor Club, db file data/aws-motor-club.db, env override AWS_DB, launch entry aws-app. Import plan rewritten: read ALL master-list column headers and carry claim notes/payout history over (Β§7).