# Identity and access

Understand accounts, roles, grants, API keys and deployment modes.

BytesMap uses OpenShip's contracts → platform → host/SDK structure for accounts,
workspaces, grants, and API keys. The dashboard, HTTP API, and native SDK call the
same authorization and access services. The packaged desktop and its Mindwire
assistant use this boundary too; local tooling does not create a second authority
model.

## Ownership

| Layer                                  | Owns                                                                                                                    |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `packages/contracts`                   | Roles, permissions, operation inputs/outputs, HTTP paths, and MCP opt-in metadata                                       |
| `packages/platform`                    | Verified execution context, authorization, shared operation lifecycle and Better Auth factory                           |
| `packages/platform/src/engine/modules` | Sources, servers, queries/views, flows, reports, teams, keys, delivery and audit services, following OpenShip's folders |
| `packages/db`                          | Metadata schema, additive upgrades, and transaction-aware repositories for Postgres/PGlite                              |
| `packages/sdk`                         | Typed HTTP client and native adapter over the same product/access operations                                            |
| `apps/cli`, `packages/bytesmap`        | Catalogue-driven commands and SDK/API/worker distribution entrypoints                                                   |
| `apps/api`                             | Request authentication, Origin/peer checks, session transport, routing, and MCP transport                               |
| `packages/ui`                          | Shared access editor, controls, tokens, and themes                                                                      |
| `apps/dashboard`                       | Settings and sign-in forms; it never decides the caller's authority                                                     |
| `apps/desktop`                         | Electron shell, private local services, protected host keys, navigation and process lifetime                            |

