From 10b29077c56334d42975b2d0f00d8acf4a6bd1cd Mon Sep 17 00:00:00 2001 From: nicolas de Barquin Date: Tue, 3 Nov 2026 14:19:30 +0100 Subject: [PATCH 1/3] Account admin from the server's terminal: TUI, token, Diataxis docs Replaces the unreleased ADMIN_EMAILS design. CSS never verifies a login's email, so a listed address nobody had used made whoever took it first an admin; and whoever runs the server already proves it with ssh + docker. - account-admin: AccountAdminHandler, POST /.account/admin/accounts/ with action list|delete, opened by a token start/admins.js renews at each start (mode 600, container filesystem only). - admin/admin.js (in the image): TUI to list, sort, filter, select and delete; plus list / delete --yes for scripts. Calls CSS on 127.0.0.1 with X-Forwarded-Host from CSS_BASE_URL. - scripts/admin.sh runs it via docker compose exec, then removes the connector's agents for what was deleted. - Gateway templates refuse /.account/admin/ from outside. - Smoke drives the tool inside the container on both layouts. - docs/admin/: tutorial, how-to, reference, explanation. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 4 +- .github/workflows/stock-watch.yml | 4 +- CHANGELOG.md | 57 ++-- Dockerfile | 9 +- README.md | 31 +-- ROADMAP.md | 28 +- account-admin/src/AccountAdminHandler.ts | 240 ++++++++++++++++ .../src/AdminDeleteAccountHandler.ts | 190 ------------- account-admin/src/index.ts | 2 +- ...er.test.js => AccountAdminHandler.test.js} | 95 ++++--- admin/admin.js | 258 ++++++++++++++++++ config/instance.json | 7 +- docs/admin/README.md | 18 ++ docs/admin/explanation.md | 72 +++++ docs/admin/how-to.md | 68 +++++ docs/admin/reference.md | 85 ++++++ docs/admin/tutorial.md | 74 +++++ docs/handoff-mapsofmaking.md | 13 +- instance-template/.env.example | 7 - instance-template/Caddyfile.tmpl | 13 + instance-template/compose.yml | 5 - instance-template/gateway-vhost.conf.tmpl | 9 + scripts/admin.sh | 109 +++----- start/admins.js | 63 ++--- start/start.sh | 4 +- test/smoke.mjs | 59 ++-- test/try-local.sh | 3 - 27 files changed, 1065 insertions(+), 462 deletions(-) create mode 100644 account-admin/src/AccountAdminHandler.ts delete mode 100644 account-admin/src/AdminDeleteAccountHandler.ts rename account-admin/test/{AdminDeleteAccountHandler.test.js => AccountAdminHandler.test.js} (52%) create mode 100755 admin/admin.js create mode 100644 docs/admin/README.md create mode 100644 docs/admin/explanation.md create mode 100644 docs/admin/how-to.md create mode 100644 docs/admin/reference.md create mode 100644 docs/admin/tutorial.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9ddc60..4894a46 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,7 +76,7 @@ jobs: run: npm ci && /usr/bin/google-chrome --version - name: smoke, subdomain pods (the image's default) - env: { POD_LAYOUT: subdomain, TRY_NETWORK: host, CHROMIUM_PATH: /usr/bin/google-chrome, ADMIN_EMAILS: smoke-admin@example.org } + env: { POD_LAYOUT: subdomain, TRY_NETWORK: host, CHROMIUM_PATH: /usr/bin/google-chrome } run: | test/try-local.sh 3123 node test/smoke.mjs http://localhost:3123 test/shots/subdomain @@ -84,7 +84,7 @@ jobs: docker rm -f try-css-overlay-3123 - name: smoke, folder pods (config/suffix.json) - env: { POD_LAYOUT: suffix, TRY_NETWORK: host, CHROMIUM_PATH: /usr/bin/google-chrome, ADMIN_EMAILS: smoke-admin@example.org } + env: { POD_LAYOUT: suffix, TRY_NETWORK: host, CHROMIUM_PATH: /usr/bin/google-chrome } run: | test/try-local.sh 3124 node test/smoke.mjs http://localhost:3124 test/shots/suffix diff --git a/.github/workflows/stock-watch.yml b/.github/workflows/stock-watch.yml index 18063ea..43e689a 100644 --- a/.github/workflows/stock-watch.yml +++ b/.github/workflows/stock-watch.yml @@ -78,7 +78,7 @@ jobs: id: smoke_subdomain if: steps.build.outcome == 'success' continue-on-error: true - env: { POD_LAYOUT: subdomain, TRY_NETWORK: host, ADMIN_EMAILS: smoke-admin@example.org, IMAGE: "solid-css-overlay:${{ matrix.tag }}" } + env: { POD_LAYOUT: subdomain, TRY_NETWORK: host, IMAGE: "solid-css-overlay:${{ matrix.tag }}" } run: | test/try-local.sh 3123 node test/smoke.mjs http://localhost:3123 "test/shots/$TAG-subdomain" @@ -86,7 +86,7 @@ jobs: id: smoke_suffix if: steps.build.outcome == 'success' continue-on-error: true - env: { POD_LAYOUT: suffix, TRY_NETWORK: host, ADMIN_EMAILS: smoke-admin@example.org, IMAGE: "solid-css-overlay:${{ matrix.tag }}" } + env: { POD_LAYOUT: suffix, TRY_NETWORK: host, IMAGE: "solid-css-overlay:${{ matrix.tag }}" } run: | docker rm -f try-css-overlay-3123 >/dev/null 2>&1 || true test/try-local.sh 3124 diff --git a/CHANGELOG.md b/CHANGELOG.md index f132e14..bc78f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,38 +4,41 @@ Versions are `-`: a new stock release starts at `-1`, a new overlay on the same stock adds one. A tag `v` publishes both images (`.github/workflows/build.yml`). -## 7.3.0-4 — 2026-11-03 +## 7.3.0-4 — unreleased On stock CSS 7.3.0. **The image's second component** (solid-kit ADR 012). An -instance updates the image; to use it, add `ADMIN_EMAILS` to `.env` -(`instance-template/.env.example`) and the line that passes it to -`compose.yml` comes with the checkout. - -- **Admins can delete an account and its pods** (#17, slice 1). Stock CSS - deletes neither, so a pod name once taken stayed taken and test rounds could - only be cleaned by wiping the volume. `account-admin/` adds - `POST /.account/admin/delete-account/ { "email" }`: the pods' contents are - deleted through the resource store, each pod root through the data accessor - under the write lock (the store refuses a root storage, and a root left - behind keeps the name taken), then the account record with its logins, - WebID links and client credentials. Only accounts whose login is in - `ADMIN_EMAILS` may call it (`start/admins.js` writes it at start, like the - email sender); empty, it refuses everyone. An admin cannot delete their own - account there. -- **`scripts/admin.sh delete-account `**: signs in, asks for the email - again, calls the route, then removes the connector's agents for that - account from `identities.json` (new `removeIdentitiesFor` in - `mcp-connector/src/identityRegistry.js`). -- Smoke (both layouts, `ADMIN_EMAILS` set in CI): a throwaway account with a - pod is refused to a non-admin and to nobody signed in, deleted by the admin, - its login no longer works, and its pod name is taken again. +instance updates the image; nothing to add to `.env`. + +- **List every account and delete one with its pods, from the server's + terminal** (#17). Stock CSS does neither, so a pod name once taken stayed + taken and test rounds could only be cleaned by wiping the volume. + `scripts/admin.sh`, run from the instance's checkout, opens a TUI in the css + container (`admin/admin.js`): list, sort, filter, select, delete, with the + number of accounts typed to confirm. `list` and `delete … --yes` do the same + without the TUI. Docs: `docs/admin/` (tutorial, how-to, reference, + explanation). +- **How it deletes** (`account-admin/`, route `POST /.account/admin/accounts/`): + the pods' contents go through the resource store, each pod root through the + data accessor under the write lock (the store refuses a root storage, and a + root left behind keeps the name taken), then the account record with its + logins, WebID links and client credentials. +- **Who may: whoever runs a command on the server.** `start/admins.js` writes a + fresh random token at every start, readable by the container only; the tool + reads it there. The first draft listed admin login emails (`ADMIN_EMAILS`), + never released: CSS does not verify a login's email, so a listed address + nobody had used yet made whoever signed up with it first an admin. +- **The connector's agents** for a deleted account are removed from + `identities.json` by `scripts/admin.sh` (new `removeIdentitiesFor`). +- **Gateway templates** answer 404 for `/.account/admin/` from outside. An + existing gateway does not need it (the token protects the route), but add it + when you next touch the vhost. - **An instance that mounts its own copy of `config/instance.json`** (more reserved names, overlay-template) adds one line to its `import` list, after - the email one, or the route answers 404: + the email one, or the tool answers 404: `"css:config/identity/handler/account-admin.json",` -- **List only emails that are already logins on the server**: stock CSS does - not verify a login's email, so an unclaimed address listed in - `ADMIN_EMAILS` makes whoever signs up with it first an admin. +- Smoke (both layouts): a throwaway account with a pod is listed by the tool + run inside the container and deleted by it. Its login no longer works, its + pod name is taken again, and a guessed token from outside is refused. ## 7.3.0-3 — 2026-11-03 diff --git a/Dockerfile b/Dockerfile index 019c7a3..5df49cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # # Nothing in CSS is patched. The image only adds files where stock CSS already # looks for them (templates/), one config, and two components (PodNameGuard, -# and AdminDeleteAccountHandler: solid-kit ADR 012). +# and AccountAdminHandler: solid-kit ADR 012) with its terminal tool (admin/). ARG CSS_VERSION=7.3.0 # PodNameGuard, built and tested in the stock image it will run in, so on the @@ -76,11 +76,14 @@ ENV CSS_CONFIG=/config/instance.json # What this instance offers (/.well-known/css/provider.json), its pod layout # for the pages, its email sender (config/identity/email/overlay.json) and its -# admin route (config/identity/handler/account-admin.json), both imported by -# both configs, written at every start from the environment, then the stock +# account admin route and its token (config/identity/handler/account-admin.json), +# both imported by both configs, written at every start from the environment, then the stock # server. The version is the tag (the publish job passes it); local builds say dev. ARG OVERLAY_VERSION=dev ENV OVERLAY_VERSION=${OVERLAY_VERSION} COPY start/describe.js start/email.js start/admins.js start/welcome.js start/start.sh /opt/overlay/ +# The account admin's terminal tool, run with `docker compose exec css node +# /opt/overlay/admin.js` (scripts/admin.sh does it). Not an entrypoint step. +COPY admin/admin.js /opt/overlay/admin.js ENTRYPOINT ["/opt/overlay/start.sh"] # CSS_BASE_URL is the instance's (compose .env); CSS_ROOT_FILE_PATH=/data is stock. diff --git a/README.md b/README.md index 60228aa..c2c0528 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ there, only without this overlay's extras. |---|---| | **Pods are subdomains** | `https://./`, WebID `https://./profile/card#me`. The default for every new instance. | | **Pod names are checked** | `PodNameGuard` (`pod-name-guard/`) refuses, with a 400 the form shows, a name that cannot be a hostname (`open_fab`, 64+ characters, a hyphen at either end) and reserved names (`www`, `mcp`, `api`, …: `config/instance.json`). CSS alone checks only that the name is not empty. The form runs the same check first. | -| **Admins can delete an account** | `AdminDeleteAccountHandler` (`account-admin/`): an account and its pods go, and the pod names are free again. Stock CSS deletes neither. Admins are the login emails in `ADMIN_EMAILS`; front door `scripts/admin.sh` (below, "Accounts and pods"). | +| **Accounts can be listed and deleted** | From the server's terminal: `scripts/admin.sh` opens a TUI that lists every account, then sorts, filters, selects and deletes them with their pods. The pod names come free again. Stock CSS does neither. [`docs/admin/`](docs/admin/README.md) | | **Authentication is stock** | DPoP and Bearer only. pocpod0's `UnsecureWebIdExtractor` is not in the image. | | **The welcome page** | Ours, or the vertical's from its design-system repo, put at the root at every start (`start/welcome.js`). | | **The README a new pod starts with** | Ours: what is in the pod, what is public, how sharing and agents work, no Databrowser. Or the vertical's, from the same repo. | @@ -317,24 +317,15 @@ by itself (`BaseLoginAccountStorage`, 1800 s). It is the only deletion CSS does. Nothing in stock deletes the account or the pod, so a pod name, once taken, stays taken. -**The image adds deletion, for admins** (since 7.3.0-4, solid-kit ADR 012). -List the admins' login emails in the instance's `.env`, then restart: +**The image adds listing and deletion, from the server's terminal** (since +7.3.0-4, solid-kit ADR 012). From the instance's checkout: ```sh -ADMIN_EMAILS=you@example.org # comma-separated; empty: nobody +scripts/admin.sh # TUI: list, sort, filter, select, delete ``` -**Sign up with that email first.** Stock CSS does not verify a login's -email, so an address listed here that is not yet a login belongs to whoever -signs up with it first, and makes them admin. -From the instance's checkout: -```sh -scripts/admin.sh delete-account someone@example.org -``` -It asks for your login, then the email again, and deletes: the account, its -pods and everything in them (the names are free again), its logins, WebID -links and client credentials, and the connector's agents that acted for it -(`identities.json`, when the connector runs there). An admin cannot delete -their own account this way. Self-service "Delete my account", the right to -erasure, comes next (`ROADMAP.md`). +Being able to run it is the permission (ssh and docker): there is no admin +password and no admin list. Tutorial, how-to, reference and explanation are in +[`docs/admin/`](docs/admin/README.md). Self-service "Delete my account", the +right to erasure, comes next (`ROADMAP.md`). **Resetting a test instance** deletes every account and pod at once, in `instance-template/`: @@ -377,15 +368,15 @@ The skin audit (contrast, complete slot set) lives in solid-kit: | `overlay/` | the pages: `main.html.ejs`, `main.css`, `pod-ui.js`, `pod-layout.js`, `templates/identity/**`, `templates/pod/base/README$.md.hbs` (a new pod's README), `welcome/`, `doc-view/` (generated from solid-kit, `SOURCE`) | | `skins/pod/` | the default skin | | `pod-name-guard/` | component 1: pod names that make valid hostnames | -| `account-admin/` | component 2: admins delete an account and its pods (solid-kit ADR 012) | -| `scripts/admin.sh` | the admin's front door to `account-admin/` | +| `account-admin/` | component 2: list accounts, delete one with its pods (solid-kit ADR 012) | +| `admin/admin.js`, `scripts/admin.sh` | the account admin's terminal tool, and its launcher on an instance | | `upstream-baseline.sha256`, `scripts/check-overrides.sh` | the stock templates `overlay/` replaces, hashed | | `test/` | `try-local.sh` (runs the image) + `smoke.mjs` (a real browser, both layouts) | | `instance-template/` | what an instance runs from (its checkout): compose, `.env.example`, `email.env.example`, `update.sh`, the Caddy and nginx gateway templates | | `overlay-template/` | what the `pods/` folder of a vertical's design-system repo starts from (ADR 011) | | `start/` | the image's entrypoint: writes `provider.json`, `pod-layout.js`, the email sender, the admin route and the welcome page, then runs stock CSS | | `gateway-caddy/` | Caddy + the OVH DNS module, for the wildcard certificate | -| `docs/` | `faq.md` (sign-in lifetimes, consent), install briefs for one VPS (`handoff-mapsofmaking.md`, `handoff-pocpod0.md`) | +| `docs/` | `admin/` (account admin: tutorial, how-to, reference, explanation), `faq.md` (sign-in lifetimes, consent), install briefs for one VPS (`handoff-mapsofmaking.md`, `handoff-pocpod0.md`) | | `ROADMAP.md` | planned features: the account manager page, the right to erasure | | `mcp-connector/` | the connector's source and image | | `skills/css-instance/` | the method, for Claude Code; never required | diff --git a/ROADMAP.md b/ROADMAP.md index b378d81..8bb631c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,25 +6,21 @@ today: README, "Accounts and pods". ## Account and pod deletion, for admins -**Slice 1 done (7.3.0-4, solid-kit ADR 012):** `account-admin/`, route -`POST /.account/admin/delete-account/`, admins from `ADMIN_EMAILS` -(`start/admins.js`), front door `scripts/admin.sh delete-account`, which -also drops the connector's agents for that account. The smoke run deletes an -account with a pod and takes the same name again, on both layouts. +**Done (7.3.0-4, solid-kit ADR 012 and its amendment):** `scripts/admin.sh`, +a TUI run from the server's terminal that lists, sorts, filters, selects and +deletes accounts with their pods, and removes the connector's agents for them. +The permission is shell access on the server (a token the container renews at +every start), not a list of admin emails. Docs: `docs/admin/`. What was learnt building it: the store refuses to delete a pod root (a root storage, Solid §5.4) and CSS refuses to create a pod while anything is at its base, so the root goes through the data accessor under the write lock -(`AdminDeleteAccountHandler.ts`, header). One more CSS internal to check at -each bump. +(`account-admin/src/AccountAdminHandler.ts`, header). One more CSS internal to +check at each bump. -**Next, slice 2: see and choose.** -- `scripts/admin.sh list`: accounts, their logins and pods. Needs the account - storage's `entries('account')`; the stores have no "list all". -- An **account-manager page** in the overlay for admins: accounts and their - pods, a delete button each, calling the same route. -- A `make` target on the VPS (vps-infra, not this repo: an instance's - checkout is never edited). +**Left:** a `make` target on the VPS (vps-infra, not this repo: an instance's +checkout is never edited). A browser page for admins is not planned: it would +need its own answer to "who is this?" (ADR 012). ## Self-service erasure: the right to erasure @@ -32,8 +28,8 @@ each bump. CSS instance today, nobody can do it, not even an admin. **Shape.** "Delete my account" on the person's own account page, on the same -handler as above, limited to their own account (the admin route refuses an -admin's own account on purpose: that is this door). Where the button lives, +deletion as above, limited to their own account and opened by their own +sign-in instead of the server's token. Where the button lives, the overlay or solid-backoffice, is ADR 012's open question. The page says plainly what goes: - their pods and everything in them; - their WebID; diff --git a/account-admin/src/AccountAdminHandler.ts b/account-admin/src/AccountAdminHandler.ts new file mode 100644 index 0000000..85c2ddd --- /dev/null +++ b/account-admin/src/AccountAdminHandler.ts @@ -0,0 +1,240 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { + AccountLoginStorage, + AuxiliaryStrategy, + DataAccessor, + JsonInteractionHandlerInput, + JsonRepresentation, + PasswordStore, + PodStore, + ReadWriteLocker, + ResourceIdentifier, + ResourceStore, + WebIdStore, +} from '@solid/community-server'; +import { + BadRequestHttpError, + ForbiddenHttpError, + getLoggerFor, + isContainerIdentifier, + JsonInteractionHandler, + NotFoundHttpError, +} from '@solid/community-server'; + +/** One account as the admin tools show it. */ +export interface AccountSummary { + accountId: string; + emails: string[]; + pods: string[]; + webIds: string[]; +} + +/** + * Lists the server's accounts and deletes one with its pods, for whoever + * runs a command on the server itself. Stock CSS can do neither (7.3.0: its + * account API deletes a login, a WebID link, client credentials and a pod + * owner, nothing more, and lists only the caller's own), so a pod name once + * taken stayed taken and the right to erasure could not be honoured. Decided + * in solid-kit ADR 012; kept small and upstream-shaped so it can be offered + * to CSS and dropped from the image the release CSS has its own. + * + * POST `{ "token": "...", "action": "list" }` or + * POST `{ "token": "...", "action": "delete", "email": "..." }` (or `accountId`). + * + * WHO MAY: whoever can read the token, i.e. has a shell on the server. The + * token is random, written at every start by start/admins.js into this + * component's config and into a file only the container's user can read; + * the admin TUI (start/admin.js, run with `docker compose exec`) reads it + * there. Until 7.3.0-4's first draft this was a list of login emails + * (ADMIN_EMAILS), dropped before release: stock CSS never verifies a login's + * email, so a listed address nobody had signed up with yet made whoever took + * it first an admin, and ssh already says who runs the server. + * + * Deletion order, and why: + * 1. Each pod's resources are deleted depth first through the resource store, + * so CSS takes the locks, removes auxiliary resources and sends the + * notifications, as it would for a DELETE request. + * 2. The pod root is then removed through the data accessor, under the write + * lock. The store refuses it: "Cannot delete a root storage container" + * (DataAccessorBasedStore.deleteResource, Solid protocol §5.4), and with + * subdomain pods every pod root is one (with folder pods too: the root is + * marked pim:Storage). Leaving it would keep the name taken: + * GeneratedPodManager.createPod answers 409 while anything is at the base. + * 3. Last, `delete('account', id)` on the account storage. The account is the + * root record of that storage, so this removes its logins, pods, WebID + * links and client credentials in one go, and updates their indexes (the + * stock 30-minute clean-up of accounts without a login uses the same call). + * If anything above fails, the account is still there and the call can be + * run again. + * + * Not here: the connector's identities.json lives outside CSS. The answer + * lists the WebIDs and pods that went, and scripts/admin.sh removes the + * connector's agents for them. + */ +export class AccountAdminHandler extends JsonInteractionHandler { + protected readonly logger = getLoggerFor(this); + + private readonly accountStorage: AccountLoginStorage>; + private readonly passwordStore: PasswordStore; + private readonly podStore: PodStore; + private readonly webIdStore: WebIdStore; + private readonly store: ResourceStore; + private readonly accessor: DataAccessor; + private readonly auxiliaryStrategy: AuxiliaryStrategy; + private readonly locker: ReadWriteLocker; + private readonly token: Buffer; + + /** + * @param accountStorage - The account storage (urn:solid-server:default:AccountStorage). + * @param passwordStore - Finds an account's logins, and an account by login. + * @param podStore - Finds an account's pods. + * @param webIdStore - Finds an account's WebID links. + * @param store - The resource store, for everything below a pod root. + * @param accessor - The backend's data accessor, for the pod root itself. + * @param auxiliaryStrategy - Tells which children the store removes with their subject. + * @param locker - The resource locker the store uses. + * @param token - The secret a caller must send. Shorter than 32 characters: every call is refused. + */ + public constructor( + accountStorage: AccountLoginStorage>, + passwordStore: PasswordStore, + podStore: PodStore, + webIdStore: WebIdStore, + store: ResourceStore, + accessor: DataAccessor, + auxiliaryStrategy: AuxiliaryStrategy, + locker: ReadWriteLocker, + token: string, + ) { + super(); + this.accountStorage = accountStorage; + this.passwordStore = passwordStore; + this.podStore = podStore; + this.webIdStore = webIdStore; + this.store = store; + this.accessor = accessor; + this.auxiliaryStrategy = auxiliaryStrategy; + this.locker = locker; + this.token = Buffer.from(token ?? ''); + } + + public async handle({ json }: JsonInteractionHandlerInput): Promise { + const input = (json ?? {}) as { token?: unknown; action?: unknown; email?: unknown; accountId?: unknown }; + this.checkToken(input.token); + if (input.action === 'list') { + return { json: { accounts: await this.list() } as never }; + } + if (input.action === 'delete') { + const deleted = await this.delete(await this.findTarget(input)); + return { json: { deleted } as never }; + } + throw new BadRequestHttpError('"action" is "list" or "delete".'); + } + + private checkToken(sent: unknown): void { + const given = Buffer.from(typeof sent === 'string' ? sent : ''); + // A short or missing configured token refuses everyone: an image started + // without start/admins.js must not answer to an empty token. + if (this.token.length < 32 || given.length !== this.token.length || !timingSafeEqual(given, this.token)) { + this.logger.warn('Refused an account admin call without the right token'); + throw new ForbiddenHttpError('Account administration is done from the server itself (scripts/admin.sh).'); + } + } + + private async list(): Promise { + const accounts: AccountSummary[] = []; + for await (const account of this.accountStorage.entries('account')) { + accounts.push(await this.summary(account.id)); + } + return accounts; + } + + private async summary(accountId: string): Promise { + return { + accountId, + emails: (await this.passwordStore.findByAccount(accountId)).map((login): string => login.email), + pods: (await this.podStore.findPods(accountId)).map((pod): string => pod.baseUrl), + webIds: (await this.webIdStore.findLinks(accountId)).map((link): string => link.webId), + }; + } + + private async findTarget({ email, accountId }: { email?: unknown; accountId?: unknown }): Promise { + if (typeof email === 'string' && email.length > 0) { + const login = await this.passwordStore.findByEmail(email.trim().toLowerCase()); + if (!login) { + throw new NotFoundHttpError(`No account has the login ${email}.`); + } + return login.accountId; + } + if (typeof accountId === 'string' && accountId.length > 0) { + // Accounts without a login exist (sign-up left halfway, removed by + // stock after 30 minutes): the id is the only way to name them. + if (!await this.accountStorage.has('account', accountId)) { + throw new NotFoundHttpError(`No account ${accountId}.`); + } + return accountId; + } + throw new BadRequestHttpError('Say which account: "email" or "accountId".'); + } + + private async delete(accountId: string): Promise { + const summary = await this.summary(accountId); + for (const pod of summary.pods) { + await this.deletePod({ path: pod }); + } + await this.accountStorage.delete('account', accountId); + this.logger.info(`Deleted account ${accountId} (${summary.emails.join(', ') || 'no login'}), ` + + `pods ${summary.pods.join(', ') || 'none'}`); + return summary; + } + + private async deletePod(root: ResourceIdentifier): Promise { + for (const child of await this.listChildren(root)) { + await this.deleteTree(child); + } + await this.locker.withWriteLock(root, async(): Promise => { + try { + // A container's deletion takes its folder and everything left in it + // (the root's .acl and .meta), FileDataAccessor 7.3.0. + await this.accessor.deleteResource(root); + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + } + }); + } + + private async deleteTree(identifier: ResourceIdentifier): Promise { + if (isContainerIdentifier(identifier)) { + for (const child of await this.listChildren(identifier)) { + await this.deleteTree(child); + } + } + try { + await this.store.deleteResource(identifier); + } catch (error: unknown) { + // Gone already: an auxiliary resource the store removed with its + // subject, or a resource someone else deleted meanwhile. + if (!NotFoundHttpError.isInstance(error)) { + throw error; + } + } + } + + /** + * The children worth deleting one by one: auxiliary resources (.acl, .meta) + * go with their subject, and the store refuses a .meta outright. + */ + private async listChildren(container: ResourceIdentifier): Promise { + const children: ResourceIdentifier[] = []; + for await (const metadata of this.accessor.getChildren(container)) { + const child = { path: metadata.identifier.value }; + if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(child)) { + children.push(child); + } + } + // Listed first, deleted after: a folder is not changed under its iterator. + return children; + } +} diff --git a/account-admin/src/AdminDeleteAccountHandler.ts b/account-admin/src/AdminDeleteAccountHandler.ts deleted file mode 100644 index 668cd09..0000000 --- a/account-admin/src/AdminDeleteAccountHandler.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { - AccountLoginStorage, - AuxiliaryStrategy, - DataAccessor, - JsonInteractionHandlerInput, - JsonRepresentation, - PasswordStore, - PodStore, - ReadWriteLocker, - ResourceIdentifier, - ResourceStore, - WebIdStore, -} from '@solid/community-server'; -import { - BadRequestHttpError, - ForbiddenHttpError, - getLoggerFor, - isContainerIdentifier, - JsonInteractionHandler, - NotFoundHttpError, - UnauthorizedHttpError, -} from '@solid/community-server'; - -/** - * Deletes an account and its pods, for an admin of the instance. Stock CSS - * cannot delete either (7.3.0: its account API deletes a login, a WebID link, - * client credentials and a pod owner, nothing more), so a pod name once taken - * stayed taken and the right to erasure could not be honoured. Decided in - * solid-kit ADR 012; kept small and upstream-shaped so it can be offered to - * CSS and dropped from the image the release CSS has its own. - * - * POST `{ "email": "" }` (or `{ "accountId": "" }`), signed - * in to the account API (cookie or `Authorization: CSS-Account-Token`) as an - * account whose login email is in `admins`. - * - * Order, and why: - * 1. Each pod's resources are deleted depth first through the resource store, - * so CSS takes the locks, removes auxiliary resources and sends the - * notifications, as it would for a DELETE request. - * 2. The pod root is then removed through the data accessor, under the write - * lock. The store refuses it: "Cannot delete a root storage container" - * (DataAccessorBasedStore.deleteResource, Solid protocol §5.4), and with - * subdomain pods every pod root is one (with folder pods too: the root is - * marked pim:Storage). Leaving it would keep the name taken: - * GeneratedPodManager.createPod answers 409 while anything is at the base. - * 3. Last, `delete('account', id)` on the account storage. The account is the - * root record of that storage, so this removes its logins, pods, WebID - * links and client credentials in one go, and updates their indexes (the - * stock 30-minute clean-up of accounts without a login uses the same call). - * If anything above fails, the account is still there and the call can be - * run again. - * - * Not here: the connector's identities.json lives outside CSS. The answer - * lists the WebIDs that went, and scripts/admin.sh removes them there. - */ -export class AdminDeleteAccountHandler extends JsonInteractionHandler { - protected readonly logger = getLoggerFor(this); - - private readonly admins: Set; - - /** - * @param accountStorage - The account storage (urn:solid-server:default:AccountStorage). - * @param passwordStore - Finds the logins of the caller and of the target. - * @param podStore - Finds the target's pods. - * @param webIdStore - Finds the target's WebID links, reported to the caller. - * @param store - The resource store, for everything below a pod root. - * @param accessor - The backend's data accessor, for the pod root itself. - * @param auxiliaryStrategy - Tells which children the store removes with their subject. - * @param locker - The resource locker the store uses. - * @param admins - Login emails allowed to delete any account. Empty: nobody can. - */ - public constructor( - private readonly accountStorage: AccountLoginStorage>, - private readonly passwordStore: PasswordStore, - private readonly podStore: PodStore, - private readonly webIdStore: WebIdStore, - private readonly store: ResourceStore, - private readonly accessor: DataAccessor, - private readonly auxiliaryStrategy: AuxiliaryStrategy, - private readonly locker: ReadWriteLocker, - admins: string[] = [], - ) { - super(); - this.admins = new Set(admins.map((email): string => email.trim().toLowerCase()).filter(Boolean)); - } - - public async handle({ accountId, json }: JsonInteractionHandlerInput): Promise { - if (!accountId) { - throw new UnauthorizedHttpError('Sign in to the account API first.'); - } - const callerLogins = await this.passwordStore.findByAccount(accountId); - const caller = callerLogins.find((login): boolean => this.admins.has(login.email.toLowerCase())); - if (!caller) { - this.logger.warn(`Account ${accountId} tried to delete an account without being an admin`); - throw new ForbiddenHttpError('Only an admin of this server can delete an account.'); - } - - const targetId = await this.findTarget(json); - if (targetId === accountId) { - // Deleting one's own account is the self-service door (ADR 012, later - // slice): it asks for the passphrase again and says what goes. An admin - // locking themself out through the admin door is a mistake, not a use. - throw new BadRequestHttpError('An admin cannot delete their own account through the admin route.'); - } - - const pods = await this.podStore.findPods(targetId); - const webIds = (await this.webIdStore.findLinks(targetId)).map((link): string => link.webId); - const emails = (await this.passwordStore.findByAccount(targetId)).map((login): string => login.email); - - for (const pod of pods) { - await this.deletePod({ path: pod.baseUrl }); - } - await this.accountStorage.delete('account', targetId); - - this.logger.info(`Admin ${caller.email} deleted account ${targetId} (${emails.join(', ')}), ` + - `pods ${pods.map((pod): string => pod.baseUrl).join(', ') || 'none'}`); - return { json: { deleted: { accountId: targetId, emails, pods: pods.map((pod): string => pod.baseUrl), webIds }}}; - } - - private async findTarget(json: unknown): Promise { - const { email, accountId } = (json ?? {}) as { email?: unknown; accountId?: unknown }; - if (typeof email === 'string' && email.length > 0) { - const login = await this.passwordStore.findByEmail(email.trim().toLowerCase()); - if (!login) { - throw new NotFoundHttpError(`No account has the login ${email}.`); - } - return login.accountId; - } - if (typeof accountId === 'string' && accountId.length > 0) { - // The account storage has no plain "get"; its login listing tells - // whether the account exists, as every account keeps at least one login. - if ((await this.passwordStore.findByAccount(accountId)).length === 0 && - (await this.podStore.findPods(accountId)).length === 0) { - throw new NotFoundHttpError(`No account ${accountId}.`); - } - return accountId; - } - throw new BadRequestHttpError('Say which account: { "email": "..." } or { "accountId": "..." }.'); - } - - private async deletePod(root: ResourceIdentifier): Promise { - for (const child of await this.listChildren(root)) { - await this.deleteTree(child); - } - await this.locker.withWriteLock(root, async(): Promise => { - try { - // A container's deletion takes its folder and everything left in it - // (the root's .acl and .meta), FileDataAccessor 7.3.0. - await this.accessor.deleteResource(root); - } catch (error: unknown) { - if (!NotFoundHttpError.isInstance(error)) { - throw error; - } - } - }); - } - - private async deleteTree(identifier: ResourceIdentifier): Promise { - if (isContainerIdentifier(identifier)) { - for (const child of await this.listChildren(identifier)) { - await this.deleteTree(child); - } - } - try { - await this.store.deleteResource(identifier); - } catch (error: unknown) { - // Gone already: an auxiliary resource the store removed with its - // subject, or a resource someone else deleted meanwhile. - if (!NotFoundHttpError.isInstance(error)) { - throw error; - } - } - } - - /** - * The children worth deleting one by one: auxiliary resources (.acl, .meta) - * go with their subject, and the store refuses a .meta outright. - */ - private async listChildren(container: ResourceIdentifier): Promise { - const children: ResourceIdentifier[] = []; - for await (const metadata of this.accessor.getChildren(container)) { - const child = { path: metadata.identifier.value }; - if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(child)) { - children.push(child); - } - } - // Listed first, deleted after: a folder is not changed under its iterator. - return children; - } -} diff --git a/account-admin/src/index.ts b/account-admin/src/index.ts index 39df6bd..63aa994 100644 --- a/account-admin/src/index.ts +++ b/account-admin/src/index.ts @@ -1 +1 @@ -export * from './AdminDeleteAccountHandler'; +export * from './AccountAdminHandler'; diff --git a/account-admin/test/AdminDeleteAccountHandler.test.js b/account-admin/test/AccountAdminHandler.test.js similarity index 52% rename from account-admin/test/AdminDeleteAccountHandler.test.js rename to account-admin/test/AccountAdminHandler.test.js index 5cada90..7b05e0b 100644 --- a/account-admin/test/AdminDeleteAccountHandler.test.js +++ b/account-admin/test/AccountAdminHandler.test.js @@ -1,30 +1,41 @@ -// What the admin route must get right, on fake stores: who may call it, and -// the order (pod contents through the store, the root through the accessor, -// the account record last), because a pod root left behind keeps its name -// taken and an account deleted first leaves pods nobody can reach. +// What the admin route must get right, on fake stores: that only the token +// opens it, and the deletion order (pod contents through the store, the root +// through the accessor, the account record last), because a pod root left +// behind keeps its name taken and an account deleted first leaves pods +// nobody can reach. const { test } = require('node:test'); const assert = require('node:assert/strict'); const { NotFoundHttpError } = require('@solid/community-server'); -const { AdminDeleteAccountHandler } = require('../dist'); +const { AccountAdminHandler } = require('../dist'); const POD = 'https://gone.example.org/'; +const TOKEN = 't'.repeat(43); // A pod as the file backend lists it: containers end in '/', .acl files are // listed as children, .meta files are not. -function makeWorld({ failOn } = {}) { +function makeWorld({ failOn, token = TOKEN } = {}) { const tree = { [POD]: [`${POD}.acl`, `${POD}profile/`, `${POD}README`, `${POD}README.acl`], [`${POD}profile/`]: [`${POD}profile/card`], }; const log = []; const logins = { - admin: [{ id: 'l1', email: 'admin@example.org' }], - bob: [{ id: 'l2', email: 'bob@example.org' }], + keep: [{ id: 'l2', email: 'keep@example.org' }], target: [{ id: 'l3', email: 'gone@example.org' }], + halfway: [], }; const world = { log, - accountStorage: { delete: async (type, id) => { log.push(`account:${type}:${id}`); } }, + accountStorage: { + delete: async (type, id) => { log.push(`account:${type}:${id}`); }, + has: async (type, id) => type === 'account' && id in logins, + entries: async function* (type) { + assert.equal(type, 'account'); + for (const id of Object.keys(logins)) { + yield { id, rememberLogin: false }; + } + }, + }, passwordStore: { findByAccount: async (id) => logins[id] ?? [], findByEmail: async (email) => { @@ -64,41 +75,43 @@ function makeWorld({ failOn } = {}) { }, }, }; - world.handler = new AdminDeleteAccountHandler( + world.handler = new AccountAdminHandler( world.accountStorage, world.passwordStore, world.podStore, world.webIdStore, - world.store, world.accessor, world.auxiliaryStrategy, world.locker, - [' Admin@Example.org '], + world.store, world.accessor, world.auxiliaryStrategy, world.locker, token, ); return world; } -const call = (world, accountId, json) => world.handler.handle({ accountId, json, method: 'POST', target: { path: 'x' } }); +const call = (world, json) => world.handler.handle({ json, method: 'POST', target: { path: 'x' } }); -test('refuses a caller who is not signed in (401)', async () => { +test('refuses a wrong, missing or same-length-but-different token (403), and deletes nothing', async () => { const world = makeWorld(); - await assert.rejects(call(world, undefined, { email: 'gone@example.org' }), (error) => error.statusCode === 401); + for (const token of [undefined, '', 'nope', 'u'.repeat(43), 42]) { + await assert.rejects(call(world, { token, action: 'delete', email: 'gone@example.org' }), + (error) => error.statusCode === 403, String(token)); + } assert.deepEqual(world.log, []); }); -test('refuses a signed-in account that is not an admin (403), and deletes nothing', async () => { - const world = makeWorld(); - await assert.rejects(call(world, 'bob', { email: 'gone@example.org' }), (error) => error.statusCode === 403); - assert.deepEqual(world.log, []); +test('a configured token shorter than 32 characters refuses everyone, even when it is sent back', async () => { + for (const token of ['', 'short']) { + const world = makeWorld({ token }); + await assert.rejects(call(world, { token, action: 'list' }), (error) => error.statusCode === 403); + } }); -test('an empty admin list lets nobody in', async () => { - const world = makeWorld(); - const handler = new AdminDeleteAccountHandler( - world.accountStorage, world.passwordStore, world.podStore, world.webIdStore, - world.store, world.accessor, world.auxiliaryStrategy, world.locker, [], - ); - await assert.rejects(handler.handle({ accountId: 'admin', json: { email: 'gone@example.org' } }), - (error) => error.statusCode === 403); +test('lists every account with its logins, pods and WebIDs, a halfway sign-up included', async () => { + const { json } = await call(makeWorld(), { token: TOKEN, action: 'list' }); + assert.deepEqual(json.accounts, [ + { accountId: 'keep', emails: ['keep@example.org'], pods: [], webIds: [] }, + { accountId: 'target', emails: ['gone@example.org'], pods: [POD], webIds: [`${POD}profile/card#me`] }, + { accountId: 'halfway', emails: [], pods: [], webIds: [] }, + ]); }); test('deletes contents depth first through the store, the root through the accessor, the account last', async () => { const world = makeWorld(); - const result = await call(world, 'admin', { email: 'gone@example.org' }); + const result = await call(world, { token: TOKEN, action: 'delete', email: 'Gone@Example.org ' }); assert.deepEqual(world.log, [ `store:${POD}profile/card`, `store:${POD}profile/`, @@ -116,9 +129,15 @@ test('deletes contents depth first through the store, the root through the acces }); }); +test('deletes an account without a login by its id', async () => { + const world = makeWorld(); + await call(world, { token: TOKEN, action: 'delete', accountId: 'halfway' }); + assert.deepEqual(world.log, ['account:account:halfway']); +}); + test('a failure inside a pod leaves the account in place, so the call can run again', async () => { const world = makeWorld({ failOn: `${POD}README` }); - await assert.rejects(call(world, 'admin', { email: 'gone@example.org' }), /disk full/u); + await assert.rejects(call(world, { token: TOKEN, action: 'delete', email: 'gone@example.org' }), /disk full/u); assert.ok(!world.log.some((entry) => entry.startsWith('account:'))); assert.ok(!world.log.some((entry) => entry.startsWith('accessor:'))); }); @@ -131,18 +150,16 @@ test('a resource already gone is not an error', async () => { } world.log.push(`store:${path}`); }; - await call(world, 'admin', { email: 'gone@example.org' }); + await call(world, { token: TOKEN, action: 'delete', email: 'gone@example.org' }); assert.equal(world.log.at(-1), 'account:account:target'); }); -test('an admin cannot delete their own account here', async () => { - const world = makeWorld(); - await assert.rejects(call(world, 'admin', { email: 'admin@example.org' }), (error) => error.statusCode === 400); - assert.deepEqual(world.log, []); -}); - -test('an unknown email is a 404; no target is a 400', async () => { +test('unknown email or id is a 404; no target or no action is a 400', async () => { const world = makeWorld(); - await assert.rejects(call(world, 'admin', { email: 'nobody@example.org' }), (error) => error.statusCode === 404); - await assert.rejects(call(world, 'admin', {}), (error) => error.statusCode === 400); + await assert.rejects(call(world, { token: TOKEN, action: 'delete', email: 'nobody@example.org' }), + (error) => error.statusCode === 404); + await assert.rejects(call(world, { token: TOKEN, action: 'delete', accountId: 'nope' }), + (error) => error.statusCode === 404); + await assert.rejects(call(world, { token: TOKEN, action: 'delete' }), (error) => error.statusCode === 400); + await assert.rejects(call(world, { token: TOKEN }), (error) => error.statusCode === 400); }); diff --git a/admin/admin.js b/admin/admin.js new file mode 100755 index 0000000..5581b72 --- /dev/null +++ b/admin/admin.js @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// The account admin of a CSS instance, run INSIDE the css container (the +// image puts it at /opt/overlay/admin.js; scripts/admin.sh runs it with +// `docker compose exec`). Stock CSS cannot list every account nor delete one; +// account-admin/ (solid-kit ADR 012) adds a route that can, opened by a token +// that start/admins.js renews at every start in a file only this container's +// user can read. Being able to run this is the permission: ssh + docker. +// +// admin.js the TUI: list, sort, filter, select, delete +// admin.js list [--json] every account, its logins, pods and WebIDs +// admin.js delete ... --yes [--json] +// +// Plain Node, nothing to install: it ships with the image and changes with it. +// +// It calls the server on 127.0.0.1 and says which host it means with +// X-Forwarded-Host/-Proto, from CSS_BASE_URL: CSS picks the route (and, with +// subdomain pods, the pod) from the host, and the gateway is not in the way +// in here. CSS honours those headers (OriginalUrlExtractor, 7.3.0). +// +// Every deletion is also appended, one JSON line each, to DELETED_LOG, which +// scripts/admin.sh reads afterwards to remove the connector's agents (the +// connector's identities.json is outside CSS and outside this container). +"use strict"; +const fs = require("fs"); +const http = require("http"); +const readline = require("readline"); + +const TOKEN_FILE = "/community-server/config/identity/handler/account-admin.token"; +const DELETED_LOG = process.env.ADMIN_DELETED_LOG || "/tmp/account-admin-deleted.jsonl"; +const base = new URL(process.env.CSS_BASE_URL || "http://localhost:3000/"); +const port = Number(process.env.CSS_PORT || 3000); + +function call(body) { + const token = fs.readFileSync(process.env.ADMIN_TOKEN_FILE || TOKEN_FILE, "utf-8").trim(); + const data = JSON.stringify({ token, ...body }); + return new Promise((resolve, reject) => { + const req = http.request({ + host: "127.0.0.1", port, method: "POST", path: "/.account/admin/accounts/", + headers: { + "content-type": "application/json", accept: "application/json", + "content-length": Buffer.byteLength(data), + "x-forwarded-host": base.host, "x-forwarded-proto": base.protocol.replace(":", ""), + }, + }, (res) => { + let text = ""; + res.on("data", (chunk) => { text += chunk; }); + res.on("end", () => { + let json = {}; + try { json = JSON.parse(text); } catch { /* not JSON: said below */ } + if (res.statusCode !== 200) { + reject(new Error(`${res.statusCode} ${json.message || text.slice(0, 200)}`)); + } else { + resolve(json); + } + }); + }); + req.on("error", reject); + req.end(data); + }); +} + +const list = async () => (await call({ action: "list" })).accounts; + +async function remove(account) { + const target = account.emails[0] ? { email: account.emails[0] } : { accountId: account.accountId }; + const { deleted } = await call({ action: "delete", ...target }); + fs.appendFileSync(DELETED_LOG, JSON.stringify(deleted) + "\n", { mode: 0o600 }); + return deleted; +} + +const label = (a) => a.emails.join(", ") || `(no login) ${a.accountId}`; + +// ---------------------------------------------------------------- commands + +async function commandList(json) { + const accounts = await list(); + if (json) { + console.log(JSON.stringify(accounts, null, 2)); + return; + } + console.log(`${accounts.length} account(s) on ${base.host}`); + for (const a of accounts) { + console.log(`- ${label(a)}`); + for (const pod of a.pods) console.log(` pod ${pod}`); + for (const webId of a.webIds) console.log(` WebID ${webId}`); + } +} + +async function commandDelete(targets, yes, json) { + if (!yes) { + throw new Error("delete deletes for good: add --yes (the TUI asks instead)"); + } + const accounts = await list(); + const results = []; + for (const target of targets) { + const account = accounts.find((a) => a.accountId === target || a.emails.includes(target.toLowerCase())); + if (!account) { + throw new Error(`no account ${target}`); + } + results.push(await remove(account)); + } + if (json) { + console.log(JSON.stringify(results, null, 2)); + } else { + for (const d of results) console.log(`deleted ${label(d)}: ${d.pods.length} pod(s) ${d.pods.join(" ")}`); + } +} + +// ---------------------------------------------------------------- the TUI + +const SORTS = [ + ["email", (a, b) => label(a).localeCompare(label(b))], + ["pods, most first", (a, b) => b.pods.length - a.pods.length || label(a).localeCompare(label(b))], + ["pod name", (a, b) => (a.pods[0] || "~").localeCompare(b.pods[0] || "~")], +]; + +async function tui() { + const out = process.stdout; + const state = { all: await list(), cursor: 0, top: 0, selected: new Set(), sort: 0, filter: "", mode: "list", input: "", message: "" }; + const esc = (s) => `\x1b[${s}`; + const bold = (s) => `${esc("1m")}${s}${esc("0m")}`; + const dim = (s) => `${esc("2m")}${s}${esc("0m")}`; + const inverse = (s) => `${esc("7m")}${s}${esc("0m")}`; + const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n)); + + const visible = () => state.all + .filter((a) => !state.filter || `${label(a)} ${a.pods.join(" ")}`.toLowerCase().includes(state.filter.toLowerCase())) + .sort(SORTS[state.sort][1]); + const chosen = () => { + const rows = visible(); + return state.selected.size ? state.all.filter((a) => state.selected.has(a.accountId)) : rows.slice(state.cursor, state.cursor + 1); + }; + + function draw() { + const width = out.columns || 100; + const height = (out.rows || 30) - 6; + const rows = visible(); + state.cursor = Math.max(0, Math.min(state.cursor, rows.length - 1)); + if (state.cursor < state.top) state.top = state.cursor; + if (state.cursor >= state.top + height) state.top = state.cursor - height + 1; + const lines = [ + bold(`Accounts on ${base.host}`) + dim(` ${rows.length} shown of ${state.all.length} · sort: ${SORTS[state.sort][0]}` + + `${state.filter ? ` · filter: "${state.filter}"` : ""} · ${state.selected.size} selected`), + dim("↑↓ move space select a all shown / filter s sort d delete r reload q quit"), + "", + ]; + const emailWidth = Math.max(20, Math.floor(width * 0.4)); + rows.slice(state.top, state.top + height).forEach((a, i) => { + const mark = state.selected.has(a.accountId) ? "[x]" : "[ ]"; + const pods = a.pods.map((p) => p.replace(/^https?:\/\//, "").replace(/\/$/, "")).join(" ") || dim("no pod"); + const line = fit(`${mark} ${fit(label(a), emailWidth)} ${pods}`, width - 1); + lines.push(state.top + i === state.cursor ? inverse(line) : line); + }); + if (rows.length === 0) lines.push(dim(" (nothing matches)")); + lines.push(""); + if (state.mode === "filter") lines.push(`filter: ${state.input}█`); + else if (state.mode === "confirm") { + const doomed = chosen(); + lines.push(bold(`Delete ${doomed.length} account(s), all their pods and everything in them, for good?`)); + for (const a of doomed.slice(0, 5)) lines.push(` ${label(a)} ${a.pods.join(" ")}`); + if (doomed.length > 5) lines.push(` … and ${doomed.length - 5} more`); + lines.push(`Type ${doomed.length} then Enter to delete, Esc to go back: ${state.input}█`); + } else lines.push(state.message); + out.write(esc("H") + esc("2J") + lines.join("\n")); + } + + async function deleteChosen() { + const doomed = chosen(); + state.mode = "busy"; + let done = 0; + for (const a of doomed) { + state.message = `deleting ${label(a)} (${done + 1}/${doomed.length})…`; + draw(); + try { + await remove(a); + done += 1; + } catch (error) { + state.message = `stopped at ${label(a)}: ${error.message} — ${done} deleted before it`; + break; + } + } + if (done === doomed.length) state.message = `${done} account(s) deleted; their pod names are free again.`; + state.selected.clear(); + state.all = await list(); + state.mode = "list"; + } + + return new Promise((resolve) => { + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + out.write(esc("?1049h") + esc("?25l")); + const quit = () => { + out.write(esc("?25h") + esc("?1049l")); + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(); + }; + process.stdin.on("keypress", async (str, key = {}) => { + if (key.ctrl && key.name === "c") return quit(); + if (state.mode === "busy") return undefined; + const rows = visible(); + if (state.mode === "filter" || state.mode === "confirm") { + if (key.name === "escape") { state.mode = "list"; state.input = ""; } + else if (key.name === "backspace") state.input = state.input.slice(0, -1); + else if (key.name === "return") { + if (state.mode === "filter") { state.filter = state.input; state.cursor = 0; state.mode = "list"; } + else if (state.input === String(chosen().length)) { state.input = ""; await deleteChosen(); } + else { state.message = "Not the number shown: nothing deleted."; state.mode = "list"; } + state.input = ""; + } else if (str && !key.ctrl && str.length === 1) state.input += str; + return draw(); + } + state.message = ""; + switch (key.name) { + case "q": return quit(); + case "up": case "k": state.cursor -= 1; break; + case "down": case "j": state.cursor += 1; break; + case "pageup": state.cursor -= 10; break; + case "pagedown": state.cursor += 10; break; + case "space": { + const a = rows[state.cursor]; + if (a) state.selected.has(a.accountId) ? state.selected.delete(a.accountId) : state.selected.add(a.accountId); + state.cursor += 1; + break; + } + case "a": { + const all = rows.every((a) => state.selected.has(a.accountId)); + for (const a of rows) all ? state.selected.delete(a.accountId) : state.selected.add(a.accountId); + break; + } + case "s": state.sort = (state.sort + 1) % SORTS.length; break; + case "r": state.all = await list(); state.message = "reloaded"; break; + case "d": if (chosen().length) { state.mode = "confirm"; state.input = ""; } break; + default: if (str === "/") { state.mode = "filter"; state.input = state.filter; } + } + return draw(); + }); + process.stdout.on("resize", draw); + draw(); + }); +} + +// ---------------------------------------------------------------- main + +(async () => { + const [command, ...rest] = process.argv.slice(2); + const json = rest.includes("--json"); + const args = rest.filter((a) => !a.startsWith("--")); + if (!command) { + if (!process.stdin.isTTY) throw new Error("the TUI needs a terminal: `admin.js list` or `admin.js delete … --yes` without one"); + await tui(); + } else if (command === "list") await commandList(json); + else if (command === "delete" && args.length) await commandDelete(args, rest.includes("--yes"), json); + else throw new Error("usage: admin.js | admin.js list [--json] | admin.js delete ... --yes [--json]"); +})().catch((error) => { + console.error(`admin: ${error.message}`); + process.exit(1); +}); diff --git a/config/instance.json b/config/instance.json index 8570040..fb076d2 100644 --- a/config/instance.json +++ b/config/instance.json @@ -57,9 +57,10 @@ " the instance's SMTP_* variables (instance-template/email.env): stock BaseEmailSender when they are", " set, stock default.json's 'No email server is configured.' when not. Stock default.json gave no", " way to reset a password.", - "5. Admins can delete an account and its pods (stock CSS cannot): identity/handler/account-admin.json,", - " written at every start by start/admins.js from ADMIN_EMAILS, route /.account/admin/delete-account/", - " (account-admin/ in this repo, solid-kit ADR 012). No ADMIN_EMAILS: the route refuses everyone." + "5. Accounts can be listed and deleted from the server's own terminal (stock CSS cannot):", + " identity/handler/account-admin.json, written at every start by start/admins.js with a fresh token", + " only the container can read. Route /.account/admin/accounts/; tool scripts/admin.sh (account-admin/", + " and admin/ in this repo, solid-kit ADR 012)." ] }, { diff --git a/docs/admin/README.md b/docs/admin/README.md new file mode 100644 index 0000000..388f42e --- /dev/null +++ b/docs/admin/README.md @@ -0,0 +1,18 @@ +# Account admin + +Stock CSS cannot show you every account on your server, and cannot delete +one. The image can, from the server's own terminal: + +```sh +scripts/admin.sh +``` + +| You want to… | Read | +|---|---| +| try it once, safely, on a throwaway server | [Tutorial: your first clean-up](tutorial.md) | +| delete accounts on a real instance | [How-to: delete accounts on a VPS](how-to.md) | +| look up a key, a command or the route | [Reference](reference.md) | +| understand why it works this way | [Explanation](explanation.md) | + +Decided in solid-kit ADR 012. Built in `account-admin/` (the component inside +CSS) and `admin/admin.js` (the terminal tool). diff --git a/docs/admin/explanation.md b/docs/admin/explanation.md new file mode 100644 index 0000000..7915164 --- /dev/null +++ b/docs/admin/explanation.md @@ -0,0 +1,72 @@ +# Explanation: why the account admin works this way + +## The problem + +Community Solid Server 7.3 never deletes an account or a pod. Its account API +removes a login (never the last one), a WebID link, client credentials and a +pod owner, and nothing more. So: + +- **a pod name, once taken, stays taken.** Every test round burned names, and + pocpod0 collected many throwaway accounts. +- **a server could only be cleaned by wiping it** (`docker compose down -v`), + which stops being possible the day one real person has an account there. +- **nobody could honour the right to erasure** (GDPR art. 17), not even an + admin. + +We checked upstream before building (2026-11-03): nothing in CSS up to 7.3.0 +or in 8.0.0-alpha.3, nothing planned that we could find. + +## Why inside CSS, and not a script on the data folder + +The data folder's layout is CSS's own business: it can change at any release. +A script deleting files there would also race the server, which may be +writing to the same pod at that moment. So the deletion runs inside CSS, +through its own resource store and locks (`account-admin/`). That is the +image's second piece of code inside CSS (ADR 009 allowed one). Every CSS +upgrade checks it, and the smoke run is what tells. + +## Why the pod root is the odd one out + +The resource store deletes everything inside a pod, but it refuses to delete +the pod itself. The Solid protocol forbids deleting a storage's root container +(§5.4), and every pod root is one. The root cannot simply stay either: CSS +refuses to create a pod while anything sits at its address, so the name would +remain taken. + +So the contents go through the store, as a DELETE request would (locks, +`.acl` files, notifications). The root alone goes one level lower, through +the data accessor, while holding the same write lock the store would take. +The account record is deleted last: if anything fails before that, the +account is still there, and running the deletion again finishes it. + +## Why the terminal, and no admin emails + +A web page needs some way to tell an admin apart from anyone else. The first +draft listed admin login emails, but CSS never checks that an email belongs to +whoever signs up with it. An address on the list that nobody had used yet +would have made whoever signed up with it first an admin. + +The people who run the server already have a stronger proof: ssh, and Docker +on that machine. So the permission is being on the server. At every start the +container writes a fresh random token into a file only it can read; the tool +runs inside the container and reads it. Nothing goes into `.env`, there is +nothing to rotate, and a guessed token is refused (and, with the gateway +templates, never reaches CSS). + +That means no browser page for admins. If one is ever wanted, it needs its own +answer to "who is this?", and ADR 012 is the place to decide it. + +## Why the connector clean-up is in the script + +The MCP connector keeps its agents' credentials in `identities.json`, outside +CSS and outside the css container. An agent acting for a deleted account can +no longer do anything, but its entry would sit there. `scripts/admin.sh` +removes those entries after the tool exits, through the connector's own +writer, so the file stays valid for the connector's next start. + +## What comes next + +"Delete my account" for the person themself, the right to erasure, uses the +same deletion with a different permission: their own sign-in, and their +passphrase asked again. Where that button lives (the account page or +solid-backoffice) is still open in ADR 012. The roadmap tracks it. diff --git a/docs/admin/how-to.md b/docs/admin/how-to.md new file mode 100644 index 0000000..fb5e600 --- /dev/null +++ b/docs/admin/how-to.md @@ -0,0 +1,68 @@ +# How-to: delete accounts on a VPS + +**Deleting cannot be undone.** An account's pods and everything in them go +with it. + +## Before you start + +- You can ssh to the server and run `docker compose` in the instance's + checkout, for example `/srv//solid-css-overlay`. +- The instance runs 7.3.0-4 or later. `docker compose exec css cat + /community-server/templates/provider.json` names the version. +- The instance mounts its own `instance.json` (for more reserved pod names)? + Then its `import` list has this line after the email one. Without it, the + tool answers 404: + ```json + "css:config/identity/handler/account-admin.json", + ``` + +## Look and choose + +```sh +ssh +cd /srv//solid-css-overlay +scripts/admin.sh +``` + +| To | Press | +|---|---| +| move | ↑ ↓ (or j k), Page Up / Page Down | +| select or unselect one | space | +| select every account shown | a | +| show only some accounts | `/`, type part of an email or pod name, Enter (empty: show all) | +| change the order | s: email, most pods first, pod name | + +## Delete + +1. Select the accounts. With none selected, `d` takes the one under the + cursor. +2. Press `d`, check the list the tool shows, type the number it asks for, + and press Enter. +3. Press `q`. + +When you quit, the script also removes the connector's agents that acted for +what went (`connector agents removed: …`). If the connector does not run on +this server, the script prints the WebIDs and pods instead, so you can remove +them where it runs. + +## Without the TUI + +For a script, or when you already know the email: + +```sh +scripts/admin.sh list +scripts/admin.sh delete gone@example.org other@example.org --yes +``` + +An account with no login (a sign-up left halfway) has no email. Use the +account id from `scripts/admin.sh list` instead. Stock CSS also removes those +by itself after 30 minutes. + +## If something goes wrong + +| You see | It means | +|---|---| +| `admin: 404 …` | the instance's own `instance.json` lacks the import line (above), or the image is older than 7.3.0-4 | +| `admin: 403 …` | the token did not match. Restart the css container: it writes a new one | +| `stopped at : …` | the deletion failed partway. That account is still there; run it again | +| `the TUI needs a terminal` | run it from an ssh session, not through a pipe | diff --git a/docs/admin/reference.md b/docs/admin/reference.md new file mode 100644 index 0000000..b527144 --- /dev/null +++ b/docs/admin/reference.md @@ -0,0 +1,85 @@ +# Reference + +## `scripts/admin.sh` + +Run from the instance's checkout. It calls the tool in the `css` container, +then cleans up the connector. + +| Command | Does | +|---|---| +| `scripts/admin.sh` | the TUI | +| `scripts/admin.sh list [--json]` | every account: logins, pods, WebIDs | +| `scripts/admin.sh delete ... --yes [--json]` | deletes those accounts, without asking | + +| Variable | Default | | +|---|---|---| +| `INSTANCE_DIR` | `instance-template/` next to the script | where `compose.yml` is | + +Exit code: the tool's own. The connector clean-up runs even if the tool +stopped partway, for whatever was deleted before it stopped. + +## TUI keys + +| Key | | +|---|---| +| ↑ ↓, j k | move | +| Page Up, Page Down | move by 10 | +| space | select or unselect, then move down | +| a | select all shown; again: unselect them | +| / | filter on email and pod address; Enter applies; empty shows all | +| s | sort: email → most pods first → pod name | +| r | reload from the server | +| d | delete the selected accounts, or the one under the cursor | +| Esc | leave the filter or the confirmation | +| q, Ctrl-C | quit | + +A deletion asks you to type the number of accounts about to go. + +## `admin/admin.js` (in the image: `/opt/overlay/admin.js`) + +| Variable | Default | | +|---|---|---| +| `CSS_BASE_URL` | set by compose | the host the tool asks CSS about (`X-Forwarded-Host`, `X-Forwarded-Proto`) | +| `CSS_PORT` | `3000` | where CSS listens inside the container | +| `ADMIN_TOKEN_FILE` | `/community-server/config/identity/handler/account-admin.token` | | +| `ADMIN_DELETED_LOG` | `/tmp/account-admin-deleted.jsonl` | one JSON line per deleted account; `admin.sh` reads it, then removes it | + +## The route + +`POST /.account/admin/accounts/`, JSON body: + +| Body | Answer (200) | +|---|---| +| `{ "token", "action": "list" }` | `{ "accounts": [ { accountId, emails, pods, webIds } ] }` | +| `{ "token", "action": "delete", "email" }` | `{ "deleted": { accountId, emails, pods, webIds } }` | +| `{ "token", "action": "delete", "accountId" }` | same | + +| Status | When | +|---|---| +| 400 | no action, or no email or accountId | +| 403 | the token is wrong or missing, or the server has none | +| 404 | no such account; or, from outside, the gateway refuses the path | + +The token is 43 characters, random, renewed at every start by +`start/admins.js`. It is written, mode 600, into +`config/identity/handler/account-admin.json` (the route's config) and +`config/identity/handler/account-admin.token` (for the tool). Both live in the +container's filesystem, not in the data volume. + +## What a deletion removes + +| Removed | How | +|---|---| +| every resource in the account's pods | resource store, depth first | +| each pod root, with its `.acl` and `.meta` | data accessor, under the write lock | +| logins, pod records, WebID links, client credentials | `delete('account', id)` on the account storage | +| the connector's agents for its WebIDs or its pods | `scripts/admin.sh`, through `removeIdentitiesFor` | + +Not removed: copies of the person's data held elsewhere, for example pulled +into a collective's pod (ADR 006). Those belong to whoever holds them. + +## The gateway + +`instance-template/Caddyfile.tmpl` answers 404 for `/.account/admin/*` on the +server's host, and `gateway-vhost.conf.tmpl` does so on every host. A gateway +set up before 7.3.0-4 lacks this. The token still protects the route. diff --git a/docs/admin/tutorial.md b/docs/admin/tutorial.md new file mode 100644 index 0000000..a241e0c --- /dev/null +++ b/docs/admin/tutorial.md @@ -0,0 +1,74 @@ +# Tutorial: your first clean-up + +In ten minutes you will run the image on your own machine, make two accounts, +delete one with the admin tool, and see its pod name come free again. Nothing +here touches a real instance. + +You need Docker and a clone of this repo. + +## 1. Start a throwaway server + +```sh +docker build -t solid-css-overlay:dev . +test/try-local.sh 3123 +``` + +The server runs at http://localhost:3123/ with an empty data folder, in a +container called `try-css-overlay-3123`. + +## 2. Make two accounts, each with a pod + +In a browser, open http://localhost:3123/.account/login/password/register/ +and sign up as `keep@example.org`. Create a pod called `keep`. + +Sign out, sign up again as `gone@example.org`, and create a pod called +`trial`. + +## 3. Open the admin tool + +The tool runs inside the server's container. On an instance, +`scripts/admin.sh` does this for you. Here, call it directly: + +```sh +docker exec -it try-css-overlay-3123 node /opt/overlay/admin.js +``` + +You see both accounts, each with its pod: + +``` +Accounts on localhost:3123 2 shown of 2 · sort: email · 0 selected +↑↓ move space select a all shown / filter s sort d delete r reload q quit + +[ ] gone@example.org trial.localhost:3123 +[ ] keep@example.org keep.localhost:3123 +``` + +Press `s` a few times and watch the order change. Press `/`, type `gone`, and +press Enter: only that account is left on screen. + +## 4. Delete it + +1. Press space to select `gone@example.org`. +2. Press `d`. The tool shows what will go and asks you to type how many + accounts that is. +3. Type `1` and press Enter. + +The line disappears and the tool says the pod names are free again. Press `q`. + +## 5. See that the name is free + +Sign up once more as `again@example.org` and create a pod called `trial`. +Before this tool, the server answered "There already is a resource" here. Now +it makes the pod. + +## 6. Clean up + +```sh +docker rm -f try-css-overlay-3123 +``` + +## What you did + +You used the server's own terminal as the permission: no admin password and +no list of admin emails. On a real instance the same tool runs over ssh; +[the how-to](how-to.md) shows the steps. diff --git a/docs/handoff-mapsofmaking.md b/docs/handoff-mapsofmaking.md index 96fb9ea..8145cd3 100644 --- a/docs/handoff-mapsofmaking.md +++ b/docs/handoff-mapsofmaking.md @@ -10,7 +10,7 @@ particular to Maps of Making. | | | |---|---| | Server address (`CSS_BASE_URL`) | `https://pod.mapsofmaking.org/` | -| Test pod | `nicolas-test` → `https://nicolas-test.pod.mapsofmaking.org/`, a throwaway. `openfab-bxl` waited until accounts could be deleted: from 7.3.0-4 an admin can (`scripts/admin.sh`), so test accounts can be cleaned before OpenFab's goes in | +| Test pod | `nicolas-test` → `https://nicolas-test.pod.mapsofmaking.org/`, a throwaway. `openfab-bxl` waited until accounts could be deleted: from 7.3.0-4 the server's terminal can (`scripts/admin.sh`), so test accounts can be cleaned before OpenFab's goes in | | Connector (`MCP_PUBLIC_BASE`) | `https://mcp.pod.mapsofmaking.org/` | | `MCP_ALLOWED_HOSTS` | `mcp.pod.mapsofmaking.org,pod.mapsofmaking.org` (the second is for `/onboard/`) | | `INSTANCE_NAME` | `mom-pods`: containers `mom-pods-css` and `mom-pods-mcp-connector`, volume `mom-pods_css-data`. One checkout per function, prefix `mom-` | @@ -283,12 +283,11 @@ only while the instance holds test accounts only. ## Next, not now -- **OpenFab's account and `openfab-bxl`**: unblocked by 7.3.0-4 (admins - delete accounts, `ROADMAP.md`). On update: set `ADMIN_EMAILS` to Nicolas's - existing login in `.env`; if `pods/instance.json` is mounted, add the - account-admin import (CHANGELOG 7.3.0-4); then `scripts/admin.sh - delete-account` cleans the test accounts one by one. A `make` target for - it belongs in vps-infra. +- **OpenFab's account and `openfab-bxl`**: unblocked by 7.3.0-4 (account + admin, `docs/admin/how-to.md`). On update: if `pods/instance.json` is + mounted, add the account-admin import (CHANGELOG 7.3.0-4); then + `scripts/admin.sh` lists the test accounts and deletes them. A `make` + target for it belongs in vps-infra. - **Backups of the `mom-pods_css-data` volume**: it holds every account and pod. This install does not set them up. diff --git a/instance-template/.env.example b/instance-template/.env.example index 1f19d92..2c0c270 100644 --- a/instance-template/.env.example +++ b/instance-template/.env.example @@ -36,13 +36,6 @@ GATEWAY_NETWORK=gateway # either way, this only says so to apps. # OPEN_REGISTRATION=true -# Who may delete an account and its pods (stock CSS cannot): login emails of -# accounts on this server, comma-separated. Empty: nobody. Front door: -# scripts/admin.sh delete-account (solid-kit ADR 012). -# Stock CSS does not verify a login's email: list only an address that is -# ALREADY your login on this server (sign up first), or whoever signs up with -# it first becomes admin. Check the list again after any change. -ADMIN_EMAILS= # mcp-connector. It runs only with this line uncommented; without agents, # leave it commented and docker compose up -d starts CSS alone. diff --git a/instance-template/Caddyfile.tmpl b/instance-template/Caddyfile.tmpl index 347abd4..b6885a8 100644 --- a/instance-template/Caddyfile.tmpl +++ b/instance-template/Caddyfile.tmpl @@ -53,6 +53,19 @@ reverse_proxy {{INSTANCE_NAME}}-mcp-connector:3939 } + # The account admin route (solid-kit ADR 012) is for the server's own + # terminal (scripts/admin.sh calls it from inside the container). It needs + # a token only the container can read; refusing it here as well means a + # guessed token never even reaches CSS. On the server's host only: on a pod + # host the same path is the pod's own. + @accountadmin { + host {{DOMAIN}} + path /.account/admin/* + } + handle @accountadmin { + respond 404 + } + handle { # reverse_proxy passes the Host through unchanged, and CSS needs it so: # the Host decides WHICH POD a request is for. Never rewrite it (ADR 002 diff --git a/instance-template/compose.yml b/instance-template/compose.yml index 3c24150..504a9b6 100644 --- a/instance-template/compose.yml +++ b/instance-template/compose.yml @@ -34,11 +34,6 @@ services: # create accounts (ADR 010 amendment, points 8-11). Set it only once the # gateway rate-limits /.account/. - OPEN_REGISTRATION=${OPEN_REGISTRATION:-} - # Login emails that may delete any account and its pods (comma-separated; - # solid-kit ADR 012, scripts/admin.sh). Empty: nobody can (start/admins.js). - # Only emails that are already logins here: CSS does not verify an email, - # so an unclaimed one goes to whoever signs up with it first. - - ADMIN_EMAILS=${ADMIN_EMAILS:-} # The email sender, so "Forgot your password?" sends its link: SMTP_HOST, # SMTP_PORT, SMTP_USER, SMTP_PASS, EMAIL_FROM (email.env.example). Its own # git-ignored file, not .env, because it holds a password; absent, the diff --git a/instance-template/gateway-vhost.conf.tmpl b/instance-template/gateway-vhost.conf.tmpl index 58f73d0..3bcd75a 100644 --- a/instance-template/gateway-vhost.conf.tmpl +++ b/instance-template/gateway-vhost.conf.tmpl @@ -68,6 +68,15 @@ server { # Files people put in their pods. client_max_body_size 100m; + # The account admin route (solid-kit ADR 012) is for the server's own + # terminal (scripts/admin.sh calls it from inside the container). It needs + # a token only the container can read; refusing it here as well means a + # guessed token never even reaches CSS. On every host here (nginx has no + # tidy per-host location): it also hides a pod resource at that path. + location ^~ /.account/admin/ { + return 404; + } + location / { # Variable + resolver: resolved per request, so nginx still starts # (and serves every other site) while this container is down. diff --git a/scripts/admin.sh b/scripts/admin.sh index e57a8ee..4ba81a5 100755 --- a/scripts/admin.sh +++ b/scripts/admin.sh @@ -1,90 +1,55 @@ #!/usr/bin/env bash -# An instance admin's front door to what stock CSS cannot do (solid-kit ADR 012). +# The account admin of an instance, from a terminal on its server (solid-kit +# ADR 012). Run from the instance's checkout: # -# scripts/admin.sh delete-account [--yes] +# scripts/admin.sh the TUI: list, sort, filter, select, delete +# scripts/admin.sh list [--json] +# scripts/admin.sh delete ... --yes [--json] # -# Deletes the account whose login is , every pod it made and -# everything in them, its WebID links and client credentials; the pod names -# are free again. Then removes from the connector's identities.json the -# agents that acted for it. Cannot be undone. +# Deleting an account takes all its pods and everything in them (the names are +# free again), its logins, WebID links and client credentials. Cannot be +# undone. Afterwards this script removes the connector's agents that acted for +# what went (identities.json, outside CSS). # -# It calls the server's admin route (POST /.account/admin/delete-account/, -# account-admin/ in this repo); nothing is deleted from the data folder by -# hand: that layout is CSS's own, and a script would race the server's locks. +# Permission is being here: the tool runs inside the css container (admin/ +# admin.js in the image) and reads the token start/admins.js renews at every +# start. No admin list, no password: whoever has ssh and docker on this +# server runs it already. # -# Who may: an account whose login email is in the instance's ADMIN_EMAILS. -# CSS_URL the server (default: CSS_BASE_URL from the instance's .env) -# ADMIN_EMAIL your login (asked if unset) -# ADMIN_PASSWORD your passphrase (asked, not echoed, if unset) -# INSTANCE_DIR where the instance's compose.yml is, for the connector -# cleanup (default: instance-template/ next to this script) -# -# Needs curl and python3 (JSON), and docker for the connector cleanup. +# INSTANCE_DIR where the instance's compose.yml is (default: instance-template/ +# next to this script) set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" instance="${INSTANCE_DIR:-$here/../instance-template}" +cd "$instance" -usage() { sed -n '4,5p' "$0" | sed 's/^# //' >&2; exit 2; } -[ "${1:-}" = delete-account ] && [ -n "${2:-}" ] || usage -target="$2" -yes="${3:-}" - -json() { python3 -c "import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1], {'d': d, 'json': json}) or '')" "$1"; } - -if [ -z "${CSS_URL:-}" ] && [ -f "$instance/.env" ]; then - CSS_URL="$(sed -n 's/^CSS_BASE_URL=//p' "$instance/.env" | tail -1)" -fi -[ -n "${CSS_URL:-}" ] || { echo "set CSS_URL (or CSS_BASE_URL in $instance/.env)" >&2; exit 2; } -base="${CSS_URL%/}" - -[ -n "${ADMIN_EMAIL:-}" ] || read -r -p "Your admin login email: " ADMIN_EMAIL -[ -n "${ADMIN_PASSWORD:-}" ] || { read -r -s -p "Your passphrase: " ADMIN_PASSWORD; echo; } +log=/tmp/account-admin-deleted.jsonl +tty=-T +[ -t 0 ] && [ -t 1 ] && tty=-it -if [ "$yes" != --yes ]; then - echo "This deletes, on $base and for good: the account of $target, all its pods and everything in them," - echo "its WebIDs, and the agents that act for it." - read -r -p "Type the email again to go on: " again - [ "$again" = "$target" ] || { echo "Not the same: nothing deleted." >&2; exit 1; } -fi - -# Sign in to the account API: the token goes in Authorization, as the stock -# JSON API documents (no cookie jar needed). -login_url="$(curl -fsS -H 'accept: application/json' "$base/.account/" | json "d['controls']['password']['login']")" -login="$(python3 -c 'import json,sys; print(json.dumps({"email": sys.argv[1], "password": sys.argv[2]}))' "$ADMIN_EMAIL" "$ADMIN_PASSWORD")" -token="$(curl -fsS -H 'content-type: application/json' -H 'accept: application/json' -d "$login" "$login_url" | json "d.get('authorization')")" \ - || { echo "Sign-in refused for $ADMIN_EMAIL." >&2; exit 1; } - -body="$(python3 -c 'import json,sys; print(json.dumps({"email": sys.argv[1]}))' "$target")" -answer="$(curl -sS -w '\n%{http_code}' -H 'content-type: application/json' -H 'accept: application/json' \ - -H "authorization: CSS-Account-Token $token" -d "$body" "$base/.account/admin/delete-account/")" -status="${answer##*$'\n'}" -answer="${answer%$'\n'*}" -if [ "$status" != 200 ]; then - echo "Refused ($status): $(printf '%s' "$answer" | json "d.get('message')" 2>/dev/null || printf '%s' "$answer")" >&2 - exit 1 -fi +status=0 +docker compose exec "$tty" css node /opt/overlay/admin.js "$@" || status=$? -deleted="$(printf '%s' "$answer" | json "json.dumps(d['deleted'])")" -echo "Deleted on $base:" -printf '%s' "$deleted" | python3 -c ' -import json, sys -d = json.load(sys.stdin) -print(" account", d["accountId"], "(" + ", ".join(d["emails"]) + ")") -for pod in d["pods"]: print(" pod ", pod) -for webId in d["webIds"]: print(" WebID ", webId)' +# What was deleted, even if the tool stopped halfway: one JSON line per account. +deleted="$(docker compose exec -T css sh -c "cat $log 2>/dev/null; rm -f $log" || true)" +[ -n "$deleted" ] || exit "$status" # The connector keeps its agents in identities.json, outside CSS: drop those -# that acted for this account, through the connector's own writer (validated, -# mode 600, safe on the single-file bind mount). -if [ -f "$instance/compose.yml" ] && (cd "$instance" && docker compose ps --status running --services 2>/dev/null | grep -qx mcp-connector); then - removed="$(cd "$instance" && docker compose exec -T mcp-connector node -e ' +# that acted for a deleted account (its WebIDs, or a WebID inside one of its +# pods), through the connector's own writer (validated, mode 600, safe on the +# single-file bind mount). +if docker compose ps --status running --services 2>/dev/null | grep -qx mcp-connector; then + removed="$(printf '%s\n' "$deleted" | docker compose exec -T mcp-connector node -e ' const { removeIdentitiesFor } = require("/app/src/identityRegistry"); - removeIdentitiesFor(JSON.parse(process.argv[1]), "/app/identities.json") + const lines = require("fs").readFileSync(0, "utf-8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); + removeIdentitiesFor({ webIds: lines.flatMap((d) => d.webIds), pods: lines.flatMap((d) => d.pods) }, "/app/identities.json") .then((labels) => console.log(labels.join(", ") || "none")) - .catch((error) => { console.error(error.message); process.exit(1); });' "$deleted")" - echo " connector agents removed: $removed" + .catch((error) => { console.error(error.message); process.exit(1); });')" + echo "connector agents removed: $removed" else - echo " connector: not running here, identities.json not touched. If this instance has one, remove" - echo " the entries whose webId is listed above (or lives in a pod above), or run this again where it runs." + echo "connector: not running here, identities.json not touched. If this instance has one," + echo "remove the entries whose webId is one of these, or lives in one of these pods:" + printf '%s\n' "$deleted" fi +exit "$status" diff --git a/start/admins.js b/start/admins.js index 1b33f7d..262fbbe 100644 --- a/start/admins.js +++ b/start/admins.js @@ -1,29 +1,24 @@ -// Runs before the server, on every start (start/start.sh): writes the admin -// route of account-admin/ (solid-kit ADR 012) with this instance's admins. -// Stock CSS cannot delete an account or a pod; this route can, for the login -// emails listed in ADMIN_EMAILS (comma-separated, from the instance's .env). -// Written here rather than in config/ for the reason start/email.js gives: an -// instance never edits the config, so what differs per instance comes from -// its environment at start. +// Runs before the server, on every start (start/start.sh): writes the account +// admin route of account-admin/ (solid-kit ADR 012) with a fresh random token, +// and the same token in a file only the container's user can read. Stock CSS +// cannot list every account nor delete one; this route can, for whoever sends +// the token, which is whoever can run a command in this container: the +// admin TUI (admin/admin.js, `scripts/admin.sh` from the instance's checkout). // -// Stock CSS does not verify the email of a password login. An address listed -// here that is not yet a login on this server belongs to whoever signs up -// with it first, and makes them admin: list only addresses already in use -// (.env.example says so to the instance). +// Why a token and not a list of admin emails (the first draft of 7.3.0-4): +// stock CSS never verifies a login's email, so a listed address nobody had +// signed up with yet made whoever took it first an admin. Who may delete an +// account is who runs the server, and ssh already says that. // -// ADMIN_EMAILS empty or unset: the route is still there and refuses everyone -// (403). Unlike a broken SMTP setting, that does not stop the start: no admin -// is the safe default, and an instance with no admin today is a normal one. -// -// The route: POST /.account/admin/delete-account/ { "email": "..." }, signed -// in to the account API. scripts/admin.sh is its front door. +// A new token at every start: nothing to rotate, nothing stored in the data +// volume, nothing to put in .env. Both files live in the container's own +// filesystem and go with it. +const crypto = require("crypto"); const fs = require("fs"); const out = "config/identity/handler/account-admin.json"; -const admins = (process.env.ADMIN_EMAILS || "") - .split(",") - .map((email) => email.trim().toLowerCase()) - .filter(Boolean); +const tokenFile = "config/identity/handler/account-admin.token"; +const token = crypto.randomBytes(32).toString("base64url"); const config = { "@context": [ @@ -32,21 +27,21 @@ const config = { ], "@graph": [ { - comment: "Written by start/admins.js at every start. account-admin/src/AdminDeleteAccountHandler.ts says why each piece is used.", - "@id": "urn:solid-server:overlay:AdminDeleteAccountRouter", + comment: "Written by start/admins.js at every start. account-admin/src/AccountAdminHandler.ts says why each piece is used.", + "@id": "urn:solid-server:overlay:AccountAdminRouter", "@type": "InteractionRouteHandler", route: { - "@id": "urn:solid-server:overlay:AdminDeleteAccountRoute", + "@id": "urn:solid-server:overlay:AccountAdminRoute", "@type": "RelativePathInteractionRoute", base: { "@id": "urn:solid-server:default:IndexRoute" }, - relativePath: "admin/delete-account/", + relativePath: "admin/accounts/", }, source: { "@type": "MethodFilterHandler", methods: ["POST"], source: { - "@id": "urn:solid-server:overlay:AdminDeleteAccountHandler", - "@type": "AdminDeleteAccountHandler", + "@id": "urn:solid-server:overlay:AccountAdminHandler", + "@type": "AccountAdminHandler", accountStorage: { "@id": "urn:solid-server:default:AccountStorage" }, passwordStore: { "@id": "urn:solid-server:default:PasswordStore" }, podStore: { "@id": "urn:solid-server:default:PodStore" }, @@ -57,21 +52,21 @@ const config = { accessor: { "@id": "urn:solid-server:default:FileDataAccessor" }, auxiliaryStrategy: { "@id": "urn:solid-server:default:AuxiliaryStrategy" }, locker: { "@id": "urn:solid-server:default:ResourceLocker" }, - admins, + token, }, }, }, { "@id": "urn:solid-server:default:InteractionRouteHandler", "@type": "WaterfallHandler", - handlers: [{ "@id": "urn:solid-server:overlay:AdminDeleteAccountRouter" }], + handlers: [{ "@id": "urn:solid-server:overlay:AccountAdminRouter" }], }, ], }; fs.writeFileSync(out, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); -console.log( - admins.length - ? `[solid-css-overlay] admins: ${admins.length} (account deletion open to them)` - : "[solid-css-overlay] admins: none (ADMIN_EMAILS unset), account deletion refused to everyone" -); +fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 }); +// writeFileSync keeps the mode of a file that already exists: set it again. +fs.chmodSync(out, 0o600); +fs.chmodSync(tokenFile, 0o600); +console.log("[solid-css-overlay] account admin: route ready, token renewed (scripts/admin.sh on the server)"); diff --git a/start/start.sh b/start/start.sh index 4a704db..5510baa 100755 --- a/start/start.sh +++ b/start/start.sh @@ -1,7 +1,7 @@ #!/bin/sh # The image's entrypoint: describe this instance (start/describe.js), write its -# email sender (start/email.js), write the admin route with this instance's -# admins (start/admins.js), put the welcome page at the root +# email sender (start/email.js), write the account admin route and its token +# (start/admins.js), put the welcome page at the root # (start/welcome.js), then run the stock server exactly as the stock # image does (["node","bin/server.js"]), with the same arguments. set -e diff --git a/test/smoke.mjs b/test/smoke.mjs index dc44150..faaf17c 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -14,6 +14,7 @@ import { chromium } from "playwright"; import { mkdirSync } from "node:fs"; import crypto from "node:crypto"; import http from "node:http"; +import { execFileSync } from "node:child_process"; const base = (process.argv[2] || "http://localhost:3123").replace(/\/$/, ""); const out = process.argv[3] || "smoke-shots"; @@ -286,17 +287,20 @@ await w.goto(`${base}/.account/login/password/forgot/`); await submitReady(w); await shot(w, "12-forgot"); -// Account deletion by an admin (account-admin/, solid-kit ADR 012). Stock CSS -// deletes no account and no pod, so a pod name once taken stayed taken and -// every test round burned names. The gate: an account with a pod is deleted, -// and the same pod name can be taken again (a pod root left behind answers -// 409). Through the JSON API from node: the account API is on the bare -// domain. Runs when try-local.sh got ADMIN_EMAILS (CI sets it). -const adminEmail = (process.env.ADMIN_EMAILS || "").split(",")[0].trim(); -if (adminEmail) { - const api = async (url, { token, data, method } = {}) => { +// Account admin (account-admin/ + admin/admin.js, solid-kit ADR 012). Stock +// CSS lists no other account and deletes none, so a pod name once taken +// stayed taken and every test round burned names. The gate: the admin tool, +// run inside the container as scripts/admin.sh runs it, lists an account +// with a pod and deletes it, and the same pod name can be taken again (a pod +// root left behind answers 409). From outside, the route refuses whatever +// token is sent. Account API calls go to the bare domain, which node reaches. +{ + const container = process.env.SMOKE_CONTAINER || `try-css-overlay-${new URL(base).port}`; + const admin = (...args) => JSON.parse(execFileSync(process.env.DOCKER || "docker", + ["exec", container, "node", "/opt/overlay/admin.js", ...args, "--json"], { encoding: "utf-8" })); + const api = async (url, { token, data } = {}) => { const res = await fetch(url, { - method: method || (data === undefined ? "GET" : "POST"), + method: data === undefined ? "GET" : "POST", // No content-type on a GET: CSS then parses the empty body as JSON and // answers 400 without controls (first CI run of 7.3.0-4). headers: { @@ -311,43 +315,40 @@ if (adminEmail) { const controlsFor = async (token) => { const { status, json } = await api(`${base}/.account/`, { token }); if (!json.controls) { - throw new Error(`delete account: GET /.account/ gave no controls (${status} ${JSON.stringify(json).slice(0, 200)})`); + throw new Error(`account admin: GET /.account/ gave no controls (${status} ${JSON.stringify(json).slice(0, 200)})`); } return json.controls; }; - // Account, password login, and optionally a pod: what the register page does. + // Account, password login, and a pod: what the register page does. const signUp = async (who, podName) => { const { json: { authorization: token } } = await api((await controlsFor()).account.create, { data: {} }); - check(Boolean(token), `delete account: the JSON API makes an account for ${who} (an authorization token)`); + check(Boolean(token), `account admin: the JSON API makes an account for ${who} (an authorization token)`); const controls = await controlsFor(token); await api(controls.password.create, { token, data: { email: who, password } }); - const pod = podName ? await api(controls.account.pod, { token, data: { name: podName } }) : undefined; - return { token, pod }; + return api(controls.account.pod, { token, data: { name: podName } }); }; - const route = `${base}/.account/admin/delete-account/`; const doomed = `gone-${Date.now()}`; const doomedEmail = `${doomed}@example.org`; - const admin = await signUp(adminEmail); const first = await signUp(doomedEmail, doomed); - check(first.pod.status < 300, `delete account: a throwaway account gets pod "${doomed}" (${first.pod.status})`); + check(first.status < 300, `account admin: a throwaway account gets pod "${doomed}" (${first.status})`); - const notAdmin = await api(route, { token: first.token, data: { email: adminEmail } }); - check(notAdmin.status === 403, `delete account: a non-admin is refused (${notAdmin.status})`); - const anonymous = await api(route, { data: { email: doomedEmail } }); - check(anonymous.status === 401, `delete account: nobody signed in is refused (${anonymous.status})`); + const outside = await api(`${base}/.account/admin/accounts/`, { data: { token: "x".repeat(43), action: "list" } }); + check(outside.status === 403, `account admin: the route refuses a guessed token from outside (${outside.status})`); - const gone = await api(route, { token: admin.token, data: { email: doomedEmail } }); - check(gone.status === 200 && gone.json.deleted?.pods?.length === 1, - `delete account: the admin deletes it and its pod (${gone.status} ${JSON.stringify(gone.json.deleted?.pods ?? gone.json.message)})`); + const listed = admin("list").find((a) => a.emails.includes(doomedEmail)); + check(listed?.pods.length === 1, `account admin: the tool lists it with its pod (${JSON.stringify(listed?.pods)})`); + check(admin("list").some((a) => a.emails.includes(email)), "account admin: the tool lists the smoke run's own account too"); + + const [gone] = admin("delete", doomedEmail, "--yes"); + check(gone?.pods?.[0] === listed?.pods?.[0], `account admin: the tool deletes it and its pod (${JSON.stringify(gone?.pods)})`); + check(!admin("list").some((a) => a.emails.includes(doomedEmail)), "account admin: it is no longer listed"); const login = await api((await controlsFor()).password.login, { data: { email: doomedEmail, password } }); - check(login.status >= 400, `delete account: its login no longer works (${login.status})`); + check(login.status >= 400, `account admin: its login no longer works (${login.status})`); // The name is free again: until this component, 409 "There already is a resource". const again = await signUp(`again-${doomedEmail}`, doomed); - check(again.pod.status < 300, `delete account: pod name "${doomed}" can be taken again (${again.pod.status} ${again.pod.json.message || ""})`); -} else { - console.log(" skip delete account: ADMIN_EMAILS unset"); + check(again.status < 300, `account admin: pod name "${doomed}" can be taken again (${again.status} ${again.json.message || ""})`); } check(pageErrors.length === 0, `no script errors in the pages${pageErrors.length ? `: ${pageErrors.join(" | ")}` : ""}`); diff --git a/test/try-local.sh b/test/try-local.sh index b10e918..d519fd2 100755 --- a/test/try-local.sh +++ b/test/try-local.sh @@ -11,8 +11,6 @@ # variable, so export it once for both. # COMPOSE_PROFILES, MCP_PUBLIC_BASE passed through when set, as compose does: # they decide what /.well-known/css/provider.json offers. -# ADMIN_EMAILS passed through when set (start/admins.js); smoke.mjs then -# signs up its first address and checks account deletion. # TRY_NETWORK=host share the host's network instead of publishing the port # (Linux; for a sandbox where published ports do not answer). # @@ -43,7 +41,6 @@ args=(--rm -d --name "$name" -v "$work/data:/data" -e "CSS_BASE_URL=http://localhost:$port/" -e "CSS_PORT=$port") [ -n "${COMPOSE_PROFILES:-}" ] && args+=(-e "COMPOSE_PROFILES=$COMPOSE_PROFILES") [ -n "${MCP_PUBLIC_BASE:-}" ] && args+=(-e "MCP_PUBLIC_BASE=$MCP_PUBLIC_BASE") -[ -n "${ADMIN_EMAILS:-}" ] && args+=(-e "ADMIN_EMAILS=$ADMIN_EMAILS") if [ "${TRY_NETWORK:-}" = host ]; then args+=(--network host); else args+=(-p "$port:$port"); fi case "$layout" in From c935e769c4f628b461e4708c076e2d8422bd1c61 Mon Sep 17 00:00:00 2001 From: nicolas de Barquin Date: Tue, 3 Nov 2026 14:23:54 +0100 Subject: [PATCH 2/3] smoke: print the server log when the admin tool fails Co-Authored-By: Claude Opus 5.5 --- test/smoke.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/smoke.mjs b/test/smoke.mjs index faaf17c..3efaae1 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -296,8 +296,19 @@ await shot(w, "12-forgot"); // token is sent. Account API calls go to the bare domain, which node reaches. { const container = process.env.SMOKE_CONTAINER || `try-css-overlay-${new URL(base).port}`; - const admin = (...args) => JSON.parse(execFileSync(process.env.DOCKER || "docker", - ["exec", container, "node", "/opt/overlay/admin.js", ...args, "--json"], { encoding: "utf-8" })); + const docker = process.env.DOCKER || "docker"; + const admin = (...args) => { + try { + return JSON.parse(execFileSync(docker, ["exec", container, "node", "/opt/overlay/admin.js", ...args, "--json"], + { encoding: "utf-8", timeout: 60000 })); + } catch (error) { + // What the server said is the only clue when the tool dies without a word. + let logs = ""; + try { logs = execFileSync(docker, ["logs", "--tail", "60", container], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { logs = `${e.stdout || ""}${e.stderr || ""}` || e.message; } + console.log(`--- admin.js ${args.join(" ")} failed (${error.status ?? error.signal}): ${error.stderr || error.message}\n--- server log:\n${logs}`); + throw error; + } + }; const api = async (url, { token, data } = {}) => { const res = await fetch(url, { method: data === undefined ? "GET" : "POST", From c2ab370353715938f933ea5a2ea4f24afc55b64b Mon Sep 17 00:00:00 2001 From: nicolas de Barquin Date: Tue, 3 Nov 2026 14:24:09 +0100 Subject: [PATCH 3/3] try-local: keep a dead server's container for its log Co-Authored-By: Claude Opus 5.5 --- test/try-local.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/try-local.sh b/test/try-local.sh index d519fd2..4853a88 100755 --- a/test/try-local.sh +++ b/test/try-local.sh @@ -37,7 +37,9 @@ work="${TMPDIR:-/tmp}/$name" rm -rf "$work" && mkdir -p "$work/data" chmod 777 "$work/data" -args=(--rm -d --name "$name" -v "$work/data:/data" +# No --rm: a server that dies keeps its log for `docker logs` (the smoke run +# prints it when the admin tool fails). The next run removes it (rm -f below). +args=(-d --name "$name" -v "$work/data:/data" -e "CSS_BASE_URL=http://localhost:$port/" -e "CSS_PORT=$port") [ -n "${COMPOSE_PROFILES:-}" ] && args+=(-e "COMPOSE_PROFILES=$COMPOSE_PROFILES") [ -n "${MCP_PUBLIC_BASE:-}" ] && args+=(-e "MCP_PUBLIC_BASE=$MCP_PUBLIC_BASE")