---
title: "Portal auth and capability model"
description: "Short-lived capability token permission model used by the mikan admin, login, and session portals."
url: "https://geminixiang.github.io/portal-auth-model/"
---

# Portal auth and capability model

The design goal is to let users conveniently open management, login, and session-view pages while avoiding mixing "read data", "change settings", and "write secrets" into one permission.

## Three portal links

| Interface            | How users get it                                                         | What it can do                                                                                                            | Token lifetime | One-time token? |
| -------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | -------------- | --------------- |
| Admin portal         | `/admin` / `/pi-admin`                                                   | Manage conversations, model, sandbox, auto-reply, workspace previews, and events. Can also generate session/login links.  | 30 minutes     | No              |
| Login / vault portal | `/login` / `/pi-login`, or generated by the admin portal                 | Store API keys or complete built-in OAuth flows, writing credentials into the vault.                                      | 15 minutes     | Yes, on write   |
| Session view         | `session` / `/session` / `/pi-session`, or generated by the admin portal | View the session timeline; when interactive mode is available, also send messages from the web page back to that session. | 24 hours       | No              |

In short:

```text
/admin   → change settings, view workspace, generate other links
/link    → write vault secrets or OAuth credentials
/session → view session; optionally send messages back to the session
```

These three pages share the same portal shell, but they do not share the same authorization token.

## Permission boundaries

### Admin portal

The admin portal is control-plane access. Anyone with an admin link can manage mikan settings and conversation state for a short time.

The admin portal can:

- view the current user and conversation identity
- list conversations from the office registry (the durable raw-id ↔ office mapping), not by scanning the workspace
- read and update conversation model, thinking level, workspace door policy and layout, auto-reply, and Slack reply mode
- read and update global model, sandbox resource defaults, global door policy, and Slack defaults
- view limited workspace files, skills, and events metadata/files, and create or edit skills at either level
- list and mutate the package sources for a scope
- view session and conversation usage
- query metadata-only agent-run audit runs, timelines, and health across registered conversations
- delete events for the selected conversation
- generate a session view link or login/vault link for the target conversation

Door policy can also be set from chat with `/pi-sandbox door`, but never by the agent itself:
conversation settings live under the host-only state dir precisely because conversation directories
are bind-mounted read-write into the sandbox, and the settings file inside the mount is migrated
once and then never read again.

The agent audit surface intentionally exposes metadata only: identities, lifecycle types, timings, model/tool names, status, and usage totals. Prompt text, model output, thinking, tool arguments/results, provider payloads, headers, file bodies, and terminal output are not stored by the durable audit subsystem and therefore cannot be revealed by an Admin token. Audit list and detail queries are intersected with the currently materialized office registry, so retained rows for absent offices are not exposed through Admin. Health responses expose counters and a generic failure state; detailed worker and filesystem errors remain server-side.

The admin portal does not directly write secret values. Even when it generates a login link, the real secret write still goes through the one-time token flow of the Login / vault portal.

### Login / vault portal

The Login / vault portal is the highest-risk action capability because it can write credentials.

The Login / vault portal can:

- show the credential or OAuth onboarding form for a specified vault
- write environment variables into that vault
- write credential files from a preset or OAuth flow, such as config files required by supported tools
- complete supported OAuth flows and save access tokens, refresh tokens, or credential files
- notify the source conversation after a successful write

Important login token behavior:

- opening the `/link` page does not consume the token
- starting OAuth does not consume the token
- completing a credential POST or OAuth callback consumes the token
- when the same platform user creates a new login token, the old login token becomes invalid

Additional protections:

- Credential POST routes require `Content-Type: application/json`.
- When `LINK_URL` / `MIKAN_LINK_URL` is set, credential POST routes check same-origin `Origin` or `Referer`.
- OAuth state is independent from the login token, has a 10-minute TTL, and uses a PKCE verifier.
- Secret values are not re-rendered to the browser; existing vault summaries show only secret names and mount targets.

