Skip to main content
Glama
README.md
# PrivOS Onboarding MCP App

The v4 app uses PrivOS Lists for positions, hire records, templates, progress and scores, and PrivOS
Files for shared learning materials. HR builds a template per position; each onboarding run gets its
own isolated List. Learning days can be opened in any order, and the first quiz score is official.

Current Hub evidence (2026-09-24): the two room registries were created and read back through
`mcpapp.lists.*`. A Ready template containing one week, one day and one lesson saved successfully;
after reload, Open displayed the complete tree. One provision case also passed: the current admin
account was onboarded from Ready template `Kiểm thử Cha P8 2026-09-24` for 2026-09-29; People showed
Learning 0/1 day and its drawer showed the correct position, date and status. A/B/C access, file
open/download, quiz, cancellation, importer and full P8 manual acceptance remain unrun. A full
template copy saved as a new Ready position (1/1/1); disabling that copy removed it from the
new-onboarding position picker.
See the [v4 design](../docs/superpowers/specs/2026-09-23-onboarding-app-v4-design.md),
[implementation plan](../docs/superpowers/plans/2026-09-23-onboarding-v4-index.md) and
[acceptance record](../docs/superpowers/specs/2026-09-23-onboarding-v4-acceptance.md).

## What it stores

The app creates isolated Lists in the current room through the signed-in user's Hub session:

| List | Key | One per |
|---|---|---|
| Position registry | `onb-positions` (requested key) | room |
| Hire registry | `onb-hires` (requested key) | room |
| Position template | `onb-tpl-<positionItemId>` (requested key) | position |
| Roadmap run | `onb-run-<userId>-<yyyymmdd>` (requested key) | onboarding run |

Hub may rewrite a requested List key, so the app discovers Lists by room and verified name/ID rather
than treating the key as authoritative. A template or run List has one physical Stage, `Nội dung`.
All content items are physically flat: `Cha:TEXT` stores the parent item ID and `Thứ tự:NUMBER`
stores display order. The app reconstructs `Tuần → Ngày → Bài học/Câu hỏi` for templates and
`Tổng quan → Tuần → Ngày → Bài học/Câu hỏi` for runs. Hub `createItem` was observed ignoring native
`parentId`; do not use native `parentId` filters to load this tree. A missing, invalid or cyclic
`Cha` is a data error. Each run item a hire needs must be assigned directly with `ASSIGNEE`, since
the logical tree does not create Hub ACL inheritance.

Shared files belong under the room's `Onboarding/<positionItemId>/` folder. Lesson items store a
full file object in `FILE_MULTIPLE` plus a `[fileId:...]` description marker. Opening or downloading
uses fresh file metadata; a temporary signed URL is not stored in Lists.

## Who sees what

- **Room owner/admin** — Admin/HR screens for positions, templates, hires and provisioning.
- **Assigned hire** — "Lộ trình của tôi" for the hire's own run once item grants and discovery are
  verified on Hub.

Real access enforcement belongs to Hub isolated List ACL, with
`Isolated_Item_Write_ACL_Enforce` enabled by a workspace administrator. The current A/B/C read and
write matrix has not been verified on Hub; UI role checks alone are not acceptance evidence.

## Member learning flow

The member service exposes `listMine()` to list the signed-in member's active onboarding runs,
`load(hireId?)` to open the selected run, and `resume(hireId)` to continue a persisted quiz
submission after a reload or interrupted write. The UI selects the newest run by default and lets
the member switch to an older active run.

Provisioning writes `ASSIGNEE` directly to the hire, overview, week, day, lesson and question items.
It reads the entire run back and verifies the exact item set and assignee before moving the hire to
`Đang học`. A member can read their roadmap and files, mark lessons read, submit or retake quizzes,
and resume a pending submission. The first completed quiz score remains the official score.

The submission journal makes retries of the same operation idempotent and detects conflicting
score state before overwriting it. PrivOS Lists currently provide no proven compare-and-swap or
atomic append contract, so concurrent submissions from two devices are not claimed as exactly-once.
Local typecheck, automated tests, production build and artifact inspection passed on 2026-09-29.
Live Hub A/B/C authorization, file download, recovery and full P8 acceptance remain unrun.

