From 4b535e4f4f0dd9cccc47d242250b22b9e7322b50 Mon Sep 17 00:00:00 2001 From: Acadiac <13487264+hkrlogic@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:49:08 -0400 Subject: [PATCH 1/3] Fix deep-test findings: PG18 volume, pending edits, sign-out 401, usernames, audit page, Reader key, and UX polish --- CHANGELOG.md | 27 +++ README.md | 2 +- docker-compose.yml | 8 +- docs/concepts.md | 14 +- docs/deploy.md | 40 +++- migrations/023_callback_host_revoked.sql | 5 + public/favicon.svg | 1 + public/styles.css | 9 + src/auth/account-guard.ts | 12 +- src/auth/embedded-idp.ts | 57 ++++- src/auth/idp-store.ts | 7 +- src/db/accounts.ts | 5 +- src/db/actors.ts | 31 +++ src/db/audit.ts | 72 +++++- src/db/items.ts | 2 +- src/db/libraries.ts | 22 ++ src/db/models.ts | 2 + src/http/app.ts | 81 +++++-- src/http/helpers.ts | 5 +- src/http/pages/apps.ts | 2 + src/http/pages/audit.ts | 103 +++++++++ src/http/pages/items.ts | 4 +- src/http/pages/layout.ts | 48 +++- src/http/pages/libraries.ts | 53 +++-- src/http/pages/login.ts | 8 +- src/http/request-context.ts | 24 ++ src/http/routes/audit.ts | 54 +++++ src/http/routes/callback-hosts.ts | 8 +- src/http/routes/items.ts | 14 +- src/http/routes/libraries.ts | 63 +++++- src/index.ts | 3 +- src/items/library-schema.ts | 65 ++++-- src/markdown/render.ts | 2 +- src/markdown/table.ts | 14 ++ src/mcp/server.ts | 160 ++++++++----- test/account-revocation.test.ts | 4 +- test/callback-hosts.test.ts | 2 +- test/connect-token.test.ts | 9 +- test/deep-test-findings.test.ts | 276 +++++++++++++++++++++++ test/deep-test-http.test.ts | 98 ++++++++ test/oauth-clients.test.ts | 4 +- test/write-mode.test.ts | 5 +- 42 files changed, 1244 insertions(+), 181 deletions(-) create mode 100644 migrations/023_callback_host_revoked.sql create mode 100644 public/favicon.svg create mode 100644 src/db/actors.ts create mode 100644 src/http/pages/audit.ts create mode 100644 src/http/request-context.ts create mode 100644 src/http/routes/audit.ts create mode 100644 test/deep-test-findings.test.ts create mode 100644 test/deep-test-http.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bebd605..26add10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ ## Unreleased +### Breaking: Compose uses Postgres 18 on a new volume + +- `docker-compose.yml` runs `postgres:18` with the volume mounted at `/var/lib/postgresql` (Postgres 18 refused the old `/var/lib/postgresql/data` mount, so a fresh `docker compose up` failed). The volume is now **`postgres18_data`**. +- **Existing Compose installs:** your data stays in the old `postgres_data` volume (Postgres 16) and is **not** used by Postgres 18; after upgrading the app would start empty. Dump and restore it, or pin `postgres:16` with an override. Steps: [docs/deploy.md → Postgres 16 to 18](docs/deploy.md#postgres-16-to-18-compose). + +### Breaking: `update_item` keeps fields you leave out + +- `update_item` used to replace the whole item, so a call without `labels` cleared the labels. Now omitted fields keep their current value. Pass `replace: true` for the old behaviour (omitted optional fields are cleared). Send an empty string or `[]` to clear one field. + +### Fixed and changed after the deep test + +- Items tab lists live items that have a pending edit (tagged **Edit pending**); the save message after editing a published item now says the published version stays live. +- After a web sign-out (or password reset) the MCP link answers **401** with `WWW-Authenticate` and "Your sign-in ended; reconnect…". A disabled account still gets its own message. +- Usernames instead of account ids on Settings → Apps, field/table proposals, and `list_audit` (new `actor` field). +- New read-only **Audit log** page (`/audit`), 50 per page. Site admin: every event; Publisher: their libraries. +- Reader key works on `/libraries//mcp` alone; the guidance no longer says to rotate Connect, and Readers do not see rotate advice. +- Web forms show plain-language validation errors (MCP wording unchanged). "Kiosk" removed from all text. +- `create_item` with an existing `entity_key` updates that item. Unknown fields are refused with a clear error. +- A fresh install no longer has an empty **Main** library (removed when the first account is created, only if unused). Existing installs keep it. Orphan libraries are listed once, under Unclaimed. +- Item bodies no longer repeat the summary; empty optional fields are left out. +- Flash messages after deleting an item or library, granting or removing access. A deleted library returns 404, not 403. +- Settings → Apps lists a revoked host as **Revoked** (migration `023_callback_host_revoked.sql`), not Denied. +- Token responses report the real scope (a library Publisher gets `mcpencode:edit`). +- Sign-in for an account with no access to the library is refused before the consent page, in plain words. +- Favicon; "1 item" singular; error pages show who is signed in. +- Not done: `notifications/tools/list_changed` cannot be pushed (each MCP request builds a fresh stateless server); the field-approval wording now says the next request picks up the new field. + ### Added: freshness: review-by dates, stale warnings, strict mode, review queue ([#37](https://github.com/hkrlogic/MCPEncode/issues/37)) - **Review-by dates.** diff --git a/README.md b/README.md index f1ae0fc..01271cc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ MCPEncode keeps the facts your IT team's AI agents rely on (help desk runbooks, - **Freshness.** Items get a review-by date (90 days for Policy and Procedure by default). Agents relay a warning when an item is past review, and **strict mode** refuses stale items and points to the owner. A **Review queue** lists what is due, with one-click **Still accurate**. - **Aliases.** "We got phished" finds the Incident Response runbook. Agents can suggest labels and aliases with `suggest_labels`; a person approves them like any other edit. -**Status:** Early (0.1). Issues welcome; please open one before a large pull request. +**Status:** Early (0.2). Issues welcome; please open one before a large pull request. ## Quick start diff --git a/docker-compose.yml b/docker-compose.yml index 9d703ff..699953e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,10 @@ # Credentials come from .env (run scripts/init-env.sh first). Postgres reads the # password only when this volume is first created. # +# Postgres 18 keeps its data in a versioned subdirectory, so the volume is +# mounted at /var/lib/postgresql (not .../data). Upgrading from the old +# Postgres 16 volume (postgres_data)? See "Postgres 16 to 18" in docs/deploy.md. +# # docker compose up -d --build # app + Postgres # docker compose up -d postgres # Postgres only, for npm start during development services: @@ -15,7 +19,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env (run scripts/init-env.sh)} POSTGRES_DB: ${POSTGRES_DATABASE:-mcpencode} volumes: - - postgres_data:/var/lib/postgresql/data + - postgres18_data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 5s @@ -49,4 +53,4 @@ services: condition: service_healthy volumes: - postgres_data: + postgres18_data: diff --git a/docs/concepts.md b/docs/concepts.md index e4c6e19..41984bc 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -53,11 +53,13 @@ Each Connect link is one library. Tools offered depend on the caller's role in t | `list_items` | Reader+ | Look up by question (`q`) or browse. One clear match returns the full item. Returns the published version; Publisher connections also get `status` and `has_pending_changes`. | | `open_item` | Reader+ | Full item by id (published version). Publishers may pass `version: "draft"` to read a Pending edit. | | `search_shelf` | Reader+ | Published summaries across the library's shelf | -| `create_item` / `update_item` | Publisher | Save: Pending (Review Lock) or published (Review Unlock) | +| `create_item` | Publisher | Save: Pending (Review Lock) or published (Review Unlock). With an `entity_key` that already exists in the library, updates that item instead (upsert). | +| `update_item` | Publisher | Change an item. Fields you leave out keep their value; `replace: true` replaces the whole item (omitted optional fields are cleared). Unknown fields are refused. | | `suggest_labels` | Publisher | Add or remove labels and aliases on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) | -| `delete_item` | Publisher | Remove an item | -| `create_library` / `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema | -| `list_audit` | Publisher, Site admin | Who called which tool in this library | +| `delete_item` | Site admin | Hard-delete an item (Publishers archive instead) | +| `create_library` | Site admin | Create a library, optionally with a proposed table | +| `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema. After approval the tools pick up the new field on the next request; there is no need to reconnect. | +| `list_audit` | Publisher, Site admin | Who called which tool in this library; each row has `actor` (the username) | **How `list_items q=` searches.** 1. Every word must match (name, labels and aliases, summary, then body; the full-text index applies English stemming). Aliases score exactly like labels. @@ -102,7 +104,9 @@ There is **no publish, approve, write-mode or revert tool.** Those stay with peo - **Connect link** (`/libraries//mcp/`): paste into the MCP client. The client signs in (built-in sign-in: it registers itself, the person enters a password and clicks **Allow** on the consent page). - **Header form**: `/libraries//mcp` with `X-Library-Token: `, which keeps the token out of URLs and logs. -- **Reader key**: `Authorization: Bearer ` for a shared read-only agent, no sign-in. +- **Reader key**: `Authorization: Bearer ` on `/libraries//mcp` (no Connect token needed) for a shared read-only agent, no sign-in. Rotating the Connect link does not affect the Reader key; rotate the Reader key itself if it leaks. + +**Audit log page.** `/audit` (Settings → Audit log) is a read-only, paged list of events: the Site admin sees every event, a Publisher sees the libraries they publish (`?library=` for one). Rotating the Connect link (**Connect → Rotate**) invalidates the old token in both forms; Access grants stay. diff --git a/docs/deploy.md b/docs/deploy.md index 5217565..5a744bd 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -174,4 +174,42 @@ Treat backups as secret: Connect tokens are stored in plain text (see [known lim ## Postgres version -PostgreSQL 16 is the documented version (Compose and CI use it). The app and test suite are also verified on PostgreSQL 17. +Compose runs **PostgreSQL 18**. CI runs the test suite on PostgreSQL 16, and the app is also verified on 17 and 18. Any of 16–18 works for an external database. + +### Postgres 16 to 18 (Compose) + +Postgres 18's image stores data in a versioned subdirectory and refuses a volume mounted at `/var/lib/postgresql/data`. Compose therefore mounts a **new** volume, `postgres18_data`, at `/var/lib/postgresql`. The old `postgres_data` volume (Postgres 16 data) is left untouched, but **it is not read by Postgres 18**: if you simply pull and run `docker compose up`, the app starts on an empty database and asks for a setup code. Your data is still in the old volume. Choose one: + +**A. Move the data to Postgres 18 (recommended).** Dump with the old version, restore into the new one: + +```bash +# 1. Stop the app, start the OLD Postgres 16 on the old volume, and dump it +set -a; . ./.env; set +a +docker compose stop app +docker run -d --name pg16-old -v mcpencode_postgres_data:/var/lib/postgresql/data postgres:16 +sleep 5 +docker exec pg16-old pg_dump -U "${POSTGRES_USER:-mcpencode}" -Fc "${POSTGRES_DATABASE:-mcpencode}" > mcpencode-pg16.dump +docker rm -f pg16-old +# 2. Start Postgres 18 on the new volume and restore +docker compose up -d postgres +docker compose exec -T postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --clean --if-exists' < mcpencode-pg16.dump +# 3. Start the app and check /healthz; sign in with your existing account +docker compose up -d --build +# 4. Only when everything is there: docker volume rm mcpencode_postgres_data +``` + +(The volume name is `_postgres_data`; `docker volume ls` shows it. If you ran the app on the new volume before restoring, start from an empty one: `docker compose down`, `docker volume rm mcpencode_postgres18_data`, then step 2.) + +**B. Stay on Postgres 16 for now.** Add a `docker-compose.override.yml` next to `docker-compose.yml`: + +```yaml +services: + postgres: + image: postgres:16 + volumes: !override + - postgres_data:/var/lib/postgresql/data +volumes: + postgres_data: +``` + +`pg_upgrade --link` also works if you prefer an in-place upgrade; dump and restore is simpler and the database is small. diff --git a/migrations/023_callback_host_revoked.sql b/migrations/023_callback_host_revoked.sql new file mode 100644 index 0000000..bd7c186 --- /dev/null +++ b/migrations/023_callback_host_revoked.sql @@ -0,0 +1,5 @@ +-- Settings → Apps: a host a site admin revoked (after approving it) is listed +-- as Revoked, not Denied. Both refuse sign-in the same way. +ALTER TABLE callback_hosts DROP CONSTRAINT IF EXISTS callback_hosts_status_check; +ALTER TABLE callback_hosts + ADD CONSTRAINT callback_hosts_status_check CHECK (status IN ('pending', 'approved', 'denied', 'revoked')); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..2d9cf92 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/styles.css b/public/styles.css index ab6cb9a..e1c0f32 100644 --- a/public/styles.css +++ b/public/styles.css @@ -2357,3 +2357,12 @@ mark.diff-ins { .freshness .meta { margin: 0; } label.checkbox { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; } label.checkbox input { width: auto; margin: 0; } + +/* Settings → Audit log */ +.audit-filter { display: flex; gap: 0.75rem; align-items: end; margin: 0 0 1rem; } +.audit-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.audit-table th, .audit-table td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid #e7e5e4; vertical-align: top; } +.audit-detail { word-break: break-word; color: #57534e; } +.pager { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; } +.menu-link { display: block; padding: 0.5rem 0.75rem; color: inherit; text-decoration: none; } +.menu-link:hover { background: #f5f5f4; } diff --git a/src/auth/account-guard.ts b/src/auth/account-guard.ts index 43da106..6e8b49e 100644 --- a/src/auth/account-guard.ts +++ b/src/auth/account-guard.ts @@ -12,7 +12,13 @@ import type { AccountStore } from "./accounts.ts"; import type { Role } from "./rbac.ts"; -export type AccountCheck = { allowed: true; role: Role } | { allowed: false }; +/** + * `disabled`: the account is disabled or gone; an SA must act. + * `signed_out`: the credential predates a sign-out everywhere or a password + * reset; signing in again fixes it. + */ +export type AccountDenial = "disabled" | "signed_out"; +export type AccountCheck = { allowed: true; role: Role } | { allowed: false; reason: AccountDenial }; /** * Returns the account's current standing, or `allowed: false` when it is @@ -24,10 +30,10 @@ export type AccountGuard = (sub: string, issuedAt?: number) => Promise { const account = await accounts.findById(sub); - if (!account || account.disabled) return { allowed: false }; + if (!account || account.disabled) return { allowed: false, reason: "disabled" }; const revokedAt = account.sessionsRevokedAt ?? 0; if (revokedAt > 0 && (issuedAt === undefined || issuedAt * 1000 < revokedAt)) { - return { allowed: false }; + return { allowed: false, reason: "signed_out" }; } return { allowed: true, role: account.role }; }; diff --git a/src/auth/embedded-idp.ts b/src/auth/embedded-idp.ts index 99d9f39..9e8e423 100644 --- a/src/auth/embedded-idp.ts +++ b/src/auth/embedded-idp.ts @@ -21,6 +21,7 @@ import { cspWithFormAction, formActionSource } from "../http/security-headers.ts import type { AccountStore } from "./accounts.ts"; import { hashRefreshToken, + hostRefused, memoryIdpStore, newClientId, newRefreshToken, @@ -81,6 +82,12 @@ function scopeFor(realmRole: string): string { return realmRole === "mpos-editor" ? "openid mcpencode:edit mcpencode:sa" : "openid mcpencode:readonly"; } +/** Scope in the token response: for a library link, a Publisher there also has edit. */ +export function grantedScope(realmRole: string, libraryRole: "publisher" | "reader" | undefined): string { + if (realmRole === "mpos-editor") return scopeFor(realmRole); + return libraryRole === "publisher" ? "openid mcpencode:readonly mcpencode:edit" : scopeFor(realmRole); +} + type VerifyKey = Awaited>; export type EmbeddedIdp = { @@ -120,6 +127,11 @@ export type EmbeddedIdpOptions = { store?: IdpStore; /** Library name for the consent page. */ libraryName?: (libraryId: string) => Promise; + /** + * The account's role on a library (from Access), or undefined with no access. + * Used to refuse consent early and to report the granted scope honestly. + */ + libraryRole?: (libraryId: string, sub: string) => Promise<"publisher" | "reader" | undefined>; /** Audit log writer (audit_events in production). */ audit?: (event: IdpAuditEvent) => Promise; }; @@ -231,6 +243,7 @@ function loginPage(input: { ${title} · MCPEncode + @@ -279,6 +292,7 @@ function simplePage(title: string, body: string): string { ${escapeHtml(title)} · MCPEncode + @@ -314,6 +328,15 @@ function hostWaitingPage(host: string): string { ); } +/** Signed in, but the account is not on this library's Access list. */ +function noLibraryAccessPage(username: string, library: string): string { + return simplePage( + "No access to this library", + `