Query/flow/chart protocols and pure execution remain in their shared packages.
Product services run through the platform's operation boundary across HTTP, MCP,
CLI and native SDK. The catalogue covers sources, servers, queries, charts,
dashboards, flows, reports, reporting, notifications, permissions, tokens and audit.
See [architecture](https://github.com/oblien/bytesmap/blob/main/docs/architecture.md) and [SDK/CLI usage](https://bytesmap.com/docs/develop/cli-sdk.md).

Workspace owners/admins manage notification channels and scheduler/SMTP settings
with an account session. API keys and MCP cannot configure outbound destinations.
Report authors choose safe channel summaries without receiving credentials.
See [scheduled reports](https://bytesmap.com/docs/automate/reports.md) for delivery and verification behavior.

## A request's identity

Explicit API credentials are validated first. A real signed-in session then takes
precedence over the anonymous desktop account, including in `auth=none` mode.
Only a request without a session can use the guarded local fallback. Session
lookup failures never select a different account. Workspace selection stays
bound to that account's current membership.

1. The host verifies a Better Auth session, an API key, or an explicitly enabled
   local device connection.
2. It resolves an organization from a key binding, an explicit requested scope,
   the signed workspace cookie, or the user's membership list.
3. The platform loads the live user, membership, role, and credential. It produces
   a frozen `ExecutionContext`; role and identity fields never come from an
   operation body.
4. The operation validates its input, checks access, performs the work, and
   validates its public response. Mutating access operations recheck authority
   inside the transaction that changes it.

Contexts include the user, membership id, workspace, session kind, credential id,
scope, and request source. Membership changes, expiry, revocation, and removal
apply to subsequent operations, including already-resolved native contexts.
Background work also carries the membership id so leaving and rejoining a team
cannot revive an older job's authority.

HTTP is fixed to the selected workspace. A native host can deliberately request
resource-based scope, which resolves a resource's owning workspace and checks
membership there again. A role from one workspace is never reused in another.
Keys always remain fixed to their bound workspace.

## Roles and grants

| Role                         | Workspace access                                                                             |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| Owner                        | Product operations and team administration; can change owners                                |
| Admin                        | Product operations and team administration; cannot change owners                             |
| Member                       | Sources, queries, charts, dashboards, and execution; no team/settings/billing administration |
| Viewer                       | Read/list; no query execution                                                                |
| Custom access (`restricted`) | Granted projects/items and their allowed actions                                             |

Grants use `{ resourceType, resourceId, permissions }`. `*` deliberately grants
the whole resource collection. `read`, `write`, `admin`, `list`, and `run` are
semantic actions, independent of the HTTP method. `write` includes reading;
`admin` includes management but **does not imply `run`** for a custom grant.

Schema, profile, model, and SQL permissions are rooted in their source. Queries,
charts, dashboards, and servers have their own grants. Executing a saved query
also checks the sources it touches. Lists filter inaccessible rows; dashboard
responses filter nested charts, queries, and sources too. Reusing a saved server's
SSH connection while adding a source requires access to manage that server.

The same grant validator is used for keys, custom members, and invitations. It
checks every action, actual resource ownership, and wildcard authority. The UI
loads only resources and actions the current caller can grant.

[Projects](https://bytesmap.com/docs/workspace/projects.md) have stored membership and inherit their grants to their
queries, charts, actions, panels, dashboards and folders. Individual item shares
remain additive and do not reveal neighboring items. Project Write permits
authoring its items; Run and source/data permissions remain separate. Moves and
project removal change inherited access immediately without copying grants to
items. Keys and agent tools apply the same intersection with current member access.
The sidebar project choice controls navigation, never authority or ownership.
Canonical project links select that context; old unscoped resource links resolve
stored ownership independently of the remembered collection filter.

## Workspaces and invitations

New accounts receive a workspace with one owner. **Settings → Workspace → Add
people** invites directly to that workspace. Its first valid invitation marks it
as shared in the same transaction; its ID, sources, queries, charts, dashboards,
memberships, and API keys stay in place. A rejected invitation request leaves the
workspace unchanged. The SDK's `createTeamOrg` operation remains available when a
caller explicitly wants a separate workspace; it is not a prerequisite for adding people.
Workspace switching persists the verified account session's active organization.
The shared resolver validates that membership on every operation, keeps a valid
selection, and otherwise prefers the first team before the personal workspace.
Sessionless local access uses a signed, account-bound navigation preference.
Selecting a workspace never proves membership.

Workspace owners/admins can invite members by a copyable link or configured SMTP,
change roles, edit custom grants, and remove members. The last owner cannot be
removed or demoted. Removing or leaving a workspace revokes its keys and grants,
clears stale session selections, and removes notification subscriptions in the
same transaction. A self-leave cannot remove the last owner.

Invitations expire after seven days. Acceptance checks the recipient's signed-in
email, current status/expiry, and the sender's current authority, then creates
membership and grants atomically. Acceptance can be retried safely. Cancellation
removes pending grants. If email delivery fails after creation, the response still
returns the saved invitation link with `deliveryStatus: 'failed'`.

The Better Auth organization plugin remains installed for schema/session support.
Its alternate organization mutation endpoints are blocked. Team lifecycle goes
through the shared platform operations in every host.

## API keys and MCP

Create a key in **Settings → API keys**. Keys are bound to the current workspace,
displayed once, and stored only as a SHA-256 hash plus a short display prefix.
The list includes expiry, revocation, last use, and request count.

Key authority is the intersection of the owner's **current** membership/grants
and the key's selected grants. An empty selection cannot silently become full
access; full access requires an explicit choice. Read-only keys permit read/list
only, including for POST endpoints whose action is read. They cannot run queries.

Keys cannot create more credentials or manage accounts/team authority, even when
full access was selected. Those operations require an account/device session.

MCP is a stateless Streamable HTTP endpoint at `/api/mcp`. Give a key the MCP
connection permission plus its desired resource permissions. Tools are generated
from the registered routes/contracts, filtered against current capabilities, and
dispatched through the real app. A process-local `WeakMap<Request, Context>` passes
verified identity to the internal request; headers cannot fabricate that context,
and one tool request increments key usage only once.

MCP includes query/chart/dashboard authoring and updates. A key can author without
execution permission. Separate run/preview tools retain the same source checks as
the UI. Account, key, membership, and invitation mutations are not exposed as
agent tools. See [MCP setup](https://bytesmap.com/docs/agents/mcp.md).

On hosted endpoints, OAuth MCP clients use the same live grant-holder model as
keys. Consent chooses one workspace and uses the shared resource editor; connected clients can be
edited or disconnected from Settings. Tokens are audience-bound to `/api/mcp`,
require S256 PKCE, and cannot create credentials or change account/team authority.
Reconnect creates a new binding so a revoked token never becomes valid again.

The packaged desktop has a different external transport: its private host accepts
MCP POST requests from direct loopback clients only with an expiring, explicitly
scoped API key and the MCP connection permission. Browser cookies, OAuth tokens,
Origin/proxy headers and broad keys do not enter that path. Ordinary REST and
account routes still require the owning desktop's private capability. Settings
reads the host's declared endpoint and authentication capabilities instead of
promising OAuth on every host. The CLI's desktop stdio transport discovers the
current per-launch identity, verifies a fresh proof, and sends the scoped key on
the same non-reconnecting TCP connection. It never uses the private host capability.

Desktop assistant conversations have private, non-exportable grant holders too.
The caller must have an account/device session on a locally verified desktop host.
Each conversation belongs to one user and workspace. Permission changes stop its
tools and require fresh model context; approving a tool never grants additional
access. These services are unavailable to API keys, OAuth clients, cloud, and
self-hosted hosts. See [native assistant](https://github.com/oblien/bytesmap/blob/main/docs/assistant.md).

## Deployment and authentication modes

Edition and authentication mode are independent:

| Edition         | Host configuration                       | Current account behavior                                                                 |
| --------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| Desktop         | `DEPLOY_MODE=desktop`                    | Packaged Electron host starts private loopback services and explicitly provisions a pinned local identity |
| Self-hosted/VPS | `DEPLOY_MODE=bare` or `docker`           | Instance-local accounts; first-admin setup followed by invitation-only signup            |
| Cloud           | `DEPLOY_MODE=cloud` or `CLOUD_MODE=true` | Accounts and workspaces hosted by the cloud API; verified email required                 |

`BYTESMAP_AUTH_MODE` explicitly pins `none`, `local`, or `cloud`. When unset, the
resolver reads `instance_settings`; a missing value or read failure defaults to
`local`, never anonymous access. The `cloud` auth mode has a shared desktop login
handoff implementation with stored one-use attempts, PKCE and local session
claims. Cloud edition hosts its own Better Auth accounts. The packaged desktop
starts in guarded `none` mode; connecting that local workspace to a cloud account
is a separate, explicit operation.

`none` additionally requires an actual kernel-reported loopback peer, no public
URL, no require-auth flag, and either desktop mode or
`BYTESMAP_ALLOW_ZERO_AUTH=true`. Host/forwarded headers do not satisfy it. Every
dashboard/proxy in a local deployment must also bind to loopback; a public proxy
must use authenticated mode. An invalid supplied key never falls back to local
identity. Deleting the pinned local user cannot select another account silently.

The packaged desktop launcher starts the existing Next dashboard and Bun API on
separate IPv4 loopback ports. It isolates its PGlite store from development data,
loads persistent keys through the operating system credential store, and supplies
a fresh private capability for each launch. Its owning IPC channel is required;
losing that owner stops the API. Only this explicit desktop startup, or the native
CLI's local setup, provisions a pinned device identity. Ordinary network API
startup does not provision an anonymous administrator. See the
[desktop host guide](https://github.com/oblien/bytesmap/blob/main/apps/desktop/README.md).

## First account and upgrades

For a fresh self-hosted/VPS instance:

1. Choose persistent PostgreSQL or embedded PGlite metadata. API startup applies
   the shared baseline/additive migrations automatically.
2. Set a generated `BETTER_AUTH_SECRET`, `BYTESMAP_AUTH_MODE=local`, and
   `BYTESMAP_REQUIRE_AUTH=true`. Set `BYTESMAP_PUBLIC_URL` for public hosting.
3. Generate a host-only setup token, for example `openssl rand -base64 32`, and set
   `BYTESMAP_SETUP_TOKEN` (at least 24 characters).
4. Open `/setup`, enter that token, and create the first administrator.

Local desktop/CLI setup may use the same independently checked loopback proof.
The setup token ceases to authorize signup once an account exists. Subsequent
self-hosted accounts require a valid invitation for the submitted email.

First-account checks use a metadata row lock across concurrent requests. User,
credential, session, and personal workspace creation commit together; a failed
provision cannot leave a half-created administrator or return a session cookie.
Signup and other account routes retain Origin checks and their existing rate
limits.

Startup applies the shared metadata migrations before constructing the auth
adapter. Upgrades include passkeys, two-factor fields, invitation grants, key
usage, report settings and audit tables. They do not rewrite roles or reconnect customer databases.
Legacy keys without a workspace binding are refused and must be recreated.

## Account security

The shared auth factory pins Better Auth, its core, and passkeys to **1.6.26**.
OpenShip's passkey/TOTP guards were adapted to that version's challenge handling.
Security endpoints join one transaction, serialize credential changes on the user
row, reject stale cached sessions, and preserve failed-code attempt budgets.

Settings supports profile/password changes, session revocation, passkeys,
authenticator setup, and recovery codes. A working second factor cannot be
replaced by a concurrent enrollment. Enabled two-factor authentication also applies
to non-password sign-in hooks; no primary sign-in can issue its final session
before the challenge. Invitation destinations survive verification and two-factor
navigation.

Email verification/recovery and invitation mail use the host's configured email
transport. The platform owns reusable delivery logic; hosts inject configuration
or alternative adapters. It never prints credentials.

## Activity and request logs

Settings → Activity and `client.audit` expose workspace-scoped operation history:
actor, action, resource, outcome, duration and server-issued trace ID. Owner/admin
accounts can change recording and retention. Custom members and keys may receive
audit read access; it is not enabled for ordinary members/viewers by default.

The shared observer records mutations and execution, including native calls.
Account creation and session lifecycle have shared hooks. Routine successful
reads are omitted. Audit payloads exclude SQL, request bodies, result data and
credentials. Logging is best effort and does not roll back a committed operation.
It is not an exhaustive record of every failed sign-in or network denial.

HTTP diagnostics use structured logs and `x-request-id`, without query strings,
headers or raw error payloads. MCP's internal dispatch preserves that trace and
does not duplicate the key-use count or operation event. Report delivery retains
its separate schedule/run history. The shared scheduler prunes expired activity.

## Desktop integrations and release limits

Mindwire is integrated through its TypeScript SDK and native daemon. The desktop
assistant discovers installed Claude Code/Codex harnesses, manages private profiles,
and gives each conversation a permission-scoped BytesMap tool bridge. Model
arguments cannot choose an identity or filesystem path; native execution tools
are restricted by the host's harness policy. The current native assistant requires
macOS or Linux on ARM64/x64; the Windows desktop shell does not imply Windows
assistant support. Cloud and self-hosted hosts do not launch it. See
[native assistant](https://github.com/oblien/bytesmap/blob/main/docs/assistant.md) for supported capabilities and fixture coverage.

Cloud workspace linking has a packaged external-browser return path. The desktop
accepts only a registered, unexpired state from its configured cloud authorization
origin and consumes it once. The platform independently checks the stored PKCE
verifier, authorization code, local owner and workspace. The packaged smoke verifies
browser-to-desktop navigation using a local redirect fixture; it intentionally
does not mint a real cloud session. Separate platform tests cover the exchange and
identity rules. Real provider login is still a release gate.

macOS arm64 packaging and isolated native tests have local evidence. Signed
releases, signed Keychain/DPAPI continuity across versions, and the remaining
platform installation checks need their own results. Automatic updates are not
shipped. Desktop discovery and the scoped CLI stdio bridge have local Node/Bun
integration evidence; Windows account-directory ACL and native installation
checks still need their platform results. The
[launch-readiness record](https://github.com/oblien/bytesmap/blob/main/docs/launch-readiness.md) tracks these gates; fixture login
and an unsigned package are not evidence of a production provider or signed release.

## Verification

`packages/platform/test` uses isolated PGlite databases for access, invitations,
key revocation, first-account rollback, real WebAuthn/TOTP, and concurrent security
changes. `apps/api/src/lib/identity-http.test.ts` starts a separate API fixture and
checks HTTP/native SDK/CLI parity, scoped MCP authoring, shared audit, setup races, origin/peer
guards, workspace isolation, and revoked authority. It never connects to customer
data sources. The same fixture runs against disposable PostgreSQL metadata in the
real database integration job; native hosts also have isolated PGlite tests.

`packages/platform/test/mcp-oauth.test.ts` verifies consent, S256 PKCE, audience
binding, refresh rotation and live revocation. `cloud-auth-proxy.test.ts` and
`cloud-link.test.ts` cover local/cloud identity and one-use exchange using isolated
fixtures. Desktop host integration checks private transport and process cleanup;
its packaged smoke checks navigation, rendering and the external-browser return.
Native assistant tests use the real Mindwire daemon with fixture CLIs and local
scripted model responses, without paid inference or real provider sign-in.

`apps/dashboard/e2e/auth.e2e.ts` and `settings.e2e.ts` cover user flows and
Light/Dark/Dim layouts with keyboard and narrow-viewport checks. Their HTTP fixtures
exercise the real browser SDK; backend behavior is checked separately by the API
and platform integration tests.
