From 333bb836d6e3b637ebbf8f9e4d07ee1f5fe242c6 Mon Sep 17 00:00:00 2001 From: DebelToni Date: Tue, 20 Oct 2026 12:28:02 +0300 Subject: [PATCH] Consume site-owned service grants with strict private admin boundary --- .env.example | 6 +- AGENTS.md | 2 +- README.md | 10 +- SECURITY.md | 4 +- apps/server/src/app.ts | 33 +++-- apps/server/src/auth.ts | 4 +- apps/server/src/identity.ts | 81 +++++++----- apps/server/src/internal-admin.ts | 31 +++++ apps/server/src/openapi.ts | 7 +- apps/server/src/routes/auth-routes.ts | 8 +- apps/server/tests/helpers.ts | 4 +- apps/server/tests/identity-transport.test.ts | 122 +++++++++++++++++++ apps/server/tests/internal-admin.test.ts | 103 ++++++++++++++++ apps/server/tests/shared-accounts.test.ts | 89 +++++--------- apps/server/tests/socket-revocation.test.ts | 21 +++- apps/web/public/agent-guide.md | 2 +- apps/web/src/components/DashboardDialogs.tsx | 2 +- apps/web/src/pages/AuthPage.test.tsx | 2 +- apps/web/src/pages/AuthPage.tsx | 2 +- apps/web/src/pages/DashboardPage.test.tsx | 16 +++ apps/web/src/pages/DashboardPage.tsx | 5 +- deploy/README.md | 6 +- deploy/SHARED-ACCOUNTS.md | 30 +++-- 23 files changed, 449 insertions(+), 141 deletions(-) create mode 100644 apps/server/src/internal-admin.ts create mode 100644 apps/server/tests/identity-transport.test.ts create mode 100644 apps/server/tests/internal-admin.test.ts diff --git a/.env.example b/.env.example index 07fa77f..4feaf79 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,9 @@ OVERROOT_DOCKER_IMAGE=overroot-tectonic:0.16.9 OVERROOT_DOCKER_CONTAINER=overroot-tectonic-worker OVERROOT_FAKE_COMPILER=false -# Required shared account issuer and private backchannel config. +# Required site-owned account issuer and explicit private backchannel config. OVERROOT_IDENTITY_ORIGIN=https://accounts.toni.foo -OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18746 +OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18760 # Set OVERROOT_IDENTITY_CLIENT_SECRET privately (at least 32 characters). Never commit its value. + +# Private administration URLs belong only to the site authority, never this frontend. diff --git a/AGENTS.md b/AGENTS.md index 7f6a413..7e49873 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,4 +10,4 @@ Collaborative LaTeX studio. Public frontend, API and WebSockets: `https://overro - Verify code with `pnpm check && pnpm test:compiler`. The latter uses real Docker to test isolated compiler access to nested private source files. Keep the production data directory private while preserving the isolated worker's staging traversal/read permissions. - Persistent service changes must update the response check at `~/uptime/overroot.py` and any schedule checks. Liveness probes never compile documents or mutate app data. -- Human auth is a consumer of Space shared accounts, never local passwords. Read `deploy/SHARED-ACCOUNTS.md` before auth/config/migration work. Preserve explicit subject-to-local-ID mappings and local project/admin grants. Tests inject `IdentityProvider`; do not add production auth bypasses or infer identity from email/nickname. Agent Bearer credentials stay independent. +- Human auth consumes the site-owned accounts authority, never local passwords or Space-hosted authority. Read `deploy/SHARED-ACCOUNTS.md` before auth/config/migration work. Preserve explicit subject-to-local-ID mappings and local project grants and the legacy Admin-button flag. Require service_id `overroot` and live permission `use`; central roles never imply project permissions. Public account-admin APIs are removed; only issuer /admin navigation is public, with directory data behind exact internal Host + raw loopback + service key. Tests inject `IdentityProvider`; do not add production auth bypasses or infer identity from email/nickname. Agent Bearer credentials stay independent. Native http(s).request must preserve issuer Host on loopback; hosted tests capture real HTTP rather than mock fetch. Do not activate the ef15999 stage with the proven Host bug. diff --git a/README.md b/README.md index cb77fb8..64746dd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Overroot serves its frontend, REST API, and collaborative WebSockets from one Node server on the DGX at `https://overroot.toni.foo`, through a dedicated named Cloudflare Tunnel. GitHub Actions checks changes before a host-side deployment timer can publish an immutable release. Docker is used only for isolated Tectonic compilation. -Overroot and Space share approved accounts at `https://accounts.toni.foo`. Public signup requires account-admin approval. Questions about using or hosting Overroot are welcome in [GitHub Issues](https://github.com/DebelToni/Overroot/issues). +Overroot and Space use site-owned accounts at `https://accounts.toni.foo`. Signup is immediate; access is requested and approved separately for each service. Questions about using or hosting Overroot are welcome in [GitHub Issues](https://github.com/DebelToni/Overroot/issues). Overroot is independent and unaffiliated with Overleaf or Underleaf.ai. @@ -45,11 +45,11 @@ Overroot is independent and unaffiliated with Overleaf or Underleaf.ai. ### 1. Sign in with your shared account -Select **Continue to shared accounts**. Existing Space passwords work at the central accounts origin only. New users can sign up there and wait for administrator approval for both apps. Overroot never asks for or stores that password. +Select **Continue to shared accounts**. Existing Space passwords are preserved by the explicit site account import and used only at the central origin. New users sign up there, request Overroot access, and wait for service approval. Space access is independent. Overroot never asks for or stores that password. **Manage account** opens the central account controls. Signing out of Overroot revokes its app grant; central logout revokes grants derived from that identity session. A password change revokes other identity/app sessions. There is no password recovery, email delivery, or MFA in this flow. -Approval does not share projects or grant Overroot administrator access. Existing users retain their local IDs and permissions through an operator-supplied mapping, described in [shared account migration](deploy/SHARED-ACCOUNTS.md). +Service approval and registered roles do not share projects or change their owner/editor/viewer permissions. The preserved **Admin** button opens central `/admin`, which checks the configured owner and redirects to private, tailnet-only administration. Public account-admin data and mutation endpoints are unavailable, including to that owner. Existing users retain their local IDs and permissions through an operator-supplied mapping, described in [shared account migration](deploy/SHARED-ACCOUNTS.md). ### 2. Create and share a project @@ -154,7 +154,7 @@ SQLite and generated runtime data live outside the source repository. Backup loc pnpm dev ``` -The HTTP Vite preview at http://localhost:5173 is suitable for UI work, but human sign-in requires a same-origin HTTPS setup with an exact callback registered by the provider. Configure `OVERROOT_PUBLIC_URL` for that HTTPS origin, proxy to Node preserving Host, and use the corresponding provider client registration. Do not disable Secure cookies or Origin checks for development. Tests inject a mocked identity provider; they do not require live Space endpoints. +The HTTP Vite preview at http://localhost:5173 is suitable for UI work, but human sign-in requires a same-origin HTTPS setup with an exact callback registered by the provider. Configure `OVERROOT_PUBLIC_URL` for that HTTPS origin, proxy to Node preserving Host, and use the corresponding provider client registration. Do not disable Secure cookies or Origin checks for development. Auth fixtures inject a v2 identity provider; hosted transport tests use a real loopback HTTP receiver to verify issuer Host, confidential client binding, response bounds, no redirects and the absolute timeout. They do not require a live authority. Run every quality gate with: @@ -166,7 +166,7 @@ This performs ESLint, strict TypeScript checks, backend/frontend tests, producti ## Security scope -Overroot is designed for approved accounts with private project memberships. Human passwords belong exclusively to the shared authority. App cookie and agent secrets are stored as hashes; WebSocket tickets are short-lived and single-use. Human API access introspects the central grant on every request and returns 503 during provider outages. Live human sockets revalidate every 10 seconds with a 3-second backchannel timeout, giving a target revocation/outage window of at most 13 seconds under a responsive event loop. Known revocation closes with 1008; provider failures close with retryable 1013. Upgrade tickets also revalidate their grant and project permissions. ZIP extraction and project paths are bounded, CORS is allowlisted, and LaTeX compilation is isolated from the host. +Overroot requires current service permission `use` plus explicit private project membership. Human passwords belong exclusively to the shared authority. App cookie and agent secrets are stored as hashes; WebSocket tickets are short-lived and single-use. Human API access introspects the subject-bound `overroot` service grant on every request and returns 503 during provider outages. Live human sockets revalidate every 10 seconds with a 3-second backchannel timeout, giving a target revocation/outage window of at most 13 seconds under a responsive event loop. Known revocation closes with 1008; provider failures close with retryable 1013. Upgrade tickets also revalidate their grant and project permissions. ZIP extraction and project paths are bounded, CORS is allowlisted, and LaTeX compilation is isolated from the host. The production frontend and API share one origin behind the dedicated Cloudflare Tunnel. If the host or its network is unavailable, the service is unavailable while SQLite data remains on the host. diff --git a/SECURITY.md b/SECURITY.md index b474b2d..3d35472 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,10 +6,12 @@ Supported code is the current `main` branch. ## Deployment boundaries -Shared accounts permit public signup but require administrator approval before either app can be used. Overroot project access remains explicit and private. Human credentials are handled only by the central Space authority at `accounts.toni.foo`; Overroot has no local-password fallback. Keep the server bound to loopback and expose it only through the dedicated Cloudflare Tunnel. Do not commit `.env`, runtime data, agent passwords, or backup snapshots. +Site-owned accounts permit immediate public signup, with separately requested/approved service access and registered roles. Overroot project access remains explicit and private. Human credentials are handled only by the site-owned authority at `accounts.toni.foo`; Overroot has no local-password fallback. Keep the server bound to loopback and expose it only through the dedicated Cloudflare Tunnel. Do not commit `.env`, runtime data, agent passwords, or backup snapshots. Production builds and the app currently run as the DGX owner's account. Docker isolation applies only to LaTeX, not the application or deployment builds. A malicious merged change can access that account's files and Docker privileges. PR-only main, owner-only merging, exact-revision review and CI are a trust gate, not an operating-system sandbox. AI review can miss malicious code or prompt injection; a dedicated VM without owner credentials or the host Docker socket is needed for a stronger boundary. See [review policy](deploy/REVIEW.md). Agent passwords are project-scoped and displayed once. Revoke a credential immediately if it may have leaked. Stop the server before restoring a database snapshot. Shared account configuration, migration and revocation limits are documented in [the operator runbook](deploy/SHARED-ACCOUNTS.md). App cookies are host-only, Secure, HttpOnly and SameSite=Lax. Every cookie-authenticated mutation requires the exact app Origin; agent Authorization headers take precedence and retain independent project scope. + +Public account-admin APIs are unavailable, even for the owner. The legacy local admin flag controls only an issuer-admin navigation button. Read-only local-directory access is restricted to actual loopback peers, exact Host `overroot.admin.internal`, and the confidential service client key; forwarded IP/Host headers and human cookies cannot authorize it. Every human grant must identify service `overroot` and retain permission `use`; central roles do not alter project ACLs. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a10897d..0876b36 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -13,6 +13,7 @@ import { config } from './config.js'; import type { AppContext } from './context.js'; import { Database } from './database.js'; import { AccountsProvider, type IdentityProvider } from './identity.js'; +import { INTERNAL_USERS_PATH, registerInternalAdminRoutes, requireInternalAdmin } from './internal-admin.js'; import { HttpError } from './errors.js'; import { openapi } from './openapi.js'; import { ProjectStore } from './project-store.js'; @@ -33,7 +34,7 @@ export async function buildApp(options: BuildAppOptions = {}) { const app = Fastify({ logger: options.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', - redact: ['req.headers.authorization', 'req.headers.cookie', 'req.url', 'req.query', 'res.headers.location', 'res.headers["set-cookie"]', 'res.headers.authorization'] + redact: ['req.headers.authorization', 'req.headers.cookie', 'req.headers["x-accounts-client-key"]', 'req.url', 'req.query', 'res.headers.location', 'res.headers["set-cookie"]', 'res.headers.authorization'] }, trustProxy: config.trustProxy, bodyLimit: 30 * 1024 * 1024, @@ -73,6 +74,23 @@ export async function buildApp(options: BuildAppOptions = {}) { return reply.code(500).send({ error: { code: 'internal_error', message: 'Internal server error' } }); }); + app.addHook('onRequest', async (request, reply) => { + if (!request.url.startsWith('/api/') && !request.routeOptions.url?.startsWith('/api/')) return; + if (request.method === 'GET' && request.routeOptions.url === INTERNAL_USERS_PATH) { + requireInternalAdmin(request); + reply.header('Cache-Control', 'no-store'); + return; + } + if (request.headers.host !== new URL(config.publicUrl).host) throw new HttpError(403, 'Host is not allowed', 'host_denied'); + reply.header('Cache-Control', 'no-store'); + const bearer = request.headers.authorization !== undefined; + if (!bearer && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && request.headers.origin !== config.publicUrl) { + throw new HttpError(403, 'Origin is not allowed', 'origin_denied'); + } + // Login callbacks must work even when an old app grant is revoked or unavailable. + if (!['/api/v1/auth/start', '/api/v1/auth/callback', '/api/v1/auth/account', '/api/v1/status'].includes(request.url.split('?')[0]!)) await auth.prepare(request); + }); + await app.register(cors, { origin(origin, callback) { if (!origin || config.allowedOrigins.includes(origin)) callback(null, true); @@ -88,18 +106,6 @@ export async function buildApp(options: BuildAppOptions = {}) { limits: { files: 1, fileSize: 25 * 1024 * 1024, fields: 10 } }); - app.addHook('onRequest', async (request, reply) => { - if (!request.url.startsWith('/api/')) return; - if (request.headers.host !== new URL(config.publicUrl).host) throw new HttpError(403, 'Host is not allowed', 'host_denied'); - reply.header('Cache-Control', 'no-store'); - const bearer = request.headers.authorization !== undefined; - if (!bearer && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && request.headers.origin !== config.publicUrl) { - throw new HttpError(403, 'Origin is not allowed', 'origin_denied'); - } - // Login callbacks must work even when an old app grant is revoked or unavailable. - if (!['/api/v1/auth/start', '/api/v1/auth/callback', '/api/v1/auth/account', '/api/v1/status'].includes(request.url.split('?')[0]!)) await auth.prepare(request); - }); - app.addHook('onSend', async (_request, reply, payload) => { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Referrer-Policy', 'no-referrer'); @@ -116,6 +122,7 @@ export async function buildApp(options: BuildAppOptions = {}) { return reply.type('text/markdown').send(await readFile(path, 'utf8')); }); + registerInternalAdminRoutes(app, context); registerAuthRoutes(app, context); registerProjectRoutes(app, context); registerFileRoutes(app, context); diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 8816590..d1f3c2a 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -35,7 +35,7 @@ export class AuthService { createSession(grant: IdentityGrant): { token: string; user: PublicUser; maxAge: number } { this.assertMigrated(); - if (!grant.user.approved || grant.expires_at * 1000 <= Date.now()) throw new HttpError(401, 'Inactive account grant', 'inactive_grant'); + if (grant.service_id !== 'overroot' || !grant.permissions.includes('use') || !grant.user.approved || grant.expires_at * 1000 <= Date.now()) throw new HttpError(401, 'Inactive account grant', 'inactive_grant'); return this.db.transaction(() => { let user = this.db.get('SELECT u.* FROM users u JOIN external_identities e ON e.user_id = u.id WHERE e.subject = ?', grant.user.id); if (!user) { @@ -56,7 +56,7 @@ export class AuthService { const row = this.db.get(`SELECT u.*, s.central_session_id, s.expires_at, e.subject FROM sessions s JOIN users u ON u.id = s.user_id JOIN external_identities e ON e.user_id = u.id WHERE s.token_hash = ?`, sessionHash); if (!row?.central_session_id || row.expires_at <= new Date().toISOString()) return null; const grant = await this.identity.introspect(row.central_session_id); - if (!grant.active || !grant.user.approved || grant.user.id !== row.subject || grant.expires_at * 1000 <= Date.now()) { + if (!grant.active || grant.service_id !== 'overroot' || !grant.permissions.includes('use') || !grant.user.approved || grant.user.id !== row.subject || grant.expires_at * 1000 <= Date.now()) { this.db.run('DELETE FROM sessions WHERE token_hash = ?', sessionHash); return null; } diff --git a/apps/server/src/identity.ts b/apps/server/src/identity.ts index 88b7e91..8e0d337 100644 --- a/apps/server/src/identity.ts +++ b/apps/server/src/identity.ts @@ -1,9 +1,17 @@ +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; import { z } from 'zod'; import { HttpError } from './errors.js'; -const userSchema = z.object({ id: z.string().min(1).max(256), email: z.email(), nickname: z.string().min(1).max(256), approved: z.literal(true) }); -const grantSchema = z.object({ session_id: z.string().min(1).max(512), expires_at: z.number().int().positive(), user: userSchema }); -const introspectionSchema = z.discriminatedUnion('active', [z.object({ active: z.literal(false) }), z.object({ active: z.literal(true), expires_at: z.number().int().positive(), user: userSchema })]); +const userSchema = z.object({ id: z.string().min(1).max(256), email: z.email().max(254), nickname: z.string().min(1).max(256), approved: z.literal(true) }); +const policyKey = z.string().min(1).max(64); +const serviceFields = { + service_id: z.literal('overroot'), + role: policyKey, + permissions: z.array(policyKey).max(64) +}; +const grantSchema = z.object({ session_id: z.string().min(1).max(512), expires_at: z.number().int().positive(), ...serviceFields, user: userSchema }); +const introspectionSchema = z.discriminatedUnion('active', [z.object({ active: z.literal(false) }), z.object({ active: z.literal(true), expires_at: z.number().int().positive(), ...serviceFields, user: userSchema })]); export type IdentityGrant = z.infer; export type Introspection = z.infer; export interface IdentityProvider { @@ -23,10 +31,11 @@ export class AccountsProvider implements IdentityProvider { constructor(publicUrl: string) { const origin = process.env.OVERROOT_IDENTITY_ORIGIN; const secret = process.env.OVERROOT_IDENTITY_CLIENT_SECRET; - if (!origin || !secret) throw new Error('Shared account configuration is required; local password auth is disabled'); + const backchannel = process.env.OVERROOT_IDENTITY_BASE_URL; + if (!origin || !secret || !backchannel) throw new Error('Shared account configuration is required; local password auth is disabled'); if (secret.length < 32) throw new Error('OVERROOT_IDENTITY_CLIENT_SECRET must contain at least 32 characters'); const issuer = new URL(origin); - const base = new URL(process.env.OVERROOT_IDENTITY_BASE_URL ?? origin); + const base = new URL(backchannel); if (issuer.origin !== origin || issuer.protocol !== 'https:' || issuer.username || issuer.password || base.username || base.password || base.pathname !== '/' || base.search || base.hash || !(base.protocol === 'https:' || (base.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(base.hostname)))) { @@ -40,33 +49,43 @@ export class AccountsProvider implements IdentityProvider { this.secret = secret; } - private async call(endpoint: string, input: Record): Promise { - try { - const response = await fetch(`${this.base}/api/accounts/${endpoint}`, { - method: 'POST', redirect: 'error', signal: AbortSignal.timeout(3000), - headers: { 'Content-Type': 'application/json', 'X-Accounts-Client-Key': this.secret, Host: new URL(this.origin).host }, - body: JSON.stringify({ client_id: 'overroot', ...input }) - }); - if (!response.ok) throw new Error('Identity provider rejected request'); - // Bound even a malformed provider response, without buffering an unlimited body. - const reader = response.body?.getReader(); - if (!reader) throw new Error('Empty identity response'); - let body = ''; - let size = 0; - const decoder = new TextDecoder(); - try { - while (true) { - const part = await reader.read(); - if (part.done) break; - size += part.value.length; - if (size > 16_384) throw new Error('Oversized identity response'); - body += decoder.decode(part.value, { stream: true }); + private call(endpoint: string, input: Record): Promise { + const url = new URL(`/api/accounts/${endpoint}`, this.base); + const request = url.protocol === 'https:' ? httpsRequest : httpRequest; + const body = Buffer.from(JSON.stringify({ client_id: 'overroot', ...input })); + // Native request preserves the issuer Host on a literal-loopback connection. + // No redirect handling, browser cookies, WAN fallback or fetch Host rewriting. + return new Promise((resolve, reject) => { + const req = request(url, { + method: 'POST', signal: AbortSignal.timeout(3000), maxHeaderSize: 16_384, + headers: { + Host: new URL(this.origin).host, 'X-Accounts-Client-Key': this.secret, + 'Content-Type': 'application/json', 'Content-Length': body.length, Accept: 'application/json' } - return JSON.parse(body + decoder.decode()); - } finally { await reader.cancel(); } - } catch { - throw unavailable(); - } + }, (response) => { + response.once('error', () => reject(unavailable())); + response.once('aborted', () => reject(unavailable())); + if (response.statusCode !== 200) { + response.destroy(); + reject(unavailable()); + return; + } + let size = 0; + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > 16_384) { + req.destroy(); response.destroy(); reject(unavailable()); + } else chunks.push(chunk); + }); + response.once('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch { reject(unavailable()); } + }); + }); + req.once('error', () => reject(unavailable())); + req.end(body); + }).catch(() => { throw unavailable(); }); } async exchange(code: string, verifier: string): Promise { diff --git a/apps/server/src/internal-admin.ts b/apps/server/src/internal-admin.ts new file mode 100644 index 0000000..6817cbe --- /dev/null +++ b/apps/server/src/internal-admin.ts @@ -0,0 +1,31 @@ +import { isIP } from 'node:net'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { AppContext } from './context.js'; +import { HttpError } from './errors.js'; +import { safeEqualText, sha256 } from './security.js'; + +export const INTERNAL_USERS_PATH = '/api/internal/admin/users'; + +export function requireInternalAdmin(request: FastifyRequest): void { + const peer = request.raw.socket.remoteAddress ?? ''; + const ipv4 = peer.startsWith('::ffff:') ? peer.slice(7) : peer; + const loopback = peer === '::1' || (isIP(ipv4) === 4 && ipv4.startsWith('127.')); + const key = request.headers['x-accounts-client-key']; + const expected = process.env.OVERROOT_IDENTITY_CLIENT_SECRET; + const singleHeader = (name: string) => request.raw.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === name).length === 1; + if (request.headers.host !== 'overroot.admin.internal' || !loopback || + !singleHeader('host') || !singleHeader('x-accounts-client-key') || + typeof key !== 'string' || !expected || expected.length < 32 || + !safeEqualText(sha256(key), sha256(expected))) { + throw new HttpError(403, 'Internal administration access denied', 'internal_admin_denied'); + } +} + +export function registerInternalAdminRoutes(app: FastifyInstance, context: AppContext): void { + app.get(INTERNAL_USERS_PATH, async () => ({ + users: context.db.all<{ id: string; subject: string | null; username: string; displayName: string; isAdmin: number; createdAt: string }>( + `SELECT u.id, e.subject, u.username, u.display_name AS displayName, u.is_admin AS isAdmin, u.created_at AS createdAt + FROM users u LEFT JOIN external_identities e ON e.user_id = u.id ORDER BY u.created_at, u.id` + ).map((user) => ({ ...user, isAdmin: user.isAdmin === 1 })) + })); +} diff --git a/apps/server/src/openapi.ts b/apps/server/src/openapi.ts index ca8362a..d738f6b 100644 --- a/apps/server/src/openapi.ts +++ b/apps/server/src/openapi.ts @@ -18,7 +18,7 @@ export const openapi = { title: 'Overroot Agent API', version: '0.1.0', description: - 'Project-scoped, revision-safe REST access. Agents use a scoped Bearer token independently of shared human accounts. Humans use a host-only HttpOnly cookie after central authorization-code/PKCE sign-in. Every human API request introspects its central grant; outages return 503. Cookie-authenticated mutations require Origin matching the app origin. Send If-Match when mutating existing files.' + 'Project-scoped, revision-safe REST access. Agents use a scoped Bearer token independently of shared human accounts. Humans use a host-only HttpOnly cookie after site-owned authorization-code/PKCE sign-in. Every human API request introspects its overroot service grant and requires live permission use; outages return 503. Service roles do not grant project membership or change content permissions. Cookie-authenticated mutations require Origin matching the app origin. Send If-Match when mutating existing files.' }, externalDocs: { description: 'Agent guide', @@ -37,6 +37,9 @@ export const openapi = { '/api/v1/auth/callback': { get: { operationId: 'completeHumanSignIn', security: [], parameters: [parameter('state', 'query', true, { type: 'string' }), parameter('code', 'query', true, { type: 'string' })], responses: { '302': { description: 'Consumes browser-bound state, exchanges the single-use code with confidential client authentication, sets __Host-overroot_session, and redirects locally. Invalid/expired attempts redirect to /#/login?authError=invalid_attempt without exchanging a code. Provider exchange failures redirect to /#/login?authError=accounts_unavailable. These screens offer an explicit fresh-sign-in action. No browser bearer token.' }, '503': error('Identity migration required') } } }, + '/api/v1/auth/admin': { + get: { operationId: 'openSiteAdministration', security: [{ humanSession: [] }], responses: { '302': { description: 'Existing local admin navigation affordance only: redirect to issuer /admin, which independently checks its configured owner and redirects to private administration. No public admin data or mutations.' }, '401': error(), '403': error('Local admin affordance unavailable'), '503': error('Accounts unavailable') } } + }, '/api/v1/auth/account': { get: { operationId: 'openSharedAccount', security: [], responses: { '302': { description: 'Redirect to central account controls, signup and password settings' } } } }, @@ -177,7 +180,7 @@ export const openapi = { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'Overroot project agent password' } }, schemas: { - UserEnvelope: { type: 'object', required: ['user'], properties: { user: { type: 'object', required: ['id', 'username', 'displayName', 'isAdmin', 'createdAt'], properties: { id: { type: 'string' }, username: { type: 'string' }, displayName: { type: 'string' }, isAdmin: { type: 'boolean', description: 'Overroot-local grant, never inferred from central approval' }, createdAt: { type: 'string' } } } } }, + UserEnvelope: { type: 'object', required: ['user'], properties: { user: { type: 'object', required: ['id', 'username', 'displayName', 'isAdmin', 'createdAt'], properties: { id: { type: 'string' }, username: { type: 'string' }, displayName: { type: 'string' }, isAdmin: { type: 'boolean', description: 'Preserved local Admin-button visibility only; never central policy or project authority' }, createdAt: { type: 'string' } } } } }, ErrorEnvelope: { type: 'object', required: ['error'], properties: { error: { type: 'object', required: ['code', 'message'], properties: { code: { type: 'string' }, message: { type: 'string' }, details: {} } } diff --git a/apps/server/src/routes/auth-routes.ts b/apps/server/src/routes/auth-routes.ts index 3c85f0a..da99246 100644 --- a/apps/server/src/routes/auth-routes.ts +++ b/apps/server/src/routes/auth-routes.ts @@ -83,10 +83,12 @@ export function registerAuthRoutes(app: FastifyInstance, context: AppContext): v return { users: users.map(publicUser) }; }); - app.get('/api/v1/admin/users', async (request) => { + app.get('/api/v1/auth/admin', async (request, reply) => { const actor = context.auth.requireUser(request); - if (!actor.isAdmin) throw new HttpError(403, 'Administrator access is required', 'admin_required'); - return { users: context.db.all('SELECT * FROM users ORDER BY created_at').map(publicUser) }; + if (!actor.isAdmin) throw new HttpError(403, 'Local administrator entry required', 'admin_required'); + // This is only a navigation affordance. The issuer independently requires + // its configured owner, then redirects to the private administration origin. + return reply.redirect(new URL('/admin', context.auth.identity.origin).toString()); }); } diff --git a/apps/server/tests/helpers.ts b/apps/server/tests/helpers.ts index c6706a5..accccb8 100644 --- a/apps/server/tests/helpers.ts +++ b/apps/server/tests/helpers.ts @@ -15,7 +15,7 @@ export class MockIdentity implements IdentityProvider { outage = false; calls = 0; grant(subject: string = randomUUID(), nickname = 'Member'): IdentityGrant { - const grant = { session_id: randomUUID(), expires_at: Math.floor(Date.now() / 1000) + 3600, user: { id: subject, nickname, email: 'member@example.test', approved: true as const } }; + const grant: IdentityGrant = { service_id: 'overroot', role: 'member', permissions: ['use'], session_id: randomUUID(), expires_at: Math.floor(Date.now() / 1000) + 3600, user: { id: subject, nickname, email: 'member@example.test', approved: true as const } }; this.grants.set(grant.session_id, grant); return grant; } @@ -29,7 +29,7 @@ export class MockIdentity implements IdentityProvider { this.calls++; if (this.outage) throw unavailable(); const grant = this.grants.get(id); - return grant ? { active: true, expires_at: grant.expires_at, user: grant.user } : { active: false }; + return grant ? { active: true, expires_at: grant.expires_at, service_id: grant.service_id, role: grant.role, permissions: grant.permissions, user: grant.user } : { active: false }; } async revoke(id: string): Promise { if (this.outage) throw unavailable(); diff --git a/apps/server/tests/identity-transport.test.ts b/apps/server/tests/identity-transport.test.ts new file mode 100644 index 0000000..7282007 --- /dev/null +++ b/apps/server/tests/identity-transport.test.ts @@ -0,0 +1,122 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, expect, it, vi } from 'vitest'; +import { AccountsProvider } from '../src/identity.js'; + +const secret = 'test-only-confidential-secret-32-characters'; +const origin = 'https://accounts.example.test:8443'; +const callback = 'https://overroot.example.test/api/v1/auth/callback'; +const admitted = () => ({ + session_id: 'fixture-grant', expires_at: Math.floor(Date.now() / 1000) + 3600, + service_id: 'overroot', role: 'member', permissions: ['use'], + user: { id: 'immutable-subject', email: 'member@example.test', nickname: 'Member', approved: true } +}); +let server: Server | undefined; +afterEach(async () => { + if (server) { + server.closeAllConnections(); + await new Promise((resolve, reject) => server!.close((error) => error ? reject(error) : resolve())); + server = undefined; + } + vi.unstubAllEnvs(); +}); +async function provider(handler: (request: IncomingMessage, response: ServerResponse) => void) { + server = createServer(handler); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + vi.stubEnv('OVERROOT_IDENTITY_ORIGIN', origin); + vi.stubEnv('OVERROOT_IDENTITY_BASE_URL', `http://127.0.0.1:${port}`); + vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', secret); + return new AccountsProvider('https://overroot.example.test'); +} +it('captures real HTTP Host, client key, methods and JSON bindings for all backchannel endpoints', async () => { + const captures: Array<{ path: string; method: string; headers: IncomingMessage['headers']; body: unknown }> = []; + const client = await provider((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + captures.push({ path: request.url!, method: request.method!, headers: request.headers, body: JSON.parse(Buffer.concat(chunks).toString()) }); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(request.url!.endsWith('/token') ? admitted() : request.url!.endsWith('/introspect') ? { active: true, ...admitted() } : { ok: true })); + }); + }); + expect(await client.exchange('single-use-code', 'pkce-verifier')).toMatchObject({ service_id: 'overroot', permissions: ['use'] }); + expect(await client.introspect('fixture-grant')).toMatchObject({ active: true, role: 'member' }); + await client.revoke('fixture-grant'); + expect(captures.map((c) => [c.method, c.path, c.body])).toEqual([ + ['POST', '/api/accounts/token', { client_id: 'overroot', grant_type: 'authorization_code', redirect_uri: callback, code: 'single-use-code', code_verifier: 'pkce-verifier' }], + ['POST', '/api/accounts/introspect', { client_id: 'overroot', session_id: 'fixture-grant' }], + ['POST', '/api/accounts/revoke', { client_id: 'overroot', session_id: 'fixture-grant' }] + ]); + for (const capture of captures) { + expect(capture.headers.host).toBe('accounts.example.test:8443'); + expect(capture.headers['x-accounts-client-key']).toBe(secret); + expect(capture.headers['content-type']).toBe('application/json'); + expect(capture.headers['content-length']).toBe(String(Buffer.byteLength(JSON.stringify(capture.body)))); + expect(capture.headers.cookie).toBeUndefined(); + expect(capture.headers.authorization).toBeUndefined(); + expect(capture.headers.origin).toBeUndefined(); + } +}); +it.each([301, 302, 307, 400, 401, 403, 500])('rejects HTTP %i without following redirects or forwarding secrets', async (status) => { + const paths: string[] = []; + const client = await provider((request, response) => { + paths.push(request.url!); response.writeHead(status, { Location: '/must-not-follow' }); response.end('{}'); + }); + await expect(client.exchange('code', 'verifier')).rejects.toMatchObject({ statusCode: 503 }); + expect(paths).toEqual(['/api/accounts/token']); +}); +it.each(['headers', 'body'] as const)('enforces the absolute 3s timeout on stalled %s over real HTTP', async (phase) => { + const client = await provider((_request, response) => { + if (phase === 'body') { response.writeHead(200, { 'Content-Type': 'application/json' }); response.write('{"active":'); } + }); + const started = performance.now(); + await expect(client.introspect('grant')).rejects.toMatchObject({ statusCode: 503 }); + const elapsed = performance.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(2700); + expect(elapsed).toBeLessThan(4500); +}, 10_000); +it.each(['oversize', 'oversize-headers', 'invalid-json', 'disconnect'] as const)('bounds and rejects %s responses over real HTTP', async (kind) => { + const client = await provider((request, response) => { + if (kind === 'disconnect') return request.socket.destroy(); + if (kind === 'oversize-headers') response.setHeader('X-Oversize', 'x'.repeat(16_385)); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(kind === 'oversize' ? 'x'.repeat(16_385) : '{invalid'); + }); + await expect(client.introspect('grant')).rejects.toMatchObject({ statusCode: 503 }); +}); +it.each([ + { service_id: 'space' }, { service_id: undefined }, { role: undefined }, { role: '' }, { role: 'x'.repeat(65) }, + { permissions: undefined }, { permissions: ['x'.repeat(65)] }, { permissions: Array(65).fill('use') }, + { user: { ...admitted().user, approved: false } }, { user: { ...admitted().user, nickname: 'x'.repeat(257) } } +])('rejects malformed or wrong-service policy fields: %j', async (fields) => { + const client = await provider((_request, response) => response.end(JSON.stringify({ active: true, ...admitted(), ...fields }))); + await expect(client.exchange('code', 'verifier')).rejects.toMatchObject({ statusCode: 503 }); + // Introspection has the identical active-grant schema, not a looser policy path. + await expect(client.introspect('grant')).rejects.toMatchObject({ statusCode: 503 }); +}); +it('accepts registered service-specific role/permission fields without inferring content authority', async () => { + const client = await provider((_request, response) => response.end(JSON.stringify({ active: true, ...admitted(), role: 'registered-role', permissions: ['use', 'registered:permission'] }))); + expect(await client.introspect('grant')).toMatchObject({ active: true, role: 'registered-role', permissions: ['use', 'registered:permission'] }); +}); +it('rejects expired token responses and accepts explicit inactive introspection', async () => { + const client = await provider((request, response) => response.end(JSON.stringify(request.url!.endsWith('/token') ? { ...admitted(), expires_at: 1 } : { active: false }))); + await expect(client.exchange('expired', 'verifier')).rejects.toMatchObject({ statusCode: 503 }); + expect(await client.introspect('revoked')).toEqual({ active: false }); +}); +it.each([0, 1, 16, 31])('fails closed with a %i-character configured client key', async (length) => { + await provider((_request, response) => response.end('{}')); + vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', 'x'.repeat(length)); + expect(() => new AccountsProvider('https://overroot.example.test')).toThrow(); +}); +it('accepts the 32-character minimum client key', async () => { + await provider((_request, response) => response.end('{}')); + vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', 'x'.repeat(32)); + expect(() => new AccountsProvider('https://overroot.example.test')).not.toThrow(); +}); + +it('requires an explicit backchannel instead of silently using the public WAN issuer', async () => { + await provider((_request, response) => response.end('{}')); + vi.stubEnv('OVERROOT_IDENTITY_BASE_URL', ''); + expect(() => new AccountsProvider('https://overroot.example.test')).toThrow('configuration is required'); +}); diff --git a/apps/server/tests/internal-admin.test.ts b/apps/server/tests/internal-admin.test.ts new file mode 100644 index 0000000..89f7c89 --- /dev/null +++ b/apps/server/tests/internal-admin.test.ts @@ -0,0 +1,103 @@ +import { request as httpRequest } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { config } from '../src/config.js'; +import { INTERNAL_USERS_PATH } from '../src/internal-admin.js'; +import { buildApp, humanHeaders, login, MockIdentity, seedUser } from './helpers.js'; + +const key = 'internal-directory-test-only-key-32-characters'; +let dir: string; +let built: Awaited>; +let provider: MockIdentity; +let ownerToken: string; +let memberToken: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'overroot-private-admin-')); + vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', key); + config.fakeCompiler = true; + provider = new MockIdentity(); + built = await buildApp({ databasePath: join(dir, 'db.sqlite3'), identityProvider: provider, logger: false }); + const owner = seedUser(built.context.db, 'existing-owner', true); + const member = seedUser(built.context.db, 'ordinary-member', false); + ownerToken = login(built.context.auth, owner, provider); + memberToken = login(built.context.auth, member, provider); + built.context.projects.createProject(owner.id, 'PRIVATE_PROJECT_MUST_NOT_APPEAR'); + built.context.db.run("UPDATE users SET password_hash = 'PASSWORD_HASH_MUST_NOT_APPEAR'"); +}); +afterEach(async () => { await built.app.close(); vi.unstubAllEnvs(); await rm(dir, { recursive: true, force: true }); }); +it('removes public account-admin APIs even for the existing owner and a correct service key', async () => { + for (const path of ['/api/v1/admin/users', '/api/v1/admin/invites']) { + const response = await built.app.inject({ url: path, headers: { ...humanHeaders(ownerToken), 'x-accounts-client-key': key } }); + expect(response.statusCode).toBe(404); + } + const response = await built.app.inject({ url: INTERNAL_USERS_PATH, headers: { ...humanHeaders(ownerToken), 'x-accounts-client-key': key } }); + expect(response.statusCode).toBe(403); +}); +it('redirects the local admin affordance only to issuer /admin; central owner policy remains there', async () => { + const response = await built.app.inject({ url: '/api/v1/auth/admin', headers: humanHeaders(ownerToken) }); + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe(`${provider.origin}/admin`); + expect(response.body).not.toContain('private'); + expect((await built.app.inject({ url: '/api/v1/auth/admin', headers: humanHeaders(memberToken) })).statusCode).toBe(403); + expect((await built.app.inject({ url: '/api/v1/auth/admin' })).statusCode).toBe(401); +}); +it.each(['127.0.0.1', '127.0.0.2', '::1', '::ffff:127.0.0.1'])('allows only explicit client-key access from raw loopback %s without public sessions', async (remoteAddress) => { + provider.outage = true; + const response = await built.app.inject({ url: INTERNAL_USERS_PATH, remoteAddress, headers: { host: 'overroot.admin.internal', 'x-accounts-client-key': key } }); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const users = response.json().users; + expect(users).toHaveLength(2); + expect(Object.keys(users[0]).sort()).toEqual(['id', 'subject', 'username', 'displayName', 'isAdmin', 'createdAt'].sort()); + expect(users.find((u: { username: string }) => u.username === 'existing-owner').isAdmin).toBe(true); + for (const user of users) expect(user.subject).toBe(user.id); + expect(provider.calls).toBe(0); + for (const secret of ['PASSWORD_HASH_MUST_NOT_APPEAR', 'PRIVATE_PROJECT_MUST_NOT_APPEAR', ownerToken, memberToken, ...provider.grants.keys()]) expect(response.body).not.toContain(secret); +}); +it.each([ + { remoteAddress: '198.51.100.4', host: 'overroot.admin.internal', key }, + { remoteAddress: '127.0.0.1', host: 'overroot.admin.internal', key: '' }, + { remoteAddress: '127.0.0.1', host: 'overroot.admin.internal', key: 'incorrect-key' }, + { remoteAddress: '127.0.0.1', host: 'overroot.admin.internal:4317', key }, + { remoteAddress: '127.0.0.1', host: 'evil.test', key } +])('denies wrong raw peer/Host/key despite forged forwarded headers: %j', async (input) => { + const response = await built.app.inject({ url: INTERNAL_USERS_PATH, remoteAddress: input.remoteAddress, headers: { + host: input.host, 'x-accounts-client-key': input.key, 'x-forwarded-for': '127.0.0.1', 'x-forwarded-host': 'overroot.admin.internal', cookie: humanHeaders(ownerToken).cookie + } }); + expect(response.statusCode).toBe(403); + expect(response.json()).not.toHaveProperty('users'); +}); +it('fails closed when internal client configuration is absent', async () => { + vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', ''); + expect((await built.app.inject({ url: INTERNAL_USERS_PATH, headers: { host: 'overroot.admin.internal', 'x-accounts-client-key': key } })).statusCode).toBe(403); +}); +it('does not allow the internal hostname to reach other API methods/routes', async () => { + const headers = { host: 'overroot.admin.internal', 'x-accounts-client-key': key }; + expect((await built.app.inject({ url: '/api/v1/status', headers })).statusCode).toBe(403); + expect((await built.app.inject({ method: 'POST', url: INTERNAL_USERS_PATH, headers })).statusCode).toBe(403); + expect((await built.app.inject({ method: 'OPTIONS', url: '/api/v1/status', headers: { ...headers, origin: config.publicUrl, 'access-control-request-method': 'GET' } })).statusCode).toBe(403); +}); +it('proves the private directory boundary over a real loopback HTTP socket, including duplicate Host rejection', async () => { + await built.app.listen({ host: '127.0.0.1', port: 0 }); + const port = (built.app.server.address() as AddressInfo).port; + function get(headers: string[], path = INTERNAL_USERS_PATH) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = httpRequest({ hostname: '127.0.0.1', port, path, headers }, (response) => { + let body = ''; + response.setEncoding('utf8'); response.on('data', (part: string) => { body += part; }); + response.on('end', () => resolve({ status: response.statusCode!, body })); + }); + request.on('error', reject); request.end(); + }); + } + expect((await get(['Host', 'overroot.admin.internal', 'X-Accounts-Client-Key', key])).status).toBe(200); + expect((await get(['Host', new URL(config.publicUrl).host, 'X-Accounts-Client-Key', key])).status).toBe(403); + expect([403, 404]).toContain((await get(['Host', new URL(config.publicUrl).host, 'X-Accounts-Client-Key', key], 'http://overroot.admin.internal' + INTERNAL_USERS_PATH)).status); + for (const headers of [ + ['Host', 'overroot.admin.internal', 'Host', new URL(config.publicUrl).host, 'X-Accounts-Client-Key', key], + ['Host', 'overroot.admin.internal', 'X-Accounts-Client-Key', key, 'X-Accounts-Client-Key', key] + ]) expect([400, 403]).toContain((await get(headers)).status); +}); diff --git a/apps/server/tests/shared-accounts.test.ts b/apps/server/tests/shared-accounts.test.ts index a986ab6..664b9b4 100644 --- a/apps/server/tests/shared-accounts.test.ts +++ b/apps/server/tests/shared-accounts.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildApp as productionBuild } from '../src/app.js'; -import { AccountsProvider, unavailable } from '../src/identity.js'; +import { unavailable } from '../src/identity.js'; import { config } from '../src/config.js'; import { sha256 } from '../src/security.js'; import { localReturnPath } from '../src/routes/auth-routes.js'; @@ -126,7 +126,7 @@ describe('central sign-in consumer', () => { for (const path of ['https://evil.test', '//evil.test', '/\\evil.test', '/api/v1/auth/start', '/#/\nLocation:evil']) expect(localReturnPath(path)).toBe('/#/'); expect(localReturnPath('/#/project/opaque')).toBe('/#/project/opaque'); }); - it('never matches unknown identities to existing names or elevates approval', () => { + it('never matches unknown identities or derives local admin from service admission', () => { const existing = seedUser(built.context.db, 'Same nickname', true); const fresh = built.context.auth.createSession(provider.grant('different-subject', 'Same nickname')); expect(fresh.user.id).not.toBe(existing.id); @@ -135,6 +135,34 @@ describe('central sign-in consumer', () => { expect(mapped.user.id).toBe(existing.id); expect(mapped.user.isAdmin).toBe(true); }); + it('requires the overroot service and live use permission without interpreting central roles as local grants', async () => { + const user = seedUser(built.context.db, 'ordinary', false); + const grant = provider.grant(user.id); + grant.role = 'registered-service-role'; + grant.permissions = ['use', 'registered:feature', 'admin']; + const session = built.context.auth.createSession(grant); + const headers = humanHeaders(session.token); + const me = await built.app.inject({ url: '/api/v1/auth/me', headers }); + expect(me.statusCode).toBe(200); + expect(me.json().user.isAdmin).toBe(false); + expect((await built.app.inject({ url: '/api/v1/auth/admin', headers })).statusCode).toBe(403); + grant.role = 'another-registered-role'; + expect((await built.app.inject({ url: '/api/v1/projects', headers })).statusCode).toBe(200); + grant.permissions = ['admin']; + expect((await built.app.inject({ url: '/api/v1/projects', headers })).statusCode).toBe(401); + expect(built.context.db.get('SELECT token_hash FROM sessions WHERE token_hash = ?', sha256(session.token))).toBeUndefined(); + }); + it('refuses to provision service grants without use and never accepts a grant for another service', async () => { + const denied = provider.grant('not-yet-admitted'); + denied.permissions = []; + expect(() => built.context.auth.createSession(denied)).toThrow('Inactive account grant'); + expect(built.context.db.get('SELECT user_id FROM external_identities WHERE subject = ?', denied.user.id)).toBeUndefined(); + const user = seedUser(built.context.db, 'mapped'); + const grant = provider.grant(user.id); + const session = built.context.auth.createSession(grant); + Object.assign(grant, { service_id: 'space' }); + expect((await built.app.inject({ url: '/api/v1/auth/me', headers: humanHeaders(session.token) })).statusCode).toBe(401); + }); it('fails on unmapped legacy users and removes all local credential endpoints', async () => { seedUser(built.context.db, 'legacy'); built.context.db.run('DELETE FROM external_identities'); @@ -168,6 +196,8 @@ describe('central sign-in consumer', () => { const created = await built.app.inject({ method: 'POST', url: '/api/v1/projects', headers: humanHeaders(token), payload: { name: 'Private', template: 'article' } }); const hash = created.json().project.hash; const credential = await built.app.inject({ method: 'POST', url: `/api/v1/projects/${hash}/agents`, headers: humanHeaders(token), payload: { name: 'Agent' } }); + for (const grant of provider.grants.values()) grant.permissions = []; + expect((await built.app.inject({ url: '/api/v1/auth/me', headers: humanHeaders(token) })).statusCode).toBe(401); provider.outage = true; expect((await built.app.inject({ url: `/api/v1/projects/${hash}`, headers: { ...humanHeaders(token), authorization: `Bearer ${credential.json().access.password}` } })).statusCode).toBe(200); expect((await built.app.inject({ url: '/api/v1/auth/me', headers: { ...humanHeaders(token), authorization: 'Bearer invalid' } })).statusCode).toBe(401); @@ -225,58 +255,3 @@ describe('sign-in admission', () => { expect((await from('127.0.0.1', { 'x-forwarded-for': '192.0.2.99, 198.51.100.2' })).statusCode).toBe(302); }); }); - -describe('confidential provider backchannel', () => { - function configured() { - vi.stubEnv('OVERROOT_IDENTITY_ORIGIN', 'https://accounts.toni.foo'); - vi.stubEnv('OVERROOT_IDENTITY_BASE_URL', 'http://127.0.0.1:18746'); - vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', 'test-only-confidential-secret-32-characters'); - return new AccountsProvider('https://overroot.toni.foo'); - } - it.each([1, 16, 31])('rejects a configured confidential secret of %i characters', (length) => { - configured(); - vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', 'x'.repeat(length)); - expect(() => new AccountsProvider('https://overroot.toni.foo')).toThrow('at least 32 characters'); - }); - it('accepts a 32-character confidential secret', () => { - configured(); - vi.stubEnv('OVERROOT_IDENTITY_CLIENT_SECRET', 'x'.repeat(32)); - expect(() => new AccountsProvider('https://overroot.toni.foo')).not.toThrow(); - }); - it('sends the client key, issuer Host, exact redirect, verifier, timeout, and no browser cookie', async () => { - const client = configured(); - const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(provider.grant()))); - vi.stubGlobal('fetch', fetcher); - await client.exchange('code', 'verifier'); - const [url, init] = fetcher.mock.calls[0]!; - expect(url).toBe('http://127.0.0.1:18746/api/accounts/token'); - expect(init.redirect).toBe('error'); - expect(init.signal).toBeInstanceOf(AbortSignal); - expect(init.headers).toEqual({ 'Content-Type': 'application/json', 'X-Accounts-Client-Key': 'test-only-confidential-secret-32-characters', Host: 'accounts.toni.foo' }); - expect(JSON.parse(init.body)).toEqual({ client_id: 'overroot', grant_type: 'authorization_code', code: 'code', code_verifier: 'verifier', redirect_uri: client.callback }); - }); - it('fails closed on a network timeout without clearing a local session', async () => { - const client = configured(); - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('Timeout', 'TimeoutError'))); - await expect(client.introspect('grant')).rejects.toMatchObject({ statusCode: 503 }); - }); - it('rejects pending accounts and expired token exchange responses', async () => { - const client = configured(); - const grant = provider.grant(); - vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response(JSON.stringify({ ...grant, user: { ...grant.user, approved: false } })))); - await expect(client.exchange('pending-code', 'verifier')).rejects.toMatchObject({ statusCode: 503 }); - vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response(JSON.stringify({ ...grant, expires_at: 1 })))); - await expect(client.exchange('expired-code', 'verifier')).rejects.toMatchObject({ statusCode: 503 }); - }); - it.each([401, 400, 500, 302])('fails closed on provider HTTP %i (wrong/reused code, PKCE/client/redirect rejection)', async (status) => { - const client = configured(); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status }))); - await expect(client.exchange('rejected-code', 'wrong-verifier')).rejects.toMatchObject({ statusCode: 503 }); - }); - it.each([{ active: true, user: { approved: false } }, {}, { active: true, expires_at: 1, user: { id: 'x', email: 'x@example.test', nickname: 'x', approved: true } }])('rejects malformed/pending or expires stale grants: %j', async (body) => { - const client = configured(); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - if ('expires_at' in body) expect((await client.introspect('grant'))).toMatchObject({ active: true, expires_at: 1 }); - else await expect(client.introspect('grant')).rejects.toMatchObject({ statusCode: 503 }); - }); -}); diff --git a/apps/server/tests/socket-revocation.test.ts b/apps/server/tests/socket-revocation.test.ts index 51724b6..1b47f96 100644 --- a/apps/server/tests/socket-revocation.test.ts +++ b/apps/server/tests/socket-revocation.test.ts @@ -34,8 +34,9 @@ function connect(url: string) { client = new WebSocket(url, { headers: { host: new URL(config.publicUrl).host, origin: config.publicUrl } }); return client; } -it.each(['revoked', 'outage', 'logged-out', 'membership-removed'] as const)('revalidates a ticket before upgrade: %s', async (mode) => { +it.each(['revoked', 'outage', 'logged-out', 'membership-removed', 'no-use'] as const)('revalidates a ticket before upgrade: %s', async (mode) => { const grant = ticket(); + if (mode === 'no-use') for (const grant of provider.grants.values()) grant.permissions = []; if (mode === 'revoked') provider.grants.clear(); if (mode === 'outage') provider.outage = true; if (mode === 'membership-removed') built.context.db.run('DELETE FROM project_members'); @@ -48,13 +49,14 @@ it.each(['revoked', 'outage', 'logged-out', 'membership-removed'] as const)('rev }); expect(response).toBe(mode === 'outage' ? 503 : 401); }); -it.each([['revoked', 1008], ['outage', 1013]] as const)('closes a live human socket on %s within the bounded interval', async (mode, code) => { +it.each([['revoked', 1008], ['no-use', 1008], ['outage', 1013]] as const)('closes a live human socket on %s within the bounded interval', async (mode, code) => { const grant = ticket(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const socket = connect(grant.url); await once(socket, 'open'); const closed = once(socket, 'close'); if (mode === 'revoked') provider.grants.clear(); + else if (mode === 'no-use') for (const grant of provider.grants.values()) grant.permissions = []; else provider.outage = true; await vi.advanceTimersByTimeAsync(10_000); expect((await closed)[0]).toBe(code); @@ -81,3 +83,18 @@ it('drains in-flight introspection before database teardown in preClose', async await closing; expect(stopped).toBe(true); }); + +it('keeps an admitted socket and local content permissions unchanged after a registered service role change', async () => { + const grant = ticket(); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const socket = connect(grant.url); + await once(socket, 'open'); + const before = provider.calls; + for (const grant of provider.grants.values()) { + grant.role = 'registered-role'; + grant.permissions = ['use', 'registered:feature']; + } + await vi.advanceTimersByTimeAsync(10_000); + expect(provider.calls).toBeGreaterThan(before); + expect(socket.readyState).toBe(WebSocket.OPEN); +}); diff --git a/apps/web/public/agent-guide.md b/apps/web/public/agent-guide.md index f58a0e8..67f27d3 100644 --- a/apps/web/public/agent-guide.md +++ b/apps/web/public/agent-guide.md @@ -29,4 +29,4 @@ A `409 revision_conflict` means a person or another agent edited the file. Fetch Direct file edits and compilation results are broadcast immediately to connected collaborators. Use `POST /api/v1/projects/{projectHash}/compile`, then inspect `/compile/latest` or the job endpoint. The OpenAPI document is authoritative for request and response fields. -Human sign-in uses shared Space accounts and host-only browser cookies. Agents continue to use their independent project-scoped Bearer credentials, including during account-provider outages. Never request a human password or copy a browser cookie into an agent workflow. Human sockets revalidate central grants on a bounded interval; agents should continue using the REST API and existing project scope. +Human sign-in uses site-owned accounts with per-service admission and host-only browser cookies. Agents continue to use their independent project-scoped Bearer credentials, including during account-provider outages. Never request a human password or copy a browser cookie into an agent workflow. Human APIs and sockets require the live `overroot` permission `use`; service roles never widen project permissions. Human sockets revalidate central grants on a bounded interval; agents should continue using the REST API and existing project scope. diff --git a/apps/web/src/components/DashboardDialogs.tsx b/apps/web/src/components/DashboardDialogs.tsx index d86ff52..dbb3272 100644 --- a/apps/web/src/components/DashboardDialogs.tsx +++ b/apps/web/src/components/DashboardDialogs.tsx @@ -63,7 +63,7 @@ export function NewProjectDialog({ export function AccountDialog({ open, onClose, api, username }: { open: boolean; onClose: () => void; api: ApiClient; username: string }) { return -

