Portal URL scheme
Status: RESOLVED (2026-08-31). Decision:
/portal/:token— an opaque, revocable token issued per notified party, in a namespace that sits outside the authenticated shell and outside/c/:companyId. Kept a path rather than a separate host, so the namespace is host-agnostic and moving the portal toportal.<domain>later needs no route changes. Recorded infrontend-frontoffice/src/lib/routes.tsasPORTAL_ROUTE, a reserved namespace with no page in this app;src/lib/routes.test.tsfails if any authenticated route is ever mounted under it. Settled while building #76 (route registry), which could not define the authenticated namespace without knowing what the portal's was.
Why it needed deciding now
The PoC's UI assumed a slug-based public URL (/consulta/:slug) that was never routed — the only
portal route it registered was /portal/:token. Every button pointing at the slug URL dead-ended at
not-found (broken internal links). Two spellings of
one namespace, with nothing to catch the drift.
#76 centralises every UI path in one registry. That registry has to state what the authenticated app owns and what it must never claim, and the portal is the one namespace in the product that belongs to a different application — the arguido's and counsel's unauthenticated view (portal flow). Leaving it undecided meant the authenticated app could grow into it later by accident.
The options
/portal/:token — chosen. An opaque, unguessable token, one per notified party, revocable
independently.
- The arguido is not an account holder. There is no login to scope access with, so the URL is the credential — which means it has to be unguessable and individually revocable.
- SYS-REQ-201 requires every consultation to be recorded as an attributable legal act (the registo de acesso), and SYS-REQ-202 models it as a command, not a query. A per-party token gives each access an identity to attribute it to. A shared URL does not.
- SYS-REQ visibility windows are per-disclosure. A token can carry an expiry that matches the window; revoking one party's access must not revoke another's.
/consulta/:slug — rejected. Human-readable, and what the PoC's UI assumed.
- A company slug is guessable, and the case file is confidential disciplinary material.
- It leaks tenant identity in a URL that gets emailed, forwarded, and logged.
- It cannot express per-party access or revocation — one slug is one company, not one recipient, so there is nothing to attribute an access to or to revoke.
portal.<domain> (separate host) — deferred, not rejected. Strongest isolation: no shared
cookies, no shared bundle, a separate CSP and rate-limit posture. But it adds DNS, TLS, CORS and
deployment work now, for an application that has no epic on either side yet (see the "Not
covered" section of the Frontend Foundation epic). Because the decision above is a path, adopting a
separate host later is a deployment change, not a URL change — the path namespace works identically
under either host.
What this does not decide
- The portal application itself — token issuance and rotation, the unauthenticated shell, rate limiting, the submission path. It is a second frontend and needs its own epic on both layers; phases 4/8 and 5/8 cannot work end-to-end without it.
- Token format and lifetime. "Opaque and revocable" is the requirement; whether it is a random 128-bit identifier with a server-side record (the likely answer, since revocation and the access log both want a row anyway) or a signed value is the backend's call when that epic is cut.