### Session view

Session view is session content access. It is mainly used to view a structured session timeline.

Session view can:

- render the session timeline
- navigate parent/thread session relationships
- subscribe to live status and timeline updates through SSE
- when interactive wiring is available, send messages from the web page back to the selected session

Session view is not purely read-only. As long as the `/session/message` route exists and interactive wiring is available, a session view token can send a `session_view` event and call the bot handler.

The session view token is anchored to the base session file. When navigating with `/session?session=<file.jsonl>`, it can only switch to session files in the same directory.

## Route and token mapping

| Route                | Method | Token source      | Validation                               | Notes                                                    |
| -------------------- | ------ | ----------------- | ---------------------------------------- | -------------------------------------------------------- |
| `/admin`             | `GET`  | query `token`     | `adminTokenStore.peek()`                 | Render admin portal.                                     |
| `/admin/api/*`       | `GET`  | query `token`     | `adminTokenStore.peek()`                 | Unauthorized returns 403.                                |
| `/admin/api/*`       | `POST` | JSON body `token` | `adminTokenStore.peek()`                 | Unauthorized returns 403.                                |
| `/link`              | `GET`  | query `token`     | `linkTokenStore.peek()`                  | Render login/vault page; does not consume token.         |
| `/api/link/complete` | `POST` | JSON body `token` | `linkTokenStore.consume()`               | Write credentials; consumes token.                       |
| `/api/oauth/start`   | `POST` | JSON body `token` | `linkTokenStore.peek()` + OAuth state    | Create OAuth redirect; does not consume login token yet. |
| `/oauth/callback`    | `GET`  | query `state`     | OAuth state + `linkTokenStore.consume()` | Complete OAuth; consumes OAuth state and login token.    |
| `/session`           | `GET`  | query `token`     | `sessionViewTokenStore.peek()`           | Render session page.                                     |
| `/session/stream`    | `GET`  | query `token`     | `sessionViewTokenStore.peek()`           | Open SSE stream; requires interactive wiring.            |
| `/session/message`   | `POST` | JSON body `token` | `sessionViewTokenStore.peek()`           | Send session message; requires interactive wiring.       |

## Why not use one token type

The three token types have different risks:

- Admin token: reusable short-lived management permission.
- Login token: can write secrets, so it has a shorter lifetime and is consumed on write.
- Session view token: useful for sharing and reviewing sessions, so it lasts longer, but permission is limited to the session view scope.

Even if a full dashboard is added later, these boundaries should remain:

- Dashboard identity may authorize viewing and settings operations.
- Secret writes should still require a short-lived one-time capability, or equivalent second confirmation.
- Standalone session links can remain capability links for session viewing.

## Implementation locations

| Feature              | Main code                                 |
| -------------------- | ----------------------------------------- |
| Portal HTTP server   | `startWebServer()` in `src/web/server.ts` |
| Admin portal         | `src/web/admin/portal.ts`                 |
| Login / vault portal | `src/web/login/portal.ts`                 |
| Session view         | `src/web/session-view/portal.ts`          |
| Shared token store   | `src/web/token-store.ts`                  |
| Shared portal shell  | `src/web/portal-shell.ts`                 |

`startWebServer()` dispatch order:

1. `GET /health`
2. Agent event HTTP routes
3. Admin routes
4. Session view routes
5. Login / vault routes
6. `404`

The server starts only when `LINK_PORT` / `MIKAN_LINK_PORT` can be parsed as a port. If `LINK_URL` / `MIKAN_LINK_URL` is set but no port is configured, mikan uses the default port `8181`.

Token stores are currently in-memory and `src/main.ts` cleans expired tokens every five minutes. A process restart invalidates all unexpired web tokens.

These URLs are bearer capabilities. Query-string tokens can leak through browser history, screenshots, copied URLs, or proxy logs; share them only with the intended user and never publish them in chat channels or issue trackers.
