# 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.<epoch>`),
   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.<epoch>`) — 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:<id>` 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/#/<path>`. 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:<id>`                                                                                |
| `/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=<JWT> ──────────────────────────────│
   │                                                                                                │
   │── 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 <VITE_ERP_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<identifier, {count, windowStart}>` 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:<id>`
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/<tech-token>`
— 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.<epoch> /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=<epoch>` 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=<JWT>`. 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=<Customer.name>`. 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
(<louis@targo.ca>). 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)
