Portal¶
platform/portal: SvelteKit 2, Svelte 5 (runes only — no $:, no
export let), strict TypeScript. Deployed to Cloudflare Pages with
@sveltejs/adapter-cloudflare. Device pages, enrolment, callback and the
Monsieur Wifi hand-off render client-side only (ssr = false).
Stack¶
| Area | Choice |
|---|---|
| Auth | oidc-client-ts, authorization code + PKCE against Zitadel |
| RPC | @connectrpc/connect-web v1, @bufbuild/protobuf v1 |
| Data fetching | @tanstack/svelte-query (src/lib/api/queries/) |
| Tables | @tanstack/svelte-table |
| Charts | layerchart, d3-scale, d3-shape |
| Console | @xterm/xterm |
| i18n | @inlang/paraglide-js — see i18n |
| Tests | Vitest (unit and Svelte SSR render tests), Playwright, e2e/check-*.mjs page checks |
Talking to nexus¶
src/lib/api/transport.ts builds one Connect transport to VITE_NEXUS_URL
and one client per service: sessionClient, orgClient, gatewayClient,
adminClient, usersClient. An interceptor adds
Authorization: Bearer <access_token> from the OIDC user manager.
- Connect-JSON by default;
VITE_NEXUS_BINARY=trueswitches to binary protobuf. int64is a string in JSON. Generated message types usebigintforint64fields; JSON decoded by hand (from abytesfield or a WebSocket frame) carries strings.bytesfields defeat typing.state_data,config_dataand similar are JSON documents in aUint8Array; decoding them and casting withas Tis an assertion, not a check. Prefer the typedstatefield onGetGatewayResponse/GetGatewayStateResponse.- Mappers drop unnamed fields.
detailToGateway()andgatewayToConfig()in the gateway store name fields explicitly;src/lib/mapper-completeness.test.tsreads the source and fails when a field of a config type is not named by both mappers. - Permissions come from the server.
ListGatewaysandGetGatewayreturncaller_actionsper device;src/lib/can.tsanswers "should this control be offered" from it. Absent actions mean unknown and answer true. Nothing in the portal is a security boundary; every RPC gates for itself.
Authentication¶
src/lib/auth/config.ts configures oidc-client-ts with
VITE_ZITADEL_ISSUER and VITE_ZITADEL_CLIENT_ID, redirect /callback, scope
openid profile email urn:zitadel:iam:org:projects:roles, tokens in
localStorage, silent renew on. No project-audience scope is requested, so the
token's audience is the portal client's own project and only the matching
environment's nexus accepts it.
After sign-in, Me supplies roles and permissions (src/lib/auth/store.svelte.ts).
A user with no permission at all sees NoOrganization instead of the app
shell.
Environment variables¶
| Variable | Purpose |
|---|---|
VITE_NEXUS_URL |
nexus base URL (default http://localhost:4001) |
VITE_REALTIME_WS_URL |
realtime Worker WebSocket base URL (default ws://localhost:8787) |
VITE_ZITADEL_ISSUER, VITE_ZITADEL_CLIENT_ID |
OIDC |
VITE_MRWIFI_URL |
Monsieur Wifi link in the sidebar |
VITE_RADIUS_URL |
radius-ctrl UI link on the hub page |
VITE_ENV |
development, staging, production (About badge) |
VITE_NEXUS_BINARY |
true for binary protobuf |
VITE_DEV_AUTH |
true sends a fixed bearer, for a nexus running with NEXUS_DEV_AUTH=true |
Guides¶
- Layout & Navigation — app shell, sidebar, secondary panel, breadcrumb.
- Stores — gateway store, realtime subscription, preferences.
- Components — key UI components.
- i18n — Paraglide and locale coverage.