Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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.**
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -49,4 +53,4 @@ services:
condition: service_healthy

volumes:
postgres_data:
postgres18_data:
14 changes: 9 additions & 5 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -102,7 +104,9 @@ There is **no publish, approve, write-mode or revert tool.** Those stay with peo

- **Connect link** (`/libraries/<id>/mcp/<token>`): 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/<id>/mcp` with `X-Library-Token: <token>`, which keeps the token out of URLs and logs.
- **Reader key**: `Authorization: Bearer <key>` for a shared read-only agent, no sign-in.
- **Reader key**: `Authorization: Bearer <key>` on `/libraries/<id>/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=<id>` for one).

Rotating the Connect link (**Connect → Rotate**) invalidates the old token in both forms; Access grants stay.

Expand Down
40 changes: 39 additions & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>_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.
5 changes: 5 additions & 0 deletions migrations/023_callback_host_revoked.sql
Original file line number Diff line number Diff line change
@@ -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'));
1 change: 1 addition & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
12 changes: 9 additions & 3 deletions src/auth/account-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,10 +30,10 @@ export type AccountGuard = (sub: string, issuedAt?: number) => Promise<AccountCh
export function createAccountGuard(accounts: AccountStore): AccountGuard {
return async (sub, issuedAt) => {
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 };
};
Expand Down
Loading
Loading