Your account ${escapeHtml(username)} is signed in, but it does not have access to ${escapeHtml(library)}.

+

Ask a publisher on that library to add you under Access, then connect again from the app.

`, + ); +} + /** A site admin signed in on an authorize request whose callback host is pending. */ function approveHostPage(input: { consentId: string; @@ -358,6 +381,7 @@ function consentPage(input: { Allow access? · MCPEncode + @@ -502,15 +526,15 @@ export async function createEmbeddedIdp( async decide({ host: rawHost, decision, by }) { const host = rawHost.trim().toLowerCase(); if (!isApprovableHostname(host)) return { ok: false, message: "That is not a hostname that can be approved." }; - const status = decision === "approve" ? "approved" : "denied"; + const status = decision === "approve" ? "approved" : decision === "revoke" ? "revoked" : "denied"; const record = await store.decideHost({ host, status, by, now: Date.now() }); // Denying or revoking ends every refresh chain of clients that return to this host. - const revokedTokens = status === "denied" ? await store.revokeHostTokens(host) : 0; + const revokedTokens = hostRefused(status) ? await store.revokeHostTokens(host) : 0; await audit({ actorSub: by, tool: `callback_host.${decision}`, success: true, - detail: `host=${host}${status === "denied" ? ` revoked_refresh_tokens=${revokedTokens}` : ""}`, + detail: `host=${host}${hostRefused(status) ? ` revoked_refresh_tokens=${revokedTokens}` : ""}`, }); console.log(`Callback host ${host} ${status} by ${by}.`); return { ok: true, record, revokedTokens }; @@ -626,6 +650,12 @@ export async function createEmbeddedIdp( res.redirect(next.toString()); } + async function roleOnResource(resource: string, sub: string): Promise<"publisher" | "reader" | undefined> { + const libraryId = resourceLibraryId(resource, config); + if (!libraryId || !options?.libraryRole) return undefined; + return options.libraryRole(libraryId, sub).catch(() => undefined); + } + async function libraryLabel(resource: string): Promise { const libraryId = resourceLibraryId(resource, config); if (!libraryId) return "your MCPEncode account"; @@ -718,10 +748,10 @@ export async function createEmbeddedIdp( const host = typeof entry === "string" ? approvableCallbackHost(entry) : undefined; if (host) { const known = await store.findHost(host); - if (known?.status === "denied") { + if (hostRefused(known?.status)) { res.status(400).json({ error: "invalid_redirect_uri", - error_description: `The callback host ${host} was denied by this site's admin.`, + error_description: `The callback host ${host} was ${known?.status === "revoked" ? "revoked" : "denied"} by this site's admin.`, callback_host: host, }); return; @@ -857,7 +887,7 @@ export async function createEmbeddedIdp( maxPending: maxPendingHosts, }); logRefusal(host, client.clientId, client.kind === "registered" && client.selfDeclared ? client.name : ""); - if ((await store.findHost(host))?.status === "denied") { + if (hostRefused((await store.findHost(host))?.status)) { res.status(403).send(hostDeniedPage(host)); return undefined; } @@ -1042,6 +1072,17 @@ export async function createEmbeddedIdp( record: Omit, username: string, ): Promise { + // A library sign-in for someone who is not on its Access list would only + // fail later at the MCP endpoint; say so now, before Allow. + const libraryId = resourceLibraryId(record.resource, config); + if (libraryId && record.role !== "sa" && options?.libraryRole) { + const role = await options.libraryRole(libraryId, record.sub).catch(() => undefined); + if (!role) { + res.setHeader("Cache-Control", "no-store"); + res.status(403).send(noLibraryAccessPage(username, await libraryLabel(record.resource))); + return; + } + } const consentId = await rememberConsent(res, record); res.setHeader("Cache-Control", "no-store"); // Allow and Deny redirect to the callback; Chrome checks form-action on that redirect. @@ -1199,7 +1240,7 @@ export async function createEmbeddedIdp( token_type: "Bearer", expires_in: 900, refresh_token: refreshToken, - scope: scopeFor(record.realmRole), + scope: grantedScope(record.realmRole, await roleOnResource(record.resource, record.sub)), }); }); @@ -1262,7 +1303,7 @@ export async function createEmbeddedIdp( token_type: "Bearer", expires_in: 900, refresh_token: refreshToken, - scope: scopeFor(realmRole), + scope: grantedScope(realmRole, await roleOnResource(record.resource, record.sub)), }); } diff --git a/src/auth/idp-store.ts b/src/auth/idp-store.ts index 695fa2b..64e0ff6 100644 --- a/src/auth/idp-store.ts +++ b/src/auth/idp-store.ts @@ -47,7 +47,12 @@ export type SigningKeyRecord = { createdAt: number; }; -export type CallbackHostStatus = "pending" | "approved" | "denied"; +export type CallbackHostStatus = "pending" | "approved" | "denied" | "revoked"; + +/** Denied and revoked hosts both refuse sign-in; the label in Settings → Apps differs. */ +export function hostRefused(status: CallbackHostStatus | undefined): boolean { + return status === "denied" || status === "revoked"; +} /** A remote https callback host a site admin approves or denies (migration 017). */ export type CallbackHostRecord = { diff --git a/src/db/accounts.ts b/src/db/accounts.ts index 7bce31e..f1729de 100644 --- a/src/db/accounts.ts +++ b/src/db/accounts.ts @@ -7,6 +7,7 @@ import { type StoredAccount, } from "../auth/accounts.ts"; import { hashPassword, normalizeUsername, passwordError } from "../auth/passwords.ts"; +import { removeUnusedDefaultLibrary } from "./libraries.ts"; import { getRow } from "./query-helpers.ts"; import type { Database } from "./types.ts"; @@ -100,7 +101,7 @@ export function accountStore(db: Database): AccountStore { const username = normalizeUsername(input.username); const passwordHash = await hashPassword(input.password); // Hold a transaction lock so two browsers cannot both become the first account. - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { await tx.get("SELECT pg_advisory_xact_lock(hashtext('mcpencode-accounts'))"); const existing = await getRow<{ id: string }>(tx, "SELECT id FROM accounts LIMIT 1"); if (existing) return { ok: false as const, message: "An account already exists. Sign in." }; @@ -117,6 +118,8 @@ export function accountStore(db: Database): AccountStore { account: { id, username, role: "sa" as const, disabled: false }, }; }); + if (result.ok) await removeUnusedDefaultLibrary(db).catch(() => undefined); + return result; }, async list() { const rows = (await db.all( diff --git a/src/db/actors.ts b/src/db/actors.ts new file mode 100644 index 0000000..639e9a9 --- /dev/null +++ b/src/db/actors.ts @@ -0,0 +1,31 @@ +/** Turn stored actor subjects (account ids) into names people recognise. */ +import { readerKeySubject } from "../auth/reader-key.ts"; +import { allRows } from "./query-helpers.ts"; +import type { Database } from "./types.ts"; + +/** + * Display name for each subject: the account's username, "Reader key" for a + * library Reader key, otherwise the subject itself (an external IdP subject or + * "system"). Unknown subjects fall back to the raw value, never to blank. + */ +export async function actorNames(db: Database, subjects: Iterable): Promise> { + const wanted = [...new Set([...subjects].filter((s): s is string => typeof s === "string" && s !== ""))]; + const names = new Map(); + if (wanted.length === 0) return names; + const rows = await allRows<{ id: string; username: string }>( + db, + "SELECT id, username FROM accounts WHERE id = ANY(?::text[])", + wanted, + ); + for (const row of rows) names.set(row.id, row.username); + for (const sub of wanted) { + if (names.has(sub)) continue; + names.set(sub, sub.startsWith(readerKeySubject("")) ? "Reader key" : sub); + } + return names; +} + +export async function actorName(db: Database, subject: string | null | undefined): Promise { + if (!subject) return ""; + return (await actorNames(db, [subject])).get(subject) ?? subject; +} diff --git a/src/db/audit.ts b/src/db/audit.ts index 00226fa..d85ee9f 100644 --- a/src/db/audit.ts +++ b/src/db/audit.ts @@ -43,12 +43,14 @@ export async function listAudit(db: Database, libraryId: string, limit = 50): Pr success: boolean; detail: string; created_at: number; + username: string | null; }>( db, - `SELECT id, actor_sub, tool, success, detail, created_at - FROM audit_events - WHERE library_id = ? - ORDER BY created_at DESC + `SELECT e.id, e.actor_sub, e.tool, e.success, e.detail, e.created_at, a.username + FROM audit_events e + LEFT JOIN accounts a ON a.id = e.actor_sub + WHERE e.library_id = ? + ORDER BY e.created_at DESC LIMIT ?`, libraryId, capped, @@ -56,6 +58,7 @@ export async function listAudit(db: Database, libraryId: string, limit = 50): Pr return rows.map((row) => ({ id: row.id, actor_sub: row.actor_sub, + actor: row.username ?? (row.actor_sub.startsWith("reader-key:") ? "Reader key" : row.actor_sub), tool: row.tool, success: row.success === true, detail: row.detail, @@ -87,3 +90,64 @@ export async function writePublishAudit( Date.now(), ); } + +export type AuditLogRow = AuditRow & { + /** Username for built-in accounts, "Reader key", or the raw subject. */ + actor: string; + library_id: string | null; + library_name: string | null; +}; + +/** + * One page of the web audit log, newest first. `libraryIds` limits it to those + * libraries (a Publisher's); null means every event (Site admin). Returns one + * extra row's worth of information as `hasMore`. + */ +export async function listAuditLog( + db: Database, + input: { libraryIds: string[] | null; limit?: number; offset?: number }, +): Promise<{ rows: AuditLogRow[]; hasMore: boolean }> { + const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); + const offset = Math.max(input.offset ?? 0, 0); + if (input.libraryIds && input.libraryIds.length === 0) return { rows: [], hasMore: false }; + const where = input.libraryIds ? "WHERE e.library_id = ANY(?::text[])" : ""; + const params: unknown[] = input.libraryIds ? [input.libraryIds] : []; + const rows = await allRows<{ + id: string; + actor_sub: string; + tool: string; + success: boolean; + detail: string; + created_at: number | string; + library_id: string | null; + library_name: string | null; + username: string | null; + }>( + db, + `SELECT e.id, e.actor_sub, e.tool, e.success, e.detail, e.created_at, e.library_id, + l.name AS library_name, a.username + FROM audit_events e + LEFT JOIN libraries l ON l.id = e.library_id + LEFT JOIN accounts a ON a.id = e.actor_sub + ${where} + ORDER BY e.created_at DESC, e.id DESC + LIMIT ? OFFSET ?`, + ...params, + limit + 1, + offset, + ); + return { + hasMore: rows.length > limit, + rows: rows.slice(0, limit).map((row) => ({ + id: row.id, + actor_sub: row.actor_sub, + actor: row.username ?? (row.actor_sub.startsWith("reader-key:") ? "Reader key" : row.actor_sub), + tool: row.tool, + success: row.success === true, + detail: row.detail, + created_at: Number(row.created_at), + library_id: row.library_id, + library_name: row.library_name, + })), + }; +} diff --git a/src/db/items.ts b/src/db/items.ts index 5d0e7e3..560bae4 100644 --- a/src/db/items.ts +++ b/src/db/items.ts @@ -576,7 +576,7 @@ export async function publishItems( } /** - * Make a draft visible to readers and the kiosk. + * Make a draft visible to readers and agents. * Copies working fields into published_snapshot; keeps the prior snapshot for Revert. */ export async function publishItem( diff --git a/src/db/libraries.ts b/src/db/libraries.ts index b93905b..abcd370 100644 --- a/src/db/libraries.ts +++ b/src/db/libraries.ts @@ -11,6 +11,7 @@ import { grantLibraryAccess } from "./access.ts"; import { connectTokenLast4, hashConnectToken } from "../http/library-url.ts"; import type { LibraryRow } from "./models.ts"; import { allRows, escapeLike, getRow, runSql } from "./query-helpers.ts"; +import { DEFAULT_LIBRARY_ID, DEFAULT_LIBRARY_NAME } from "./migrate.ts"; function libraryTemplate(value: string | undefined): LibraryTemplate { return value && isLibraryTemplate(value) ? value : "general"; @@ -447,3 +448,24 @@ export async function resolveLibrary( if (partial.length > 1) return { choices: partial }; return undefined; } + +/** + * A fresh database gets an empty "Main" library from the baseline migration. + * When the first account is created it is removed if nobody has used it (no + * items, no access, still named Main), so a new install starts with an empty + * list. Existing installs keep Main: they already have an account. + */ +export async function removeUnusedDefaultLibrary(db: Database): Promise { + const row = await getRow<{ id: string }>( + db, + `DELETE FROM libraries l + WHERE l.id = ? AND l.name = ? + AND NOT EXISTS (SELECT 1 FROM items i WHERE i.library_id = l.id) + AND NOT EXISTS (SELECT 1 FROM library_access a WHERE a.library_id = l.id) + AND NOT EXISTS (SELECT 1 FROM publish_audit p WHERE p.library_id = l.id) + RETURNING l.id`, + DEFAULT_LIBRARY_ID, + DEFAULT_LIBRARY_NAME, + ); + return Boolean(row); +} diff --git a/src/db/models.ts b/src/db/models.ts index 660ffb7..bf2c528 100644 --- a/src/db/models.ts +++ b/src/db/models.ts @@ -128,6 +128,8 @@ export type ItemSummary = { export type AuditRow = { id: string; actor_sub: string; + /** Username of the actor (built-in accounts), "Reader key", or the subject. */ + actor: string; tool: string; success: boolean; detail: string; diff --git a/src/http/app.ts b/src/http/app.ts index 6842630..fd7ec77 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -1,4 +1,5 @@ /** Express app composer: static assets, OAuth metadata, routes, MCP mount. */ +import { runWithRequestState } from "./request-context.ts"; import path from "node:path"; import { forwardedForWarning } from "./proxy-warning.ts"; import { existsSync } from "node:fs"; @@ -30,6 +31,7 @@ import { loginScopes } from "../auth/scopes.ts"; import { createTokenVerifier } from "../auth/verifier.ts"; import { ACCOUNT_UNAVAILABLE, + SIGNED_OUT_MCP, FORBIDDEN_LIBRARY, READER_KEY_REJECTED, UNAUTHORIZED_MCP, @@ -49,6 +51,7 @@ import { import { registerAccountRoutes } from "./routes/accounts.ts"; import { registerAuthRoutes } from "./routes/auth.ts"; import { registerCallbackHostRoutes } from "./routes/callback-hosts.ts"; +import { registerAuditRoutes } from "./routes/audit.ts"; import type { CallbackHostAdmin } from "../auth/embedded-idp.ts"; import type { AppRouteContext } from "./routes/context.ts"; import { registerEditorRoutes } from "./routes/editor.ts"; @@ -105,6 +108,7 @@ export function createApp(input: { const app = express(); // No auth and no Host check. Returns only ok / unavailable. app.get("/healthz", healthHandler(db)); + app.use((_req, _res, next) => runWithRequestState(next)); app.use(express.json()); app.use(hostHeaderValidation(allowedNames)); app.use((req, res, next) => { @@ -203,6 +207,13 @@ export function createApp(input: { sendResourceMetadata(res, new URL(libraryMcpBaseUrl(config.publicBaseUrl, libraryId.toLowerCase()))); }); + // Browsers ask for /favicon.ico on every page; answer with the SVG mark. + app.get("/favicon.ico", (_req, res) => { + res.type("image/svg+xml"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.sendFile(path.join(publicDir, "favicon.svg")); + }); + // Fingerprinted query strings bust Cloudflare/browser caches after deploys. // Keep a short max-age so a missed fingerprint still revalidates quickly. app.use( @@ -245,6 +256,7 @@ export function createApp(input: { registerAuthRoutes(app, ctx); registerAccountRoutes(app, ctx); registerCallbackHostRoutes(app, ctx); + registerAuditRoutes(app, ctx); registerLibraryRoutes(app, ctx); registerShelfRoutes(app, ctx); registerItemRoutes(app, ctx); @@ -272,6 +284,22 @@ export function createApp(input: { app.all("/libraries/:libraryId/mcp", mcpLimit, async (req, res, next) => { const header = req.headers["x-library-token"]; if (typeof header !== "string" || header === "") { + // A Reader key is a library secret of its own: it works on this URL + // without the Connect token, so publishers never rotate Connect to set + // up a shared agent. + const readerBearer = bearerFromAuthorization( + typeof req.headers.authorization === "string" ? req.headers.authorization : undefined, + ); + if (readerBearer && isReaderKeyToken(readerBearer)) { + const libraryId = String(req.params.libraryId); + const library = isLibraryId(libraryId) ? await getLibrary(db, libraryId) : null; + if (!library) { + res.status(404).json({ error: "not_found", error_description: "Library not found." }); + return; + } + await serveReaderKey(req, res, library, readerBearer).catch(next); + return; + } res.status(410).json({ error: "gone", error_description: @@ -286,6 +314,31 @@ export function createApp(input: { await serveLibraryMcp(req, res, next, String(req.params.connectToken), "path").catch(next); }); + async function serveReaderKey( + req: express.Request, + res: express.Response, + library: NonNullable>>, + bearer: string, + ): Promise { + const stored = await getLibraryReaderKeyHash(db, library.id); + if (!readerKeyMatches(bearer, stored)) { + res.status(401).json({ + error: "invalid_token", + error_description: READER_KEY_REJECTED, + }); + return; + } + const authInfo = readerKeyAuthInfo(library.id); + (req as express.Request & { auth?: typeof authInfo }).auth = authInfo; + const shelfName = library.shelfId ? (await getShelf(db, library.shelfId))?.name ?? null : null; + const scoped = readerKeyLibraryContext({ ...library, shelfName }); + const mcpHandler = createMcpHandler(({ authInfo: info }) => { + if (!info) throw new Error("MCP handler invoked without authInfo"); + return buildMcpServer(db, info, scoped, { publicBaseUrl: config.publicBaseUrl }); + }); + void toNodeHandler(mcpHandler)(req, res, req.body); + } + async function serveLibraryMcp( req: express.Request, res: express.Response, @@ -320,23 +373,7 @@ export function createApp(input: { const authorization = req.headers.authorization; const bearer = bearerFromAuthorization(typeof authorization === "string" ? authorization : undefined); if (bearer && isReaderKeyToken(bearer)) { - const stored = await getLibraryReaderKeyHash(db, library.id); - if (!readerKeyMatches(bearer, stored)) { - res.status(401).json({ - error: "invalid_token", - error_description: READER_KEY_REJECTED, - }); - return; - } - const authInfo = readerKeyAuthInfo(library.id); - (req as express.Request & { auth?: typeof authInfo }).auth = authInfo; - const shelfName = library.shelfId ? (await getShelf(db, library.shelfId))?.name ?? null : null; - const scoped = readerKeyLibraryContext({ ...library, shelfName }); - const mcpHandler = createMcpHandler(({ authInfo: info }) => { - if (!info) throw new Error("MCP handler invoked without authInfo"); - return buildMcpServer(db, info, scoped, { publicBaseUrl: config.publicBaseUrl }); - }); - void toNodeHandler(mcpHandler)(req, res, req.body); + await serveReaderKey(req, res, library, bearer); return; } @@ -385,6 +422,16 @@ export function createApp(input: { libraryId, app.locals.accountGuard as AccountGuard | undefined, ); + if ("error" in scoped && scoped.error === SIGNED_OUT_MCP) { + // 401 with a challenge, so the MCP client starts a fresh sign-in. + res.setHeader( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Sign-in ended; reconnect", resource_metadata="${getOAuthProtectedResourceMetadataUrl(resourceUrl)}"`, + ); + res.status(401); + sendJson({ error: "invalid_token", error_description: SIGNED_OUT_MCP }); + return; + } if ("error" in scoped) { const status = scoped.error === FORBIDDEN_LIBRARY || scoped.error === ACCOUNT_UNAVAILABLE ? 403 : 404; diff --git a/src/http/helpers.ts b/src/http/helpers.ts index 9923746..947ee9d 100644 --- a/src/http/helpers.ts +++ b/src/http/helpers.ts @@ -1,4 +1,5 @@ /** Cookie names and form/session helpers for HTTP routes. */ +import { rememberViewer } from "./request-context.ts"; import { timingSafeEqual } from "node:crypto"; import type { Request, Response } from "express"; import type { AppConfig } from "../config.ts"; @@ -85,10 +86,12 @@ export async function sessionAccess( if (!check.allowed) return undefined; role = check.role; } - return { + const access = { ...accessFromIdentity({ sub: session.sub, clientId: session.clientId, role }), csrf: session.csrf, }; + rememberViewer(access); + return access; } /** Constant-time compare of two secrets; an empty value never matches. */ diff --git a/src/http/pages/apps.ts b/src/http/pages/apps.ts index dbb2996..77ea7c8 100644 --- a/src/http/pages/apps.ts +++ b/src/http/pages/apps.ts @@ -55,6 +55,7 @@ export function appsPage(input: { const pending = input.hosts.filter((row) => row.status === "pending"); const approved = input.hosts.filter((row) => row.status === "approved"); const denied = input.hosts.filter((row) => row.status === "denied"); + const revoked = input.hosts.filter((row) => row.status === "revoked"); const body = input.local ? `${flash} ${pending.length === 0 ? `

No apps are waiting for approval.

` : ""} @@ -67,6 +68,7 @@ export function appsPage(input: { ${section("Approved", approved, (row) => decisionForm(input.csrf, row.host, "revoke", "Revoke", `Revoke ${row.host}?\n\nApps on this host are signed out (their refresh tokens end) and cannot sign in again unless you approve it.`), )} + ${section("Revoked", revoked, (row) => decisionForm(input.csrf, row.host, "approve", "Approve again"))} ${section("Denied", denied, (row) => decisionForm(input.csrf, row.host, "approve", "Approve"))} ${ input.envHosts.length diff --git a/src/http/pages/audit.ts b/src/http/pages/audit.ts new file mode 100644 index 0000000..38de247 --- /dev/null +++ b/src/http/pages/audit.ts @@ -0,0 +1,103 @@ +/** Settings → Audit log: read-only list of audit events, newest first. */ +import type { Access } from "../../auth/rbac.ts"; +import type { AuditLogRow } from "../../db/audit.ts"; +import { esc, layout, type AppShell } from "./layout.ts"; + +function when(ms: number): string { + return new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC"; +} + +export function auditPage(input: { + access: Access; + csrf: string; + shell: Pick; + rows: AuditLogRow[]; + page: number; + hasMore: boolean; + /** Libraries this person may filter by. Empty: nothing to show. */ + libraries: { id: string; name: string }[]; + libraryId?: string; + /** True for a Site admin, who sees every event. */ + all: boolean; +}): string { + const shell: AppShell = { ...input.shell, settingsMode: true, settingsActive: "audit" }; + const link = (page: number) => { + const params = new URLSearchParams(); + if (input.libraryId) params.set("library", input.libraryId); + if (page > 1) params.set("page", String(page)); + const query = params.toString(); + return esc(query ? `/audit?${query}` : "/audit"); + }; + const filter = + input.libraries.length > 0 + ? `
+ + +
` + : ""; + const table = + input.rows.length === 0 + ? `

${ + input.libraries.length === 0 && !input.all + ? "You are not a Publisher on any library, so there is no audit log to show." + : "No audit events yet." + }

` + : ` + + + ${input.rows + .map( + (row) => ` + + + + + + + `, + ) + .join("")} + +
WhenWhoWhatLibraryResultDetail
${esc(when(row.created_at))}${esc(row.actor)}${esc(row.tool)}${row.library_id ? `${esc(row.library_name ?? "(deleted)")}` : "—"}${row.success ? "OK" : "Failed"}${esc(row.detail.length > 160 ? `${row.detail.slice(0, 160)}…` : row.detail)}
`; + const pager = + input.page > 1 || input.hasMore + ? `` + : ""; + return layout({ + title: "Audit log · MCPEncode", + csrf: input.csrf, + access: input.access, + shell, + body: ` +
+
+
+

Audit log

+

${ + input.all + ? "Every recorded action: sign-ins to apps, edits, publishes, access changes and MCP tool calls." + : "Recorded actions in the libraries where you are a Publisher." + } Read-only.

+
+
+ ${filter} + ${table} + ${pager} +
+ `, + }); +} diff --git a/src/http/pages/items.ts b/src/http/pages/items.ts index 410daf9..59cac45 100644 --- a/src/http/pages/items.ts +++ b/src/http/pages/items.ts @@ -351,9 +351,9 @@ export function editPage(input: { input.library.writeMode === "direct" ? `

This library is ${esc(writeModeLabel("direct"))} — saving makes this item live for readers and agents.

` : input.item.status === "published" - ? `

Saving moves this item to Pending and hides it from the kiosk until you Publish again.

` + ? `

Saving puts this edit in Pending. Readers and agents keep the published version until you Publish.

` : input.item.status === "draft" - ? `

This item is Pending. Publish from the item page when it is ready for the kiosk.

` + ? `

This item is Pending. Publish from the item page when readers and agents should see it.

` : `

This item is Archived. Restore it before publishing.

`; const schemaForm = usesSchemaForm(input.library); return layout({ diff --git a/src/http/pages/layout.ts b/src/http/pages/layout.ts index dd7aeee..926d546 100644 --- a/src/http/pages/layout.ts +++ b/src/http/pages/layout.ts @@ -70,25 +70,28 @@ export function noticeMessage(notice: string | undefined, _status?: ItemStatus): return "Review-by date saved."; } if (notice === "created") { - return "Item saved as Pending. Publish it when it is ready for the kiosk."; + return "Item saved as Pending. Readers and agents see it once you Publish."; } if (notice === "created_live") { return "Item saved and live. Readers and agents can see it now."; } - if (notice === "pending") { - return "Moved to Pending. It is hidden from the kiosk until you Publish again."; + if (notice === "edit_pending") { + return "Edit saved as Pending. Readers and agents keep the published version until you Publish this edit."; + } + if (notice === "unpublished" || notice === "pending") { + return "Unpublished. Readers and agents no longer see this item. It is in Pending until you Publish again."; } if (notice === "saved_live") { return "Saved and live. Readers and agents see this version now."; } if (notice === "published") { - return "Published. Readers and the kiosk can see this item."; + return "Published. Readers and agents can see this item."; } if (notice === "archived") { - return "Archived. Hidden from the kiosk and from Pending."; + return "Archived. Hidden from readers, agents and Pending."; } if (notice === "restored") { - return "Restored to Pending. Publish when it is ready for the kiosk."; + return "Restored to Pending. Readers and agents see it once you Publish."; } if (notice === "canceled") { return "Publish request canceled. This item is back to the last published version."; @@ -255,7 +258,13 @@ export function itemListMarkup( ${esc(row.name)} ${showStatus ? statusBadge(row.status) : ""} - ${showStatus && row.status === "draft" && row.has_pending_changes ? `Published copy live` : ""} + ${ + row.status === "draft" && row.has_pending_changes + ? showStatus + ? `Published copy live` + : `Edit pending` + : "" + } ${summaryInReview || !row.summary ? "" : `${esc(row.summary)}`} ${row.review ? reviewMarkup(row.review, "list") : ""} @@ -330,7 +339,7 @@ export type AppShell = { /** Left pane lists Settings items instead of libraries. */ settingsMode?: boolean; /** Which Settings item is current. */ - settingsActive?: "accounts" | "apps"; + settingsActive?: "accounts" | "apps" | "audit"; }; /** @@ -406,6 +415,15 @@ function settingsSidebar(shell: AppShell): string { ` : ""; + const auditActive = shell.settingsActive === "audit"; + const audit = `
  • + + + Audit log + Who did what + + +
  • `; return `