## Current-room member IDs

For a connected PrivOS SDK instance, read the current room and its member IDs without configuring a room ID:

```ts
import { getCurrentRoomMemberIds } from './src/ui/onboarding/data/room-members';

const { roomId, memberIds } = await getCurrentRoomMemberIds(app);
```

The function reads `mcpapp.context.get` on each call, selects `channels.members` for a public room
(`c`) or `groups.members` for a private room (`p`), reads all pages, and checks room identity again
before returning. Other room types raise `ROOM_TYPE_UNSUPPORTED`. Denied or unavailable member
routes raise `ROOM_MEMBERS_UNAVAILABLE`; malformed or incomplete member pages also raise errors.
The function does not read or create onboarding Lists. `rooms:read` is a required installation
permission because the onboarding form must provide a member selector. The current user must also
have access to the room; a manifest declaration alone does not grant access to a stale installation.

## Code layout

`src/ui/onboarding/` has `domain/` models and validation, `ports/` typed capabilities, `flows/`
multi-call operations, `data/` Hub adapters and `views/` React screens. Lists/Items use mediated
`mcpapp.*` tools through the Hub bridge; Files use the documented tool, REST and upload channels.
The server serves the UI and MCP entrypoint; it holds no onboarding data store.

### Dependency injection

The browser composition root is `src/ui/composition/PrivosOnboardingRoot.tsx`; it creates one
actor/room session scope, browser effects and PrivOS adapters, then passes typed services to views.
`src/server.ts` composes the MCP handler, assets and runtime lifecycle. Script entries in `scripts/`
compose Node filesystem, process, prompt and pairing adapters with `scripts/core/`. Pure modules do
not construct default adapters. The compatibility functions in `data/privos/` support integration
fixtures; production views use the session services.

| Capability | Port | Concrete adapter |
|---|---|---|
| Clock, IDs, hash, scheduler, links | `src/shared/ports/effects.ts` | `src/ui/adapters/browser-effects.ts` |
| Lists and catalog reads | `src/ui/onboarding/ports/lists.ts`, `catalogs.ts` | `src/ui/onboarding/data/privos/lists-adapter.ts` |
| Files and room members | `src/ui/onboarding/ports/files.ts`, `members.ts` | `src/ui/onboarding/data/privos/files-adapter.ts`, `members-adapter.ts` |
| MCP UI resources and runtime | `src/server-core/ports.ts` | `src/server-adapters/` |
| Filesystem, commands and prompt | `scripts/core/ports.ts` | `scripts/adapters/node-script-effects.ts` |

For a fixed-time test, inject the same port shape used in production:

```ts
import { createRequestBudget } from './src/ui/onboarding/data/request-budget';

const budget = createRequestBudget({
  clock: { now: () => new Date('2026-09-25T09:00:00Z') },
  scheduler: { after: () => () => {} },
  isRetryableReadError: () => false,
});
```

To add another backend, implement the existing port in a new adapter and select it in the relevant
composition root. Do not change a flow to import that adapter. Run the local gates from this app
directory:

```powershell
npm run typecheck:all
npm run architecture:check
npx vitest run --exclude tests/packaging.spec.ts --exclude tests/di/bundle-boundaries.spec.ts
npm run build
npx vitest run tests/di/bundle-boundaries.spec.ts
```

The migration changes source wiring only. It does not change Hub schema, manifest scopes, tool name
or UI resource identity. Local gates and live Hub/P8 outcomes are recorded separately in the
[DI acceptance ledger](../docs/superpowers/specs/2026-09-25-project-di-acceptance.md).

The v1/v2 plans remain in the parent workspace as historical records. Follow the v4 design and
acceptance links above for current behavior and test status.

## Runtime trust model

`@privos_ai/app-server`'s `resolveRuntimeMode()` picks exactly one of three modes, in this
precedence, and never guesses:

1. **`managed`** — a workload identity socket is present (App Cluster mounts one per
   installation). No pair URL, OAuth client secret, or browser user token is ever used.