Your account and password are shared with Space. Project access is managed in Overroot.

+

Your account and service access are managed centrally. Project sharing stays in Overroot.

; } diff --git a/apps/web/src/pages/AuthPage.test.tsx b/apps/web/src/pages/AuthPage.test.tsx index 8545649..2a334b7 100644 --- a/apps/web/src/pages/AuthPage.test.tsx +++ b/apps/web/src/pages/AuthPage.test.tsx @@ -9,7 +9,7 @@ it.each(['login', 'register'] as const)('%s directs people to central sign-in wi const { container } = render(); expect(screen.getByRole('link', { name: /Continue to shared accounts/ })).toHaveAttribute('href', 'https://overroot.toni.foo/api/v1/auth/start'); expect(container.querySelector('input')).toBeNull(); - expect(screen.getByText(/administrator approval/)).toBeInTheDocument(); + expect(screen.getByText(/Request Overroot access/)).toBeInTheDocument(); }); it.each([ diff --git a/apps/web/src/pages/AuthPage.tsx b/apps/web/src/pages/AuthPage.tsx index 1c92166..d12c77c 100644 --- a/apps/web/src/pages/AuthPage.tsx +++ b/apps/web/src/pages/AuthPage.tsx @@ -24,7 +24,7 @@ export function AuthPage({ mode }: { mode: 'login' | 'register' }) {

Collaborative LaTeX

Write, compile, and share LaTeX projects with people and agents.

{mode === 'register' ? 'Create an account' : 'Sign in'}

-

Use your shared Space account. New accounts require administrator approval.

+

Use your site account. Request Overroot access from your account dashboard.

{error ? 'Start fresh sign-in' : 'Continue to shared accounts'}
diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx index ebf8c1c..2e84064 100644 --- a/apps/web/src/pages/DashboardPage.test.tsx +++ b/apps/web/src/pages/DashboardPage.test.tsx @@ -144,3 +144,19 @@ it('keeps import progress tied to the upload, then restores the action after an await screen.findByText('Imported workspace'); expect(harness.api.importProject).toHaveBeenCalledTimes(2); }); + +it('exposes only an issuer-admin entry button for the preserved local admin affordance', async () => { + harness.user.isAdmin = true; + try { + render(); + await screen.findByText('No projects yet.'); + const link = screen.getByRole('link', { name: 'Admin' }); + expect(link).toHaveAttribute('href', '/api/v1/auth/admin'); + expect(screen.queryByText(/Invitations|Approve accounts/)).toBeNull(); + } finally { harness.user.isAdmin = false; } +}); +it('does not offer site administration to ordinary local users', async () => { + render(); + await screen.findByText('No projects yet.'); + expect(screen.queryByRole('link', { name: 'Admin' })).toBeNull(); +}); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index fafa7fb..80c4656 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, FileArchive, FileText, LogOut, Plus, Upload } from 'lucide-react'; +import { ArrowRight, FileArchive, FileText, LogOut, Plus, Settings2, Upload } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Brand } from '../components/Brand'; @@ -83,7 +83,8 @@ export function DashboardPage() {
- + + {user?.isAdmin && Admin}
diff --git a/deploy/README.md b/deploy/README.md index a61e397..10996a1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -19,10 +19,10 @@ OVERROOT_PUBLIC_URL=https://overroot.toni.foo OVERROOT_TRUST_PROXY=true OVERROOT_FAKE_COMPILER=false OVERROOT_IDENTITY_ORIGIN=https://accounts.toni.foo -OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18746 +OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18760 ``` -Set `OVERROOT_IDENTITY_CLIENT_SECRET` privately in the protected runtime environment, with the same client secret registered at the provider. The app fails closed if central configuration is absent. Before allowing this release through the updater, complete [the shared-account rollout and explicit identity migration](SHARED-ACCOUNTS.md). +Set `OVERROOT_IDENTITY_CLIENT_SECRET` privately in the protected runtime environment, with the same client secret registered for Overroot at the site-owned authority. The app fails closed if central configuration is absent. The v1 ef15999 staged release has a proven Host transport bug and must not activate. This v2 source is a candidate, not an installed release. Before allowing a newly reviewed/merged/rebuilt release through the updater, complete [the shared-account rollout and explicit identity migration](SHARED-ACCOUNTS.md). Store the named tunnel's connector token in `~/.config/overroot/tunnel-token` with mode 0600. Never put it in a unit, Git, or command arguments. Configure a proxied DNS CNAME for the app hostname to the dedicated tunnel's `UUID.cfargotunnel.com` target. Resource IDs are recorded privately in `~/.local/state/overroot/cloudflare.json`. @@ -36,7 +36,7 @@ systemctl --user daemon-reload systemctl --user enable overroot.service ``` -A fresh empty database provisions ordinary approved accounts on central sign-in. Central approval/admin status never creates an Overroot admin grant. Existing installations require the explicit mapping migration before sign-in. For a restoration, use the verified database with filename `overroot.sqlite3`; retain the original recovery copy. Do not launch a second server against production data. +A fresh empty database provisions ordinary local projections only after central Overroot service admission with permission `use`. Central roles never create content memberships or the legacy local Admin-button flag. Existing installations require the explicit mapping migration before sign-in. For a restoration, use the verified database with filename `overroot.sqlite3`; retain the original recovery copy. Do not launch a second server against production data. After pushing to `main` and waiting for CI: diff --git a/deploy/SHARED-ACCOUNTS.md b/deploy/SHARED-ACCOUNTS.md index 6c3bf89..8f44f28 100644 --- a/deploy/SHARED-ACCOUNTS.md +++ b/deploy/SHARED-ACCOUNTS.md @@ -1,6 +1,6 @@ -# Shared accounts operator runbook +# Site-owned accounts consumer runbook -This is proposed consumer source, not evidence of live activation. Parent coordinates the Space provider, accounts hostname, secrets, recovery and rollout. Do not let the consumer auto-deploy before those prerequisites are ready. +This is proposed consumer source, not evidence of live activation. Parent coordinates the site-owned authority, private administration, accounts hostname, secrets, recovery and rollout. Authority source is `~/tonifoo/accounts/`; the site integration contract is `~/tonifoo/docs/ACCOUNTS.md`. Space and Overroot are consumers. The staged ef15999 release has the proven Host transport bug and MUST NOT activate; this candidate requires new exact-head hosted CI/review/merge and a rebuilt stage. Do not let the consumer auto-deploy before those prerequisites are ready. ## Configuration and release gate @@ -9,10 +9,10 @@ Required protected runtime environment: ```dotenv OVERROOT_PUBLIC_URL=https://overroot.toni.foo OVERROOT_IDENTITY_ORIGIN=https://accounts.toni.foo -OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18746 +OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18760 ``` -Set `OVERROOT_IDENTITY_CLIENT_SECRET` privately with at least 32 characters, matching Space's confidential `overroot` client. Never pass it in argv or commit it. The fixed production callback is `https://overroot.toni.foo/api/v1/auth/callback`. An explicit HTTPS public origin and matching registered callback are required for other installations/test providers. Backchannel HTTP is allowed only to literal loopback; HTTPS is otherwise required. Requests carry the issuer Host, client key and no browser cookies, prohibit redirects, and time out after 3 seconds. Missing configuration or a client secret shorter than 32 characters prevents startup, with no password fallback. +Set `OVERROOT_IDENTITY_CLIENT_SECRET` privately with at least 32 characters, matching the site authority's confidential `overroot` client. Never pass it in argv or commit it. The fixed production callback is `https://overroot.toni.foo/api/v1/auth/callback`. An explicit HTTPS public origin and matching registered callback are required for other installations/test providers. Backchannel HTTP is allowed only to literal loopback; HTTPS is otherwise required. Native `node:http`/`node:https.request` carries the issuer Host, client key and JSON body without browser cookies, rejects redirects/non-200 responses, bounds headers/body to 16 KiB, and applies an absolute 3-second timeout including stalled response bodies. The base URL is required explicitly; no silent WAN fallback is provided. Missing configuration or a client secret shorter than 32 characters prevents startup, with no password fallback. Before merging or activating the consumer, the parent must hold the existing updater, verify the provider and accounts route, supply private config, review exact-head hosted CI/CodeQL, and arrange the stopped-service migration below. This branch does not change installed host automation. `/health` proves only process liveness, not provider connectivity or migration readiness. Parent must review the existing external uptime probe for this release. @@ -29,7 +29,7 @@ The mapping file is operator-supplied JSON outside Git, mode 0600. Format is str } ``` -Include **every** existing Overroot user exactly once. Space IDs must also be unique. Anton's confirmed links must be supplied privately by the parent; none are embedded in source. Obtain local IDs with a read-only query of `users(id, username)` and central IDs from the authorized Space operator. Never infer a link from equal names or email addresses. +Include **every** existing Overroot user exactly once. Canonical site account subject IDs must also be unique. The mapping key `space_user_id` is retained because the offline site import preserves the exact canonical Space IDs; its value is now the immutable site account subject, not a Space-owned credential or inferred profile match. Anton's confirmed links must be supplied privately by the parent; none are embedded in source. Obtain local IDs with a read-only query of `users(id, username)` and central IDs from the authorized site account operator. Never infer a link from equal names or email addresses. After review, with deploy/backup jobs held and the app stopped gracefully: @@ -41,12 +41,20 @@ After review, with deploy/backup jobs held and the app stopped gracefully: ``` 3. The command checks backup integrity and compares the current users, projects, memberships, file bytes/revisions, agent credentials, audit and compile records against that snapshot. It refuses the target as its own backup, incomplete/duplicate/unknown mappings, unexpected prior links, and repeat execution. With the database stopped, it atomically installs the links, empties local password verifiers, deletes old human sessions and invitations, and marks migration complete. Local IDs, project foreign keys, permissions, history and agent secrets are unchanged. Schema preparation alone never retires verifiers. -4. Start the reviewed consumer only after the provider/config are ready. Verify existing users' memberships/admin grants, non-elevation for new identities, scoped agent access, central sign-in, logout and password-change revocation. Resume the established updater only after the parent accepts the activation evidence. +4. Start the reviewed consumer only after the provider/config are ready. Verify existing users' memberships and preserved Admin-button flag, non-elevation for new identities, scoped agent access, central sign-in, logout and password-change revocation. Resume the established updater only after the parent accepts the activation evidence. -Unmapped users block all new human sign-in rather than silently provisioning duplicates. A fresh empty database needs no legacy mapping; first central sign-in provisions an ordinary user, never an admin. Unknown approved subjects get unique opaque `account-` Overroot handles for sharing, without inheriting similarly named users. Existing handles and display names remain unchanged. The account control shows the local handle. +Unmapped users block all new human sign-in rather than silently provisioning duplicates. A fresh empty database needs no legacy mapping; first central sign-in provisions an ordinary user, never an admin. Unknown service-admitted subjects get unique opaque `account-` Overroot handles for sharing, without inheriting similarly named users. Existing handles and display names remain unchanged. The account control shows the local handle. There is no automatic rollback. Before restoring, stop the app and updater, preserve current data, verify schema compatibility, and restore the full pre-migration database plus needed artifacts. Old code cannot open schema version 3. Do not restore local-password auth as an outage workaround. +## Private-only local account information + +The existing local `is_admin` flag is preserved solely for the **Admin** navigation affordance. `/api/v1/auth/admin` requires a valid human service grant and that flag, then redirects only to the public issuer's `/admin`. The issuer independently checks its configured owner and redirects to its private origin; no private administration URL is compiled into Overroot or supplied by a browser. Public account-admin endpoints such as `/api/v1/admin/users` are absent even for the owner. Normal project sharing and its user search remain app content features. + +The site private-admin server may fetch `GET /api/internal/admin/users` over loopback with exact `Host: overroot.admin.internal` (no port suffix) and `X-Accounts-Client-Key` equal to Overroot's configured service secret. Authorization uses the actual raw socket peer, never Fastify's forwarded `request.ip`; only IPv4 loopback, IPv6 loopback and IPv4-mapped loopback peers qualify. Duplicate Host/key headers and public Hosts are denied regardless of owner cookies/key. The client-key comparison hashes both inputs to equal-length digests and compares them with the installed timing-safe helper. All other API routes require the exact public app Host, including preflights. + +The response is `{users:[{id,subject,username,displayName,isAdmin,createdAt}]}`, with null subject for unmapped legacy rows. `isAdmin` is local history/button information, not central service policy. This read-only adapter never returns password hashes, cookies/tokens, grant references or private project content, and sets no-store. It does not itself introspect a browser grant: the site private server must validate its owner-bound private session before proxying this fixed adapter. Do not expose this Host through public ingress or a browser-supplied target URL. Parent owns private adapter configuration, tailnet routing and probes; no host changes are installed by this source. + ## Runtime semantics Sign-in attempts are in-memory, capped at 1000, browser-bound and single-use, with 120-second expiry and S256 PKCE. @@ -57,12 +65,12 @@ With `OVERROOT_TRUST_PROXY=true`, admission uses the closest forwarded client ad Unauthenticated project navigation retains its route, query and fragment in React Router state; the sign-in link passes only a validated app-local `returnTo` to auth/start. The default sign-in link is unchanged. Callback exchange uses the confidential backchannel, creates a hashed-token local session referencing the central grant and returns a host-only `__Host-overroot_session` cookie, never a browser bearer token. The cookie is Secure, HttpOnly, SameSite=Lax, Path=/, with no Domain. Old localStorage human tokens are discarded by the frontend and never accepted by the API. -Malformed, missing, mismatched, replayed or expired callback state never exchanges a code: the callback clears the attempt cookie and redirects to the app sign-in screen with a static error and an explicit fresh-sign-in action. An approval wait longer than the attempt TTL requires that fresh action; no validation is relaxed. Provider exchange failures also redirect to a retry screen, without echoing codes/state or automatically retrying the exchange. +Malformed, missing, mismatched, replayed or expired callback state never exchanges a code: the callback clears the attempt cookie and redirects to the app sign-in screen with a static error and an explicit fresh-sign-in action. A service approval wait longer than the attempt TTL requires that fresh action; no validation is relaxed. Provider exchange failures also redirect to a retry screen, without echoing codes/state or automatically retrying the exchange. -Every human API request introspects its grant through the async hook before synchronous authorization. Inactive/expired/mismatched grants deny access. Provider errors return visible 503 and preserve the local session for retry. Authorization headers, including invalid ones, always take precedence over cookies; agent scopes do not depend on the provider. Host isolation is exact and cookie-authenticated mutations require the exact app Origin. +Every human API request introspects its grant through the async hook before synchronous authorization. Token/active-introspection responses must contain `service_id: "overroot"`, a nonempty role of at most 64 characters, at most 64 permission strings of 1–64 characters, and an approved user (bounded subject, email and nickname). `approved: true` means service admission on this wire, not account-wide approval. Inactive/expired/subject-mismatched grants or missing live permission `use` deny access. Registered central roles/extra permissions are not mapped onto content permissions or the local Admin-button flag. Provider errors return visible 503 and preserve the local session for retry. Authorization headers, including invalid ones, always take precedence over cookies; agent scopes do not depend on the provider. Host isolation is exact and cookie-authenticated mutations require the exact app Origin. Live human sockets revalidate both the central grant and project permission every 10 seconds. With the 3-second backchannel timeout, the target revocation/outage bound is 13 seconds under a responsive event loop. Known revoked grants close 1008; unreachable/rejecting/malformed providers close 1013 so clients can retry. No further collaboration messages are accepted once socket closing begins. Human upgrade tickets also revalidate before upgrade, even if issued before logout. The existing preClose sequence stops validation timers and closes sockets before compiler/collaboration/database teardown. -Local logout revokes its central app grant before clearing the cookie/session and closing local sockets; an outage visibly fails logout so it can be retried. Central logout and password changes are authority responsibilities and invalidate the grants checked by the consumer. Approval confers no Overroot memberships or admin privileges. Account controls open the central origin. No email delivery, recovery or MFA is introduced. +Local logout revokes its central app grant before clearing the cookie/session and closing local sockets; an outage visibly fails logout so it can be retried. Central logout and password changes are authority responsibilities and invalidate the grants checked by the consumer. Account controls open the central origin. No email delivery, recovery or MFA is introduced. -CI injects a mock provider; production has no bypass flag. Candidate tests/builds/installations are not run on DGX. Installed static TypeScript/lint analysis may run without importing candidate application modules or changed executable configs. +Hosted CI injects v2 service-grant fixtures and uses a real local HTTP capture server for transport/Host/timeout regressions, plus raw-socket internal-directory checks; production has no bypass flag. Candidate tests/builds/installations are not run on DGX. Installed static TypeScript/lint analysis may run without importing candidate application modules or changed executable configs.