This guide walks through building a custom Trellis portal for custom login and onboarding experiences. Trellis already ships with a built-in portal for both user login and generic device onboarding. The built-in device activation portal is the Trellis-owned app contract trellis.portal.activation@v1. Custom login portals are auth-owned portal records selected by global login route rules; custom device portals remain device-deployment routing configuration.
Before starting, read Trellis Concepts and Write a SvelteKit app for background on contracts, auth flows, and packages.
What you need
- a running Trellis environment
- the
trellisCLI (see Install the Trellis CLI) - admin access to the Trellis instance
How the portal fits in
When a browser app starts a Trellis login with POST /auth/requests, Trellis creates a unique flowId for that login and returns the best matching portal URL for that flow.
For a normal user login, the portal usually shows the supported identity providers, starts the chosen provider flow, and then shows the approval screen so the user can allow the original app to access their account.
That approval screen shows Trellis capabilities derived from the app contract. They are internal Trellis permissions, not OAuth scopes from the external login provider.
Approval decisions belong to the Trellis user account and app identity. If the same account later signs in through another linked local or OIDC identity, Trellis can reuse the account’s existing approval for that app; the provider identity shown during approval is audit context, not a separate grant key.
For device onboarding, the portal may need to do more than show login screens. For example, a user might be activating a new device and the portal may need to collect extra details, confirm payment, or call Trellis APIs to finish setup. In that kind of flow, the portal is not just a login screen anymore. It is also acting like a Trellis web app for that user.
Some flows may also pause for administrative review before the final step is allowed. That review might be done manually by an admin user or by a local Trellis service that checks some outside condition first.
For device-user authority resolution, a required review is still part of the
original Auth.DeviceUserAuthorities.Resolve operation. The custom portal
should keep watching or waiting on that operation; the admin review decision
completes the same operation with either the activated output or a rejected
terminal result.
Trellis trusts a portal because a Trellis admin registered its URL. A portal
record is routing configuration, not a portal contract kind. If the portal also
talks to Trellis after login, it does so as the logged-in user through a normal
browser app contract, not as a service.
1. Create the project
deno run -A npm:sv create my-portal
cd my-portal Install Trellis packages:
deno add @qlever-llc/trellis @qlever-llc/trellis-svelte 2. Optionally define a portal app contract
Passive portals that only handle sign-in and approval do not need a Trellis app contract, so this step can be skipped.
Portals that keep going after login and call Trellis for the user need their own app contract. Keep it in contracts/portal.ts next to your project manifest:
import { defineAppContract } from "@qlever-llc/trellis";
export const portalContract = defineAppContract(() => ({
id: "my-portal@v1",
displayName: "My Portal",
description: "Portal-hosted app routes used after login.",
})); Baseline app auth such as Auth.Sessions.Me and Auth.Sessions.Logout is derived automatically.
Declare only additional Trellis-owned surfaces that the portal app needs.
3. Implement the portal flow
The portal app needs one page that reads flowId from the URL, loads the current flow state, and renders the right user experience.
This flow state can also carry app-defined context from the original app. In this example, the portal accepts an optional subtitle supplied by the initiating app.
Flow state page
The intended Trellis developer experience is to use portal helpers rather than rebuilding the raw flow protocol by hand. The framework-agnostic helpers belong in @qlever-llc/trellis, while Svelte-specific wrappers belong in @qlever-llc/trellis-svelte.
For a SvelteKit portal, the target shape is a page like src/routes/login/+page.svelte:
<script lang="ts">
import { env } from "$env/dynamic/public";
import { page } from "$app/state";
import { onMount } from "svelte";
import { createPortalFlow } from "@qlever-llc/trellis-svelte";
const trellisUrl = env.PUBLIC_TRELLIS_URL ?? "http://localhost:3000";
const portal = createPortalFlow({
authUrl: trellisUrl,
getUrl: () => page.url,
});
function appContext(): { subtitle?: string } | null {
if (portal.state?.status !== "choose_provider") return null;
const value = portal.state.app.context;
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
return {
subtitle: typeof record.subtitle === "string" ? record.subtitle : undefined,
};
}
onMount(async () => {
await portal.load();
});
</script>
{#if portal.loading}
<p>Loading...</p>
{:else if portal.error}
<p class="text-error">{portal.error}</p>
{:else if portal.state?.status === "choose_provider"}
<h1>Sign in to {portal.state.app.displayName}</h1>
{#if appContext()?.subtitle}
<p>{appContext()?.subtitle}</p>
{/if}
<ul>
{#each portal.state.providers as provider}
<li>
<a href={portal.providerUrl(provider.id)}>{provider.displayName}</a>
</li>
{/each}
</ul>
{:else if portal.state?.status === "approval_required"}
<h1>Approve {portal.state.approval.displayName}</h1>
<p>Signed in as {portal.state.user.name ?? portal.state.user.email ?? portal.state.user.id}</p>
<p>{portal.state.approval.description}</p>
<p>Contract evidence: {portal.state.approval.contractId}</p>
<ul>
{#each Object.entries(portal.state.approval.capabilities) as [key, capability]}
<li><strong>{capability.displayName}</strong>: {capability.description} <code>{key}</code></li>
{/each}
</ul>
<button onclick={() => portal.approve()}>Approve</button>
<button onclick={() => portal.deny()}>Deny</button>
{:else if portal.state?.status === "approval_denied"}
<h1>Access denied</h1>
<p>You denied access to {portal.state.approval.displayName}.</p>
{:else if portal.state?.status === "insufficient_capabilities"}
<h1>Missing capabilities</h1>
<p>Your account is missing: {portal.state.missingCapabilities.join(", ")}</p>
{:else if portal.state?.status === "expired"}
<p>This login session has expired. Return to the app and try again.</p>
{:else if portal.state?.status === "redirect"}
<p>Redirecting...</p>
{:else}
<p>Loading flow state...</p>
{/if} The helper shown above is the intended public shape. Under the hood it still uses the auth-owned flow endpoints and maps approve/deny actions to the canonical approved: boolean request body, but app authors should not need to rebuild those request details by hand.
portal.state.approval.capabilities is the Trellis-side capability list that
the app contract requested. A portal should present it as application access
inside Trellis, not as provider-owned OAuth scopes.
portal.state.approval.contractDigest is technical evidence for the reviewed
contract body. Custom portals may show it in an advanced details view, but the
user-facing decision should be framed around the app identity, requested
capabilities, and contract evidence rather than a digest-only grant key.
The portal.state.app.context payload is optional and app-defined. Portals must validate fields and should only access the fields they define.
This is still a static SPA. The portal can read flowId straight from the browser URL, so it does not need a SvelteKit server route or redirect shim.
Use https://my-portal.example.com/login as the portal entry URL when creating the auth-owned login portal record.
Use HTTPS for public portal entry URLs. Trellis can run public CORS in credentialless mode for arbitrary browser apps, but portal flow routes still validate the selected portal origin and use credentialed flow handling where the OAuth/browser-flow protocol requires it. Do not rely on a broad CORS setting to make an unregistered portal trusted.
4. Deploy the portal app
Build and deploy the portal to a stable URL. The URL must be reachable by browsers that are logging into your Trellis deployment.
For a basic static adapter deployment:
deno task build Deploy the build/ output to your hosting provider and note the base URL, for example https://my-portal.example.com.
5. Configure portal routing
Use Console Portals or Auth.Portals.* admin RPCs to create the custom login
portal record, configure registration policy, and add global route rules. Use Auth.Portals.Put to create or update non-built-in login portal records, Auth.Portals.Remove to remove non-built-in portals that are no longer targeted
by routes, and Auth.Portals.LoginRoutes.* to manage route selectors.
The built-in login portal is always visible, cannot be removed, and cannot be replaced by portal upsert. Route ids are internal RPC keys; normal operator UI should present contract/origin match fields and selected portals instead.
For login flows, route by the app identity being authenticated. For device activation, route by the device deployment. Leaving login routes unset uses the built-in Trellis login portal.
Portal settings can limit federated providers per portal. The default allowedFederatedProviders: null allows all configured providers, [] allows no
federated providers, and a non-empty list allows only that subset.
6. Test the flow
Open the Trellis-connected browser app or device flow you want to test. The browser should redirect to your custom portal, let you complete the flow, and then continue into a normal authenticated user session or device activation.
Updating the portal route
Update the auth-owned login portal record when the login portal entryUrl changes. Update auth-owned login route rules when the route match changes. Device
activation portal URL changes remain deployment-owned device routing metadata.
If the portal app contract changes, treat it like any other browser app contract change: the portal presents the new contract during login, and Trellis resolves it through the user’s identity authority and any deployment grant overrides. It does not receive service deployment authority.
Disabling a custom portal
Remove the auth-owned login route or point it back to the built-in portal. Remove the custom portal record only after no routes target it. Existing user approvals and device activations remain governed by identity and deployment authority records; disabling a portal route only changes future browser flow routing.