2. **`standalone-production`** — a paired standalone identity file is present (see
   [Standalone production](#standalone-production-self-hosted-against-a-standalone-hub) below).
3. **`development`** — neither is present, and `NODE_ENV` is not `production`.

Both a workload socket and a paired identity file present is a fatal startup error (stale state
from a prior deployment mode, or a misconfigured host — never silently picked). `NODE_ENV=production`
with neither is also a fatal startup error: there is no unsigned-production fallback in any mode.

In `managed` mode, App Cluster mounts a per-installation Unix socket. `@privos_ai/app-server`
creates an ephemeral P-256 DPoP key in memory, obtains short-lived sender-constrained workload
tokens through the socket, and refreshes them without writing credentials to disk or environment
variables. Hub-to-app `/mcp` requests travel through private Cluster dispatch and carry a
short-lived signed assertion bound to the request body, installation, replica, receipt hash, and
permission epoch. The backend actor passed to `handleMcpMessage` comes from that verified assertion. The iframe
receives only non-secret host context and uses `app.rest()`, `app.uploadFile()`, and MCP tools
through the Hub bridge as the current user.

Production accepts these non-secret values from the platform:

- `PRIVOS_HUB_ORIGIN`
- `PRIVOS_APP_ID`
- `PRIVOS_INSTALLATION_ID`
- `PRIVOS_WORKLOAD_SOCKET` (normally `/run/privos/identity.sock`)

## Local development

Requirements: Node.js 22+, npm, Git, and Docker.

```bash
git clone https://github.com/PrivOS-AI/privos-mcp-app-demo
cd privos-mcp-app-demo
npm ci
cp .env.example .env
npm run dev
```

`npm run dev` resolves to `development` mode (no workload socket, no paired identity file) and
connects over the Relay WebSocket. Obtain a pairing URL from PrivOS Admin and paste it into the
prompt; credentials are cached to `.env` for the next run. This relaxed-compatibility path — no
verified backend actor, credentials cached to disk — is only ever reachable when
`NODE_ENV` is not `production`; the SDK's mode resolver refuses `development` outright otherwise.

The Vite UI defaults to `http://localhost:5179`. `DEV_TUNNEL=cloudflared` is optional when the
browser displaying Hub is on another machine.

### V4 review in a Hub test room

With the paired Relay configured, start the app from WSL in this app directory:

```bash
npm start
```

`prestart` builds the production UI automatically. A paired standalone session serves
`dist/standalone-ui.html`, with JS/CSS embedded so Hub does not fetch separate assets.
The normal split bundle remains in `dist/ui` for managed/runtime-v3 installations.
For P0 diagnostics only, use `NODE_ENV=development PRIVOS_DEV_UI=1 npm start`.

Open the app inside the test room as owner/admin. On a room without onboarding registries, first
open creates `Onboarding positions` and `Onboarding hires`; reload should reuse their IDs. In
Templates, create a position with one week, one day and one lesson, choose `Sẵn sàng`, reload and
use Open to inspect the tree. This exact Ready save/reload/Open path has been observed on Hub. The
List stores flat items; inspect `Cha:TEXT` and `Thứ tự` on each item if comparing native Lists with
the v4 UI. The single observed provision case used the current admin account and the Ready template
`Kiểm thử Cha P8 2026-09-24`, with start date 2026-09-29; People showed Learning 0/1 day, and the
drawer showed the correct position, date and status. Account B/C, Files, other provision cases,
quiz and cancellation require their separate acceptance cases in the linked record.

For local verification from this app directory:

```bash
npm run typecheck:strict-unused
npx vitest run --exclude tests/packaging.spec.ts
npm run build
```

Local verification on 2026-09-24: strict-unused PASS; 68 files/430 tests PASS with
`--exclude tests/packaging.spec.ts`; `npm run build` PASS; manifest lint valid; the production
bundle contains no P0, role-switch or `demoPositions` labels. These results are separate from Hub
manual acceptance; P8 manual acceptance remains unrun.

## Managed direct runtime

The Marketplace image starts Direct HTTP transport by default (`managed` mode once the platform
mounts `PRIVOS_WORKLOAD_SOCKET`; falls back to `development` mode locally when it isn't mounted):

```bash
npm run build
PORT=3000 npm start
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/ready
curl http://127.0.0.1:3000/.well-known/mcp/manifest.json
```

Development compatibility reports manifest-verified readiness without a broker. In production,
`/health` only proves the process is alive; `/ready` returns 200 only after the manifest is valid,
workload identity is paired, and the current receipt/epoch is active. A public or unsigned
production `POST /mcp` returns 403.

## Standalone production (self-hosted against a standalone hub)

A publisher can also run this exact app against a portal-less, self-hosted Hub — same manifest,
same tools, same permission contract as a Marketplace install, but the app pairs directly with the
Hub over the Relay WebSocket instead of App Cluster mounting a socket. Direct HTTP `/mcp` has no
trust source in this mode and always returns 403 (`DISPATCH_ASSERTION_INVALID`); every MCP
dispatch rides the Relay connection with a mandatory Hub-signed assertion.

### Pair, twice

```bash
npm run pair     # or: pnpm pair
```

The command asks for the one-time pairing URL the Hub operator gives you — it takes no arguments,
so the URL never lands in your shell history. It then announces `privos-app.json` over the pairing
socket, which means no admin ever handles the manifest file: the app states what it wants, and an
admin decides what it gets.

Run it **twice**, because the two runs mean different things:

1. The first run REGISTERS the app. The Hub stores the announced contract, grants nothing, and
   reports `awaitingApproval`. No identity file is written and the app does not start — dispatch
   trust belongs to the generation an approved permission ceiling creates, and there is nothing
   to run until then. Approve the declared permissions in Hub Admin > Apps.
2. Run it again with a fresh pairing URL from that app's own settings. The Hub re-hands the same
   credentials plus its dispatch trust, the identity file is written, and **the app starts
   automatically** — `pair` continues into `start:standalone` through whichever package manager
   you invoked it with, so there is no second command to remember.

The identity file lands at `./privos-standalone-identity.json` (override with
`PRIVOS_STANDALONE_IDENTITY_FILE`) at mode `0600`, and the Hub's fingerprint is printed:

```
PrivOS Hub fingerprint: SHA256:<43-char base64url> — verify this out-of-band before trusting dispatch from this Hub.
```

**Verify this fingerprint out-of-band** — over a channel other than the one that gave you the
pairing URL (a phone call, a separately-verified chat, the operator's own documentation). The
fingerprint is the same SSH-host-key-style trust-on-first-use model as `ssh` printing a host key:
a compromised pairing URL could otherwise hand you a Hub that signs dispatch you'd wrongly trust.
Because the second `pair` run starts the app for itself, verify the fingerprint the moment it is
printed and stop the process if it does not match.

### Identity file handling

The identity file is the sole source of Relay OAuth credentials and Hub dispatch trust for this
mode — treat it like an SSH private key:

- Back it up. Losing it means re-pairing (a new pairing URL from the Hub operator); there is no
  recovery path from the file alone.
- Never commit it, `docker cp` it into an image, or log its contents. `scripts/package-source.sh`
  already refuses to package any `.env*` / credential-like file; keep this file out of the
  Marketplace source archive the same way.
- A re-pair attempt over an existing file refuses (`IDENTITY_FILE_ALREADY_EXISTS`) rather than
  silently overwriting it — remove the file first if you intend to re-pair from scratch.

### Run

The second `pair` run already started the app. Every later start — after a reboot, a redeploy, or
any ordinary restart — uses the identity file that pairing wrote, and needs no pairing URL:

```bash
npm run start:standalone
curl http://127.0.0.1:3000/health
curl http://127.0.0.1:3000/ready
```

`/ready` reports `not_ready` (503) with a specific `reason` — `IDENTITY_NOT_LOADED`,
`RELAY_NOT_AUTHENTICATED`, `MANIFEST_LINT_INVALID`, or `MANIFEST_DRIFT` — until the identity
loads, the Relay connection authenticates, and the locally-built manifest's canonical digest still
matches the digest pinned at pairing time.

### Verified caller identity over Relay

The Relay runtime-dispatch assertion (`SELF_HOSTED_LOCAL` / `PUBLISHER_HOSTED`) proves *which
installation* dispatched a call, but — unlike the managed Cluster assertion — carries no embedded
actor claim. `connectRelay` independently verifies a SEPARATE Hub-signed RS256 user token
(`_meta.privosUser.userToken`) against the Hub's published JWKS
(`/.well-known/mcp-apps/jwks.json`) and cross-binds its room claim to the already-verified dispatch
`roomId`. This is wired in automatically (`hubUserTokenAuth: 'auto'`, the default) whenever a Hub
dispatch trust is configured — true here, since `start:standalone` pins the paired identity's
trust — so the backend receives a verified actor for `standalone-production` exactly like it does
for `managed`, with `provenance: 'user-token'` distinguishing it from the managed path's
`'dispatch-assertion'`.

This verification requires the app host to reach the Hub's JWKS endpoint over the network. A
fetch failure or timeout degrades that request's actor to unavailable (no actor is
forwarded) — it never crashes dispatch and never falls back to the plain, unverified
`_meta.privosUser.userId` / `username` fields that ride alongside the token.

`npm run dev` / `npm run start:relay` (`development` mode) intentionally configure no Hub dispatch
trust at all (see [Local development](#local-development) above), so this auto-wiring does not
apply there and no verified actor is forwarded for any relay-transported call in that mode — by
design, not a gap.

### Rotation

The Hub can push secret rotation, trust rotation (re-key or a generation/manifest update), and
capability changes over the same authenticated Relay connection, each as an ES256-signed control
notification verified against the identity file's *currently* pinned Hub key before it is applied.
No operator action is required; the identity file is rewritten atomically (temp file + rename) in
place.

### Upgrade path (manifest changes)

`/ready` returns `MANIFEST_DRIFT` when the locally-built `privos-app.json` no longer matches the
canonical manifest digest pinned at pairing — this is the standalone analogue of the managed
image-label digest check. A manifest change (new tool, new permission, new env declaration) needs
re-approval: the Hub operator re-reviews the new manifest and pushes a trust rotation carrying the
new digest before `/ready` goes green again. There is no way to silently start serving traffic
under a manifest the Hub never approved.

The operator's side of that re-approval is Hub Admin → Apps → this app → Settings → **Refresh**
(see the Hub's "Install and operate your own MCP app" doc). Re-pairing this app while it is live is
refused and points back to Refresh — it is never needed for a manifest change.

Every signed exchange in this mode — dispatch assertions and control notifications alike — is
capped at a 30-second signature lifetime with zero verifier headroom (`exp - iat <= 30`, hard
capped even if the Hub asked for more). NTP-synchronized clocks on both the Hub and this app are a
hard requirement, not an optimization; `/ready`'s `RELAY_NOT_AUTHENTICATED` reason is the
observable symptom of clock skew large enough to fail verification.

## Permission contract

[`privos-app.json`](privos-app.json) is the canonical reviewed manifest. Each permission declares:

- required or optional;
- workspace/room context and user/background execution context;
- a stable feature identifier and publisher reason;
- deterministic degraded behavior for every optional permission.

Required permissions are locked during approval. Optional permissions start from the exact
approved subset and may be disabled later; Hub enforces the new epoch immediately. UI capability
checks only hide or explain features and are never the authorization boundary. See
[`SCOPES.md`](SCOPES.md) for the declaration-to-call-site map.

Run the shared linter to print the deterministic canonical manifest and publisher permission hashes:

```bash
npm run manifest:lint
```

Portal and Hub add the versioned authoritative permission catalog, data policy, and immutable image
digest when computing the final permission-contract hash.

## UI build and asset delivery

`npm start` runs `npm run build:ui` before starting the server. This produces both the
split bundle below and `dist/standalone-ui.html`, a production HTML resource with all
JS/CSS embedded. Paired standalone sessions use the inline resource by default;
managed/runtime-v3 sessions use the split bundle. `npm run build` prepares both formats
for the Docker image, whose runtime starts the server directly without rebuilding.

`npm run build` compiles `src/ui` with Vite (`vite.config.ts`: `base: './'`, code-split
`manualChunks`, `build.manifest: true`, `build.sourcemap: false`, `build.assetsInlineLimit: 0`)
into a small shell (`dist/ui/index.html`) plus hashed, content-addressed files under
`dist/ui/assets/`. `@privos_ai/app-server`'s `serveBuiltUi` helper (`src/mcp-message-handlers.ts`)
reads that build output once and answers three kinds of `resources/read` request: the shell
(`ui://ai.privos.mcp-app-demo/form.html`, meta-tagged for relay delivery, with an inline boot
watchdog), the assets manifest (`ui://ai.privos.mcp-app-demo/assets-manifest.json`), and each
individual asset (`ui://ai.privos.mcp-app-demo/assets/<file>`). Any other URI is refused with
JSON-RPC `-32602`.

The Hub fetches the shell once per open and the hashed assets once per installation generation,
caches them, and re-serves everyone from its own origin behind a short-lived per-user token —
**this app's built bundle is never served to end users unmodified from this container**, and it
must never embed a secret (no `VITE_*` build-time env values; the platform's own non-secret
values are read at runtime instead, see [Environment configuration](#environment-configuration)).
Two build constraints follow directly from that: no sourcemaps are ever produced, and nothing may
be served from a Vite `publicDir` — every asset the UI references (including the bundled sample
agent-set archive) must be a real hashed file under `dist/ui/assets/`, which the build enforces at
construction time (`serveBuiltUi` throws on an unhashed, oversized, or `.map` file, or on a shell
with a non-relative asset reference).

**This build requires the installing Hub to be at tenant.N or later** — an older Hub has no route
to fetch the split-out asset files, and the shell's boot watchdog shows a "App assets unavailable
— Retry" panel instead of a blank frame until the tenant is upgraded.

### Signed bundle at publish time

`ui.distDir` in `privos-app.json` is set to `"dist/ui"` — it must match wherever `npm run build`
actually writes the UI, since this is what `privos-app bundle-ui` (`npm run bundle:ui`) packages
into the tar the marketplace build node produces and the Portal signs. At install and upgrade, a
capable Hub pulls that signed bundle, verifies its digest, and preloads the UI into the workspace's
own storage before the app is allowed to go active — this is on top of, not instead of, the
`resources/read` contract `serveBuiltUi` implements above. A version whose UI build output didn't
change reuses the already-stored bundle rather than re-shipping it, so a manifest- or backend-only
version bump does not by itself ship a UI change. See privos-dev-docs:
[mcp-app-platform/ui-bundle.md](https://github.com/PrivOS-AI/privos-dev-docs/blob/main/mcp-app-platform/ui-bundle.md)
for the full mechanism and its refusal codes.

## Ubuntu Docker Compose

See [Ubuntu deployment instructions](docs/deployment/ubuntu-compose.md). The server needs Docker and Compose; no host Node/npm is required.

```bash
cp compose.env.example .env  # set APP_PORT if host port 3000 is occupied
bash scripts/build-compose.sh
docker compose run --rm --no-deps pair
docker compose up -d --no-build
```

Pairing waits for Hub approval and writes a persistent identity volume. Use `/ready` to verify readiness. Create the transferable Ubuntu source bundle with `bash scripts/package-ubuntu.sh`.

## Verification

```bash
npm run typecheck
npm test
npm run build
npm run preflight
npm run docker:build
```

Preflight validates schema v2, canonical hashes, documented call sites, Docker inputs, license
guards (when a `license` block is declared), safe source packaging, and the served manifest. Its versioned rules mirror Portal until the
Marketplace validation package is published.

## Safe source packaging

```bash
npm run package
```

This creates `dist-source/ai.privos.mcp-app-demo-2.0.0.zip` plus a SHA-256 provenance file from
Git-tracked source. It rejects dirty trees by default, credential-like files, `.env`, dependencies,
build output, and archives over 200 MiB. `--allow-dirty` is for local inspection only.

The multi-stage image installs only lockfile-pinned package inputs, runs as `node`, supports a
read-only root filesystem, and needs no production credential environment variables.

## Privacy, support, and release

Marketplace review/build source remains publisher-confidential; buyer workspaces receive the
digest-pinned image. See [`PRIVACY.md`](PRIVACY.md), [`TERMS.md`](TERMS.md), and
[`CHANGELOG.md`](CHANGELOG.md). Support is available at `dev@privos.ai`.

Maintenance

ActivityActive
ResponsivenessNo issues