# Customer Portal — Architecture & Integration Guide > Authoritative reference for the Gigafibre customer portal: the Vue 3 + > Quasar SPA at `portal.gigafibre.ca`, its passwordless magic-link > authentication, and the standalone `nginx:alpine` container that serves > it. Companion document to [../architecture/overview.md](../architecture/overview.md) > and [../architecture/data-model.md](../architecture/data-model.md). > > Last updated: 2026-04-22. Plan A (magic-link login replacing ERPNext > `/login`) shipped today. See commits `2b04e6b` and `7ac9a58` for the > deploy-topology cutover. --- ## 1. Goals & Principles The customer portal is the only front door a residential or commercial customer ever sees. It replaces the legacy PHP portal and the ERPNext `/login` password form; staff continue to use Authentik (`ops.gigafibre.ca` / `erp.gigafibre.ca`) and never touch the portal host. Design tenets: 1. **Passwordless by default.** The customer types an email or phone, the hub mails/SMSes a 24 h JWT, the SPA decodes it client-side. No password, no session cookie, no Authentik for customers — rationale documented in `~/.claude/projects/-Users-louispaul-Documents-testap/memory/project_portal_auth.md` (legacy passwords are unsalted MD5 and cannot be trusted). 2. **Anti-enumeration by contract.** `POST /portal/request-link` *always* returns `HTTP 200 {"ok":true, "sent":[…]}`. An unknown identifier returns `sent: []`. An attacker cannot use the endpoint to probe whether an email or phone is in our customer database. 3. **Rate limit the send surface.** 3 requests per 15-minute window per identifier (case-insensitive). Survives restart loss gracefully — the Map is in-memory and repopulates on the next abuse attempt. 4. **Both channels, best effort.** A customer with both email and phone gets the link on both — SMS failure does not block email, and vice versa. The `sent: [...]` array tells the UI which channels actually delivered so it can display redacted destinations. 5. **Two routes, one SPA.** `portal.gigafibre.ca` is the canonical host. `client.gigafibre.ca` is a 307 redirect at the Traefik layer so stale bookmarks, SMS notifications from before the cutover, and old invoice links still land on the right place. 6. **Hash router, not path router.** The SPA uses `createWebHashHistory` so the token survives behind the `#` and never reaches nginx. Also lets the single `index.html` serve every route without per-path nginx rules. 7. **Retire, don't migrate.** ERPNext's `/login` is dead for customers — there is no "staff impersonation" path in the passwordless flow. Staff who need to act as a customer use the Ops "Send magic link" action on the client-detail page (implemented via `POST /portal/request-link`). --- ## 2. Topology & Deployment ### 2.1 Hosts & routing ``` ┌───────────────────────────────────────┐ │ Traefik (erp.gigafibre.ca:443) │ │ *.gigafibre.ca wildcard → 96.125.196.67│ └───────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ ┌───────────────────────┐ ┌────────────────────┐ ┌──────────────┐ │ portal.gigafibre.ca │ │ client.gigafibre.ca│ │ erp.*/ops.* │ │ (canonical host) │ │ (legacy alias) │ │ (staff) │ ├───────────────────────┤ ├────────────────────┤ ├──────────────┤ │ docker container │ │ Traefik dynamic │ │ ERPNext │ │ client-portal │ │ route — 307 to │ │ frontend + │ │ image nginx:alpine │ │ portal.gigafibre.ca│ │ Authentik │ │ bind-mounts │ │ /$1 (preserves │ │ forwardAuth │ │ /opt/client-app/ │ │ path) │ │ │ │ ─ index.html │ │ │ │ NO customer │ │ ─ assets/* │ │ (traefik-client- │ │ /login form │ │ ─ nginx.conf │ │ portal.yml) │ │ — endpoint is│ │ ─ docker-compose │ │ │ │ blocked by │ └───────────────────────┘ └────────────────────┘ │ redirect │ └──────────────┘ ▲ │ magic-link JWT + REST │ (msg.gigafibre.ca) │ ┌──────────────────┐ │ targo-hub │ │ portal-auth.js │ │ magic-link.js │ └──────────────────┘ │ │ resource REST ▼ ┌──────────────────┐ │ ERPNext backend │ │ (Customer, Sales │ │ Invoice, Issue) │ └──────────────────┘ ``` ### 2.2 Two distinct Vue builds used to coexist — no more Before 2026-04-22 the same Vue SPA was built twice: once with `base=/assets/client-app/` and dropped into the ERPNext public folder so ERPNext's frontend would serve it at `client.gigafibre.ca/assets/client-app/`; a second build ran for local dev. That's retired — we now build with `base=/` and ship to a standalone nginx container. `apps/client/quasar.config.js` keeps `/assets/client-app/` as the fallback so a local `quasar dev` still works, but production always overrides with `DEPLOY_BASE=/`: ```js // apps/client/quasar.config.js extendViteConf (viteConf) { viteConf.base = process.env.DEPLOY_BASE || '/assets/client-app/' } ``` ```bash # apps/client/deploy.sh (prod build) VITE_ERP_TOKEN="…" DEPLOY_BASE=/ npx quasar build -m pwa ``` ### 2.3 `deploy.sh` — atomic swap + the inode gotcha The deploy does three things remotely: 1. Upload the fresh build into a staging directory (`mktemp -d /opt/client-app.new.XXXXXX`). 2. `mv` the live `/opt/client-app` aside (`/opt/client-app.bak.`), then `mv` the staging dir onto `/opt/client-app`. This is atomic — nginx never sees a half-written `index.html` referencing hashed assets that haven't finished uploading. 3. Keep the three most recent backups, delete older ones. Then the non-obvious part: > **Docker bind mounts follow the inode, not the path.** When `docker run` > records a bind mount, it resolves `/opt/client-app` once and pins that > inode. After the atomic `mv` swap, the container is still pointed at the > *old* directory (now `/opt/client-app.bak.`) — so you'd serve > stale content from the backup until you recreate the mount. > > Fix: `docker restart client-portal` at the end of the deploy. The > restart re-resolves the bind and the container picks up the new inode. That `docker restart` line is the entire reason `deploy.sh` needed an update today; previously we relied on the bind mount "just reflecting the new files" which only works when you edit them in place, not when you swap the entire directory. ### 2.4 Local build only ```bash ./deploy.sh local # build with DEPLOY_BASE=/ into dist/pwa/, no upload ``` Handy when you want to test the PWA manifest / service worker path behaviour before shipping. --- ## 3. File Inventory ### 3.1 Front-end — `apps/client/` | Path | Responsibility | |--------------------------------------------------|--------------------------------------------------------------------------| | `deploy.sh` | Build + rsync + atomic swap + `docker restart client-portal` | | `quasar.config.js` | Vite base override (`DEPLOY_BASE`), hash router, PWA manifest | | `src/App.vue` | Root — calls `store.init()` once on mount | | `src/router/index.js` | Routes + navigation guard (pushes unauthenticated to `/login`) | | `src/layouts/PortalLayout.vue` | Drawer + header + cart badge, shows customer name | | `src/stores/customer.js` | Pinia store: `hydrateFromToken`, JWT decode, `stripTokenFromUrl`, `init` | | `src/stores/cart.js` | Pinia store for the public catalog cart | | `src/composables/useMagicToken.js` | Read-through: `{authenticated, expired, customerId}` for payment pages | | `src/composables/useSSE.js` | EventSource wrapper for `msg.gigafibre.ca/sse` (topic-based) | | `src/composables/useFormatters.js` | `formatDate`, `formatMoney`, `formatShortDate`, `formatPrice` | | `src/composables/useOTP.js` | OTP send/verify for catalog checkout existing-customer path | | `src/composables/useAddressSearch.js` | RQA address autocomplete wrapper | | `src/composables/usePolling.js` | Generic polling helper | | `src/api/auth-portal.js` | `requestPortalLink()` — posts to hub `/portal/request-link` | | `src/api/auth.js` | `authFetch()` wraps ERPNext REST with service token (`VITE_ERP_TOKEN`) | | `src/api/portal.js` | ERPNext REST client: invoices, tickets, profile, service locations, comms | | `src/api/payments.js` | Hub client: balance, methods, checkout, setup card, billing portal, PPA | | `src/api/catalog.js` | Hub client: catalog, checkout, address search, OTP | | `src/pages/*.vue` | One page per route (see §4) | ### 3.2 Back-end — `services/targo-hub/lib/` | Path | Responsibility | |----------------------|--------------------------------------------------------------------------------------| | `portal-auth.js` | `POST /portal/request-link` — lookup, mint JWT, dispatch SMS + email, rate limit | | `magic-link.js` | `generateCustomerToken()` — HS256 JWT with `scope=customer`, 24 h TTL | | `config.js` | `CLIENT_PORTAL_URL`, `JWT_SECRET` — wired to `portal.gigafibre.ca` by default | | `helpers.js` | `lookupCustomerByPhone()` — last-10-digit match across `cell_phone`, `tel_home`, `tel_office` | | `twilio.js` | `sendSmsInternal()` — used for the SMS channel | | `email.js` | `sendEmail()` — used for the email channel | | `checkout.js` / `payments.js` | Stripe endpoints that the portal hits from `InvoiceDetailPage` | | `conversation.js` | SSE topic `conversations` + `customer:` for real-time inbox updates | ### 3.3 Traefik — `apps/portal/` | Path | Responsibility | |------------------------------|----------------------------------------------------------------------------| | `traefik-client-portal.yml` | Dynamic route: `Host(\`client.gigafibre.ca\`)` → 307 to `portal.…/$1` | | `deploy-portal.sh` | `scp` the YAML to `/opt/traefik/dynamic/client-portal.yml` + smoke-test | --- ## 4. Routes & Pages The router uses `createWebHashHistory`, so every URL above the portal root looks like `https://portal.gigafibre.ca/#/`. Route paths in French match the customer-facing UI vocabulary (`/me`, `/panier`, `/paiement/merci`, `/commande/confirmation`). | Path | Name | Purpose | Endpoints | |-------------------------------|---------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------| | `/login` | `login` | Passwordless login form — email or phone input, sent state, rate banner | hub `POST /portal/request-link` | | `/` | `dashboard` | "Bonjour, {name}" + unpaid count + open tickets + 5 most recent invoices | ERPNext `Sales Invoice`, `Issue`; hub `POST /payments/checkout` | | `/invoices` | `invoices` | Paginated table of Sales Invoices with download PDF action | ERPNext `Sales Invoice`, `frappe.utils.print_format.download_pdf` | | `/invoices/:name` | `invoice-detail` | Single invoice: summary, lines, taxes, pay CTA (card + Klarna), PDF | ERPNext `Sales Invoice/:name`, `printview.get_html_and_style`; hub `POST /payments/checkout-invoice` | | `/tickets` | `tickets` | Support ticket list + create dialog | ERPNext `Issue` (list + create) | | `/tickets/:name` | `ticket-detail` | Conversation thread + reply box | ERPNext `Issue/:name`, `Communication` (list + post) | | `/messages` | `messages` | All communications (SMS / Email / Phone) grouped by ticket or medium | ERPNext `Communication` (direct + via Issues); hub SSE topic `customer:` | | `/me` | `account` | Customer info, service locations + monthly total, saved cards, PPA toggle | ERPNext `Customer/:name`, `Service Location`, `Service Subscription`; hub `/payments/balance`, `/payments/methods`, `/payments/setup`, `/payments/portal`, `/payments/toggle-ppa` | | `/catalogue` | `catalog` | Public add-on catalog (Internet / Téléphonie / Bundle / Équipement) | hub `GET /api/catalog` | | `/panier` | `cart` | Cart review, install scheduling, OTP flow for existing customers | hub `POST /api/otp/send`, `POST /api/otp/verify`, `POST /api/address-search`, `POST /api/checkout` | | `/commande/confirmation` | `order-success` | Post-checkout confirmation with order number + install notice | hub `GET /api/order/:id` | | `/paiement/merci` | `payment-success` | Stripe return (session mode) — "Paiement reçu" + link back to invoice | — (return landing only) | | `/paiement/annule` | `payment-cancel` | Stripe return when the user backs out | — (return landing only) | | `/paiement/carte-ajoutee` | `payment-card-added`| Stripe Setup Intent return — confirms the card is saved, nudges PPA | — (return landing only) | ### 4.1 Public routes (skip the nav guard) ```js // apps/client/src/router/index.js const PUBLIC_ROUTES = new Set([ 'login', 'catalog', 'cart', 'order-success', 'payment-success', 'payment-cancel', 'payment-card-added', ]) ``` Payment returns stay public so a customer whose JWT has expired still sees a human confirmation (plus a re-auth CTA) rather than a bounce. The catalog and cart are public so unauthenticated prospects can shop add-ons — the OTP flow in the cart escalates to auth only at checkout. --- ## 5. Authentication Flow ### 5.1 Sequence ``` Customer Login Page targo-hub ERPNext Email/SMS │ │ │ │ │ │── types email/phone ──▶ │ │ │ │ │── POST /portal/ │ │ │ │ │ request-link ─────────▶│ │ │ │ │ │── lookupCustomer ─────▶│ │ │ │ │◀── Customer row ───────│ │ │ │ │ │ │ │ │── generateCustomerToken (HS256, 24h) ───────│ │ │ │── sendSmsInternal ─────────────────────────▶│ │ │ │── sendEmail ───────────────────────────────▶│ │ │◀── 200 {ok:true, │ │ │ │ │ sent:[sms,email], │ │ │ │ │ redacted:[…]} ────────│ │ │ │◀── "Lien envoyé!" ────│ │ │ │ │ │ │ (customer opens SMS / email) │ │◀───────────────────── https://portal.gigafibre.ca/#/?token= ──────────────────────────────│ │ │ │── click ──▶ SPA loads │ │ └─ router guard: to.query.token → store.hydrateFromToken() │ │ └─ JWT parts[1] base64-decoded, exp checked, scope==='customer' │ │ └─ customerStore.customerId = payload.sub │ │ └─ stripTokenFromUrl() (history.replaceState) │ │ └─ dashboard renders │ ``` ### 5.2 JWT shape Minted by `generateCustomerToken(customerId, customerName, email, ttlHours=24)` in `services/targo-hub/lib/magic-link.js`: ```json { "alg": "HS256", "typ": "JWT" } . { "sub": "C-00042", "scope": "customer", "name": "Jean Tremblay", "email": "jean@example.com", "iat": 1713798000, "exp": 1713884400 } ``` Signed HS256 with `cfg.JWT_SECRET` (shared only between the hub processes — the portal never holds the secret). The portal trusts the signature implicitly because the token arrived over HTTPS via a channel the user already controls (their own inbox or SMS). Any tampering breaks signature verification the next time the user clicks the link; the currently-loaded SPA never re-verifies. ### 5.3 Identifier resolution `portal-auth.js → lookupCustomer(raw)` handles three forms: | Input shape | Lookup order | |------------------------|-----------------------------------------------------------------------------------------------| | Contains `@` | `Customer.email_id` → `Customer.email_billing` (both lowercased) | | Matches `^(C-|CUST-|GI-)/i` or `^\d{4,7}$` | `Customer.legacy_customer_id` → direct `Customer.name` fetch | | Anything else (phone) | `helpers.lookupCustomerByPhone()` — last 10 digits matched against `cell_phone`/`tel_home`/`tel_office` | Returned record always includes `name`, `customer_name`, `cell_phone`, `tel_home`, `email_id`, `email_billing` so the sender knows which channels are available. ### 5.4 URL hygiene after hydration `stores/customer.js → stripTokenFromUrl()` uses `history.replaceState` to rewrite the URL without `?token=` as soon as the store is hydrated. This keeps the token out of: - Browser history (back button won't show the token). - Bookmarks ("add this page to bookmarks" captures the stripped URL). - Third-party referer headers (external links on subsequent pages). The JWT still lives in the Pinia store for the rest of the tab's life; a hard refresh reloads the store from…nothing, and the router guard bounces back to `/login` unless the URL carries a token or a previous hydration put `customerId` into `localStorage` (the store does not persist — this is intentional; a refresh is a fresh auth attempt). ### 5.5 Subsequent calls Two backends, two auth styles: - **ERPNext REST** (`BASE_URL/api/resource/…`, `/api/method/…`): served through `authFetch()` in `src/api/auth.js`, which attaches `Authorization: token `. The token is a fixed service key baked in at build time; it's not the customer's JWT. The customer's identity is enforced at the query layer by always filtering on `customer = store.customerId` from the store. - **Hub** (`msg.gigafibre.ca/payments/*`, `/api/*`, `/sse`): currently called unauthenticated. Endpoints that need customer context receive `customer` as a body/path parameter. The hub trusts the portal origin. Upgrading these endpoints to verify the magic-link JWT is tracked as a hardening follow-up (see §10). --- ## 6. Anti-Enumeration Contract `POST /portal/request-link` adheres strictly to the following response matrix. Callers (portal UI, staff-ops tools, any future integration) must treat `200 OK` as the *only* success signal and must *not* branch on the identity of sent channels to decide whether the account exists. | Situation | HTTP | Body | |-----------------------------------|------|---------------------------------------------------------------------------------| | Unknown identifier | 200 | `{"ok":true, "sent":[]}` | | Known, only email on file | 200 | `{"ok":true, "sent":["email"], "redacted":["j***n@example.com"]}` | | Known, only phone on file | 200 | `{"ok":true, "sent":["sms"], "redacted":["***-***-1234"]}` | | Known, both channels | 200 | `{"ok":true, "sent":["sms","email"], "redacted":["***-***-1234","j***n@…"]}` | | Known, both channels, both failed | 200 | `{"ok":true, "sent":[]}` (logged internally as delivery failure) | | Empty body | 400 | `{"error":"identifier_required"}` | | Rate limit hit | 429 | `{"error":"rate_limit", "message":"Trop de tentatives. Réessayez dans N min.", "retry_after_sec":N}` | Two downstream rules follow: 1. **The login page shows "Lien envoyé"** even on `sent: []`. The copy says "Si votre compte existe, un lien vous a été envoyé" — the timing and the UI are identical to the happy path. A typo'd email looks indistinguishable from a genuine send. 2. **The internal log still records the miss** (`log('Portal request-link: no match for "…"')`) so ops can spot fat-finger traffic vs. probing. The log is not served publicly. Redaction rules (`redactEmail`, `redactPhone`): - Email: keep first + last char of local part, mask the middle, keep the domain (`j***n@example.com`). - Phone: `***-***-XXXX` where `XXXX` is the last 4 digits of the raw `cell_phone`/`tel_home` field (not the E.164-normalized one). --- ## 7. Rate Limiting A single `Map` in the hub process enforces 3 requests per 15 minutes (`RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS` in `portal-auth.js`). The key is `rawIdentifier.trim().toLowerCase()` so `jean@example.com` and `JEAN@Example.com` share a bucket. Lifecycle: ```js // portal-auth.js function checkRateLimit (key) { const now = Date.now() const entry = rateLimits.get(key) if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { rateLimits.set(key, { count: 1, windowStart: now }) return { ok: true } } if (entry.count >= RATE_LIMIT_MAX) { return { ok: false, retryAfterMs: RATE_LIMIT_WINDOW_MS - (now - entry.windowStart) } } entry.count++ return { ok: true } } // Cull every 15 min so the Map stays small setInterval(() => { /* drop entries older than window */ }, RATE_LIMIT_WINDOW_MS).unref() ``` ### 7.1 Failure modes we accept - **Process restart** wipes the Map. Worst case a throttled attacker gets their window reset — still bounded by Twilio + Mailjet rate limits, and the internal `Portal request-link` log surfaces the abuse for ops. - **Hub horizontal scale** would duplicate the Map per replica. Today the hub is a single container so the Map is authoritative; if we scale out, move to Redis (tracked under the hub hardening line in [../roadmap.md](../roadmap.md)). - **Identifier rotation** (attacker types slightly different strings): mitigated by Twilio/Mailjet per-destination throttles that kick in downstream — the attacker can't out-run our carrier tier. ### 7.2 Error surfacing to the UI `requestPortalLink()` rethrows the 429 as a typed error: ```js // src/api/auth-portal.js if (r.status === 429) { const err = new Error(data.message || 'Trop de tentatives. Réessayez plus tard.') err.code = 'rate_limit' err.retryAfterSec = data.retry_after_sec || 900 throw err } ``` `LoginPage.vue` displays the message in an orange banner (`q-banner bg-orange-1`) without clearing the input — the user can read the message and adjust their identifier. --- ## 8. Cross-Module References ### 8.1 Billing — Stripe Checkout + Klarna `InvoiceDetailPage.vue` exposes both payment rails: ```js payInvoice('card') → hub POST /payments/checkout-invoice { payment_method: 'card' } payInvoice('klarna') → hub POST /payments/checkout-invoice { payment_method: 'klarna' } ``` The hub returns a hosted Checkout URL; the SPA does `window.location.href = url`. Stripe's return URLs come back to `/#/paiement/merci?invoice=…`, `/#/paiement/annule?invoice=…`, and `/#/paiement/carte-ajoutee` (Setup Intent flow). All three live under `PUBLIC_ROUTES` so an expired token still gets a civilized landing. Full lifecycle: [billing-payments.md](billing-payments.md) — especially §4 "Flux de paiement public" and §7 "Configuration & secrets" for Stripe keys and webhook wiring. ### 8.2 Messaging — Server-Sent Events `MessagesPage.vue` subscribes to `msg.gigafibre.ca/sse?topics=customer:` via `useSSE`. The hub broadcasts `conversations` and per-customer topics from `lib/conversation.js` (see lines 68–70 of that file); every inbound SMS or email handled by the hub fires a broadcast, and the portal reloads `fetchAllCommunications` on receipt and shows a toast for inbound-direction messages. The SPA's SSE connection is resilient: `useSSE` implements exponential backoff (1 s → 30 s) on disconnect. Read path details in `apps/client/src/composables/useSSE.js`. ### 8.3 Tickets — read-only window into ERPNext HD Ticket `TicketsPage` and `TicketDetailPage` hit the stock ERPNext `Issue` doctype. Creation goes through `POST /api/resource/Issue` with `customer`, `subject`, `description`, `issue_type='Support'`, `priority='Medium'`. Replies from the portal create a `Communication` tied to the Issue; staff see them in the Ops CRM pane. Cross-ref `docs/architecture/data-model.md` for the full `Issue` field list. ### 8.4 Catalog — shared SKUs with the quote wizard `CatalogPage` lists the same Items the sales wizard exposes — Internet plans, phone service, bundles, equipment. Prices and `requires_visit` flags come from ERPNext Items via the hub's `/api/catalog` endpoint. Add-on purchases placed through `/panier` become `Sales Order` drafts that the dispatch queue picks up. See `billing-payments.md` §2 "App Frappe custom — `gigafibre_utils`" for the shared SKU catalog source of truth. ### 8.5 Contract acceptance Two separate tracks, both documented elsewhere: - **Residential JWT** (promotion framing, 24 h link, same style as this portal's auth) — `services/targo-hub/lib/contracts.js`. See `billing-payments.md` and memory `project_contract_acceptance.md`. - **Commercial DocuSeal** — `services/targo-hub/lib/acceptance.js`, documented in the same memory file. Neither track reuses the portal's magic-link JWT today; the `scope=customer` token is strictly for browsing. A signed contract produces its own short-lived acceptance JWT minted by `contracts.js`. ### 8.6 Tech mobile app The field tech interface lives at `msg.gigafibre.ca/t/` — a separate HS256 token scope (`scope='all'` or per-job) minted by `magic-link.js`. It shares the hub's HS256 primitive (`signJwt`) with the customer portal but not the token itself. There is no customer-facing touchpoint on the tech app; cross-ref is kept here only so future refactors remember the two scopes sign with the same secret. --- ## 9. Deploy & Operations ### 9.1 Ship a portal change ```bash cd apps/client ./deploy.sh # build PWA + rsync + flip + docker restart ``` What the script prints on success: ``` Deployed. Backup: /opt/client-app.bak.1713798123 Done! Customer Portal: https://portal.gigafibre.ca/ Legacy alias (302): https://client.gigafibre.ca/ ``` ### 9.2 Ship a Traefik redirect change (`client.gigafibre.ca`) ```bash cd apps/portal ./deploy-portal.sh # scp YAML to /opt/traefik/dynamic/ + smoke test ``` Traefik auto-reloads anything under `/opt/traefik/dynamic/` — no restart. The script also curls `https://client.gigafibre.ca/login` and prints the HTTP code so you know whether Let's Encrypt has provisioned the cert yet (first request may return 000 while the cert is being issued). ### 9.3 Roll back ```bash ssh root@96.125.196.67 ls -dt /opt/client-app.bak.* # pick the timestamp you want mv /opt/client-app /opt/client-app.broken mv /opt/client-app.bak. /opt/client-app docker restart client-portal # same inode trap as deploy ``` `deploy.sh` keeps the three most recent backups (`ls -dt … | tail -n +4 | xargs -r rm -rf`), which is usually enough to roll back a same-day mistake. ### 9.4 Smoke tests ```bash # SPA shell reachable curl -sI https://portal.gigafibre.ca/ | head -5 # Legacy redirect preserves path curl -sI https://client.gigafibre.ca/invoices | grep -iE 'location|http/' # expect: HTTP/2 307 # expect: location: https://portal.gigafibre.ca/invoices # Magic-link endpoint: anti-enumeration contract curl -s -X POST https://msg.gigafibre.ca/portal/request-link \ -H 'Content-Type: application/json' \ -d '{"identifier":"does-not-exist@example.com"}' # expect: {"ok":true,"sent":[]} # Rate limit (send the same identifier 4 times quickly) for i in 1 2 3 4; do curl -s -X POST https://msg.gigafibre.ca/portal/request-link \ -H 'Content-Type: application/json' \ -d '{"identifier":"rl-test@example.com"}' ; echo done # expect 4th call: 429 + rate_limit body ``` ### 9.5 Cache bust The Quasar PWA service worker is configured with `skipWaiting: true` + `clientsClaim: true` + `cleanupOutdatedCaches: true`. A user who's been sitting on a stale tab usually picks up the new build on next nav. For a hard bust, append `?v=` to the URL or instruct the user to do a reload; the hash router handles the query string transparently. --- ## 10. Failure Modes | Symptom | Likely cause | Remedy | |-----------------------------------------------------|---------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| | User clicks magic link, lands on `/login` | JWT expired (>24 h) | Re-request a link. Payment-return pages show a banner with a one-click re-auth button | | User clicks magic link, lands on dashboard briefly, then bounces to `/login` on refresh | Store not persisted; token already stripped from URL | Expected — refresh is a fresh auth. Re-request or click from the original email again | | "Lien envoyé" but no SMS arrives | Twilio rejected the number (no `cell_phone`/`tel_home` on file, or number invalid) | Check `docker logs targo-hub | grep 'Portal link'` — the log reports `sent via email` vs `NONE` | | "Lien envoyé" but no email arrives | Mailjet rate limit / bounce | Same log line. Investigate Mailjet dashboard for the destination | | Dashboard renders blank after click | `VITE_ERP_TOKEN` broken; ERPNext returns 401 | Browser console shows `[portal] ERPNext returned 401 — check VITE_ERP_TOKEN`. Rebuild with the right token | | `client.gigafibre.ca` serves the old portal | Traefik dynamic config not reloaded / not placed in `/opt/traefik/dynamic/` | `scp` via `deploy-portal.sh`, then `docker logs --tail 50 traefik` | | Deploy succeeds, but users still see old UI | Docker bind mount still pinned to backup inode | `ssh … docker restart client-portal` (deploy.sh does this; if you copied files manually, restart manually) | | SSE never connects in `/messages` | CSP or mixed-content block on `msg.gigafibre.ca` | Check browser console — the nginx `Content-Security-Policy` must allow `connect-src https://msg.gigafibre.ca` | | Hash router breaks behind corporate proxy | Proxy strips the `#` fragment | Issue a plain `/login` link (no token) — users self-request magic link from there | | 429 loop for a well-meaning user | Same identifier typed 4 times inside 15 min | Wait the window out, or restart the hub container to flush the Map | ### 10.1 CSP for the hash router The nginx container's config (`/opt/client-app/nginx.conf`, preserved across deploys by the `cp docker-compose.yml nginx.conf` lines in `deploy.sh`) must include at minimum: ``` connect-src 'self' https://msg.gigafibre.ca https://erp.gigafibre.ca; ``` The first permits hub SSE and REST; the second permits ERPNext REST via `authFetch`. If either is missing, the SPA loads but every data call fails. --- ## 11. Retirement Notes ### 11.1 ERPNext `/login` — retired for customers - The form is still reachable at `erp.gigafibre.ca/login` but is gated behind Authentik forwardAuth — non-staff accounts cannot authenticate. - `client.gigafibre.ca/login` (the old direct ERPNext frontend) no longer serves the form because the whole host is 307-redirected to `portal.gigafibre.ca/login`. - No migration of legacy MD5 password hashes is required. Ever. See memory `project_portal_auth.md` for rationale. ### 11.2 `client.gigafibre.ca` — legacy alias, not canonical - All outbound communications (email templates, SMS, invoice footers, QR codes on PDFs) now use `https://portal.gigafibre.ca/#/…`. See `project_invoice_qr.md` memory for the QR wiring. - The redirect in `traefik-client-portal.yml` is currently a `302` (`permanent: false`) while the cutover is still warm — we can flip to `301` once we're confident nothing else points at the old host. Tracking this in [../roadmap.md](../roadmap.md). - DNS: `*.gigafibre.ca` wildcard already resolves to `96.125.196.67`. Let's Encrypt auto-provisions the cert for both hosts. ### 11.3 Authentik — not a factor for customers Staff continue to use Authentik forwardAuth for Ops (`ops.gigafibre.ca`) and ERPNext (`erp.gigafibre.ca`). The portal host has no Authentik middleware; the JWT is the sole credential. Memory files `reference_authentik.md` and `reference_authentik_client.md` cover the staff side. --- ## 12. Glossary - **Magic link** — `https://portal.gigafibre.ca/#/?token=`. The only authenticated entry point for customers. Minted by the hub, delivered via SMS + email. - **Customer JWT** — HS256 token with `scope=customer`, 24 h TTL, `sub=`. Decoded client-side; never re-verified. - **Anti-enumeration** — The contract that every `POST /portal/request-link` returns 200 regardless of whether the identifier matched. - **Hash router** — Vue Router's `createWebHashHistory` mode. URL paths live after `#`, so the server always serves the same `index.html`. - **Inode trap** — The Docker bind-mount behaviour where `mv`-swapping a directory leaves the container pointed at the old inode. Solved by `docker restart client-portal`. - **Legacy alias** — `client.gigafibre.ca`. Still resolves, still has a cert, but 307-redirects every request to the canonical host. - **Service token** — `VITE_ERP_TOKEN`: a fixed ERPNext API key baked into the SPA bundle. Identifies the *portal* to ERPNext; not the customer. Customer identity is enforced at the query layer. --- *Last updated: 2026-04-22. Owners: Targo Platform team (). Related roadmap items: hub JWT verification on `/payments/*`, flip `client.gigafibre.ca` redirect to 301, Redis-backed rate limit for multi-replica hub. See [../roadmap.md](../roadmap.md).* --- Back to [docs/README.md](../README.md) · [roadmap.md](../roadmap.md)