openvibers/openvibe.network Commits
Emit network.node.revoked@1 on every node-principal revoke; pin openvibe-contracts v0.99.0 (#58)
Changes
@@@ -34,6 +34,9 @@ Identity and account service for the OpenVibe network. Manages user accounts, OAdeveloper projects, apps, credentials, grants and quotas (ADR-014), mod principals- notifications and email, themes and theme preferences, user modules, platform blocks, the followgraph (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 networkchangelog 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 (PostgreSQLand 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)@@@ -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)",@@@ -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.@@@ -4,8 +4,9 @@ Status: **N1–N7 merged** on `origin/main` `fbd6312` (registry slice 5, the nodoperator 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 waswritten 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 notdeployed (`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 Networkslices (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 endpointbacks 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 machineinto 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 atv0.84.0 and there is no `manifests/services/bot.json`; that is a separate T1/T15 job, not a blocker here.@@@ -5,14 +5,13 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_ionly: `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 **slices8–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 wasre-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.@@@ -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@@@ -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",@@@ -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",@@@ -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) {@@@ -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 };@@@ -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 } };@@@ -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) VALconst 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 });@@@ -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, hconst 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 () => {
@@ -34,6 +34,9 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -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
developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
graph (ADR-030), creator analytics (ADR-021), the OpenCoins wallet
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
- 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)
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
@@ -45,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
## 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
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`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"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": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
"mods.grant.manage",
"mods.grant.manage",
@@ -40,6 +40,7 @@
@@ -40,6 +40,7 @@
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
"OpenCoins wallet",
"OpenCoins wallet",
"service registry, public discovery and the status page with incidents",
"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",
"operator alerts relay target and admin panel",
"serves /shared/* from its pinned openvibe-shared",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
@@ -43,9 +43,9 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
@@ -43,9 +43,9 @@ A new project is `sandbox` only. Staff switch it to `sandbox+production`, which
## Grants, the allowance and the capability catalog
## 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 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 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 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.
- 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.
- `GET /api/v1/projects/catalog` lists what could ever be granted.
@@ -4,8 +4,9 @@ Status: **N1–N7 merged** on `origin/main` `fbd6312` (registry slice 5, the nod
@@ -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
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`,
`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.
`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
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
`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.
deployed (`ov access run openvibe-ovh releases bot` → `unknown service "bot"`), so no production device exists.
@@ -226,8 +227,8 @@ instances carry endpoints.
@@ -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 /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. |
| `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. |
| `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)
### 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
@@ -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) |
| 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) |
| 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) |
| 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
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
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 `
@@ -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
connect; restart with `device_id` empty re-binds; `reauth` sent before expiry; `401` from the token endpoint
backs off.
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`
- **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
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.
into a project" is decided.
- **Route-weight writes** (cells and instances): no writer until the scheduler exists; weights stay at the default.
- **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
- **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.
`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
- **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.
v0.84.0 and there is no `manifests/services/bot.json`; that is a separate T1/T15 job, not a blocker here.
@@ -5,14 +5,13 @@ merge `4ec36e1`) and **lane B step 3 built** (`GET /internal/projects/:project_i
@@ -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:
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,
`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
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
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`
`server/developer/confirmations.js`; and the owner notification, `confirmations.notifyOwner` sending `openvibe-shared`
`TYPES.CONFIRMATION_REQUESTED`, PR #47, merge `fbd6312`).
`TYPES.CONFIRMATION_REQUESTED`, PR #47, merge `fbd6312`).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
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)".
`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).
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),
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.
the `agent` actor in `identity.service-token-claims@1` and `network.confirmation.changed@1`; v0.86–v0.97 are additive.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
Status: **resource registry and N6 built** (`migrations/0007_resource_registry.sql`,
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**
`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.
(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,
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
runtime, agent and harness offers — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -16,7 +16,8 @@ const cache = require('openvibe-shared/cache-policy');
@@ -16,7 +16,8 @@ const cache = require('openvibe-shared/cache-policy');
* - modules, follows and blocks both ways, notifications, preferences, push subscriptions, effects;
* - modules, follows and blocks both ways, notifications, preferences, push subscriptions, effects;
* - history, username history, the sign-in IP log and email log;
* - 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
* - 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
* 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;
* 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
* 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() } = {}) {
@@ -363,6 +364,10 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
}
}
await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
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);
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.
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
for (const id of userIds) {
@@ -16,6 +16,11 @@
@@ -16,6 +16,11 @@
* GET /api/v1/me/nodes session → the person's own machines, newest first
* 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)
* 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
* 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
* 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.
* token, never from the body or the path.
@@ -28,6 +33,7 @@ const express = require('express');
@@ -28,6 +33,7 @@ const express = require('express');
const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
const { TOKEN_TTL_S, SELF_AUDIENCE } = require('../identity/principals');
const { TOKEN_TTL_S, SELF_AUDIENCE } = require('../identity/principals');
+const eventRelay = require('../developer/event-relay');
const CODE_TTL_MS = 10 * 60 * 1000;
const CODE_TTL_MS = 10 * 60 * 1000;
const MAX_TRIES = 5;
const MAX_TRIES = 5;
@@ -184,23 +190,87 @@ async function byScope(db, id, scope) {
@@ -184,23 +190,87 @@ async function byScope(db, id, scope) {
/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
/** 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] });
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
* 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
* 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);
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. */
/** 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. */
/** 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]));
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()) {
@@ -269,7 +339,7 @@ function userRouter(requireAuth, now = () => Date.now()) {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
const db = req.app.locals.db;
const db = req.app.locals.db;
const me = await meOf(db, req.user);
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}` });
if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.principal}` });
res.json(principalView(p));
res.json(principalView(p));
}));
}));
@@ -405,7 +475,9 @@ function routers({ guard, now = () => Date.now() }) {
@@ -405,7 +475,9 @@ function routers({ guard, now = () => Date.now() }) {
});
});
internal.post('/node-principals/:id/revoke', guard, async (req, res) => {
internal.post('/node-principals/:id/revoke', guard, async (req, res) => {
res.set('Cache-Control', 'no-store');
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}` });
if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
res.json(principalView(p));
res.json(principalView(p));
});
});
@@ -431,4 +503,4 @@ function routers({ guard, now = () => Date.now() }) {
@@ -431,4 +503,4 @@ function routers({ guard, now = () => Date.now() }) {
return { internal, pairing };
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 };
@@ -18,7 +18,7 @@ const express = require('express');
@@ -18,7 +18,7 @@ const express = require('express');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
-const { validate, serviceAuth } = require('openvibe-contracts');
+const { ids, validate, serviceAuth } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const wallet = require('../server/coins/wallet');
const wallet = require('../server/coins/wallet');
@@ -199,8 +199,32 @@ const server = http.createServer(app);
@@ -199,8 +199,32 @@ const server = http.createServer(app);
// dana had folded an older account in (ADR-029): its subject goes with her.
// dana had folded an older account in (ADR-029): its subject goes with her.
const old = await mk('dana_old');
const old = await mk('dana_old');
await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
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);
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
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);
const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
assert.match(gone.username, /^deleted-/);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
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);
@@ -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.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.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 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.
// 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 } };
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
@@ -7,7 +7,7 @@ const assert = require('assert');
@@ -7,7 +7,7 @@ const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { ids } = require('openvibe-contracts');
+const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const nodePrincipals = require('../server/registry/node-principals');
const nodePrincipals = require('../server/registry/node-principals');
@@ -32,6 +32,7 @@ await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VAL
@@ -32,6 +32,7 @@ await db.prepare("INSERT INTO platform_cells (id, region, residency, status) VAL
const clock = { offset: 0 };
const clock = { offset: 0 };
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const app = express();
const app = express();
+app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
app.locals.db = db;
app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
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`, {
@@ -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 })
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 }; });
.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 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;
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
try {
try {
@@ -193,13 +196,38 @@ try {
@@ -193,13 +196,38 @@ try {
assert.ok(r.body.revoked_at);
assert.ok(r.body.revoked_at);
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(paired.principal);
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]);
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;
clock.offset += 5000;
const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
clock.offset -= 5000;
clock.offset -= 5000;
assert.deepStrictEqual([again.status, again.body.revoked_at], [200, r.body.revoked_at], 'a second revoke changes nothing');
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');
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 () => {
await check('pairing creates no platform machine; Host cannot report a paired node', async () => {
const { body: pairing } = await mint('robot-platform');
const { body: pairing } = await mint('robot-platform');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
@@ -9,7 +9,7 @@ const assert = require('assert');
@@ -9,7 +9,7 @@ const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { ids } = require('openvibe-contracts');
+const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const nodePrincipals = require('../server/registry/node-principals');
const nodePrincipals = require('../server/registry/node-principals');
@@ -56,6 +56,8 @@ const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, h
@@ -56,6 +56,8 @@ const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, h
const bearer = (t) => ({ authorization: `Bearer ${t}` });
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 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 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;
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
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'];
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 {
@@ -114,7 +116,7 @@ try {
await check('revoking your own machine clears the previous credential and is idempotent', async () => {
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);
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.status, 200, r.text);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.deepStrictEqual([r.body.principal, r.body.status], [a1.principal, 'revoked']);
assert.deepStrictEqual([r.body.principal, r.body.status], [a1.principal, 'revoked']);
@@ -122,10 +124,22 @@ try {
@@ -122,10 +124,22 @@ try {
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(a1.principal);
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.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');
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;
clock.offset += 5000;
const again = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
const again = await call('POST', `/api/v1/me/nodes/${a1.principal}/revoke`, null, asAlex);
clock.offset -= 5000;
clock.offset -= 5000;
assert.deepStrictEqual([again.status, again.body], [200, r.body], 'a second revoke answers the same view');
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 () => {
await check('no answer ever carries a hash, credential or secret', async () => {