> ## Documentation Index
> Fetch the complete documentation index at: https://cona.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Portal

> The customer-facing document portal — share-token entry, PLZ verification, signed sessions, and rate limiting

# Portal

`@cona/portal` — the only CONA surface an external customer touches. 38 files, deployed to
Vercel, reading through `@cona/core` only.

Small app, but it carries the platform's only non-Auth0 authentication scheme, so the
access flow deserves precision.

## Access flow

Access is **two-factor**: possession of the share link, plus knowledge of the customer's
postal code. Neither alone is sufficient.

```mermaid theme={null}
sequenceDiagram
    actor C as Customer
    participant Page as Page
    participant S as Session
    participant L as Limits
    participant Core as Core
    participant DB as DB

    C->>Page: open share link
    Page->>S: getPortalSession
    S-->>Page: null

    Page-->>C: render PlzForm
    C->>Page: submit postal code
    Page->>L: checkPlzRateLimit

    alt rate limited
        L-->>C: retry after N min
    else allowed
        Page->>Core: verifyEntityPlz
        Core->>DB: look up entity
        alt PLZ wrong
            Core-->>Page: failure
            Page->>L: recordPlzFailure
            Page-->>C: error, retry
        else PLZ correct
            Page->>L: resetPlzFailures
            Page->>S: setPortalSessionCookie
            Page-->>C: document list
        end
    end
```

Participants: `Page` = `/portal/[accessToken]`, `S` = `portal-session.ts`,
`L` = `rate-limit.ts`, `Core` = `@cona/core/domains/portal`.

The signed cookie is bound to the access token: `verifyPortalSession` rejects a cookie
whose `accessToken` does not match the URL
(`app/lib/portal-session.ts:42`). A session stolen from one share link cannot open
another.

## Routes

```mermaid theme={null}
flowchart LR
    Root["/ · page.tsx"]
    PortalPage["/portal/[accessToken]<br/>document list, PLZ gate"]
    Download["/download/[shareToken]<br/>route handler"]
    ShopifyEntry["/shopify/[shopDomain]/[customerId]<br/>Shopify customer entry"]
    ShopifyDownload["/shopify/…/orders/[orderId]/download"]
    Processing["/shopify/download-processing"]
    Ingest["/api/axiom/ingest"]

    PortalPage --> Download
    ShopifyEntry --> ShopifyDownload
    ShopifyDownload --> Processing

    classDef app fill:#dbe2fb,stroke:#3B56C5,color:#111827
    classDef observability fill:#f0e8fd,stroke:#7c3bc5,color:#111827
    class Root,PortalPage,Download,ShopifyEntry,ShopifyDownload,Processing app
    class Ingest observability
```

Two entry paths: the generic share link, and a Shopify-specific path where a customer
arrives from their Shopify account to fetch an order document.

## Rate limiting

Three independent limiters (`app/lib/rate-limit.ts:11-27`), all using atomic Redis helpers
from `@cona/core/redis`:

| Limiter         | Keyed by                 | Protects against                       |
| --------------- | ------------------------ | -------------------------------------- |
| PLZ brute force | **access token**, not IP | guessing the postal code from many IPs |
| Download        | source IP                | unauthenticated download hammering     |
| Shopify entry   | source IP                | customer/order enumeration             |

Keying the PLZ limiter per access token rather than per IP is the right choice — a
distributed guessing attack against one link is still throttled.

## Dependencies

| Package                 | Imports |
| ----------------------- | ------: |
| `@cona/ui`              |      11 |
| `@cona/core`            |      10 |
| `@cona/opentelemetry`   |       7 |
| `@cona/utils`           |       2 |
| `@cona/observability`   |       2 |
| `@cona/tailwind-config` |       2 |

Portal reaches the database **only** through `@cona/core` — no direct Prisma, no
`@cona/database` import at all. It is the cleanest of the three Next.js apps in that
respect.

## Notes

<Warning>
  **Portal has no `proxy.ts` or `middleware.ts`, and sets no security headers in code.**
  Its entire header surface is three entries in `apps/portal/vercel.json`:
  `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`.

  Compared to webapp it lacks **CSP**, **`Strict-Transport-Security`**, and
  **`Permissions-Policy`**. Webapp generates a per-request CSP nonce in `proxy.ts:22`;
  portal has no equivalent.

  This is a hardening gap on the one publicly reachable surface. It is a headers issue, not
  an authentication one — the access flow above is sound.
</Warning>

**No Sentry, no PostHog.** Portal reports to Axiom and OpenTelemetry only. See
[Observability](/architecture/observability).

**Portal tokens are minted upstream.** `ensurePortalTokensForDocumentsActivity` runs as
step 5.6 of the shared ingestion pipeline, so documents arrive portal-ready. See
[Temporal Orchestration](/architecture/temporal-orchestration).
