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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions account-admin/src/AccountAdminHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export class AccountAdminHandler extends JsonInteractionHandler {
private readonly auxiliaryStrategy: AuxiliaryStrategy;
private readonly locker: ReadWriteLocker;
private readonly token: Buffer;
private readonly accountsContainer: ResourceIdentifier;

/**
* @param accountStorage - The account storage (urn:solid-server:default:AccountStorage).
Expand All @@ -94,6 +95,7 @@ export class AccountAdminHandler extends JsonInteractionHandler {
* @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.
* @param baseUrl - The server's base URL, where CSS keeps its internal storage (`.internal/`).
*/
public constructor(
accountStorage: AccountLoginStorage<Record<string, never>>,
Expand All @@ -105,6 +107,7 @@ export class AccountAdminHandler extends JsonInteractionHandler {
auxiliaryStrategy: AuxiliaryStrategy,
locker: ReadWriteLocker,
token: string,
baseUrl: string,
) {
super();
this.accountStorage = accountStorage;
Expand All @@ -116,6 +119,10 @@ export class AccountAdminHandler extends JsonInteractionHandler {
this.auxiliaryStrategy = auxiliaryStrategy;
this.locker = locker;
this.token = Buffer.from(token ?? '');
// Where config/storage/key-value/resource-store.json (JsonResourceStorage,
// container /.internal/) and identity/handler/storage/default.json
// (ContainerPathStorage, /accounts/data/) put one document per account.
this.accountsContainer = { path: new URL('.internal/accounts/data/', baseUrl).href };
}

public async handle({ json }: JsonInteractionHandlerInput): Promise<JsonRepresentation> {
Expand All @@ -142,9 +149,31 @@ export class AccountAdminHandler extends JsonInteractionHandler {
}

private async list(): Promise<AccountSummary[]> {
// NOT accountStorage.entries('account'). That walks every document under
// CSS's /.internal/ through the resource store, the lock files included
// (they live in the same container), and on 7.3.0 it stalled until "Lock
// expired after 6000ms" and the server exited (first CI run of the admin
// tool, 2026-11-03). Stock CSS never calls it while serving requests.
// Instead: the names in the accounts folder, read through the data
// accessor (a directory listing, no lock), each confirmed by the account
// storage itself.
const ids: string[] = [];
try {
for await (const metadata of this.accessor.getChildren(this.accountsContainer)) {
const id = decodeURIComponent(metadata.identifier.value.slice(this.accountsContainer.path.length));
if (id && !id.includes('/') && await this.accountStorage.has('account', id)) {
ids.push(id);
}
}
} catch (error: unknown) {
// No account was ever made: the folder does not exist yet.
if (!NotFoundHttpError.isInstance(error)) {
throw error;
}
}
const accounts: AccountSummary[] = [];
for await (const account of this.accountStorage.entries('account')) {
accounts.push(await this.summary(account.id));
for (const id of ids) {
accounts.push(await this.summary(id));
}
return accounts;
}
Expand Down
20 changes: 14 additions & 6 deletions account-admin/test/AccountAdminHandler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ const { AccountAdminHandler } = require('../dist');

const POD = 'https://gone.example.org/';
const TOKEN = 't'.repeat(43);
const BASE = 'https://example.org/';
const ACCOUNTS = `${BASE}.internal/accounts/data/`;

// A pod as the file backend lists it: containers end in '/', .acl files are
// listed as children, .meta files are not.
function makeWorld({ failOn, token = TOKEN } = {}) {
const tree = {
[POD]: [`${POD}.acl`, `${POD}profile/`, `${POD}README`, `${POD}README.acl`],
[`${POD}profile/`]: [`${POD}profile/card`],
// The accounts folder as the file backend lists it, plus a stray entry
// the account storage does not know.
[ACCOUNTS]: ['keep', 'target', 'halfway', 'stale'].map((id) => `${ACCOUNTS}${id}`),
};
const log = [];
const logins = {
Expand All @@ -29,11 +34,8 @@ function makeWorld({ failOn, token = TOKEN } = {}) {
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 };
}
entries: async function* () {
throw new Error('entries() walks the lock files too and stalls the server: never call it');
},
},
passwordStore: {
Expand Down Expand Up @@ -77,7 +79,7 @@ function makeWorld({ failOn, token = TOKEN } = {}) {
};
world.handler = new AccountAdminHandler(
world.accountStorage, world.passwordStore, world.podStore, world.webIdStore,
world.store, world.accessor, world.auxiliaryStrategy, world.locker, token,
world.store, world.accessor, world.auxiliaryStrategy, world.locker, token, BASE,
);
return world;
}
Expand All @@ -100,6 +102,12 @@ test('a configured token shorter than 32 characters refuses everyone, even when
}
});

test('an empty server (no accounts folder yet) lists nothing', async () => {
const world = makeWorld();
world.accessor.getChildren = async function* () { throw new NotFoundHttpError(); };
assert.deepEqual((await call(world, { token: TOKEN, action: 'list' })).json.accounts, []);
});

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, [
Expand Down
54 changes: 54 additions & 0 deletions docs/admin/explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,60 @@ 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.

## What building it taught us

Each of these was found by running the real server, not by reading the docs.
Together they are the case for doing this inside CSS, carefully, and testing
it on every CSS release.

**1. The pod root cannot be deleted the normal way, and leaving it keeps
the name taken.**
- *Found:* the store answers "Cannot delete a root storage container" (405,
Solid §5.4), and pod creation answers 409 "There already is a resource"
while anything is at the address.
- *So:* only deleting through the store would have looked like success, yet
never freed a name.
- *What we do:* the contents go through the store and the root through the
accessor, under the lock (above).
- *Where it shows:* the smoke run takes the same pod name again after every
deletion.

**2. CSS's own "list everything" stalls, and a stalled lock takes the whole
server down.**
- *Found (first CI run of the tool, 2026-11-03):*
- Listing accounts with the account storage's `entries('account')` walks
every document CSS keeps under `/.internal/` through the resource store.
That includes the server's lock files, which live in the same place.
- The request waited on a lock until `Lock expired after 6000ms`. Then CSS
exited, taking every pod on that server offline.
- Nothing in stock CSS calls that walk while serving requests, so nobody had
hit it.
- *What we do:* the tool lists the accounts folder's names through the data
accessor (a directory read, no lock). It checks each one against the account
storage, and only then reads that account's logins and pods.
- *Two lessons:*
- **One wrong storage call in a component is an outage, not an error
message.** The smoke run must exercise every route on a real server, on
both pod layouts, at every CSS bump.
- **A script on the data folder would be worse still.** It would hit the
same locks from outside the process that owns them. ("Why inside CSS",
above.)

**3. An email is not an identity in CSS.**
- *Found:* CSS never verifies that a login's email belongs to whoever signs up
with it.
- *So:* an admin list of emails would make whoever claims an unused address an
admin.
- *What we do:* the permission is the server's terminal ("Why the terminal",
above).

**4. Stock CSS cannot answer "who has an account here?"**
- *Found:* its account API only shows the signed-in account its own data.
- *So:* without this tool an admin can neither see nor clean what accumulates:
test accounts, sign-ups left halfway, accounts nobody uses.
- *Why it matters:* knowing what lives on the server comes before any
responsible deletion, and before opening a server to the public.

## Why the connector clean-up is in the script

The MCP connector keeps its agents' credentials in `identities.json`, outside
Expand Down
1 change: 1 addition & 0 deletions start/admins.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const config = {
auxiliaryStrategy: { "@id": "urn:solid-server:default:AuxiliaryStrategy" },
locker: { "@id": "urn:solid-server:default:ResourceLocker" },
token,
baseUrl: { "@id": "urn:solid-server:default:variable:baseUrl" },
},
},
},
Expand Down
Loading