openvibers/openvibe.network Commits

Emit network.node.revoked@1 on every node-principal revoke; pin openvibe-contracts v0.99.0 (#58)

OP OpenVibers committed 5 Oct 2026 · 509de58d20 · 13 files, +189 −37

**Implementation**
- `server/registry/node-principals.js`: every revoke now writes `network.node.revoked@1` to Network's outbox (`eventRelay.writerFor(db).enqueue`) in the **same transaction** as the row update (`revokeScoped` wraps a `db.tx`; `revokeRow` only enqueues when the conditional `UPDATE … status <> 'revoked'` changed a row, so a repeat revoke emits nothing). Payload is exactly `{node_id, principal_id, owner (schema oneOf), reason?, at}`, with the actor derived as `svc:<service>` or the owner `usr_…`, and it validates payload+envelope before enqueue. Added optional `reason` from the request body (400 `registry.invalid_reason` if invalid) and `revokeOwnedBy()` for account deletion.
- `server/identity/account-data.js`: `erase()` now revokes each of the person's/aliases'/merged-in accounts' user-owned node principals via `revokeOwnedBy()` inside its one transaction and counts them (`nodes_revoked`) — the third revoke path named by the contract/task, which did not previously revoke principals at all.

**Docs**
- `docs/t2-cells-and-node-principal.md`: pin → v0.99.0; §10 deferred bullet marked resolved (N8), §8 row N8 added, route table notes the event.
- `docs/t2-resource-registry.md`, `docs/t2-projects-and-grants.md`, `docs/developer-projects.md`: pin mentions v0.97.0 → v0.99.0.
- `README.md`: pin → v0.99.0; added a node-principals feature line.
- `STATUS.json`: contracts → v0.99.0; added node-principals/`network.node.revoked@1` feature line.

**Tests**
- `test/node-pairing.test.js`: internal revoke emits exactly one schema-valid event (owner `{kind:'user',subject:alex}`, actor `svc:bot`, no secret fields), repeat emits none; reason carried, invalid reason refused with nothing written/emitted.
- `test/node-principals-me.test.js`: owner revoke emits one valid event (owner/actor the owner, owner's reason), over-long reason → 400, repeat emits none.
- `test/account-data.test.js`: account deletion revokes two principals (person + merged-in account) and emits exactly two valid events with reason `account_deleted`, no secrets; a second sweep emits none.
- `test/node-tokens.test.js` re-run since it exercises the internal revoke route with the new transaction/event path.

**Test commands / results**
- `ov test test/node-pairing.test.js` → 1 passed
- `ov test test/node-principals-me.test.js` → 1 passed
- `ov test test/node-tokens.test.js` → 1 passed
- `ov test test/account-data.test.js` → 1 passed
- `ov test test/nodes.test.js test/registry-admin.test.js test/cells-registry.test.js test/registry-instances.test.js test/resource-registry.test.js test/user-owned-trust.test.js` → 6 passed
- Final combined re-run reported all four direct files green on the exact code (cached `unchanged since green`).

**Could not do / notes**
- No commit, push, deploy, or package install performed; `package.json`/`package-lock.json` were left as the environment pinned them (v0.99.0).
- No test asserted the literal pinned contracts version, so none needed changing.
- Per instructions I did not run the whole suite; the remaining farther-away files in `ov test --list`'s reach set are left to CI verify.

Changes

ddiff --git a/README.md b/README.md
iindex 7594de29..2b704a52 100644
---- a/README.md
++++ b/README.md
@@@ -34,6 +34,9 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
graph (ADR-030), creator analytics (ADR-021), the OpenCoins wallet
++- node principals: a person's paired machine (`nod_…`) with a one-time pairing code, a scoped credential and a
++ token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced
++ as `network.node.revoked@1`
- the service registry (`/api/v1/registry/*`, `/.well-known/openvibe`), the status page, the network
changelog proxy, and the files at `/shared/*` (the pinned openvibe-shared release)
@@@ -45,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
--- `openvibe-contracts` v0.97.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
++- `openvibe-contracts` v0.99.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v2.9.0, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
ddiff --git a/STATUS.json b/STATUS.json
iindex 78ed96b5..b42017e2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.97.0",
++ "contracts": "openvibe-contracts v0.99.0",
"capabilities": [
"identity.subject.resolve",
"mods.grant.manage",
@@@ -40,6 +40,7 @@
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
"OpenCoins wallet",
"service registry, public discovery and the status page with incidents",
++ "node principals: pairing a person's machine, scoped service and owner revoke, and network.node.revoked@1 for instant revocation",
"operator alerts relay target and admin panel",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 09e3ba61..80cf70d3 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -43,9 +43,9 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
## Grants, the allowance and the capability catalog
- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
--- **The grantability rule.** The contracts `visibility` enum is `public | partner | first-party | internal` (`partner` since the v0.83.0 release; Network's pin moves to v0.97.0). Only `active` capabilities with visibility `public` or `partner` may be granted to apps. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default or the sandbox allowance. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again.
++- **The grantability rule.** The contracts `visibility` enum is `public | partner | first-party | internal` (`partner` since the v0.83.0 release; Network pins v0.99.0). Only `active` capabilities with visibility `public` or `partner` may be granted to apps. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default or the sandbox allowance. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again.
- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project's **production** apps.
--- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.97.0); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
++- **The sandbox allowance** is added to the allowance for **sandbox apps only**, in every project, without a staff decision. It comes from `DEV_SANDBOX_ALLOWANCE`. When that is unset, the code default is `media.object.upload`, `media.object.read`, `media.object.list`, `media.object.delete`, `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`, `tools.job.read` and `tools.job.cancel`. Only `public` + `active` capabilities of the installed openvibe-contracts catalog count. `partner`, `first-party`, `internal` and unknown ids are dropped. The three `events.app.*` ids are defined from openvibe-contracts v0.28.0 (Network pins v0.99.0); with an older release they would be left out. `media.object.list` and `media.object.delete` (Media's list and delete verbs, WS-G task 2) are left out the same way until the pinned release defines them; until then a sandbox app's upload and read grants cover them at Media. The project view and `GET /catalog` show it as `sandbox_allowance`. Production apps never use it.
- **A grant never exceeds the allowance** (for a sandbox app: allowance ∪ sandbox allowance). It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. A sandbox app's grant that is still inside the sandbox allowance is kept. Token issuance intersects approved grants with the current allowance and with the current grantability.
- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex 15973d6b..5006de23 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -4,8 +4,9 @@ Status: **N1–N7 merged** on `origin/main` `fbd6312` (registry slice 5, the nod
operator writers; each slice's state is in §8). The cells layer of `docs/t2-resource-registry.md` §9 was already on
`origin/main` (`d6ed8c4`, PR #5: `migrations/0008_cells_and_node_principals.sql`, `server/registry/cells.js`,
`test/cells-registry.test.js`); this document designed the rest of lane B on top of it and re-planned registry slice 5.
--Pinned: Network `openvibe-contracts` **v0.97.0** (`package.json:33`, `origin/main` `fbd6312`; the latest Contracts
--release, to which Network's pin moves in a separate PR). v0.85.0 closed every contract gap in §2 and the §9.1 T1 brief (the doc was
++Pinned: Network `openvibe-contracts` **v0.99.0** (`package.json:33`). v0.98.0 added `network.node.revoked@1`, the event
++Network now emits on every node-principal revoke (§10), so a connected machine is stopped at once rather than at its next
++reauth. v0.85.0 closed every contract gap in §2 and the §9.1 T1 brief (the doc was
written against v0.83.0, checked then against v0.84.0). OpenVibe.Bot
`origin/main` `17ce069` (pins Contracts v0.79.0, `package.json:29`), OpenVibe.Node `origin/main` `4d2b8e1`. Bot is not
deployed (`ov access run openvibe-ovh releases bot` → `unknown service "bot"`), so no production device exists.
@@@ -226,8 +227,8 @@ instances carry endpoints.
| `POST /internal/node-pairings` | service token, `network.node.manage` | body `{owner: {kind:'user', subject:'usr_…'}, ref, home_cell?}`; the requesting service is the token's `svc:<id>`. Refuses an unknown, banned or deleted user (`404 registry.unknown_owner`) and a `home_cell` that is not `active`. Deletes that service's unused codes for the same `ref` (Bot's rule, `domain/index.js:164`), inserts one, → `201 {pairing_id, code: 'XXXX-XXXX', expires_at}`; the code is shown once, only its sha256 stored. TTL 10 min. `owner.kind:'project'` → `501 registry.not_yet` in this slice (§8). |
| `POST /api/v1/node-pairing` | none: the code is the credential; `rateLimit({windowMs: 60_000, max: 10})` on the route | body `{pairing?: 'pair_…', code, name?, region?}`. Same outcomes as Bot's redeem (`domain/index.js:180-205`): `422 registry.invalid_pairing_code` (shape), `403 registry.pairing_code_invalid\|_used\|_expired\|_locked`; with `pairing` a wrong code counts a try against that row, 5 kills it. In one `db.tx`: mark used, create the principal (`nod_<ULID>`; `node_id = 'n-' + ulid.toLowerCase()`, 28 chars, matches `network.node@1`'s id pattern; owner from the code; `trust` `community` for a user; `home_cell` = the code's, else `cellForRegion(region)` `cells.js:32`; `paired_by_service`/`pairing_ref` from the code; `created_by = 'pairing:<pair_id>'`), set `principal_id` on the code. → `201 {principal, node_id, home_cell, credential, token_endpoint: '<issuer>/oauth/token', paired_for: {service, ref}}`; `credential` shown once, `Cache-Control: no-store`, never logged. |
| `GET /internal/node-principals/:id` | `network.node.manage` | only a principal whose `paired_by_service` is the caller (else `404 registry.unknown_node`, never 403, so a service cannot probe others) → `{principal, node_id, name, owner, home_cell, status, paired_for, last_seen_at, created_at, revoked_at}`. No hash ever. |
--| `POST /internal/node-principals/:id/revoke` | `network.node.manage`, same scoping | `status = 'revoked'`, `revoked_at`, `revoked_by = 'svc:<id>'`, previous hash cleared; idempotent. |
--| `GET /api/v1/me/nodes`, `POST /api/v1/me/nodes/:principal/revoke` | session (`requireAuth`), the owner only (slice N5) | the person's own machines (`owner_subject = me`): the same view; revoke as above with `revoked_by = usr_…`. |
++| `POST /internal/node-principals/:id/revoke` | `network.node.manage`, same scoping | `status = 'revoked'`, `revoked_at`, `revoked_by = 'svc:<id>'`, previous hash cleared; idempotent. In the same transaction writes `network.node.revoked@1` to the outbox (actor the service; `reason` from the body when given). A second revoke changes nothing and emits nothing. |
++| `GET /api/v1/me/nodes`, `POST /api/v1/me/nodes/:principal/revoke` | session (`requireAuth`), the owner only (slice N5) | the person's own machines (`owner_subject = me`): the same view; revoke as above (same event, actor the owner) with `revoked_by = usr_…`. |
### 4.3 Node tokens and self routes (slice N4c)
@@@ -377,6 +378,7 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
| N5 | Owner API for own machines | `server/registry/node-principals.js` (`userRouter`), `server/index.js` (`/api/v1/me/nodes`, `rateLimit` as `/api/v1/me/blocks` `:427`), `test/node-principals-me.test.js` | N4b | basic (page: For Opus) | **merged** (PR #18) |
| N6 | Registry slice 5 (offer kinds as `detail`) | `package.json` pin, `migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`, `server/index.js:473`, `principals.js` grant, `test/resource-registry.test.js` | **T1 release** (§9.1 items 4-5) | basic | **merged** (pin is v0.85.0) |
| N7 | Registry writers & operator reads | `server/registry/cells.js` (`cellView`, `setCell`, internal node-principals read), `server/registry/instances.js` (`setInstance`), `server/registry/nodes.js` (`GET /internal/nodes`), `server/index.js` (staff `/api/admin/registry`), tests | nothing (staff session; no new capability) | basic | **merged** (PR #27) |
++| N8 | Node-revoked event | `server/registry/node-principals.js` (emit in `revokeScoped`/`revokeRow`, `revokeOwnedBy`), `server/identity/account-data.js` (account deletion), tests | Contracts v0.98.0 (`network.node.revoked@1`) | basic | **in review (this PR)** |
Bot (T15) and Node (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Network
slices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's
@@@ -512,15 +514,18 @@ on the v2 path; Bot's `device_id` and publish key come over HTTP instead (B1's `
connect; restart with `device_id` empty re-binds; `reauth` sent before expiry; `401` from the token endpoint
backs off.
--## 10. Unresolved
++## 10. Unresolved, and resolved since
++- ~~**Revocation latency outside Bot**~~ — **resolved (N8)**: a revoke made on Network (the service route, the owner's
++ route, or an account deletion) now writes `network.node.revoked@1` to the outbox in the same transaction as the
++ revoke (Contracts v0.98.0), so a service holding the machine's session stops it at once instead of at its next
++ `reauth` (≤ 330 s). A second revoke emits nothing; the payload carries the ids, the owner and the reason, never the
++ credential or its hash.
- **Project-owned machines** (`owner.kind: 'project'`): the schema carries them; the pairing route answers `501`
until Services (T13) or Run (T14) is the requesting service and the member/role rule for "who may pair a machine
into a project" is decided.
- **Route-weight writes** (cells and instances): no writer until the scheduler exists; weights stay at the default.
- **A second cell** (O24): `INSERT` into `platform_regions`/`platform_cells` by migration, then staff moves
`home_cell`; no programming-model change. Changing a project's home cell is staff-only and not designed here.
--- **Revocation latency outside Bot**: a revoke made on Network (N5) reaches a connected device at its next `reauth`
-- (≤ 330 s). An event (`network.node.revoked`) would make it instant; deferred, it needs an event contract.
- **Bot's own capabilities** (`bot.robot.control`, `bot.device.connect`, …) are not in the Contracts catalog at
v0.84.0 and there is no `manifests/services/bot.json`; that is a separate T1/T15 job, not a blocker here.
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex 216de651..3fd972e3 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -5,14 +5,13 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_i
only: `server/internal/routes.js:490`; PR #28, merge `e422342`) and **slice 3 built** (delegated grants and modes:
`migrations/0017_agent_grants.sql`, the ceiling in `server/developer/agents.js`), **slices 4–5 built** (confirmations,
owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /internal/agents/:agent` and the four
--`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.97.0) and **slices
++`/internal/confirmations` routes, `server/internal/routes.js`, with the pin at `openvibe-contracts` v0.99.0) and **slices
8–9 built** (agent tokens, `server/developer/agent-tokens.js`; `network.confirmation.changed@1` decision events from
`server/developer/confirmations.js`; and the owner notification, `confirmations.notifyOwner` sending `openvibe-shared`
`TYPES.CONFIRMATION_REQUESTED`, PR #47, merge `fbd6312`).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
--Pinned version: `openvibe-contracts` **v0.97.0** (`package.json:33`; the latest Contracts release, to which Network's pin
--moves in a separate PR; `origin/main` `fbd6312`); every contract claim below was
++Pinned version: `openvibe-contracts` **v0.99.0** (`package.json`); every contract claim below was
re-checked against the `v0.85.0` tag of OpenVibe.Contracts when slices 1–2 were built (it was written against v0.83.0).
v0.89.0 published `network.confirmation.manage` (`planned`, `implementedBy` the four `/internal/confirmations` routes),
the `agent` actor in `identity.service-token-claims@1` and `network.confirmation.changed@1`; v0.86–v0.97 are additive.
ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex 1441fe09..dc510e13 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -3,7 +3,7 @@
Status: **resource registry and N6 built** (`migrations/0007_resource_registry.sql`,
`migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`); **cells and node principals built**
(section 9, `migrations/0008`). The original slice plan in §7 is historical.
--Current pin: `openvibe-contracts` **v0.97.0** (`package.json` at `origin/main` `fbd6312`; the latest release, to which Network's pin moves in a separate PR; the kinds below are as of v0.85.0); the v0.83.0 observations below describe the original design.
++Current pin: `openvibe-contracts` **v0.99.0** (`package.json`; the kinds below are as of v0.85.0); the v0.83.0 observations below describe the original design.
Scope: one table and one module that store **what the network can sell or place on** — node, provider, storage, delivery,
runtime, agent and harness offers — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
ddiff --git a/package-lock.json b/package-lock.json
iindex 3594b074..44560ee6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0",
@@@ -1556,9 +1556,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.97.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
-- "integrity": "sha512-HPGBJYx6Dh2kaTg5B41WGhpw38zUdoU1VnWtkcsFkaU4E521uQOJxf6sIaLdHMsahqov7b9k2HKEFCnD9065MQ==",
++ "version": "0.99.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
++ "integrity": "sha512-2dL6gKDbxseR9/kj8pgNGHBV1Drn0xq6gx6+Cz4Qees7hZ822VhPIg7BfBVUhCI8D7348J9YWG9E2nreRGWf6g==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 9122184e..ffbebd57 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0",
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex bd0dd3f9..71bf7c2f 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -16,7 +16,8 @@ const cache = require('openvibe-shared/cache-policy');
* - modules, follows and blocks both ways, notifications, preferences, push subscriptions, effects;
* - history, username history, the sign-in IP log and email log;
* - project memberships; a project the person owns passes to another owner or admin, else it is archived and
-- * its apps revoked.
++ * its apps revoked;
++ * - the person's paired node principals, revoked and announced as network.node.revoked@1.
* The OpenCoins balance is closed with one ledger entry (account_deleted); ledger rows stay. The user row becomes
* a tombstone (username released, email, password and profile cleared, deleted_at), as do accounts merged into it;
* the subject is never reused and resolves as deleted. Its tokens are revoked strictly, and network.account.deleted
@@@ -363,6 +364,10 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
}
await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
}
++ // The person's paired machines (plan T2): each is revoked with the account and announced as
++ // network.node.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
++ const nodePrincipals = require('../registry/node-principals');
++ count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
iindex 2fc7c485..18670cf7 100644
---- a/server/registry/node-principals.js
++++ b/server/registry/node-principals.js
@@@ -16,6 +16,11 @@
* GET /api/v1/me/nodes session → the person's own machines, newest first
* POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
*
++ * Every revoke — the service route, the owner's route, or an account deletion — writes network.node.revoked@1 into
++ * the outbox in the same transaction (Contracts 0.98.0; docs/t2-cells-and-node-principal.md section 10): a service
++ * holding the machine's session stops it at once, not at its next reauth (≤ 330 s). A second revoke emits nothing;
++ * the payload carries the ids, the owner and the reason, never the credential or its hash.
++ *
* The paired machine itself (section 4.3, slice N4c): its credential buys a node token (sub node:nod_…) at
* POST /oauth/token, and with it the machine manages only its own row. The routes resolve the node from the verified
* token, never from the body or the path.
@@@ -28,6 +33,7 @@ const express = require('express');
const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
const { TOKEN_TTL_S, SELF_AUDIENCE } = require('../identity/principals');
++const eventRelay = require('../developer/event-relay');
const CODE_TTL_MS = 10 * 60 * 1000;
const MAX_TRIES = 5;
@@@ -184,23 +190,87 @@ async function byScope(db, id, scope) {
/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
const pairedBy = (db, id, service) => byScope(db, id, { where: 'paired_by_service = ?', params: [service] });
++/** The payload's `owner` (network.node.revoked@1's oneOf), from the principal row. */
++const ownerOf = (p) => p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject }
++ : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id }
++ : { kind: 'platform' };
++
++/**
++ * The network.node.revoked@1 envelope for a principal row just revoked (Contracts 0.98.0; docs/t2-cells-and-node-
++ * principal.md section 10). Only the ids, who owns the machine, why and when: never the credential or its hash.
++ * Validates payload and envelope (a bug if either is off, never user input).
++ */
++function revokedEnvelope(p, { actor, reason, at }) {
++ const payload = { node_id: p.node_id, principal_id: p.id, owner: ownerOf(p), at };
++ if (reason) payload.reason = reason;
++ const env = {
++ event_id: ids.newId('event', Date.parse(at)), event_type: 'network.node.revoked', version: 1, source: 'network',
++ actor, timestamp: at, visibility: 'internal', subject: { type: 'node_principal', id: p.id }, payload,
++ };
++ const ev = validate('events.event-envelope@1', env);
++ const pv = validate('network.node.revoked@1', payload);
++ if (!ev.valid || !pv.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
++ return env;
++}
++
++/** The caller's reason text (1-500 chars, no control characters), or null; anything else is a 400. */
++function reasonOf(raw) {
++ if (raw === undefined || raw === null) return null;
++ const r = text(raw, 500);
++ if (!r) throw new RegistryError(400, 'registry.invalid_reason', 'reason must be 1-500 characters');
++ return r;
++}
++
++/**
++ * Revoke one active principal row and enqueue its network.node.revoked@1 on the ambient transaction, so the change
++ * and the event share it. → changes (0 or 1): a row already revoked changes nothing and emits nothing.
++ */
++async function revokeRow(db, p, { revokedBy, actor, reason, at }) {
++ const changed = (await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
++ prev_valid_until = NULL, updated_at = ? WHERE id = ? AND status <> 'revoked'`).run(at, revokedBy, at, p.id)).changes;
++ if (!changed) return 0;
++ await eventRelay.writerFor(db).enqueue(db, revokedEnvelope(p, { actor, reason, at }));
++ return 1;
++}
++
/**
-- * Revoke a principal matching a caller-owned scope; a second revoke changes nothing. → the row, or null.
-- * The previous credential and its grace window are cleared. `credential_hash` stays: migrations/0014's
++ * Revoke a principal matching a caller-owned scope; a second revoke changes nothing (and emits nothing). → the row,
++ * or null. The previous credential and its grace window are cleared. `credential_hash` stays: migrations/0014's
* platform_node_principals_credential CHECK requires one for a user-owned principal, and a revoked row cannot
* authenticate because every reader requires status = 'active'. One function, so the service route and the
-- * person's own route revoke identically.
++ * person's own route revoke identically, both writing the event in the same transaction as the revoke.
*/
--async function revokeScoped(db, id, scope, revokedBy, now = Date.now()) {
-- if (!await byScope(db, id, scope)) return null;
++async function revokeScoped(db, id, scope, revokedBy, now = Date.now(), reason = null) {
const at = iso(now);
-- await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
-- prev_valid_until = NULL, updated_at = ? WHERE id = ? AND ${scope.where} AND status <> 'revoked'`).run(at, revokedBy, at, String(id), ...scope.params);
-- return byScope(db, id, scope);
++ const actor = String(revokedBy).startsWith('svc:')
++ ? { type: 'service', id: String(revokedBy).slice(4) }
++ : { type: 'user', id: String(revokedBy) };
++ return await db.tx(async () => {
++ const p = await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
++ if (!p) return null;
++ await revokeRow(db, p, { revokedBy, actor, reason, at });
++ return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
++ });
}
/** Revoke a principal `service` paired; revoked_by is svc:<service>. → the row, or null. */
--const revoke = (db, id, service, now = Date.now()) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now);
++const revoke = (db, id, service, now = Date.now(), reason = null) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now, reason);
++
++/**
++ * Revoke every active principal owned by any of `subjects` and enqueue its event, on the caller's ambient
++ * transaction (an account deletion: the person's machines go with the account, and each revoke is announced so a
++ * consumer stops the machine at once). Returns how many changed; a row already revoked emits nothing.
++ */
++async function revokeOwnedBy(db, subjects, { revokedBy = 'account_deleted', actor, reason = 'account_deleted', now = Date.now() } = {}) {
++ const at = iso(now);
++ let revoked = 0;
++ for (const sid of subjects || []) {
++ if (!SUBJECT.test(String(sid))) continue;
++ const rows = await db.prepare("SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ? AND status <> 'revoked'").all(String(sid));
++ for (const p of rows) revoked += await revokeRow(db, p, { revokedBy, actor: actor || { type: 'user', id: String(sid) }, reason, at });
++ }
++ return revoked;
++}
/** The query filters of the operator and service principal lists; anything but a string is ignored. */
const principalFilter = (q = {}) => Object.fromEntries(['cell', 'owner_kind', 'status'].map((k) => [k, typeof q[k] === 'string' ? q[k] : null]));
@@@ -269,7 +339,7 @@ function userRouter(requireAuth, now = () => Date.now()) {
res.set('Cache-Control', 'private, no-store');
const db = req.app.locals.db;
const me = await meOf(db, req.user);
-- const p = await revokeScoped(db, req.params.principal, ownScope(me), me, now());
++ const p = await revokeScoped(db, req.params.principal, ownScope(me), me, now(), reasonOf((req.body || {}).reason));
if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.principal}` });
res.json(principalView(p));
}));
@@@ -405,7 +475,9 @@ function routers({ guard, now = () => Date.now() }) {
});
internal.post('/node-principals/:id/revoke', guard, async (req, res) => {
res.set('Cache-Control', 'no-store');
-- const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now());
++ let reason;
++ try { reason = reasonOf((req.body || {}).reason); } catch (e) { return problem(res, e); }
++ const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now(), reason);
if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
res.json(principalView(p));
});
@@@ -431,4 +503,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
}
--module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
++module.exports = { CODE_TTL_MS, MAX_TRIES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 9072c1ea..8544d1c3 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -18,7 +18,7 @@ const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
--const { validate, serviceAuth } = require('openvibe-contracts');
++const { ids, validate, serviceAuth } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const wallet = require('../server/coins/wallet');
@@@ -199,8 +199,32 @@ const server = http.createServer(app);
// dana had folded an older account in (ADR-029): its subject goes with her.
const old = await mk('dana_old');
await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
++ // Two paired machines, one per subject: an account deletion revokes both and announces each (network.node.revoked@1).
++ const pairedNodes = [];
++ for (const subjectId of [dana.subject_id, old.subject_id]) {
++ const ulid = ids.ulid();
++ const principal = `nod_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;
++ await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status,
++ credential_hash, created_at, created_by, updated_at)
++ VALUES (?, ?, 'wnam-1', 'user', ?, 'community', 'active', ?, ov_now_iso(), 'test', ov_now_iso())`)
++ .run(principal, nodeId, subjectId, crypto.randomBytes(32).toString('hex'));
++ pairedNodes.push([principal, nodeId, subjectId]);
++ }
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
++ const nodeEvents = await events('network.node.revoked');
++ assert.strictEqual(nodeEvents.length, 2, 'each paired machine revoked exactly once');
++ for (const [principal, nodeId, subjectId] of pairedNodes) {
++ const p = await db.prepare('SELECT status, revoked_by FROM platform_node_principals WHERE id = ?').get(principal);
++ assert.deepStrictEqual([p.status, p.revoked_by], ['revoked', 'account_deleted']);
++ const rev = nodeEvents.find((e) => e.subject.id === principal);
++ assert.ok(rev, `an event for ${principal}`);
++ assert.ok(validate('events.event-envelope@1', rev).valid, JSON.stringify(validate('events.event-envelope@1', rev).errors));
++ assert.ok(validate('network.node.revoked@1', rev.payload).valid, JSON.stringify(validate('network.node.revoked@1', rev.payload).errors));
++ assert.deepStrictEqual([rev.payload.node_id, rev.payload.principal_id, rev.payload.owner, rev.payload.reason, rev.actor],
++ [nodeId, principal, { kind: 'user', subject: subjectId }, 'account_deleted', { type: 'user', id: dana.subject_id }]);
++ assert.ok(!/hash|credential|secret/i.test(JSON.stringify(rev.payload)), 'the payload carries no secret field');
++ }
const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
@@@ -224,6 +248,7 @@ const server = http.createServer(app);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');
++ assert.strictEqual((await events('network.node.revoked')).length, 2, 'a second sweep emits nothing new');
// Services confirm; staff see what is outstanding.
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
ddiff --git a/test/node-pairing.test.js b/test/node-pairing.test.js
iindex f89a9d96..5894fa3c 100644
---- a/test/node-pairing.test.js
++++ b/test/node-pairing.test.js
@@@ -7,7 +7,7 @@ const assert = require('assert');
const crypto = require('crypto');
const http = require('http');
const express = require('express');
--const { ids } = require('openvibe-contracts');
++const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const nodePrincipals = require('../server/registry/node-principals');
@@@ -32,6 +32,7 @@ await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VAL
const clock = { offset: 0 };
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const app = express();
++app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
@@@ -59,6 +60,8 @@ const token = async (id, secret) => (await (await fetch(`${base}/oauth/token`, {
const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async (x) => { const text = await x.text(); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });
const bearer = (t) => ({ authorization: `Bearer ${t}` });
++const revokedEvents = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
++ .filter((e) => e.event_type === 'network.node.revoked');
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
try {
@@@ -193,13 +196,38 @@ try {
assert.ok(r.body.revoked_at);
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(paired.principal);
assert.deepStrictEqual([row.status, row.revoked_by, row.credential_prev_hash, row.prev_valid_until], ['revoked', 'svc:bot', null, null]);
++ // The revoke is announced once (network.node.revoked@1), valid, owned by Alex, actor the service, no secret.
++ const announced = (await revokedEvents()).filter((e) => e.subject.id === paired.principal);
++ assert.strictEqual(announced.length, 1, 'one event for one revoke');
++ const e = announced[0];
++ assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
++ assert.ok(validate('network.node.revoked@1', e.payload).valid, JSON.stringify(validate('network.node.revoked@1', e.payload).errors));
++ assert.deepStrictEqual(Object.keys(e.payload).sort(), ['at', 'node_id', 'owner', 'principal_id'], 'no reason given, none carried');
++ assert.deepStrictEqual([e.payload.node_id, e.payload.principal_id, e.payload.owner], [paired.node_id, paired.principal, { kind: 'user', subject: alex }]);
++ assert.deepStrictEqual([e.actor, e.visibility, e.source, e.subject.type], [{ type: 'service', id: 'bot' }, 'internal', 'network', 'node_principal']);
++ assert.ok(!/hash|credential|secret/i.test(JSON.stringify(e.payload)), 'the payload carries no secret field');
clock.offset += 5000;
const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
clock.offset -= 5000;
assert.deepStrictEqual([again.status, again.body.revoked_at], [200, r.body.revoked_at], 'a second revoke changes nothing');
++ assert.strictEqual((await revokedEvents()).filter((x) => x.subject.id === paired.principal).length, 1, 'a second revoke emits nothing new');
assert.strictEqual((await call('GET', `/internal/node-principals/${paired.principal}`, null, bot)).body.status, 'revoked');
});
++ await check('a revoke may carry the caller\'s reason; an invalid one is refused before anything changes', async () => {
++ const { body: pairing } = await mint('robot-reason');
++ const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
++ const bad = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, { reason: '' }, bot);
++ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_reason']);
++ assert.strictEqual((await db.prepare('SELECT status FROM platform_node_principals WHERE id = ?').get(paired.principal)).status, 'active', 'a refused reason revokes nothing');
++ assert.strictEqual((await revokedEvents()).filter((x) => x.subject.id === paired.principal).length, 0, 'a refused revoke emits nothing');
++ const ok = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, { reason: 'the machine was sold' }, bot);
++ assert.strictEqual(ok.status, 200, ok.text);
++ const e = (await revokedEvents()).find((x) => x.subject.id === paired.principal);
++ assert.deepStrictEqual([e.payload.principal_id, e.payload.reason], [paired.principal, 'the machine was sold']);
++ assert.ok(validate('network.node.revoked@1', e.payload).valid);
++ });
++
await check('pairing creates no platform machine; Host cannot report a paired node', async () => {
const { body: pairing } = await mint('robot-platform');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
ddiff --git a/test/node-principals-me.test.js b/test/node-principals-me.test.js
iindex b2ad761e..b71803e0 100644
---- a/test/node-principals-me.test.js
++++ b/test/node-principals-me.test.js
@@@ -9,7 +9,7 @@ const assert = require('assert');
const crypto = require('crypto');
const http = require('http');
const express = require('express');
--const { ids } = require('openvibe-contracts');
++const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const nodePrincipals = require('../server/registry/node-principals');
@@@ -56,6 +56,8 @@ const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, h
const bearer = (t) => ({ authorization: `Bearer ${t}` });
const serviceToken = async (id, secret) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) })).json()).access_token;
const session = async (id) => bearer(signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config));
++const revokedEvents = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
++ .filter((e) => e.event_type === 'network.node.revoked');
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
const VIEW_KEYS = ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status'];
@@@ -114,7 +116,7 @@ try {
await check('revoking your own machine clears the previous credential and is idempotent', async () => {
await db.prepare("UPDATE platform_node_principals SET credential_prev_hash = ?, prev_valid_until = ? WHERE id = ?").run('c'.repeat(64), '2026-10-02T00:01:00Z', a1.principal);
-- const r = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
++ const r = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, { reason: 'stolen laptop' }, asAlex);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.deepStrictEqual([r.body.principal, r.body.status], [a1.principal, 'revoked']);
@@@ -122,10 +124,22 @@ try {
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(a1.principal);
assert.deepStrictEqual([row.revoked_at, row.revoked_by, row.credential_prev_hash, row.prev_valid_until, row.updated_at], [r.body.revoked_at, alex, null, null, r.body.revoked_at]);
assert.strictEqual(row.credential_hash, crypto.createHash('sha256').update(a1.credential).digest('hex'), 'the row still holds its credential: migrations/0014 requires one for a user-owned principal');
++ // The owner's revoke is announced once, valid, with the owner as actor and the owner's text as reason.
++ const announced = (await revokedEvents()).filter((e) => e.subject.id === a1.principal);
++ assert.strictEqual(announced.length, 1, 'one event for one revoke');
++ const e = announced[0];
++ assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
++ assert.ok(validate('network.node.revoked@1', e.payload).valid, JSON.stringify(validate('network.node.revoked@1', e.payload).errors));
++ assert.deepStrictEqual([e.payload.node_id, e.payload.principal_id, e.payload.owner, e.payload.reason], [a1.node_id, a1.principal, { kind: 'user', subject: alex }, 'stolen laptop']);
++ assert.deepStrictEqual([e.actor, e.visibility, e.source], [{ type: 'user', id: alex }, 'internal', 'network']);
++ assert.ok(!/hash|credential|secret/i.test(JSON.stringify(e.payload)), 'the payload carries no secret field');
++ const bad = await call('POST', `/api/v1/me/nodes/${a2.principal}/revoke`, { reason: 'x'.repeat(501) }, asAlex);
++ assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_reason'], 'an over-long reason is refused');
clock.offset += 5000;
const again = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
clock.offset -= 5000;
assert.deepStrictEqual([again.status, again.body], [200, r.body], 'a second revoke answers the same view');
++ assert.strictEqual((await revokedEvents()).filter((x) => x.subject.id === a1.principal).length, 1, 'a second revoke emits nothing new');
});
await check('no answer ever carries a hash, credential or secret', async () => {