Streamlink v2 — Design Spec
Date: 2026-07-16
Status: Approved (verbal, session of 2026-07-16)
Companion doc: docs/design-input-brief.md — full Salesforce data inventory with exact API names, row counts, field allowlists/denylists, and security findings. That brief is the authority on which fields; this spec is the authority on what we build.
1. Why
Streamlink today is two Salesforce Experience Cloud sites (StreamlinkCustomer, StreamlinkVendor) that customers barely use: 117 customer logins from 19 distinct users in 6 months, 40% of login attempts failing, and the Customer Community Plus license pool at 180/180 — new portal users cannot be added at all. Meanwhile the marketing site sells "every site, every service order — one pane of glass," which the current product does not deliver.
Streamlink v2 is a customer portal that lives on AWS (like the checkout site at ~/aws-website), holds a synced read model of Salesforce data, and gives customers a real dashboard without Salesforce licenses, Salesforce UX, or Salesforce page loads.
2. Decisions (locked)
| Decision | Choice |
|---|---|
| Platform strategy | Shared AWS↔SF backbone for both customer and vendor surfaces; customer surface first. Vendor phase later, separate project. |
| V1 interactivity | Read + one write path: service requests → SF Case. No payments, no ordering. |
| Auth | Magic link (email-based, passwordless). No SF community licenses. |
| Data flow | Synced read model: poll worker copies allowlisted fields into portal Postgres; UI reads Postgres only. |
| Visibility | Account-wide: any verified contact on an account sees all of that account's orders/billing/tickets. |
| Brand scope | LDR Site Services only in v1; schema carries Company__c for later multi-brand theming. |
| Product scope | All Job_Type__c values (dumpster, porta potty, fencing, generator, fuel, storage, forklift). |
| Domain | streamlink.ldrsiteservices.com |
3. Architecture
SALESFORCE (prod) AWS
┌──────────────┐ poll Δ (SystemModstamp) ┌──────────────────┐
│ Account │ every 2 min │ Sync worker │
│ Contact │──(allowlisted fields ───▶│ (Lambda, EventBridge│
│ Customer_Orders__c │ only) │ Scheduler) │
│ Order_Line_Item__c │ └────────┬─────────┘
│ Case (slice) │ ▼
│ Transaction__c│ ┌──────────────────┐
│ Netsuite_Transactions__c │ │ Portal Postgres │
│ Vendor_Delivery_Notification__c │ │ (RDS, Prisma) │
└──────▲───────┘ └────────┬─────────┘
│ │
│ staged idempotent write: ┌────────┴─────────┐
└── service request → Case ─────────│ Next.js portal │
(Origin='Customer Portal') │ (Amplify SSR) │
└──────────────────┘
Four components, one repo (streamlink):
- Portal web app — Next.js 14 App Router, TypeScript, Tailwind, Prisma. Deployed on AWS Amplify Hosting (same ops as checkout). Secrets at runtime from AWS Secrets Manager via a
getSecret()abstraction — never inlined into the build bundle (deliberate break from checkout'snext.config.js env{}pattern, which is a flagged weakness). - Portal database — dedicated RDS Postgres, Prisma-managed schema. Contains only allowlisted fields. Vendor rates, margins, costs, card numbers, tokens, CVVs physically never enter this database.
- Sync worker — same repo, bundled as a Lambda, run by EventBridge Scheduler every 2 minutes. Incremental sync keyed on
SystemModstampper object (nine objects: the eight in the diagram plusCredit_Card__cdisplay fields for cards-on-file). Volume: ~1–2k changed rows/day. A separate one-time backfill job (paged SOQL, ORDER BY SystemModstamp with cursor) seeds history: ~250k orders, ~1M OLIs, plus billing/case slices. - Write-back path — service requests create
Caserecords (RecordTypeCustomer_Support=0123o000001cpHZAAY,Origin='Customer Portal'— existing picklist value). Uses the staged/resumable/idempotent write pattern from checkout'scompletedOrderOrchestrator. Requests are persisted locally first (service_requeststable withsf_push_stage), so a SF outage never loses a customer request; a retry job drains the queue.
Salesforce access: a new, dedicated Connected App + integration user (OAuth 2.0 Client Credentials, same recipe as aws-website/lib/salesforce/clientCredentialsAuth.ts). Its profile/permission set grants read on exactly the synced objects and zero FLS on: Credit_Card__c.Security_Code__c, Credit_Card__c.Credit_Card_Number__c, all *_Token__c, Authorize_net_Profile_Id__c, Transaction__c.Payscape_Payload__c, Transaction__c.Expiration_Date__c, Transaction__c.Authorization_Code__c. Checkout's integration user is untouched — the two apps cannot break each other.
4. Data model (portal Postgres)
Synced mirrors (SF Id as natural key, sf_system_modstamp for incremental sync, soft-delete flag):
accounts— from Account (Business_Account + PersonAccount record types only; Vendor and Disposal_Facility record types are never synced)contacts— from Contact (id, account_id, email, name, phone, is_person_account)orders— fromCustomer_Orders__c: order number, account_id, company (brand), site fields (Shipping_*,Site_Address__c,Store_Name__c,Store_Number__c,Location_Name_Other__c, site contact fields,Setting_Instructions__c), customer status (Customer_Order_Status__c), dates (Delivery_Date__c,First_OLI_Delivery_Date__c,Latest_OLI_End_Date__c,Order_Completed_Date__c), money (LIne_Total_Amount__c,Tax_Amount_Oli__c,Total_Amount_After_Tax__c,Charged_Amount__c,Credited_Amount__c,NewBalance__c),Customer_PO__c,Customer_Ref__c,Notes_to_Customer__corder_line_items— fromOrder_Line_Item__c: order_id, record_type (placement vs charge-adjustment: "Additional Tonnage"/"Extended Rental"/"Other Charges"),Job_Type__c, product/size/quantity/material, service-day fields, lifecycle (KAM_Deliveries_Removals__c,Delivery_Confirmed__c/_Date__c,Removal_Confirmed__c/_Date__c), dates (Delivery_Date__c,Expected_Delivery_Time__c,End_Date__c,Projected_End_Date__c,Swap_Date__c,Rental_Days__c), customer pricing fields,Line_Notes_to_Customer__c, plus a derivedcustomer_statuscolumn computed at sync timecases— filtered slice of Case: RecordTypeCustomer_SupportAND (Origin IN allowlist OR Type IN customer-facing allowlist). Fields: subject, description, type, mapped status (Open/Closed), priority, resolution, order/OLI links, created/closed datestransactions— fromTransaction__c: name, type, amounts, card last-4 + card type (display strings only), item description, payment reference, order/OLI links. Rows with Type Decline/Void orVoided__care synced but flagged, excluded from totalsinvoices— fromNetsuite_Transactions__cWHEREType__c='Invoice': totals, paid, due, dates, status, memo. Account resolved viaOrder__r.Account__c(directAccount__cis unreliable)delivery_confirmations— fromVendor_Delivery_Notification__c: order/OLI link, responded flag/date/response.Vendor__candNotification_Sent_to__care never syncedsaved_cards— fromCredit_Card__c: display fields only (name, last-4, card type, expiry display, primary flag, account_id, company)
Portal-native tables:
portal_users— email (verified), linked contact_id(s) + account_id(s), last_loginsessions,magic_links(token hash, single-use, 15-min expiry, rate-limit bookkeeping)service_requests— user, account, order/OLI, type, description,sf_push_stage(pending → pushed → confirmed | failed), sf_case_id, timestampssync_state(per-object high-water mark),sync_runs(audit: started, finished, rows, errors, lock — concurrency-safe: advisory lock orSELECT ... FOR UPDATE, fixing the known unsafety in checkout'srunSync.tspattern)audit_log— portal user actions (logins, account switches, requests submitted)
The allowlist is code: one module (lib/sync/fieldAllowlist.ts) declares, per object, exactly the SF fields that may be selected and stored. The SOQL SELECT clauses are generated from it. A unit test asserts the allowlist has zero intersection with a maintained denylist (every Vendor_*, margin, cost, internal-note, token, and card-sensitive field catalogued in the design-input brief). Leaking a vendor field to the portal would require editing both lists in one reviewed diff.
Status mapping is code: lib/mapping/status.ts maps raw SF picklists → customer-safe statuses (orders: Open/Closed; units: Scheduled → Delivered → Removal requested → Completed → Cancelled; cases: Open/Closed). Raw values like "Vendor Queue", "Ready for Procurement", "Accounting" never render.
5. Auth flow
- Customer enters email at
streamlink.ldrsiteservices.com/login. - If email matches ≥1 synced
contactsrow on an eligible account: send magic link via SES. (Unknown email: same "check your inbox" response — no account enumeration.) - Link click: verify single-use token (hashed at rest, 15-min expiry), create session (httpOnly secure cookie, 30-day rolling).
- If the email maps to multiple accounts: account picker; selection remembered, switchable in the header.
- Rate limits: per-email and per-IP on link requests.
Eligible population on day one: ~23,191 contacts with emails on accounts that ordered in the last 12 months. No provisioning step, no licenses, no passwords.
6. Features (v1)
- Dashboard — active units count across sites, open balance, recent activity feed (deliveries confirmed, requests updated, payments posted).
- Sites & orders — orders grouped by site (
Store_Name__c+Store_Number__c, falling back to normalized site address). Order detail: unit cards with lifecycle timeline; charge-adjustment OLIs grouped under their parent placement (no phantom dumpsters); per-line pricing and balance. - Billing — payment history first (that is what the data is: ~45k payments vs ~550 net-terms invoices/yr); open-invoices section; per-order balances; cards on file (last-4 display only). No invoice PDFs in v1 — no PDF source exists in SF; NetSuite retrieval is a fast-follow after verification.
- Support — account's ticket list + detail (filtered case slice); new service request form (types: Swap, Removal Request, Delivery ETA, Service Issue, Invoice Issue, Cancellation, Potty Service, Customer Inquiry) linked to an order/unit → SF Case; confirmation email; request status visible in portal.
- Staleness banner — if last successful sync > 15 min old, the UI says so ("Data as of 10:42 AM").
Out of scope v1: payments, invoice PDFs, live GPS/en-route tracking (no GPS data exists in SF; Vendor_Delivery_Notification__c response rate is only ~44%, so delivery timeline shows confirmed events honestly rather than promising live tracking), vendor surface, new-service ordering (checkout site's job), multi-brand theming.
7. Security
- Field allowlist at sync (see §4) — enforced by codegen + leak test.
- Every portal query account-scoped through a single session-derived guard (
withAccountScope) — no handler queries Prisma directly without it. Route-level integration tests assert cross-account reads 404. - Parameterized/escaped SOQL only (no string interpolation; checkout's regex-guarded interpolation is not carried over).
- Dedicated integration user, least-privilege FLS (see §3).
- Secrets Manager at runtime; IAM roles, no long-lived keys.
- Magic-link tokens hashed at rest; sessions httpOnly/secure/SameSite; audit log.
- CSP/security-headers middleware (adapted from checkout's
middleware.ts, allowlists rewritten for the portal).
Escalations outside this project (found during research, need owner action regardless):
- 🔴 PCI-critical:
Credit_Card__c.Security_Code__cholds plaintext CVVs — 77,945 rows, 11,347 written in the last 12 months, still being written. Storing CVV post-auth is prohibited under PCI DSS in any form. Stop the writer, purge the column, audit. External_User__c.PasswordTxt__cholds plaintext passwords (477 rows, dead since 2021). Purge the object.- Legacy formulas
Customer_Orders__c.Customer_Acess__c/HasAccess__cuse spoofable CSV substring matching; do not reuse, consider retiring. - Checkout inlines
SF_CLIENT_SECRET/DATABASE_URL/static IAM keys into its build bundle — rotate + migrate when convenient.
8. Ops & failure handling
- Sync: per-object high-water marks; a failed object doesn't block others; run lock prevents overlap;
sync_runsaudit; CloudWatch alarm on N consecutive failures or lag > 30 min. - Write-backs: local-first persistence, retry with backoff, dead-letter state visible in an internal admin view; customer sees "Submitted" immediately and "Received" once the Case exists.
- Backfill: resumable cursor job; rate-aware (Bulk/paged REST) — stays well inside API limits.
- Logging: pino structured logs (checkout convention) → CloudWatch.
9. Testing
- Unit (vitest): status mappers, allowlist leak test, OLI grouping (charge adjustments), balance math, magic-link token lifecycle.
- Integration: sync worker against mock SF (
SF_MODE='mock'pattern from checkout) — upsert, delete-flagging, high-water-mark advance, lock contention; route-level account-scoping tests. - E2E (playwright): login → dashboard → order detail → submit service request → see it tracked.
10. Rollout
- Deploy to staging against SF UAT org (
target_org='uat'alias exists) with a synthetic account. - Backfill prod data, internal dogfood (LDR staff emails exist as contacts).
- Pilot: the ~19 active Experience Cloud customer users + 1–2 national accounts, personally invited.
- Experience Cloud customer site stays live until parity confidence, then redirect
ldrsiteservices.my.site.com/StreamlinkCustomer→ new portal. ~24 named customer licenses freed. - Vendor phase: separate spec, same backbone. Holds 156 of 180 licenses and deep ops coupling (vendor pool dispatch, payment manager) — explicitly not a copy of the customer build.
Coordination prerequisites (human to-dos):
- Change freeze (or notification channel) for portal-facing SF changes with the SF dev team (V. Shetty / S. Koo — active as of 2026-07-01).
- Ruling from accounting/SF owner: is
NewBalance__corBalance__cauthoritative? (Spec assumesNewBalance__c; a liveTransaction__c.Order_Balance__cformula still referencesBalance__c.) - Create the Connected App + integration user in SF (admin action).
- DNS for
streamlink.ldrsiteservices.com; SES domain verification for magic-link mail.