openvibers/openvibe.domains Commits

The control panel: connect Cloudflare (token kept as the person's Network project secret), zones and live records, every change a proposal approved here or in the Action Center, applied with a stale check and rolled back in one step; ov_domains; contracts v0.233.0 (#4)

OP OpenVibers committed 16 days ago · 8cf0802438 · 29 files, +2496 −51

Changes

ddiff --git a/.env.example b/.env.example
iindex e651ca1..dad4447 100644
---- a/.env.example
++++ b/.env.example
@@@ -14,3 +14,16 @@ TRUST_PROXY=2
# The shared per-IP budget for /api/v1 and /search, and the data clients' timeout.
# DOMAINS_RATE_LIMIT_PER_MIN=120
# DOMAINS_UPSTREAM_TIMEOUT_MS=8000
++
++# ── The control panel (DOM-2): its database and sign-in with OpenVibe.Network ──
++# DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role); unset in development = PGlite in data/.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# OAuth client `domains` (written by Network's service-principal.js --write-env; never commit a value)
++OV_OAUTH_CLIENT_ID=domains
++OV_OAUTH_CLIENT_SECRET=
++# OpenVibe.Events → POST /internal/events (account export and deletion); 32+ characters, comma-separated to rotate
++DOMAINS_EVENTS_SECRET=
++EVENTS_URL=http://127.0.0.1:4300
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c23237b..905877e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,20 @@
What changed in OpenVibe.Domains, newest first.
++
++## 0.2.0 — 2026-10-28
++
++- **The control panel** (`/panel`, plan 2026-10-28 §5.1, DOM-2):
++ - Sign in with OpenVibe.Network (client `domains`), then connect Cloudflare. The token is verified, then stored as a
++ secret in your own Network project, granted to Domains for calls to api.cloudflare.com only (ADR-056). Domains'
++ database (`ov_domains`) keeps its name, never its value.
++ - Zones are synced; a zone's records are read live from Cloudflare.
++ - Every change (yours, a bulk edit's or an agent's) is a proposal (`domains.proposal@1`): the record diff, why, what
++ could break, and 7 days to decide. Approve on its page or in the Action Center. A record that moved since the
++ proposal is refused as stale; an applied proposal rolls back in one step; everything is audited.
++ - API: `/api/v1/panel/*`, `/api/v1/proposals`; Network's Action Center reads `/api/v1/actions` and decides with
++ `domains.actions.read` and `domains.proposal.decide`. Account export and deletion (ADR-033).
++
## 0.1.0 — 2026-10-28
The service skeleton: a stateless read-only front over OpenVibe.Host's price catalog and OpenVibe.Tools' public
ddiff --git a/README.md b/README.md
iindex 2a6597c..f3481b1 100644
---- a/README.md
++++ b/README.md
@@@ -26,6 +26,21 @@ There is no database, no sign-in, no session and no event stream. Nothing is sto
All of it is server-rendered and readable without JavaScript. The shell is the OpenVibe Frame (`openvibe-shared/shell`):
theme loader, navbar mounted into `#navbar-mount`, the shared server-rendered footer, service id `domains`.
++## The control panel (v2)
++
++`/panel` is a signed-in person's own (sign-in with OpenVibe.Network, client `domains`).
++
++- **Connect:** paste a Cloudflare API token (Edit zone DNS + Zone Read). Domains verifies it, then stores it as a
++ secret in your own Network project (`OpenVibe.Domains`, ADR-056), granted to `domains` for calls to
++ `https://api.cloudflare.com` only. It is revealed per call (`network.secret.use`) and never kept here.
++- **Zones and records:** zones are synced into `domains_zones`; records are read live.
++- **Proposals:** every change is a `domains.proposal@1`: create, update or delete records, why, what could break, and
++ 7 days to decide. Approve on `/panel/p/:id` or in the Action Center. Changes apply in order; a record that moved
++ since the proposal stops the run (stale). An applied (or part-applied) proposal rolls back in one step.
++- **API:** `GET /api/v1/panel/connections`, `/api/v1/panel/zones`, `/api/v1/panel/zones/:conn/:zone/records`,
++ `GET|POST /api/v1/proposals`, `POST /api/v1/proposals/:id/decide|rollback` with your Network token; Network's Action
++ Center reads `GET /api/v1/actions?subject=` (`domains.actions.read`) and decides (`domains.proposal.decide`).
++
## API
Everything under `/api/v1`, read-only, CORS-open (`Access-Control-Allow-Origin: *`), cached publicly for five
ddiff --git a/STATUS.json b/STATUS.json
iindex 65336ca..2cf41fb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,6 +1,6 @@
{
"repository": "OpenVibers/OpenVibe.Domains",
-- "version": "0.1.0",
++ "version": "0.2.0",
"stage": "product",
"code": true,
"wave": "v1",
@@@ -25,14 +25,16 @@
"Read-only JSON API at /api/v1: search, tlds, one tld, one domain; CORS-open; 400 problem+json for a bad name and 404 for an unknown ending; 120/min per IP shared with /search",
"Service plumbing: helmet with Help's CSP, /api/health, /api/ready (Host and Tools optional, so a failure is degraded not 503), release.json and /release-metrics, /shared, /robots.txt, /sitemap.xml (every catalog ending, lastmod from the release), /llms.txt, /metrics on loopback, graceful stop",
"Deploy files: systemd unit on /opt/openvibe.domains, nginx vhost with www -> apex, TLS, security headers, GET-only /api/v1 and loopback-only /metrics",
-- "Tests: names, pricing, the data clients' cache/expiry/failure/timeout, and the booted app's pages, redirects, API shapes, CORS, discovery and degraded readiness"
++ "Tests: names, pricing, the data clients' cache/expiry/failure/timeout, and the booted app's pages, redirects, API shapes, CORS, discovery and degraded readiness",
++ "Control panel (DOM-2): sign-in with OpenVibe.Network (client domains); connect Cloudflare (token verified, stored as a secret in the person's own Network project granted for reveal to api.cloudflare.com only); zones synced; records read live; every change a domains.proposal@1 approved on its page or in the Action Center (domains.actions.read, domains.proposal.decide), applied in order with a stale check, rolled back in one step; audit; account export and deletion"
],
"notYet": [
"The pages are plain placeholders (server/views/index.js) written for v1's plumbing; the main session replaces server/views/ with the real design",
"No service manifest in openvibe-contracts yet: no domains.* capability is registered and no other service calls this one",
"No database, no sign-in, no sessions, no event subscriptions and no stored data (v1 is deliberately stateless)",
"No registrar links or affiliate handling: prices are shown with the registrar's name, not linked out yet",
-- "Only /api/v1 and /search are rate limited; the HTML pages are served to everyone"
++ "Only /api/v1 and /search are rate limited; the HTML pages are served to everyone",
++ "Porkbun, Namecheap and Spaceship connections; DNSSEC DS at the registrar; bulk nameserver changes; agents proposing through MCP"
],
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
ddiff --git a/deploy/nginx/openvibe.domains.conf b/deploy/nginx/openvibe.domains.conf
iindex eec9048..b4c8aa5 100644
---- a/deploy/nginx/openvibe.domains.conf
++++ b/deploy/nginx/openvibe.domains.conf
@@@ -2,14 +2,17 @@
## openvibe.domains — Nginx configuration (REFERENCE)
##
## Deployed to /etc/nginx/sites-available/openvibe.domains.conf (ov-vhost-install) — keep them in sync and reload
--## nginx after changing it. The Node app (port 5070, systemd unit openvibe-domains) renders every page and serves
--## the read-only API; nginx terminates TLS, redirects www → apex, rate-limits the API, and compresses text.
++## nginx after changing it. The Node app (port 5070, systemd unit openvibe-domains) renders every page, serves the
++## API and the control panel (sign-in with OpenVibe.Network); nginx terminates TLS, redirects www → apex, rate-limits
++## the API, sign-in and the panel's forms, keeps /internal and /metrics local, and compresses text.
##
## No proxy_cache: the app sets its own public Cache-Control per response. Never log request bodies here.
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=domains_api:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=domains_search:10m rate=2r/s;
++limit_req_zone $binary_remote_addr zone=domains_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=domains_forms:10m rate=1r/s;
server {
listen 80;
@@@ -66,10 +69,24 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
-- # ── The read-only API: GET (and HEAD) only ─────────────
-- # A POST, PUT, PATCH or DELETE to /api/v1 never reaches the app. The app itself has no write route.
++ # ── Network SSO session endpoints ───────────────────────
++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
++ location = /auth/me {
++ limit_req zone=domains_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5070;
++ }
++
++ location /auth/ {
++ limit_req zone=domains_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5070;
++ }
++
++ # ── The API: reads, and the control panel's proposals (POST with a person's token) ──
++ # PUT, PATCH and DELETE never reach the app. Network's Action Center calls the app on loopback, not through here.
location /api/v1 {
-- limit_except GET { deny all; }
++ limit_except GET POST { deny all; }
limit_req zone=domains_api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5070;
@@@ -89,9 +106,19 @@ server {
proxy_pass http://127.0.0.1:5070;
}
++ # ── The control panel's forms: connect, sync, remove, propose, decide, roll back ──
++ location ~ ^/panel/(connect|c/[A-Za-z0-9_]+/(sync|remove)|z/[A-Za-z0-9_]+/[A-Za-z0-9_-]+/propose|p/[A-Za-z0-9_]+/(decide|rollback))$ {
++ limit_req zone=domains_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5070;
++ }
++
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5070/metrics) ──
location ~* ^/metrics(/|$) { return 404; }
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events, ADR-033) ──
++ location ~* ^/internal(/|$) { return 404; }
++
# ── Pages, discovery, static assets, release.json ──────
location / {
proxy_pass http://127.0.0.1:5070;
ddiff --git a/migrations/0001_panel.sql b/migrations/0001_panel.sql
nnew file mode 100644
iindex 0000000..b3e899d
---- /dev/null
++++ b/migrations/0001_panel.sql
@@@ -0,0 +1,82 @@
++-- phase: expand
++-- OpenVibe.Domains' control panel (plan 2026-10-28 §5.1, DOM-2). A person connects a registrar or DNS account through
++-- its official API; the credential itself is a Network project secret in the person's own project (ADR-056), granted
++-- to `domains` for reveal to the provider's origin, so only its name lives here. Every DNS change is a proposal with
++-- its diff and its rollback, applied only once its person approves it; the audit keeps who did what.
++
++CREATE TABLE IF NOT EXISTS domains_connections (
++ id text COLLATE "C" PRIMARY KEY, -- dcn_<ULID>
++ subject text COLLATE "C" NOT NULL, -- usr_… the person who connected it
++ provider text COLLATE "C" NOT NULL, -- cloudflare
++ label text NOT NULL, -- the person's own name for it
++ project_id text COLLATE "C" NOT NULL, -- prj_… the Network project holding the secret
++ environment text COLLATE "C" NOT NULL DEFAULT 'production',
++ secret_name text COLLATE "C" NOT NULL, -- the secret's name in that project, never its value
++ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | broken | removed
++ last_error text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ checked_at timestamptz,
++ removed_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS domains_connections_subject ON domains_connections (subject) WHERE status <> 'removed';
++
++-- The zones a connection holds, as the provider last answered (a cache: the provider stays the truth).
++CREATE TABLE IF NOT EXISTS domains_zones (
++ connection_id text COLLATE "C" NOT NULL REFERENCES domains_connections (id) ON DELETE CASCADE,
++ zone_id text COLLATE "C" NOT NULL, -- the provider's id
++ name text COLLATE "C" NOT NULL, -- example.com
++ status text,
++ name_servers jsonb NOT NULL DEFAULT '[]'::jsonb,
++ plan text,
++ synced_at timestamptz NOT NULL,
++ PRIMARY KEY (connection_id, zone_id)
++);
++CREATE INDEX IF NOT EXISTS domains_zones_name ON domains_zones (name);
++
++-- A change waiting for its person (domains.proposal@1). changes: [{ op, record_id, before, after }]; rollback is the
++-- inverse, completed with the provider's ids as each change is applied.
++CREATE TABLE IF NOT EXISTS domains_proposals (
++ id text COLLATE "C" PRIMARY KEY, -- dpr_<ULID>
++ subject text COLLATE "C" NOT NULL,
++ connection_id text COLLATE "C" NOT NULL REFERENCES domains_connections (id),
++ zone_id text COLLATE "C" NOT NULL,
++ domain text COLLATE "C" NOT NULL,
++ title text NOT NULL,
++ why text,
++ risk text,
++ changes jsonb NOT NULL,
++ rollback jsonb NOT NULL DEFAULT '[]'::jsonb,
++ status text COLLATE "C" NOT NULL DEFAULT 'pending', -- pending | applying | applied | declined | failed | rolled_back | expired
++ created_by text COLLATE "C" NOT NULL, -- user:usr_… | agent:… | service:…
++ created_at timestamptz NOT NULL DEFAULT now(),
++ expires_at timestamptz,
++ decided_at timestamptz,
++ decided_by text COLLATE "C",
++ applied_at timestamptz,
++ rolled_back_at timestamptz,
++ result jsonb,
++ error text
++);
++CREATE INDEX IF NOT EXISTS domains_proposals_subject ON domains_proposals (subject, status, created_at DESC);
++
++CREATE TABLE IF NOT EXISTS domains_audit (
++ id bigserial PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ at timestamptz NOT NULL DEFAULT now(),
++ action text COLLATE "C" NOT NULL, -- connection.added | proposal.created | proposal.applied | …
++ connection_id text COLLATE "C",
++ proposal_id text COLLATE "C",
++ detail jsonb NOT NULL DEFAULT '{}'::jsonb
++);
++CREATE INDEX IF NOT EXISTS domains_audit_subject ON domains_audit (subject, at DESC);
++
++-- Account export and deletion (ADR-033, openvibe-sdk/account-data): the events already applied, so a redelivery is a
++-- no-op.
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id text PRIMARY KEY,
++ kind text NOT NULL,
++ subject text NOT NULL,
++ outcome jsonb,
++ applied_at timestamptz NOT NULL DEFAULT now(),
++ sent_at timestamptz
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 6a794c3..b6ddd79 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,26 +1,38 @@
{
"name": "openvibe-domains",
-- "version": "0.1.0",
++ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-domains",
-- "version": "0.1.0",
++ "version": "0.2.0",
"license": "AGPL-3.0-only",
"dependencies": {
++ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
},
"engines": {
"node": ">=22.0.0"
}
},
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.13",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -171,6 +183,25 @@
"node": ">= 0.6"
}
},
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-parser/node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@@ -770,9 +801,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.225.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
-- "integrity": "sha512-msLaRYa2Byyabnf/+L3aRPwkx8MI3zWbofFhwupq+RyBCdgbA4gLlkPsJu9nK/1UTCWdNdSTfWXW5pZY1/1zbQ==",
++ "version": "0.233.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
++ "integrity": "sha512-pkX0+tsMP6HF7mwbM6jDa97C8U+kbivs+iVdjylEP0sEaqZsPYvTuYHHvnPMZXNQ6QCLriGRSBNdji1F8i3m5A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -840,6 +871,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -1067,6 +1226,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -1124,6 +1292,15 @@
"engines": {
"node": ">= 0.8"
}
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex e917848..e88fa38 100644
---- a/package.json
++++ b/package.json
@@@ -1,8 +1,8 @@
{
"name": "openvibe-domains",
-- "version": "0.1.0",
++ "version": "0.2.0",
"private": true,
-- "description": "OpenVibe.Domains \u2014 name it, price it, run it.",
++ "description": "OpenVibe.Domains — name it, price it, run it.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -15,15 +15,20 @@
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"dependencies": {
++ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0"
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
++ "pg": "^8.23.0"
},
"engines": {
"node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
}
}
ddiff --git a/public/css/domains.css b/public/css/domains.css
iindex 86b5a33..985dc5e 100644
---- a/public/css/domains.css
++++ b/public/css/domains.css
@@@ -128,3 +128,120 @@ html[data-ov-motion=reduced] .dm-bar span { transform: none !important; transiti
.dm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
.dm-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
.dm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
++
++/* ── The control panel (DOM-2): connections, zones, records, proposals ─────────────────────────────────────────── */
++.dmp .ovk-i { width: 1.05em; height: 1.05em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
++.dmp-sub { color: var(--text-muted, #7386a3); font-size: .84rem; }
++.dmp-notice { display: flex; align-items: center; gap: 10px; margin: 18px 0 0; padding: 11px 14px; border-radius: 14px; font-weight: 600;
++ color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 12%, transparent); border: 1px solid color-mix(in srgb, var(--success, #22c55e) 30%, transparent); }
++.dmp-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 18px 0 0; }
++.dm-btn { gap: 8px; border: 0; color: var(--room-on); cursor: pointer; font: inherit; font-weight: 700; }
++.dmp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, #1f2d47);
++ background: var(--bg-card, #0f1729); color: var(--text-primary, #e6edf7); font: inherit; font-size: .88rem; font-weight: 700; text-decoration: none; cursor: pointer; }
++.dmp-btn:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); }
++.dmp-btn--quiet { background: transparent; color: var(--text-secondary, #96a7c2); }
++.dmp-btn:focus-visible, .dm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
++
++/* Signed out: three steps */
++.dmp-steps { list-style: none; margin: 24px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
++.dmp-steps li { display: grid; gap: 6px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.dmp-steps li > span:last-child { color: var(--text-secondary, #96a7c2); line-height: 1.5; font-size: .92rem; }
++.dmp-step-ic { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; color: var(--room-on); background: var(--room-fill); }
++
++/* Status pills */
++.dmp-st { font-size: .76rem; padding: 2px 10px; }
++.dmp-st--pending { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 16%, transparent); }
++.dmp-st--applying { color: var(--warning-text, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 14%, transparent); }
++.dmp-st--applied { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
++.dmp-st--failed { color: var(--error-text, #f87171); background: color-mix(in srgb, var(--error, #ef4444) 14%, transparent); }
++.dmp-st--declined, .dmp-st--expired, .dmp-st--rolled_back { color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-muted, #7386a3) 16%, transparent); }
++
++/* Proposal cards */
++.dmp-props { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
++.dmp-prop a { display: grid; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7);
++ background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }
++.dmp-prop a:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.dmp-prop b { font-size: 1rem; line-height: 1.35; overflow-wrap: anywhere; }
++.dmp-prop-top { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++
++/* Zones table */
++.dmp-zones .dm-tr { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, .7fr) minmax(0, 1.2fr) auto; text-decoration: none; color: inherit; }
++.dmp-zones a.dm-tr:hover { background: color-mix(in srgb, var(--room-accent) 6%, transparent); }
++.dmp-zone-name { display: flex; align-items: center; gap: 8px; min-width: 0; overflow-wrap: anywhere; }
++.dmp-zone-name .ovk-i { color: var(--room-strong); }
++.dmp-ns { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.dmp-open { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .86rem; color: var(--room-strong); }
++
++/* Connections */
++.dmp-conns { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.dmp-conn { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 12px 16px; border-radius: 16px;
++ background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.dmp-conn.is-broken { border-color: color-mix(in srgb, var(--error, #ef4444) 45%, var(--border, #1f2d47)); }
++.dmp-conn-main { display: grid; gap: 3px; min-width: 0; }
++.dmp-conn-act { display: flex; gap: 8px; }
++.dmp-conn-act form { margin: 0; }
++.dmp-err { display: inline-flex; align-items: center; gap: 6px; color: var(--error-text, #f87171); font-size: .86rem; }
++
++/* Forms */
++.dmp-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 22px; margin-top: 22px; align-items: start; }
++.dmp-form { display: grid; gap: 14px; }
++.dmp-form--row { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.dmp-form--row .dmp-actions { margin: 0; grid-column: 1 / -1; }
++.dmp-field { display: grid; gap: 6px; min-width: 0; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }
++.dmp-field--wide { grid-column: span 2; }
++.dmp-field input, .dmp-field select { width: 100%; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47);
++ background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
++.dmp-field input:focus, .dmp-field select:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
++.dmp-check { display: inline-flex; align-items: center; gap: 8px; min-height: 42px; font-size: .88rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
++.dmp-check input { width: 18px; height: 18px; accent-color: var(--room-fill); }
++.dmp-help { padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); }
++.dmp-help h2 { font-size: 1rem; }
++.dmp-help ol { margin: 12px 0 0; padding-left: 20px; display: grid; gap: 8px; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.dmp-where { display: flex; gap: 10px; align-items: flex-start; margin: 16px 0 0; font-size: .86rem; line-height: 1.5; color: var(--text-secondary, #96a7c2); }
++.dmp-where .ovk-i { margin-top: 2px; color: var(--room-strong); }
++
++/* Records */
++.dmp-recs { list-style: none; margin: 0; padding: 0; border-radius: 16px; overflow: hidden; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
++.dmp-rec { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 8px 16px; padding: 12px 16px; border-top: 1px solid var(--border, #1f2d47); }
++.dmp-rec:first-child { border-top: 0; }
++.dmp-rec-main { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 4px 10px; min-width: 0; flex: 1 1 320px; }
++.dmp-rec-name { overflow-wrap: anywhere; }
++.dmp-rec-val, .dmp-rec-main .dmp-sub { grid-column: 2; }
++.dmp-rec-val, .dmp-diff code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; word-break: break-word; }
++.dmp-type { display: inline-grid; place-items: center; min-width: 52px; padding: 2px 8px; border-radius: 8px; font: 800 .72rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
++ color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 14%, transparent); }
++.dmp-rec-act { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; }
++.dmp-rec-act form { margin: 0; }
++.dmp-edit { flex-basis: 100%; order: 2; }
++.dmp-edit summary { list-style: none; }
++.dmp-edit summary::-webkit-details-marker { display: none; }
++.dmp-edit[open] { flex-basis: 100%; }
++.dmp-edit[open] .dmp-form { margin-top: 10px; }
++.dmp-rec:has(.dmp-edit[open]) .dmp-rec-act { flex-basis: 100%; }
++
++/* A proposal */
++.dmp-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.dmp-change { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
++.dmp-op { align-self: start; padding: 3px 10px; border-radius: 999px; font-size: .76rem; font-weight: 800; }
++.dmp-change--create .dmp-op { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
++.dmp-change--update .dmp-op { color: var(--warning-text, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 14%, transparent); }
++.dmp-change--delete .dmp-op { color: var(--error-text, #f87171); background: color-mix(in srgb, var(--error, #ef4444) 14%, transparent); }
++.dmp-change-rec { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin: 0; overflow-wrap: anywhere; }
++.dmp-diff { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 10px; }
++.dmp-diff > div { display: grid; gap: 3px; padding: 10px 12px; border-radius: 12px; min-width: 0; }
++.dmp-before { background: color-mix(in srgb, var(--error, #ef4444) 7%, transparent); }
++.dmp-before code { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--error, #ef4444) 60%, transparent); }
++.dmp-after { background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); }
++.dmp-after code { color: var(--text-primary, #e6edf7); }
++.dmp-text { margin: 0; max-width: 760px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
++.dmp-decide { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
++.dmp-decide form { margin: 0; }
++.dmp-decide .dm-btn { min-height: 46px; padding: 0 22px; font-size: 1rem; }
++
++@media (max-width: 760px) {
++ .dmp-split { grid-template-columns: minmax(0, 1fr); }
++ .dmp-field--wide { grid-column: 1 / -1; }
++ .dmp-zones .dm-tr { grid-template-columns: minmax(0, 1fr) auto; }
++ .dmp-zones .dm-tr > span:nth-child(2), .dmp-zones .dm-tr > span:nth-child(3), .dmp-zones .dm-tr > span:nth-child(4) { display: none; }
++ .dmp-change { grid-template-columns: minmax(0, 1fr); }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 73d2002..3396113 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -4,19 +4,23 @@
* OpenVibe.Domains — Express app factory.
*
* /, /search, /tld/:tld, /d/:domain the pages (http/pages.js)
-- * /api/v1/* the read-only JSON API (http/api.js)
++ * /panel/* the control panel: connections, zones, proposals (http/panel.js)
++ * /auth/* sign-in with OpenVibe.Network, OAuth client `domains` (auth/sso.js)
++ * /api/v1/* the JSON API (http/api.js; the panel's in http/panel.js)
++ * /internal/events account export and deletion from OpenVibe.Events (loopback only)
* /api/health, /api/ready liveness and truthful readiness
* /release.json, /release-metrics the release manifest (openvibe-shared/release)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /shared/* this service's own pinned Frame files
*
-- * No database, no sign-in, no Events: every request reads two public OpenVibe services through
-- * the cached clients in server/data/. The app factory takes them as options so tests can pass
-- * stubs instead of the network.
++ * The price and info pages read two public OpenVibe services through the cached clients in server/data/. The control
++ * panel keeps its own state in ov_domains (server/db.js) and its credentials in the person's Network project
++ * (panel/vault.js). The factory takes every client as an option so tests can pass stubs instead of the network.
*/
const path = require('path');
const express = require('express');
const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const serve = require('openvibe-shared/serve');
@@@ -29,14 +33,24 @@ const { createTools } = require('./data/tools');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
++const { openStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createSso } = require('./auth/sso');
++const { createPrincipal } = require('./http/principal');
++const { createVault } = require('./panel/vault');
++const { createCloudflare } = require('./panel/cloudflare');
++const { createPanel } = require('./panel/panel');
++const { createPanelRoutes } = require('./http/panel');
++const accountDataLib = require('./identity/account-data');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
const VERSION = require('../package.json').version;
/**
-- * opts: config, host, tools, fetchImpl, log, release
-- * `host` and `tools` are the two data clients; a test passes objects with the same methods.
++ * opts: config, host, tools, fetchImpl, log, release, store, vault, providers, accountSend
++ * `host`, `tools`, `vault` and `providers` are clients; a test passes objects with the same methods.
*/
--function createApp(opts = {}) {
++async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
@@@ -44,7 +58,16 @@ function createApp(opts = {}) {
const tools = opts.tools || createTools({ baseUrl: config.toolsUrl, fetchImpl, log, timeoutMs: config.timeoutMs });
const release = opts.release || releaseLib.createRelease({ service: 'domains', root: path.join(__dirname, '..') });
-- const ctx = { config, host, tools, log, release };
++ const store = opts.store || await openStore(config, { log });
++ const keys = createKeyStore({ config, fetchImpl, log });
++ const sso = createSso({ config, keys, fetchImpl, now: store.now, log });
++ const principal = createPrincipal({ config, keys });
++ const vault = opts.vault || createVault({ config, fetchImpl });
++ const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }) };
++ const panel = createPanel({ store, vault, providers, log });
++ const accountData = accountDataLib.create({ db: store.db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);
++ const ctx = { config, host, tools, log, release, store, keys, sso, principal, vault, panel, accountData };
const app = express();
app.disable('x-powered-by');
@@@ -58,7 +81,7 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-domains', version: VERSION }));
-- const readiness = createServiceReadiness({ host, tools, release: release.release });
++ const readiness = createServiceReadiness({ host, tools, store, release: release.release });
app.get('/api/ready', readiness.handler);
app.use(contracts.http.middleware());
@@@ -76,6 +99,7 @@ function createApp(opts = {}) {
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
++ // Sign-in goes through Network's authorize page; a form never posts anywhere else.
formAction: ["'self'", 'https://openvibe.network'],
},
},
@@@ -85,6 +109,17 @@ function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
++ app.use(cookieParser());
++
++ // ── OpenVibe.Events → this service (loopback only; nginx answers 404 for /internal/). The consumer reads the raw
++ // body itself, so it is mounted before any body parser.
++ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));
++
++ // ── Who is asking (the session cookie, verified offline) and sign-in ──
++ app.use(sso.middleware());
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++
// This service's own pinned copy of the Frame, and its stylesheet (content-hashed by the views: kept a year).
app.use('/shared', serve.handler());
app.use('/css', express.static(path.join(__dirname, '..', 'public', 'css'), { maxAge: '365d', immutable: true, fallthrough: false }));
@@@ -108,6 +143,8 @@ function createApp(opts = {}) {
app.use('/search', limiter);
// ── API ─────────────────────────────────────────────────
++ const panelRoutes = createPanelRoutes({ config, panel, principal, log });
++ app.use('/api/v1', panelRoutes.api);
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => {
res.set('Cache-Control', 'no-store');
@@@ -115,6 +152,7 @@ function createApp(opts = {}) {
});
// ── Pages ───────────────────────────────────────────────
++ app.use(panelRoutes.pages);
app.use(createPageRoutes(ctx));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..af48f50
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured
++ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline
++ * against it: the person's session token (sign-in) and a person's Bearer token (the API).
++ *
++ * This is Help's shim, with the service's name changed: keys.get() / keys.ensure() / keys.loaded()
++ * for readiness and the SSO middleware, and verification through the SDK's verifyUserToken /
++ * verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves this
++ * module: token.no_key answers 'signing key not loaded yet'.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null;
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[OpenVibe.Domains] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
++ }
++ async function ensure() { await refreshMirror(); }
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Domains] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++
++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Domains] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..00a9213
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,261 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `domains`.
++ *
++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
++ * (?silent=1 adds prompt=none; ?next= a same-site path)
++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
++ *
++ * This is Help's SSO module with the client id and cookie names changed. The person's Network access
++ * token lives in an httpOnly cookie (dom_at) no script can read; the refresh token (dom_rt) is
++ * httpOnly too. Neither is ever stored server-side, rendered or logged.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++
++const ACCESS_COOKIE = 'dom_at';
++const REFRESH_COOKIE = 'dom_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'dom_oauth';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++function ownOrigins(config) {
++ const out = new Set();
++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
++ }
++ return out;
++}
++
++/** Same-site relative paths only (never protocol-relative, never another origin). */
++function sanitizeNext(next, config) {
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
++ let u = null;
++ try { u = new URL(next); } catch { return '/'; }
++ if (!ownOrigins(config).has(u.origin)) return '/';
++ next = `${u.pathname}${u.search}${u.hash}`;
++ }
++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
++ return next;
++}
++
++function authorizeUrl(config, { state, challenge, silent = false }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ if (silent) q.set('prompt', 'none');
++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
++}
++
++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++
++ function setSession(res, data) {
++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, cookieBase());
++ res.clearCookie(REFRESH_COOKIE, cookieBase());
++ }
++
++ async function tokenGrant(fields) {
++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
++ let res;
++ try {
++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(10_000),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
++ { status: res.status, error: (data && data.error) || 'invalid_grant' });
++ }
++ return data;
++ }
++
++ /**
++ * A Domains session is a Network SESSION token: audience openvibe.network and a person. FedCM
++ * assertions, app/service tokens and internal tokens are signed with the same key and issuer;
++ * none of them may become a session here.
++ */
++ async function verifySession(token) {
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
++ const c = v.claims;
++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
++ return v;
++ }
++
++ function viewerFromClaims(claims, token) {
++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ const role = typeof claims.role === 'string' ? claims.role : 'user';
++ return {
++ kind: 'user',
++ subject,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
++ role,
++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
++ token,
++ };
++ }
++
++ async function refresh(req, res) {
++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
++ return req.sessionRefresh;
++ }
++
++ async function doRefresh(req, res) {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!rt) return null;
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
++ setSession(res, data);
++ const v = await verifySession(data.access_token);
++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
++ } catch (err) {
++ if (err.status && err.status < 500) clearSession(res);
++ return null;
++ }
++ }
++
++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
++ function middleware() {
++ return async (req, res, next) => {
++ req.viewer = { kind: 'anonymous' };
++ const token = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!token) return next();
++ await keys.ensure();
++ const v = await verifySession(token);
++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
++ if (v.expired) {
++ const fresh = await refresh(req, res);
++ if (fresh) { req.viewer = fresh; return next(); }
++ }
++ if (keys.loaded()) clearSession(res);
++ next();
++ };
++ }
++
++ async function asViewer(req, res, fn) {
++ try {
++ return await fn(req.viewer.token);
++ } catch (err) {
++ if (!err || err.status !== 401) throw err;
++ const fresh = await refresh(req, res);
++ if (!fresh) throw err;
++ req.viewer = fresh;
++ return fn(fresh.token);
++ }
++ }
++
++ function routes() {
++ const r = express.Router();
++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
++
++ r.get('/login', (req, res) => {
++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
++ const state = crypto.randomBytes(16).toString('hex');
++ const { verifier, challenge } = pkcePair();
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.redirect(authorizeUrl(config, { state, challenge, silent }));
++ });
++
++ r.get('/callback', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ let flow = null;
++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
++ const next = flow ? sanitizeNext(flow.next, config) : '/';
++ const { code, state, error } = req.query;
++ if (error) {
++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
++ }
++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
++ }
++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
++ setSession(res, data);
++ res.set('Clear-Site-Data', '"cache"');
++ return res.redirect(next);
++ } catch (err) {
++ log.warn('[OpenVibe.Domains] sign-in exchange failed:', err.status || '', err.error || '');
++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
++ }
++ });
++
++ const sameOriginRequest = (req) => {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
++ const origin = req.get('origin');
++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
++ return req.method === 'GET';
++ };
++ r.get('/logout', (req, res, next) => {
++ if (sameOriginRequest(req)) return next();
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
++ + `<p>Sign out of OpenVibe.Domains?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
++ });
++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ r.all('/logout', async (req, res) => {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ clearSession(res);
++ res.set('Clear-Site-Data', '"cache"');
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ res.redirect(303, sanitizeNext(req.query.next, config));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const c = req.cookies || {};
++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
++ const v = req.viewer;
++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/config.js b/server/config.js
iindex 0567414..49baa8a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -20,6 +20,7 @@ function load(env = process.env) {
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.domains' : `http://localhost:${port}`));
const hostUrl = trim(env.OV_HOST_INTERNAL_URL || hostManifest.internalOrigin || 'http://127.0.0.1:4910');
const toolsUrl = trim(env.OV_TOOLS_INTERNAL_URL || toolsManifest.internalOrigin || 'https://openvibe.tools');
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
return {
service: 'domains',
@@@ -30,12 +31,41 @@ function load(env = process.env) {
isProduction,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
-- // The two OpenVibe services this front reads. Both are read-only, public data.
++ // The two OpenVibe services the price and info pages read: both public data.
hostUrl,
toolsUrl,
// Per-IP budget for /api/v1 and /search (express-rate-limit, one window).
rateLimitPerMinute: Math.max(1, int(env.DOMAINS_RATE_LIMIT_PER_MIN, 120)),
timeoutMs: Math.max(100, int(env.DOMAINS_UPSTREAM_TIMEOUT_MS, 8000)),
++ drill: env.DOMAINS_DRILL === '1' || env.DOMAINS_DRILL === 'true',
++
++ // The control panel's own state (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner
++ // role). In development without DATABASE_URL an embedded PGlite database in data/pglite is used.
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.DOMAINS_PGLITE_DIR || '' },
++
++ // OpenVibe.Network: sign-in (OAuth 2 with PKCE, client `domains`), its JWKS, the person's project secrets and
++ // network.secret.use for revealing a connection's token for one call.
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ audience: env.DOMAINS_AUDIENCE || 'openvibe.domains',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'domains',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // OpenVibe.Events → this service: account export and deletion (ADR-033) at POST /internal/events, signed with
++ // one of these (comma-separated for rotation, 32+ characters each); unset turns the route off (503). The url is
++ // where the two subscriptions are created at boot, off when unset or in a drill.
++ events: {
++ secrets: String(env.DOMAINS_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter((x) => x.length >= 32),
++ url: trim(env.DOMAINS_EVENTS_URL || env.EVENTS_URL || ''),
++ endpoint: env.DOMAINS_EVENTS_ENDPOINT || '',
++ },
};
}
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..ea3252a
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,50 @@
++'use strict';
++
++/**
++ * OpenVibe.Domains' own PostgreSQL database (ADR-035, ov_domains): the schema is migrations/NNNN_*.sql, applied at
++ * boot. It holds the control panel's connections, synced zones, proposals and their audit. No credential is ever
++ * written here: a provider token is a Network project secret (ADR-056), and only its name is kept.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh domains)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Domains] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'domains', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'domains-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'domains', registry, log });
++}
++
++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => {
++ if (prefix === 'event') return `evt_${require('openvibe-contracts').ids.ulid(now())}`;
++ return `${prefix}_${require('openvibe-contracts').ids.ulid(now())}`;
++ },
++ tx: async (fn) => await db.tx(() => fn()),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/http/panel.js b/server/http/panel.js
nnew file mode 100644
iindex 0000000..650c78b
---- /dev/null
++++ b/server/http/panel.js
@@@ -0,0 +1,196 @@
++'use strict';
++
++/**
++ * The control panel's routes (DOM-2). Pages are a signed-in person's own (private, no-store); every change is a form
++ * POST from this site. The API serves the same person with their Network token, and OpenVibe.Network's Action Center
++ * with a service token (domains.actions.read, domains.proposal.decide) for the person X-OV-Subject names.
++ *
++ * GET /panel connections, zones, waiting proposals, recent changes
++ * GET /panel/connect, POST connect Cloudflare (verified, then stored in the person's Network project)
++ * POST /panel/c/:id/sync | /remove
++ * GET /panel/z/:conn/:zone a zone's records, live; POST …/propose (create | update | delete)
++ * GET /panel/p/:id a proposal; POST …/decide (approve | decline), POST …/rollback
++ *
++ * GET /api/v1/panel/connections | /api/v1/panel/zones | /api/v1/panel/zones/:conn/:zone/records (person)
++ * GET /api/v1/proposals, /api/v1/proposals/:id, POST /api/v1/proposals (person)
++ * POST /api/v1/proposals/:id/decide person, or a service with domains.proposal.decide (→ common.action-item@1)
++ * POST /api/v1/proposals/:id/rollback (person)
++ * GET /api/v1/actions?subject= a service with domains.actions.read → common.action-list-result@1
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const views = require('../views/panel');
++const { sameOrigin } = require('./principal');
++
++const NOTICES = {
++ connected: 'Connected. Your zones are below.',
++ synced: 'Synced with Cloudflare.',
++ removed: 'Removed. Its token was deleted from your OpenVibe project.',
++ declined: 'Declined. Nothing changed.',
++ rolled_back: 'Rolled back.',
++};
++const PROBLEMS = {
++ 'vault.secret_missing': 'That connection\'s token is gone or no longer granted. Remove it and connect again.',
++ 'provider.forbidden': 'Cloudflare refused the token. Check it in Cloudflare, then connect again.',
++ 'provider.unreachable': 'Cloudflare did not answer. Try again in a moment.',
++ 'vault.unavailable': 'OpenVibe.Network did not answer. Try again in a moment.',
++ 'vault.signed_out': 'Your sign-in ended. Sign in again.',
++};
++
++function createPanelRoutes({ config, panel, principal, log = console }) {
++ const pages = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '16kb' });
++ const privateHtml = (res, status, htmlText) => res.status(status).set('Cache-Control', 'private, no-store').type('html').send(htmlText);
++ const person = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.token ? { subject: req.viewer.subject, token: req.viewer.token } : null);
++ const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.split('?')[0])}`);
++ const crossSite = (req, res) => res.status(403).set('Cache-Control', 'no-store').type('text/plain').send('A change must come from openvibe.domains.');
++ const notice = (req) => NOTICES[String(req.query.done || '')] || PROBLEMS[String(req.query.problem || '')] || null;
++ const back = (res, path, err) => res.redirect(303, `${path}?problem=${encodeURIComponent(err && err.code ? err.code : 'error')}`);
++
++ /** A form POST: signed in and from this site, else nothing happens. */
++ const post = (path, handler) => pages.post(path, form, async (req, res) => {
++ const p = person(req);
++ if (!p) return toLogin(req, res);
++ if (!sameOrigin(req, config.baseUrl)) return crossSite(req, res);
++ return handler(req, res, p);
++ });
++
++ pages.get('/panel', async (req, res) => {
++ const p = person(req);
++ if (!p) return privateHtml(res, 200, views.signedOut());
++ const [connections, zones, list] = await Promise.all([panel.connections(p.subject), panel.zones(p.subject), panel.list(p.subject, { since: new Date(Date.now() - 30 * 86_400_000).toISOString(), limit: 40 })]);
++ const all = list.map(panel.toPublic);
++ return privateHtml(res, 200, views.home({ connections, zones, waiting: all.filter((x) => x.status === 'pending'), recent: all.filter((x) => x.status !== 'pending').slice(0, 10), notice: notice(req) }));
++ });
++
++ pages.get('/panel/connect', (req, res) => (person(req) ? privateHtml(res, 200, views.connect()) : toLogin(req, res)));
++ post('/panel/connect', async (req, res, p) => {
++ try {
++ await panel.connect(p, { provider: req.body.provider || 'cloudflare', label: req.body.label, token: req.body.token });
++ return res.redirect(303, '/panel?done=connected');
++ } catch (err) {
++ if (!err.status || err.status >= 500) log.warn('[OpenVibe.Domains] connect failed:', err.code || err.message);
++ return privateHtml(res, err.status && err.status < 500 ? 422 : 503, views.connect({ error: err.status ? err.message : 'That did not work. Try again in a moment.', values: { label: req.body.label } }));
++ }
++ });
++ post('/panel/c/:id/sync', async (req, res, p) => {
++ try { await panel.sync(p.subject, req.params.id); return res.redirect(303, '/panel?done=synced'); } catch (err) { return back(res, '/panel', err); }
++ });
++ post('/panel/c/:id/remove', async (req, res, p) => {
++ try { await panel.remove(p, req.params.id); return res.redirect(303, '/panel?done=removed'); } catch (err) { return back(res, '/panel', err); }
++ });
++
++ pages.get('/panel/z/:conn/:zone', async (req, res) => {
++ const p = person(req);
++ if (!p) return toLogin(req, res);
++ try {
++ const { conn, zone } = await panel.zone(p.subject, req.params.conn, req.params.zone);
++ const records = await panel.records(p.subject, conn.id, zone.zone_id);
++ return privateHtml(res, 200, views.zone({ conn, zone, records }));
++ } catch (err) {
++ if (err.status === 404) return res.redirect(303, '/panel');
++ return back(res, '/panel', err);
++ }
++ });
++ post('/panel/z/:conn/:zone/propose', async (req, res, p) => {
++ const b = req.body;
++ const rec = { type: b.type, name: b.name, content: b.content, ttl: b.ttl, priority: b.priority, proxied: b.proxied === 'on' };
++ const change = b.op === 'create' ? { op: 'create', after: rec } : b.op === 'update' ? { op: 'update', record_id: b.record_id, after: rec } : { op: 'delete', record_id: b.record_id };
++ try {
++ const row = await panel.propose(p.subject, `user:${p.subject}`, { connection_id: req.params.conn, zone_id: req.params.zone, why: b.why, changes: [change] });
++ return res.redirect(303, `/panel/p/${row.id}`);
++ } catch (err) {
++ if (!err.status || err.status >= 500 || err.status === 404) return back(res, `/panel/z/${encodeURIComponent(req.params.conn)}/${encodeURIComponent(req.params.zone)}`, err);
++ const { conn, zone } = await panel.zone(p.subject, req.params.conn, req.params.zone);
++ const records = await panel.records(p.subject, conn.id, zone.zone_id).catch(() => []);
++ return privateHtml(res, 422, views.zone({ conn, zone, records, error: err.message, values: b.op === 'create' ? { ...rec, why: b.why } : {} }));
++ }
++ });
++
++ pages.get('/panel/p/:id', async (req, res) => {
++ const p = person(req);
++ if (!p) return toLogin(req, res);
++ try {
++ const row = await panel.get(p.subject, req.params.id);
++ const pub = { ...panel.toPublic(row), can_roll_back: Array.isArray(row.rollback) ? row.rollback.length > 0 : JSON.parse(row.rollback || '[]').length > 0 };
++ const done = String(req.query.done || '');
++ const words = done === 'approved' ? (pub.status === 'applied' ? 'Applied. Cloudflare has the change; caches catch up within the TTL.' : 'Approved, but it did not apply. See why below.') : notice(req);
++ return privateHtml(res, 200, views.proposal({ p: pub, notice: words }));
++ } catch (err) {
++ if (err.status === 404) return res.redirect(303, '/panel');
++ throw err;
++ }
++ });
++ post('/panel/p/:id/decide', async (req, res, p) => {
++ const decision = req.body.decision === 'approve' ? 'approve' : 'decline';
++ try { await panel.decide(p.subject, req.params.id, decision, `user:${p.subject}`); } catch (err) { return back(res, `/panel/p/${encodeURIComponent(req.params.id)}`, err); }
++ return res.redirect(303, `/panel/p/${encodeURIComponent(req.params.id)}?done=${decision === 'approve' ? 'approved' : 'declined'}`);
++ });
++ post('/panel/p/:id/rollback', async (req, res, p) => {
++ try { await panel.rollBack(p.subject, req.params.id, `user:${p.subject}`); } catch (err) { return back(res, `/panel/p/${encodeURIComponent(req.params.id)}`, err); }
++ return res.redirect(303, `/panel/p/${encodeURIComponent(req.params.id)}?done=rolled_back`);
++ });
++
++ // ── API ─────────────────────────────────────────────────
++ const api = asyncRouter();
++ api.use(express.json({ limit: '64kb' }));
++ api.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ api.use(principal.middleware);
++ const problem = (req, res, err) => {
++ if (err && err.status && err.status < 500) return http.sendProblem(res, err.status, err.code || 'domains.error', { detail: err.message, ctx: req.ov });
++ if (err && err.status === 503) return http.sendProblem(res, 503, err.code || 'domains.unavailable', { detail: err.message, ctx: req.ov });
++ log.error('[OpenVibe.Domains] panel API:', err && err.message ? err.message.slice(0, 200) : err);
++ return http.sendProblem(res, 502, 'domains.upstream', { detail: 'A provider or OpenVibe.Network did not answer as expected.', ctx: req.ov });
++ };
++ const wrap = (fn) => async (req, res) => { try { await fn(req, res); } catch (err) { problem(req, res, err); } };
++ const me = principal.requirePerson;
++
++ api.get('/panel/connections', me, wrap(async (req, res) => {
++ const rows = await panel.connections(req.principal.subject);
++ res.json({ connections: rows.map((c) => ({ id: c.id, provider: c.provider, label: c.label, status: c.status, zones: c.zone_count, last_error: c.last_error || null, created_at: new Date(c.created_at).toISOString(), checked_at: c.checked_at ? new Date(c.checked_at).toISOString() : null })) });
++ }));
++ api.get('/panel/zones', me, wrap(async (req, res) => {
++ const rows = await panel.zones(req.principal.subject);
++ res.json({ zones: rows.map((z) => ({ connection_id: z.connection_id, zone_id: z.zone_id, name: z.name, status: z.status, name_servers: z.name_servers, provider: z.provider, synced_at: new Date(z.synced_at).toISOString() })) });
++ }));
++ api.get('/panel/zones/:conn/:zone/records', me, wrap(async (req, res) => {
++ res.json({ records: await panel.records(req.principal.subject, req.params.conn, req.params.zone) });
++ }));
++ api.get('/proposals', me, wrap(async (req, res) => {
++ res.json({ proposals: (await panel.list(req.principal.subject, { limit: 100 })).map(panel.toPublic) });
++ }));
++ api.get('/proposals/:id', me, wrap(async (req, res) => {
++ res.json(panel.toPublic(await panel.get(req.principal.subject, req.params.id)));
++ }));
++ api.post('/proposals', me, wrap(async (req, res) => {
++ const b = req.body || {};
++ const row = await panel.propose(req.principal.subject, `user:${req.principal.subject}`, b);
++ res.status(201).set('Location', `/api/v1/proposals/${row.id}`).json(panel.toPublic(row));
++ }));
++ api.post('/proposals/:id/rollback', me, wrap(async (req, res) => {
++ res.json(panel.toPublic(await panel.rollBack(req.principal.subject, req.params.id, `user:${req.principal.subject}`)));
++ }));
++ api.post('/proposals/:id/decide', wrap(async (req, res, next) => {
++ const decision = String((req.body || {}).decision || '');
++ if (req.principal.kind === 'service') {
++ return principal.requireService('domains.proposal.decide')(req, res, async () => {
++ try {
++ const row = await panel.decide(req.subject, req.params.id, decision, `service:${req.principal.service}`);
++ res.json(panel.toActionItem(row));
++ } catch (err) { problem(req, res, err); }
++ });
++ }
++ return me(req, res, async () => {
++ try { res.json(panel.toPublic(await panel.decide(req.principal.subject, req.params.id, decision, `user:${req.principal.subject}`))); } catch (err) { problem(req, res, err); }
++ });
++ }));
++ api.get('/actions', principal.requireService('domains.actions.read'), wrap(async (req, res) => {
++ const rows = await panel.list(req.subject, { since: new Date(Date.now() - 30 * 86_400_000).toISOString(), limit: 50 });
++ res.json({ items: rows.map(panel.toActionItem) });
++ }));
++
++ return { pages, api };
++}
++
++module.exports = { createPanelRoutes };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..88219d3
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++/**
++ * Who is calling the control panel's API — req.principal:
++ *
++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
++ * a person: their Network user token as a Bearer, or this site's session cookie (dom_at).
++ * { kind: 'service', service: 'network', subject: 'usr_…' }
++ * a first-party service token for audience openvibe.domains holding the route's capability, acting for the
++ * person named in X-OV-Subject (OpenVibe.Network's Action Center: domains.actions.read, domains.proposal.decide).
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the session
++ * or to anonymous. A change made with the session cookie must come from this site (Sec-Fetch-Site same-origin, or an
++ * Origin equal to ours): another site cannot make a signed-in visitor propose, approve or connect anything.
++ */
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++
++const SERVICE_SUB = /^(svc|app|mod|agent):/;
++const CAPABILITIES = ['domains.actions.read', 'domains.proposal.decide'];
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
++function sameOrigin(req, baseUrl) {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin';
++ const origin = req.get('origin');
++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
++ return false;
++}
++
++function createPrincipal({ config, keys }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && SERVICE_SUB.test(payload.sub)) {
++ const publicKey = await keys.pemForToken(token);
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
++ if (!String(r.claims.sub).startsWith('svc:')) return { error: [403, 'capability.denied', 'these routes are for first-party services and people only'] };
++ return { principal: { kind: 'service', service: String(r.claims.sub).slice(4), claims: r.claims, subject: null } };
++ }
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ return { principal: { kind: 'user', subject: v.claims.subject_id, role: typeof v.claims.role === 'string' ? v.claims.role : 'user', username: typeof v.claims.username === 'string' ? v.claims.username : null, token, viaSession: false } };
++ }
++ const viewer = req.viewer;
++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
++ return { principal: { kind: 'user', subject: viewer.subject, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
++ }
++ return { principal: { kind: 'anonymous' } };
++ }
++
++ async function middleware(req, res, next) {
++ const r = await resolve(req);
++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ req.principal = r.principal;
++ return next();
++ }
++
++ /** A person acting for themself; a session-cookie change must come from this site. */
++ function requirePerson(req, res, next) {
++ const p = req.principal;
++ if (!p || p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.domains, or send your Network token as a Bearer.', ctx: req.ov });
++ if (p.kind !== 'user') return http.sendProblem(res, 403, 'domains.person_required', { detail: 'This route is for a person.', ctx: req.ov });
++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'domains.cross_site', { detail: 'A change must come from openvibe.domains.', ctx: req.ov });
++ return next();
++ }
++
++ /** A first-party service holding `cap`, for the person X-OV-Subject (or ?subject=) names. */
++ function requireService(cap) {
++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireService(${cap}): not one of OpenVibe.Domains' capabilities`);
++ return function guard(req, res, next) {
++ const p = req.principal;
++ if (!p || p.kind !== 'service') return http.sendProblem(res, 401, 'token.required', { detail: `${cap} needs a Network service token for ${config.audience}.`, ctx: req.ov });
++ const decision = capabilities.check(p.claims, cap);
++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} is not granted`, ctx: req.ov });
++ const subject = String(req.get('x-ov-subject') || req.query.subject || '').trim();
++ if (!ids.isSubjectId('user', subject)) return http.sendProblem(res, 400, 'subject.invalid', { detail: 'Name the person: X-OV-Subject (or ?subject=) is a usr_… id.', ctx: req.ov });
++ req.subject = subject;
++ return next();
++ };
++ }
++
++ return { middleware, resolve, requirePerson, requireService };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, sameOrigin };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..e700afe
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,37 @@
++'use strict';
++
++/**
++ * Account export and deletion → Domains (ADR-033; openvibe-sdk/account-data). What Domains holds about a person is
++ * their control panel: connections (a provider, a label and the NAME of the Network project secret holding the token,
++ * never the token), the zones those connections hold, their proposals and the audit of what was done. The token itself
++ * is a secret in the person's own Network project, which Network's own deletion shreds.
++ *
++ * Erasing goes proposals and audit first, then connections (their zones go with them, ON DELETE CASCADE).
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'domains_proposals', subject: 'subject', file: 'proposals.json', erase: 'delete' },
++ { table: 'domains_audit', subject: 'subject', file: 'audit.json', order: 'at', erase: 'delete' },
++ { table: 'domains_connections', subject: 'subject', file: 'connections.json', erase: 'delete' },
++];
++
++/** The zones the person's connections hold (they have no subject column of their own). */
++async function extraExport(db, subject) {
++ const zones = await db.many(`SELECT z.connection_id, z.zone_id, z.name, z.status, z.name_servers, z.plan, z.synced_at FROM domains_zones z
++ JOIN domains_connections c ON c.id = z.connection_id WHERE c.subject = $1 ORDER BY z.name`, [subject]);
++ return zones.length ? [{ name: 'zones.json', content: zones }] : [];
++}
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db,
++ service: 'domains',
++ tables: TABLES,
++ note: 'Provider tokens are not here: they are secrets in your own OpenVibe.Network project.',
++ extraExport,
++ log,
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/index.js b/server/index.js
iindex 0ba6c2e..2ced5f7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1,11 +1,13 @@
'use strict';
/**
-- * OpenVibe.Domains — process entry. `node server/index.js` listens on PORT (5070) behind nginx
-- * (deploy/). Stateless: nothing to close but the HTTP server.
++ * OpenVibe.Domains — process entry. `node server/index.js` listens on PORT (5070) behind nginx (deploy/). At boot a
++ * proposal a stop left mid-apply is marked failed (panel.recover), the Network keys are warmed after listen, and the two
++ * account subscriptions are created at Events when missing (never in a drill). The stop closes them and the store.
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service): stop taking connections, drain the requests in
@@@ -19,16 +21,30 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
exit,
signals,
deadlineMs: 10_000,
-- close: [],
++ close: [
++ () => { if (ctx && ctx.subscriptions) ctx.subscriptions.stop(); },
++ () => { if (ctx && ctx.store) return ctx.store.close(); return undefined; },
++ ],
});
}
async function start() {
const { app, ctx } = await createApp();
const { config } = ctx;
++ const recovered = await ctx.panel.recover();
++ if (recovered) console.warn(`[OpenVibe.Domains] ${recovered} proposal(s) were left applying by the last stop: marked failed for their people to check`);
const server = app.listen(config.port, config.host, () => {
console.log(`[OpenVibe.Domains] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (host ${config.hostUrl}, tools ${config.toolsUrl})`);
++ // Warm the Network signing keys once listening, so readiness and the first signed-in page do not wait.
++ if (ctx.keys.ensure) ctx.keys.ensure().catch(() => {});
});
++ if (!config.drill && config.events.url && config.events.secrets.length && config.oauth.clientSecret) {
++ ctx.subscriptions = startSubscriptions({
++ eventsUrl: config.events.url, endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
++ secret: config.events.secrets[0], networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
++ }
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
return { server, ctx };
ddiff --git a/server/observability.js b/server/observability.js
iindex 79317a8..43649b7 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -1,20 +1,22 @@
'use strict';
/**
-- * Truthful readiness for GET /api/ready (openvibe-shared/ready). There is no database and no
-- * session store: the only dependencies are the two public services this front reads.
-- *
-- * Both checks are optional. If OpenVibe.Host or OpenVibe.Tools does not answer, the process is
-- * still serving (there is a page, even if it says the upstream failed), so the answer is 200 with
-- * status "degraded" — never 503. A failed check is named in `degraded`.
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready). The control panel's database is required: without it
++ * the panel cannot serve, so the answer is 503. The two public services the price pages read are optional: if
++ * OpenVibe.Host or OpenVibe.Tools does not answer, the pages still say so, and the answer is 200 "degraded".
*/
const { createReadiness } = require('openvibe-shared/ready');
--function createServiceReadiness({ host, tools, release = null }) {
++function createServiceReadiness({ host, tools, store = null, release = null }) {
return createReadiness({
service: 'domains',
release,
checks: [
++ ...(store ? [{
++ name: 'db',
++ required: true,
++ check: async () => ((await store.db.value('SELECT 1 AS n')) === 1 ? true : 'the database did not answer'),
++ }] : []),
{
name: 'host',
required: false,
ddiff --git a/server/panel/cloudflare.js b/server/panel/cloudflare.js
nnew file mode 100644
iindex 0000000..c860b71
---- /dev/null
++++ b/server/panel/cloudflare.js
@@@ -0,0 +1,146 @@
++'use strict';
++
++/**
++ * Cloudflare's official API (v4) for the control panel: one person's API token, passed to each call and never kept,
++ * logged or returned. Only https://api.cloudflare.com is ever called (ORIGIN), the same origin the token's Network grant
++ * names, so a revealed token cannot be sent anywhere else.
++ *
++ * const cf = createCloudflare({ fetchImpl });
++ * await cf.verify(token) → { ok: true } | { ok: false, reason }
++ * await cf.zones(token) → [{ id, name, status, name_servers, plan }]
++ * await cf.records(token, zoneId) → [record]
++ * await cf.record(token, zoneId, id) → record | null
++ * await cf.create(token, zoneId, rec) / update(token, zoneId, id, rec) / remove(token, zoneId, id)
++ *
++ * A record is domains.proposal@1's shape: { id, type, name, content, ttl, proxied, priority, comment }.
++ */
++const ORIGIN = 'https://api.cloudflare.com';
++const BASE = `${ORIGIN}/client/v4`;
++const PROXIABLE = new Set(['A', 'AAAA', 'CNAME']);
++const PRIORITY = new Set(['MX', 'SRV']);
++const MAX_ZONE_PAGES = 10; // 500 zones
++const MAX_RECORD_PAGES = 20; // 2 000 records in one zone
++
++class ProviderError extends Error {
++ constructor(status, message, code = 'provider.error') {
++ super(message);
++ this.status = status;
++ this.code = code;
++ }
++}
++
++/** The provider's record as domains.proposal@1 keeps it. */
++function normalize(r) {
++ if (!r || typeof r !== 'object') return null;
++ const type = String(r.type || '').toUpperCase();
++ return {
++ id: r.id ? String(r.id) : null,
++ type,
++ name: String(r.name || ''),
++ content: String(r.content == null ? '' : r.content),
++ ttl: Number.isInteger(r.ttl) ? r.ttl : 1,
++ proxied: PROXIABLE.has(type) ? Boolean(r.proxied) : null,
++ priority: PRIORITY.has(type) && Number.isInteger(r.priority) ? r.priority : null,
++ comment: typeof r.comment === 'string' && r.comment ? r.comment.slice(0, 100) : null,
++ };
++}
++
++/** The body Cloudflare takes for a create or a full update. */
++function bodyOf(rec) {
++ const type = String(rec.type).toUpperCase();
++ const out = { type, name: rec.name, content: rec.content, ttl: Number.isInteger(rec.ttl) ? rec.ttl : 1 };
++ if (PROXIABLE.has(type)) out.proxied = Boolean(rec.proxied);
++ if (PRIORITY.has(type) && Number.isInteger(rec.priority)) out.priority = rec.priority;
++ if (typeof rec.comment === 'string' && rec.comment) out.comment = rec.comment.slice(0, 100);
++ return out;
++}
++
++function createCloudflare({ fetchImpl = globalThis.fetch, timeoutMs = 10_000 } = {}) {
++ async function call(token, method, path, body) {
++ if (typeof token !== 'string' || !token) throw new ProviderError(401, 'The connection has no token.', 'provider.no_token');
++ let res;
++ try {
++ res = await fetchImpl(`${BASE}${path}`, {
++ method,
++ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
++ body: body ? JSON.stringify(body) : undefined,
++ redirect: 'error',
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
++ throw new ProviderError(502, timedOut ? 'Cloudflare did not answer in time.' : 'Cloudflare could not be reached.', 'provider.unreachable');
++ }
++ const data = await res.json().catch(() => null);
++ if (res.ok && data && data.success !== false) return data;
++ // Cloudflare's own words for what went wrong (they never carry the token).
++ const first = data && Array.isArray(data.errors) && data.errors[0];
++ const words = first && first.message ? String(first.message).slice(0, 300) : `Cloudflare answered ${res.status}.`;
++ if (res.status === 401 || res.status === 403) throw new ProviderError(res.status, `Cloudflare refused the token: ${words}`, 'provider.forbidden');
++ if (res.status === 404) throw new ProviderError(404, words, 'provider.not_found');
++ throw new ProviderError(res.status >= 500 ? 502 : 422, words, 'provider.refused');
++ }
++
++ async function pages(token, path, maxPages, perPage) {
++ const out = [];
++ for (let page = 1; page <= maxPages; page++) {
++ const sep = path.includes('?') ? '&' : '?';
++ const data = await call(token, 'GET', `${path}${sep}page=${page}&per_page=${perPage}`);
++ const list = Array.isArray(data.result) ? data.result : [];
++ out.push(...list);
++ const info = data.result_info || {};
++ if (!list.length || !info.total_pages || page >= info.total_pages) break;
++ }
++ return out;
++ }
++
++ return {
++ ORIGIN,
++ /** A user token verifies at /user/tokens/verify; an account token does not, so a zone read decides. */
++ async verify(token) {
++ try {
++ const data = await call(token, 'GET', '/user/tokens/verify');
++ const status = data.result && data.result.status;
++ return status === 'active' ? { ok: true } : { ok: false, reason: `The token is ${status || 'not active'}.` };
++ } catch (err) {
++ if (err.code === 'provider.unreachable') return { ok: false, reason: err.message };
++ try {
++ await call(token, 'GET', '/zones?per_page=1');
++ return { ok: true };
++ } catch (err2) {
++ return { ok: false, reason: err2.code === 'provider.forbidden' ? 'Cloudflare refused this token. Check it can read your zones (Zone: Read) and edit DNS (DNS: Edit).' : err2.message };
++ }
++ }
++ },
++ async zones(token) {
++ return (await pages(token, '/zones', MAX_ZONE_PAGES, 50)).map((z) => ({
++ id: String(z.id), name: String(z.name || '').toLowerCase(), status: z.status ? String(z.status) : null,
++ name_servers: Array.isArray(z.name_servers) ? z.name_servers.map(String).slice(0, 8) : [],
++ plan: z.plan && z.plan.name ? String(z.plan.name) : null,
++ }));
++ },
++ async records(token, zoneId) {
++ return (await pages(token, `/zones/${encodeURIComponent(zoneId)}/dns_records`, MAX_RECORD_PAGES, 100)).map(normalize);
++ },
++ async record(token, zoneId, id) {
++ try {
++ return normalize((await call(token, 'GET', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(id)}`)).result);
++ } catch (err) {
++ if (err.code === 'provider.not_found') return null;
++ throw err;
++ }
++ },
++ async create(token, zoneId, rec) {
++ return normalize((await call(token, 'POST', `/zones/${encodeURIComponent(zoneId)}/dns_records`, bodyOf(rec))).result);
++ },
++ async update(token, zoneId, id, rec) {
++ return normalize((await call(token, 'PUT', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(id)}`, bodyOf(rec))).result);
++ },
++ async remove(token, zoneId, id) {
++ await call(token, 'DELETE', `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(id)}`);
++ return true;
++ },
++ };
++}
++
++module.exports = { createCloudflare, normalize, bodyOf, ProviderError, ORIGIN, PROXIABLE };
ddiff --git a/server/panel/panel.js b/server/panel/panel.js
nnew file mode 100644
iindex 0000000..07970bb
---- /dev/null
++++ b/server/panel/panel.js
@@@ -0,0 +1,361 @@
++'use strict';
++
++/**
++ * The control panel (plan 2026-10-28 §5.1, DOM-2): a person's connections, the zones they hold, and every DNS change
++ * as a proposal (domains.proposal@1) that nothing applies until its person approves it.
++ *
++ * connect / remove / sync a provider account: verified, its token stored in the person's Network project
++ * (vault.js), its zones cached in domains_zones; a refused token marks it broken
++ * records a zone's records, read live from the provider (the provider stays the truth)
++ * propose a checked diff (create, update, delete) with why, the risk in words, 7 days to decide
++ * decide approve → applied in order; a record that moved since the proposal is refused as
++ * stale; a failure part-way keeps the rollback of what was applied
++ * rollBack the inverse of what was applied, newest first
++ *
++ * Every step is in domains_audit. The provider token is revealed for one call and dropped; it is never stored, logged
++ * or returned. Errors are PanelError(status, code, words a person can act on).
++ */
++const net = require('net');
++const { normalize } = require('./cloudflare');
++
++const PROVIDERS = ['cloudflare'];
++const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'CAA', 'SRV'];
++const PROXIABLE = new Set(['A', 'AAAA', 'CNAME']);
++const MAX_CHANGES = 50;
++const DECIDE_DAYS = 7;
++const PUBLIC_ORIGIN = 'https://openvibe.domains';
++const HOST_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$/i;
++
++class PanelError extends Error {
++ constructor(status, code, message) {
++ super(message);
++ this.status = status;
++ this.code = code;
++ }
++}
++const fail = (status, code, message) => { throw new PanelError(status, code, message); };
++
++const lowerName = (n) => String(n || '').trim().toLowerCase().replace(/\.$/, '');
++const parse = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
++const iso = (v) => (v == null ? null : new Date(v).toISOString());
++
++/** Is `name` the zone or inside it? */
++function inZone(name, zone) {
++ const n = lowerName(name);
++ const z = lowerName(zone);
++ return n === z || n.endsWith(`.${z}`);
++}
++
++/** A record as the person asked for it: checked, normalized, or a PanelError naming what is wrong. */
++function cleanRecord(rec, zone, where) {
++ if (!rec || typeof rec !== 'object') fail(422, 'proposal.invalid', `${where}: the record is missing.`);
++ const type = String(rec.type || '').toUpperCase();
++ if (!TYPES.includes(type)) fail(422, 'proposal.invalid', `${where}: ${type || 'that'} records cannot be changed here (${TYPES.join(', ')}).`);
++ let name = lowerName(rec.name);
++ if (name === '@' || name === '') name = lowerName(zone);
++ else if (!inZone(name, zone)) name = `${name}.${lowerName(zone)}`;
++ if (!HOST_RE.test(name) || !inZone(name, zone)) fail(422, 'proposal.invalid', `${where}: ${name} is not a name in ${zone}.`);
++ const content = String(rec.content == null ? '' : rec.content).trim();
++ if (!content || content.length > 2048) fail(422, 'proposal.invalid', `${where}: the value is empty or too long.`);
++ if (type === 'A' && net.isIPv4(content) === false) fail(422, 'proposal.invalid', `${where}: an A record holds an IPv4 address (like 192.0.2.10).`);
++ if (type === 'AAAA' && net.isIPv6(content) === false) fail(422, 'proposal.invalid', `${where}: an AAAA record holds an IPv6 address.`);
++ if (['CNAME', 'NS', 'MX'].includes(type) && !HOST_RE.test(content)) fail(422, 'proposal.invalid', `${where}: a ${type} record points at a host name.`);
++ let ttl = rec.ttl == null || rec.ttl === '' ? 1 : Number(rec.ttl);
++ if (!Number.isInteger(ttl) || (ttl !== 1 && (ttl < 60 || ttl > 86400))) fail(422, 'proposal.invalid', `${where}: TTL is automatic (1) or 60 to 86 400 seconds.`);
++ const priority = type === 'MX' || type === 'SRV' ? Number(rec.priority == null || rec.priority === '' ? 10 : rec.priority) : null;
++ if (priority !== null && (!Number.isInteger(priority) || priority < 0 || priority > 65535)) fail(422, 'proposal.invalid', `${where}: priority is 0 to 65 535.`);
++ const comment = typeof rec.comment === 'string' && rec.comment.trim() ? rec.comment.trim().slice(0, 100) : null;
++ return { type, name, content, ttl, proxied: PROXIABLE.has(type) ? Boolean(rec.proxied === true || rec.proxied === 'true' || rec.proxied === 'on') : null, priority, comment };
++}
++
++/** Do two records say the same thing (ids aside)? Used to refuse a stale change. */
++function sameRecord(a, b) {
++ if (!a || !b) return false;
++ return a.type === b.type && lowerName(a.name) === lowerName(b.name) && String(a.content) === String(b.content)
++ && Number(a.ttl) === Number(b.ttl) && (a.proxied ?? null) === (b.proxied ?? null) && (a.priority ?? null) === (b.priority ?? null);
++}
++
++/** One change in plain words: "Add TXT example.com", "Change A www.example.com: 192.0.2.1 → 192.0.2.7". */
++function describe(c) {
++ const r = c.after || c.before || {};
++ if (c.op === 'create') return `Add ${r.type} ${r.name} → ${r.content}`;
++ if (c.op === 'delete') return `Remove ${r.type} ${r.name} (${r.content})`;
++ return `Change ${r.type} ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;
++}
++
++/** What could break, in words, when the asker did not say. */
++function riskOf(changes) {
++ const words = new Set();
++ for (const c of changes) {
++ const r = c.after || c.before || {};
++ if (r.type === 'TXT' || r.type === 'CAA') words.add(c.op === 'delete' && r.type === 'CAA' ? 'Removing a CAA record lets any certificate authority issue for the name.' : 'Low: a TXT record changes no traffic, though a service that checks it (mail, verification) reads the new value.');
++ else if (r.type === 'MX') words.add('Mail for the domain goes where the MX records say; a mistake here bounces or loses mail.');
++ else if (r.type === 'NS') words.add('NS records delegate the name: a wrong one makes it unreachable.');
++ else words.add(c.op === 'delete' ? `${r.name} stops answering at ${r.content}: visitors and services using it lose it.` : `Visitors to ${r.name} reach the new address once caches expire (the record's TTL).`);
++ }
++ return [...words].join(' ').slice(0, 1000);
++}
++
++function toPublic(row) {
++ return {
++ id: row.id, domain: row.domain, provider: row.provider || 'cloudflare', connection_id: row.connection_id,
++ title: row.title, why: row.why || null, risk: row.risk || null, status: row.status,
++ changes: parse(row.changes).map((c) => ({ op: c.op, record_id: c.record_id || null, before: c.before || null, after: c.after || null })),
++ created_by: row.created_by, created_at: iso(row.created_at), decided_at: iso(row.decided_at), applied_at: iso(row.applied_at),
++ rolled_back_at: iso(row.rolled_back_at), expires_at: iso(row.expires_at), error: row.error || null,
++ href: `${PUBLIC_ORIGIN}/panel/p/${row.id}`,
++ };
++}
++
++const BUCKET = { pending: 'waiting', applying: 'running', applied: 'done', rolled_back: 'done', declined: 'done', failed: 'failed', expired: 'failed' };
++
++/** common.action-item@1 for the Action Center. */
++function toActionItem(row) {
++ const changes = parse(row.changes);
++ const summary = changes.length === 1 ? describe(changes[0]) : `${changes.length} changes to ${row.domain}: ${describe(changes[0])}…`;
++ return {
++ service: 'domains', id: row.id, kind: 'approval', title: String(row.title).slice(0, 160), summary: summary.slice(0, 400),
++ bucket: BUCKET[row.status] || 'failed', state: row.status, agent: String(row.created_by).startsWith('agent:') ? String(row.created_by).slice(6, 86) : null,
++ cost_usd: null, steps: changes.length, error: row.error ? String(row.error).slice(0, 400) : null,
++ created_at: iso(row.created_at), updated_at: iso(row.rolled_back_at || row.applied_at || row.decided_at || row.created_at),
++ next_run_at: null, href: `${PUBLIC_ORIGIN}/panel/p/${row.id}`,
++ actions: row.status === 'pending' ? ['approve', 'decline', 'open'] : ['open'],
++ };
++}
++
++function createPanel({ store, vault, providers, log = console }) {
++ const db = store.db;
++ const providerOf = (name) => providers[name] || fail(422, 'connection.provider', `${name} is not connected here yet.`);
++
++ async function audit(subject, action, { connectionId = null, proposalId = null, detail = {} } = {}) {
++ await db.exec('INSERT INTO domains_audit (subject, action, connection_id, proposal_id, detail, at) VALUES ($1, $2, $3, $4, $5::jsonb, $6::timestamptz)',
++ [subject, action, connectionId, proposalId, JSON.stringify(detail), store.iso()]);
++ }
++
++ async function connection(subject, id) {
++ const row = await db.maybe("SELECT * FROM domains_connections WHERE id = $1 AND subject = $2 AND status <> 'removed'", [id, subject]);
++ return row || fail(404, 'connection.not_found', 'No such connection of yours.');
++ }
++
++ /** Reveal the connection's token for one call; a refused reveal marks the connection broken. */
++ async function withToken(conn, fn) {
++ const p = providerOf(conn.provider);
++ let token;
++ try {
++ token = await vault.reveal(conn.project_id, conn.secret_name, p.ORIGIN);
++ } catch (err) {
++ if (err.code === 'vault.secret_missing') await markBroken(conn, err.message);
++ throw new PanelError(err.status || 503, err.code || 'vault.error', err.message);
++ }
++ try {
++ return await fn(p, token);
++ } catch (err) {
++ if (err.code === 'provider.forbidden') await markBroken(conn, err.message);
++ throw err.status ? new PanelError(err.status, err.code || 'provider.error', err.message) : err;
++ } finally {
++ token = null;
++ }
++ }
++
++ async function markBroken(conn, words) {
++ await db.exec("UPDATE domains_connections SET status = 'broken', last_error = $2, checked_at = $3::timestamptz WHERE id = $1", [conn.id, String(words).slice(0, 300), store.iso()]);
++ }
++
++ async function sync(subject, id) {
++ const conn = await connection(subject, id);
++ const zones = await withToken(conn, (p, token) => p.zones(token));
++ await store.tx(async () => {
++ await db.exec('DELETE FROM domains_zones WHERE connection_id = $1', [conn.id]);
++ for (const z of zones) {
++ await db.exec(`INSERT INTO domains_zones (connection_id, zone_id, name, status, name_servers, plan, synced_at)
++ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::timestamptz)`, [conn.id, z.id, z.name, z.status, JSON.stringify(z.name_servers), z.plan, store.iso()]);
++ }
++ await db.exec("UPDATE domains_connections SET status = 'active', last_error = NULL, checked_at = $2::timestamptz WHERE id = $1", [conn.id, store.iso()]);
++ });
++ return zones;
++ }
++
++ async function connect(person, { provider, label, token }) {
++ if (!PROVIDERS.includes(provider)) fail(422, 'connection.provider', 'Cloudflare is the provider you can connect today.');
++ const name = String(label || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) || 'Cloudflare';
++ const secret = String(token || '').trim();
++ if (secret.length < 20 || secret.length > 400 || /\s/.test(secret)) fail(422, 'connection.token', 'Paste the API token exactly as Cloudflare showed it.');
++ const p = providerOf(provider);
++ const verified = await p.verify(secret);
++ if (!verified.ok) fail(422, 'connection.token', verified.reason || 'Cloudflare did not accept this token.');
++ const count = await db.maybe("SELECT count(*)::int AS n FROM domains_connections WHERE subject = $1 AND status <> 'removed'", [person.subject]);
++ if (count && count.n >= 20) fail(409, 'connection.limit', 'You can keep 20 connections. Remove one first.');
++ const id = store.newId('dcn');
++ const secretName = `${provider}-${id.slice(4).toLowerCase()}`;
++ const projectId = await vault.projectFor(person.token);
++ await vault.store(person.token, projectId, secretName, secret, p.ORIGIN);
++ await db.exec(`INSERT INTO domains_connections (id, subject, provider, label, project_id, environment, secret_name, status, created_at, checked_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8::timestamptz, $8::timestamptz)`, [id, person.subject, provider, name, projectId, vault.ENVIRONMENT, secretName, store.iso()]);
++ await audit(person.subject, 'connection.added', { connectionId: id, detail: { provider, label: name } });
++ try { await sync(person.subject, id); } catch (err) { log.warn(`[OpenVibe.Domains] first sync of ${id} failed: ${err.code || ''}`); }
++ return await connection(person.subject, id);
++ }
++
++ async function remove(person, id) {
++ const conn = await connection(person.subject, id);
++ await vault.remove(person.token, conn.project_id, conn.secret_name);
++ await store.tx(async () => {
++ await db.exec("UPDATE domains_connections SET status = 'removed', removed_at = $2::timestamptz WHERE id = $1", [conn.id, store.iso()]);
++ await db.exec('DELETE FROM domains_zones WHERE connection_id = $1', [conn.id]);
++ await db.exec("UPDATE domains_proposals SET status = 'expired', error = 'The connection was removed.' WHERE connection_id = $1 AND status = 'pending'", [conn.id]);
++ });
++ await audit(person.subject, 'connection.removed', { connectionId: conn.id, detail: { provider: conn.provider } });
++ }
++
++ async function connections(subject) {
++ return await db.many(`SELECT c.*, (SELECT count(*)::int FROM domains_zones z WHERE z.connection_id = c.id) AS zone_count
++ FROM domains_connections c WHERE c.subject = $1 AND c.status <> 'removed' ORDER BY c.created_at`, [subject]);
++ }
++
++ async function zones(subject) {
++ return await db.many(`SELECT z.*, c.label, c.provider, c.status AS connection_status FROM domains_zones z
++ JOIN domains_connections c ON c.id = z.connection_id WHERE c.subject = $1 AND c.status <> 'removed' ORDER BY z.name`, [subject]);
++ }
++
++ async function zone(subject, connectionId, zoneId) {
++ const conn = await connection(subject, connectionId);
++ const z = await db.maybe('SELECT * FROM domains_zones WHERE connection_id = $1 AND zone_id = $2', [conn.id, zoneId]);
++ return z ? { conn, zone: z } : fail(404, 'zone.not_found', 'No such zone on this connection. Sync it again if you just added it.');
++ }
++
++ async function records(subject, connectionId, zoneId) {
++ const { conn } = await zone(subject, connectionId, zoneId);
++ const list = await withToken(conn, (p, token) => p.records(token, zoneId));
++ return list.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
++ }
++
++ /** A checked proposal from the asker's changes; update and delete read the record's current value as `before`. */
++ async function propose(subject, createdBy, { connection_id: connectionId, zone_id: zoneId, title, why, risk, changes }) {
++ const { conn, zone: z } = await zone(subject, connectionId, zoneId);
++ if (!Array.isArray(changes) || !changes.length) fail(422, 'proposal.invalid', 'A proposal holds at least one change.');
++ if (changes.length > MAX_CHANGES) fail(422, 'proposal.invalid', `A proposal holds at most ${MAX_CHANGES} changes.`);
++ const current = await withToken(conn, (p, token) => p.records(token, zoneId));
++ const byId = new Map(current.map((r) => [r.id, r]));
++ const clean = changes.map((c, i) => {
++ const where = `Change ${i + 1}`;
++ const op = String(c && c.op || '');
++ if (op === 'create') return { op, record_id: null, before: null, after: cleanRecord(c.after, z.name, where) };
++ if (op !== 'update' && op !== 'delete') fail(422, 'proposal.invalid', `${where}: create, update or delete.`);
++ const before = byId.get(String(c.record_id || ''));
++ if (!before) fail(409, 'proposal.stale', `${where}: that record is not in ${z.name} any more. Reload the zone.`);
++ const { id: _id, ...plain } = before;
++ return { op, record_id: before.id, before: plain, after: op === 'update' ? cleanRecord({ ...plain, ...c.after }, z.name, where) : null };
++ });
++ const noop = clean.find((c) => c.op === 'update' && sameRecord(c.before, c.after));
++ if (noop) fail(422, 'proposal.invalid', `${describe(noop)} changes nothing.`);
++ const id = store.newId('dpr');
++ const name = String(title || '').trim().slice(0, 160) || (clean.length === 1 ? describe(clean[0]).slice(0, 160) : `${clean.length} DNS changes to ${z.name}`);
++ const at = store.now();
++ await db.exec(`INSERT INTO domains_proposals (id, subject, connection_id, zone_id, domain, title, why, risk, changes, status, created_by, created_at, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 'pending', $10, $11::timestamptz, $12::timestamptz)`,
++ [id, subject, conn.id, zoneId, z.name, name, why ? String(why).trim().slice(0, 1000) : null, (risk ? String(risk).trim() : riskOf(clean)).slice(0, 1000),
++ JSON.stringify(clean), createdBy, new Date(at).toISOString(), new Date(at + DECIDE_DAYS * 86_400_000).toISOString()]);
++ await audit(subject, 'proposal.created', { connectionId: conn.id, proposalId: id, detail: { by: createdBy, changes: clean.length, domain: z.name } });
++ return await get(subject, id);
++ }
++
++ async function get(subject, id) {
++ await expire(subject);
++ const row = await db.maybe(`SELECT p.*, c.provider FROM domains_proposals p JOIN domains_connections c ON c.id = p.connection_id
++ WHERE p.id = $1 AND p.subject = $2`, [id, subject]);
++ return row || fail(404, 'proposal.not_found', 'No such proposal of yours.');
++ }
++
++ async function list(subject, { since = null, limit = 50 } = {}) {
++ await expire(subject);
++ return await db.many(`SELECT p.*, c.provider FROM domains_proposals p JOIN domains_connections c ON c.id = p.connection_id
++ WHERE p.subject = $1 AND (p.status IN ('pending', 'applying') OR $2::timestamptz IS NULL OR p.created_at >= $2::timestamptz)
++ ORDER BY p.created_at DESC LIMIT $3`, [subject, since, Math.min(200, limit)]);
++ }
++
++ async function expire(subject) {
++ await db.exec("UPDATE domains_proposals SET status = 'expired' WHERE subject = $1 AND status = 'pending' AND expires_at < $2::timestamptz", [subject, store.iso()]);
++ }
++
++ /** Run `ops` in order through the provider; returns { done, rollback, error }. Stale records are refused. */
++ async function run(conn, zoneId, ops) {
++ return await withToken(conn, async (p, token) => {
++ const rollback = [];
++ let done = 0;
++ try {
++ for (const c of ops) {
++ if (c.op === 'create') {
++ const made = await p.create(token, zoneId, c.after);
++ const { id: madeId, ...plain } = made;
++ rollback.unshift({ op: 'delete', record_id: madeId, before: plain, after: null });
++ } else {
++ const now = await p.record(token, zoneId, c.record_id);
++ if (!now || !sameRecord(now, c.before)) throw new PanelError(409, 'proposal.stale', `${describe(c)}: the record changed since this was proposed, so nothing more was applied.`);
++ if (c.op === 'update') {
++ const after = await p.update(token, zoneId, c.record_id, c.after);
++ const { id: _x, ...plain } = after;
++ rollback.unshift({ op: 'update', record_id: c.record_id, before: plain, after: c.before });
++ } else {
++ await p.remove(token, zoneId, c.record_id);
++ rollback.unshift({ op: 'create', record_id: null, before: null, after: c.before });
++ }
++ }
++ done++;
++ }
++ return { done, rollback, error: null };
++ } catch (err) {
++ return { done, rollback, error: err };
++ }
++ });
++ }
++
++ async function decide(subject, id, decision, by) {
++ const row = await get(subject, id);
++ if (row.status !== 'pending') fail(409, 'proposal.decided', `This proposal is ${row.status.replace('_', ' ')} already.`);
++ if (decision === 'decline') {
++ await db.exec("UPDATE domains_proposals SET status = 'declined', decided_at = $2::timestamptz, decided_by = $3 WHERE id = $1 AND status = 'pending'", [id, store.iso(), by]);
++ await audit(subject, 'proposal.declined', { connectionId: row.connection_id, proposalId: id, detail: { by } });
++ return await get(subject, id);
++ }
++ if (decision !== 'approve') fail(422, 'proposal.decision', 'The decision is approve or decline.');
++ const claimed = await db.maybe("UPDATE domains_proposals SET status = 'applying', decided_at = $2::timestamptz, decided_by = $3 WHERE id = $1 AND status = 'pending' RETURNING id", [id, store.iso(), by]);
++ if (!claimed) fail(409, 'proposal.decided', 'Someone decided this a moment ago.');
++ const conn = await connection(subject, row.connection_id).catch(() => null);
++ let out;
++ try {
++ out = conn ? await run(conn, row.zone_id, parse(row.changes)) : { done: 0, rollback: [], error: new PanelError(409, 'connection.not_found', 'The connection was removed.') };
++ } catch (err) {
++ out = { done: 0, rollback: [], error: err };
++ }
++ const ok = !out.error;
++ await db.exec(`UPDATE domains_proposals SET status = $2, applied_at = $3::timestamptz, rollback = $4::jsonb, result = $5::jsonb, error = $6 WHERE id = $1`,
++ [id, ok ? 'applied' : 'failed', ok ? store.iso() : null, JSON.stringify(out.rollback), JSON.stringify({ applied: out.done, of: parse(row.changes).length }), ok ? null : String(out.error.message || 'failed').slice(0, 400)]);
++ await audit(subject, ok ? 'proposal.applied' : 'proposal.failed', { connectionId: row.connection_id, proposalId: id, detail: { by, applied: out.done, code: ok ? null : out.error.code || null } });
++ return await get(subject, id);
++ }
++
++ async function rollBack(subject, id, by) {
++ const row = await get(subject, id);
++ const steps = parse(row.rollback) || [];
++ if (!['applied', 'failed'].includes(row.status) || !steps.length) fail(409, 'proposal.no_rollback', 'There is nothing applied to roll back.');
++ const claimed = await db.maybe("UPDATE domains_proposals SET status = 'applying' WHERE id = $1 AND status IN ('applied', 'failed') RETURNING id", [id]);
++ if (!claimed) fail(409, 'proposal.decided', 'This proposal is changing right now.');
++ const conn = await connection(subject, row.connection_id);
++ const out = await run(conn, row.zone_id, steps);
++ const ok = !out.error;
++ await db.exec(`UPDATE domains_proposals SET status = $2, rolled_back_at = $3::timestamptz, rollback = $4::jsonb, error = $5 WHERE id = $1`,
++ [id, ok ? 'rolled_back' : row.status, ok ? store.iso() : null, JSON.stringify(ok ? [] : steps.slice(out.done)), ok ? null : `Roll back stopped: ${String(out.error.message).slice(0, 360)}`]);
++ await audit(subject, ok ? 'proposal.rolled_back' : 'proposal.rollback_failed', { connectionId: row.connection_id, proposalId: id, detail: { by, steps: out.done } });
++ return await get(subject, id);
++ }
++
++ /** At boot: a proposal a stop left mid-apply is failed, with whatever rollback it had, for its person to undo. */
++ async function recover() {
++ const r = await db.many("UPDATE domains_proposals SET status = 'failed', error = 'OpenVibe.Domains stopped while applying this. Check the zone, then roll back what was applied.' WHERE status = 'applying' RETURNING id");
++ return r.length;
++ }
++
++ return { connect, remove, sync, connections, zones, zone, records, propose, get, list, decide, rollBack, recover, toPublic, toActionItem, describe };
++}
++
++module.exports = { createPanel, PanelError, cleanRecord, sameRecord, describe, riskOf, toPublic, toActionItem, inZone, PROVIDERS, TYPES, normalize };
ddiff --git a/server/panel/vault.js b/server/panel/vault.js
nnew file mode 100644
iindex 0000000..93d5354
---- /dev/null
++++ b/server/panel/vault.js
@@@ -0,0 +1,100 @@
++'use strict';
++
++/**
++ * Where a connection's credential lives: a Network project secret (ADR-056), never this service's database.
++ *
++ * as the person (their own Network token; Network's developer API, server/developer/routes.js):
++ * projectFor(token) their "OpenVibe.Domains" project, created the first time
++ * store(token, project, name, value, origin) set the secret, then grant `domains` reveal to `origin` only
++ * remove(token, project, name) delete it (its grants go with it)
++ * as this service (network.secret.use, a client-credentials token; POST /internal/secrets/use):
++ * reveal(project, name, origin) the value, for one call to `origin`; never cached, logged or returned
++ *
++ * Network answers 404 secret.not_found for anything it refuses (a removed secret, a revoked grant, another origin),
++ * so a broken connection reads the same whatever the reason.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const PROJECT_NAME = 'OpenVibe.Domains';
++const ENVIRONMENT = 'production';
++
++class VaultError extends Error {
++ constructor(status, message, code = 'vault.error') {
++ super(message);
++ this.status = status;
++ this.code = code;
++ }
++}
++
++function createVault({ config, fetchImpl = globalThis.fetch, timeoutMs = 8000, tokens = null } = {}) {
++ const base = String(config.networkInternalUrl).replace(/\/+$/, '');
++ const serviceTokens = tokens || (config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.secret.use', fetchImpl,
++ }) : null);
++
++ async function asPerson(token, method, path, body) {
++ let res;
++ try {
++ res = await fetchImpl(`${base}/api/v1/projects${path}`, {
++ method,
++ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
++ body: body ? JSON.stringify(body) : undefined,
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch {
++ throw new VaultError(503, 'OpenVibe.Network did not answer. Try again in a moment.', 'vault.unavailable');
++ }
++ const data = res.status === 204 ? {} : await res.json().catch(() => ({}));
++ if (res.ok) return data;
++ const words = (data && (data.detail || data.title)) || `OpenVibe.Network answered ${res.status}.`;
++ if (res.status === 401) throw new VaultError(401, 'Your sign-in has ended. Sign in again.', 'vault.signed_out');
++ if (res.status === 503 && data && data.code === 'secrets.unavailable') throw new VaultError(503, 'Network\'s secret store is not available right now.', 'vault.unavailable');
++ throw new VaultError(res.status >= 500 ? 502 : res.status, String(words).slice(0, 300), 'vault.refused');
++ }
++
++ return {
++ ENVIRONMENT,
++ PROJECT_NAME,
++ async projectFor(token) {
++ const list = await asPerson(token, 'GET', '/');
++ const mine = (Array.isArray(list.projects) ? list.projects : []).find((p) => p && p.name === PROJECT_NAME && p.role === 'owner' && !p.archived_at);
++ if (mine) return mine.id;
++ const created = await asPerson(token, 'POST', '/', { name: PROJECT_NAME });
++ if (!created || !created.id) throw new VaultError(502, 'OpenVibe.Network did not create the project.', 'vault.refused');
++ return created.id;
++ },
++ async store(token, projectId, name, value, origin) {
++ const path = `/${encodeURIComponent(projectId)}/secrets/${ENVIRONMENT}/${encodeURIComponent(name)}`;
++ await asPerson(token, 'PUT', path, { value });
++ await asPerson(token, 'PUT', `${path}/grants/domains`, { purposes: ['reveal'], origins: [origin] });
++ },
++ async remove(token, projectId, name) {
++ try {
++ await asPerson(token, 'DELETE', `/${encodeURIComponent(projectId)}/secrets/${ENVIRONMENT}/${encodeURIComponent(name)}`);
++ } catch (err) {
++ if (err.status !== 404) throw err; // already gone
++ }
++ },
++ async reveal(projectId, name, origin) {
++ if (!serviceTokens) throw new VaultError(503, 'This service has no Network credentials (OV_OAUTH_CLIENT_SECRET).', 'vault.unconfigured');
++ let res;
++ try {
++ res = await fetchImpl(`${base}/internal/secrets/use`, {
++ method: 'POST',
++ headers: { accept: 'application/json', 'content-type': 'application/json', ...(await serviceTokens.authHeaders()) },
++ body: JSON.stringify({ project_id: projectId, environment: ENVIRONMENT, name, purpose: 'reveal', origin }),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch {
++ throw new VaultError(503, 'OpenVibe.Network did not answer. Try again in a moment.', 'vault.unavailable');
++ }
++ const data = await res.json().catch(() => ({}));
++ if (res.ok && typeof data.value === 'string' && data.value) return data.value;
++ if (res.status === 404) throw new VaultError(409, 'This connection\'s token is gone or no longer granted to OpenVibe.Domains. Connect it again.', 'vault.secret_missing');
++ throw new VaultError(503, 'Network\'s secret store did not answer. Try again in a moment.', 'vault.unavailable');
++ },
++ };
++}
++
++module.exports = { createVault, VaultError, PROJECT_NAME, ENVIRONMENT };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 0868b0c..ca542fc 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -52,8 +52,9 @@ const tldHref = (tld) => `/tld/${encodeURIComponent(String(tld).replace(/^\./, '
const latest = (rows) => rows.map((r) => r.checked_at).filter(Boolean).sort().pop() || null;
const NAV = [
-- { label: 'Price a name', href: '/', icon: 'fa-magnifying-glass-dollar' },
++ { label: 'Price a name', href: '/', icon: 'fa-magnifying-glass' },
{ label: 'Every ending', href: '/tld', icon: 'fa-list' },
++ { label: 'Control panel', href: '/panel', icon: 'fa-server' },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
@@@ -63,7 +64,12 @@ function layout({ title, description, canonical, robots = 'index, follow', body,
service: 'domains',
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active })),
-- appBar: [{ label: 'Price', href: '/', icon: 'fa-magnifying-glass-dollar' }, { label: 'Endings', href: '/tld', icon: 'fa-list' }],
++ appBar: [{ label: 'Price', href: '/', icon: 'fa-magnifying-glass' }, { label: 'Endings', href: '/tld', icon: 'fa-list' }, { label: 'Panel', href: '/panel', icon: 'fa-server' }],
++ // Sign-in with OpenVibe.Network (server/auth/sso.js): the control panel is a person's own.
++ silentLogin: '/auth/login?silent=1&next={url}',
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
search: { page: '/search', label: NAME },
history: { type: 'page', title: title || NAME },
};
ddiff --git a/server/views/panel.js b/server/views/panel.js
nnew file mode 100644
iindex 0000000..d554429
---- /dev/null
++++ b/server/views/panel.js
@@@ -0,0 +1,166 @@
++'use strict';
++/**
++ * The control panel's pages (plan 2026-10-28 §5.1, DOM-2): a person's connections, zones and proposals. Server-rendered
++ * and whole without JavaScript (every action is a form); always a person's own, so never cached. server/http/panel.js
++ * builds each model; nothing here ever sees a provider token.
++ *
++ * signedOut() what the panel is, and Sign in
++ * home({ connections, zones, waiting, recent, notice })
++ * connect({ error, values })
++ * zone({ conn, zone, records, error, values })
++ * proposal({ p, notice })
++ */
++const kit = require('openvibe-shared/kit');
++const { layout } = require('./index');
++const { describe } = require('../panel/panel');
++
++const esc = kit.esc;
++const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'CAA', 'SRV'];
++const when = (v) => {
++ const t = Date.parse(v || '');
++ return Number.isFinite(t) ? `<time datetime="${esc(new Date(t).toISOString())}">${esc(new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }))} UTC</time>` : '';
++};
++const STATUS_WORDS = { pending: 'Waiting for you', applying: 'Applying', applied: 'Applied', declined: 'Declined', failed: 'Failed', rolled_back: 'Rolled back', expired: 'Expired' };
++const pill = (status) => `<span class="dm-pill dmp-st dmp-st--${esc(status)}">${esc(STATUS_WORDS[status] || status)}</span>`;
++const OP_WORDS = { create: 'Add', update: 'Change', delete: 'Remove' };
++
++function page(title, body, { notice = null } = {}) {
++ const flash = notice ? `<p class="dmp-notice" role="status">${kit.icon('check')}<span>${esc(notice)}</span></p>` : '';
++ // A form with data-dmp-confirm asks first (remove a connection, roll back); without JavaScript it simply submits.
++ const confirmJs = '<script>document.addEventListener("submit",function(e){var f=e.target;if(f&&f.dataset&&f.dataset.dmpConfirm&&!window.confirm(f.dataset.dmpConfirm))e.preventDefault();});</script>';
++ return layout({ title, robots: 'noindex, nofollow', body: `<div class="dm-wrap dmp">${flash}${body}</div>${confirmJs}`, active: '/panel' });
++}
++
++function head(kicker, h1, lede = '') {
++ return `<header class="dm-page-h"><p class="dm-kicker">${kicker}</p><h1>${esc(h1)}</h1>${lede ? `<p class="dm-lede">${lede}</p>` : ''}</header>`;
++}
++
++function signedOut() {
++ const steps = [
++ ['link', 'Connect', 'Paste a Cloudflare API token. It is kept as a secret in your own OpenVibe project and only ever sent to Cloudflare.'],
++ ['spark', 'Propose', 'Every DNS change, yours or an agent\'s, becomes a proposal: the exact records, why, and what could break.'],
++ ['check', 'Approve', 'Nothing changes until you approve it, here or in the Action Center. Applied changes roll back in one tap.'],
++ ];
++ return page('Control panel', `${head('Control panel', 'Run every domain you own from one place', 'Connect your DNS once and see every zone together. Changes wait for your approval, and each one can be undone.')}
++<ol class="dmp-steps">${steps.map(([icon, t, d], i) => `<li><span class="dmp-step-ic">${kit.icon(icon)}</span><b>${i + 1}. ${esc(t)}</b><span>${esc(d)}</span></li>`).join('')}</ol>
++<p class="dmp-actions"><a class="dm-btn" href="/auth/login?next=%2Fpanel">Sign in with OpenVibe</a></p>
++<p class="dm-fine">Cloudflare today; Porkbun, Namecheap and Spaceship follow. OpenVibe never stores your token in its own database and never shows it again.</p>`);
++}
++
++function connectionRow(c) {
++ const broken = c.status === 'broken';
++ return `<li class="dmp-conn${broken ? ' is-broken' : ''}" id="dmp-conn-${esc(c.id)}">
++<div class="dmp-conn-main"><b>${esc(c.label)}</b><span class="dmp-sub">Cloudflare · ${c.zone_count} zone${c.zone_count === 1 ? '' : 's'}${c.checked_at ? ` · checked ${when(c.checked_at)}` : ''}</span>
++${broken ? `<span class="dmp-err">${kit.icon('alert')}${esc(c.last_error || 'The token stopped working.')}</span>` : ''}</div>
++<div class="dmp-conn-act">
++<form method="post" action="/panel/c/${esc(c.id)}/sync"><button class="dmp-btn" type="submit">Sync</button></form>
++<form method="post" action="/panel/c/${esc(c.id)}/remove" data-dmp-confirm="Remove ${esc(c.label)}? Its token is deleted from your OpenVibe project and waiting proposals expire."><button class="dmp-btn dmp-btn--quiet" type="submit">Remove</button></form>
++</div></li>`;
++}
++
++function proposalCard(p) {
++ return `<li class="dmp-prop" id="dmp-prop-${esc(p.id)}"><a href="/panel/p/${esc(p.id)}">
++<span class="dmp-prop-top">${pill(p.status)}<span class="dmp-sub">${esc(p.domain)} · ${when(p.created_at)}</span></span>
++<b>${esc(p.title)}</b>
++<span class="dmp-sub">${p.changes.length} change${p.changes.length === 1 ? '' : 's'}${String(p.created_by).startsWith('agent:') ? ` · asked by ${esc(String(p.created_by).slice(6))}` : ''}</span>
++</a></li>`;
++}
++
++function home({ connections = [], zones = [], waiting = [], recent = [], notice = null } = {}) {
++ if (!connections.length) {
++ return page('Control panel', `${head('Control panel', 'Connect your first account', 'Domains reads your zones through the provider\'s own API. Every change is proposed to you first.')}
++<p class="dmp-actions"><a class="dm-btn" href="/panel/connect">${kit.icon('plus')}Connect Cloudflare</a></p>`, { notice });
++ }
++ const facts = `<dl class="dm-facts"><div><dt>Zones</dt><dd><b>${zones.length}</b></dd></div><div><dt>Waiting for you</dt><dd><b>${waiting.length}</b></dd></div><div><dt>Connections</dt><dd><b>${connections.length}</b>${connections.some((c) => c.status === 'broken') ? '<small>one needs attention</small>' : ''}</dd></div></dl>`;
++ const zoneRows = zones.map((z) => `<a class="dm-tr dmp-zone" id="dmp-zone-${esc(z.zone_id)}" href="/panel/z/${esc(z.connection_id)}/${esc(z.zone_id)}">
++<span class="dmp-zone-name">${kit.icon('globe')}<b>${esc(z.name)}</b></span><span>${esc(z.label)}</span><span>${esc(z.status || '')}</span><span class="dmp-ns">${(z.name_servers || []).slice(0, 2).map(esc).join('<br>')}</span><span class="dmp-open">Open${kit.icon('chevron')}</span></a>`).join('');
++ return page('Control panel', `${head('Control panel', 'Your domains')}
++${facts}
++${waiting.length ? `<section aria-labelledby="dmp-wait-h"><h2 id="dmp-wait-h">Waiting for you</h2><ul class="dmp-props">${waiting.map(proposalCard).join('')}</ul></section>` : ''}
++<section aria-labelledby="dmp-zones-h"><div class="dm-head-row"><h2 id="dmp-zones-h">Zones</h2><a class="dm-more" href="/panel/connect">Connect another account${kit.icon('chevron')}</a></div>
++${zones.length ? `<div class="dm-table dmp-zones" role="table"><div class="dm-tr dm-th" role="row"><span>Domain</span><span>Account</span><span>Status</span><span>Name servers</span><span></span></div>${zoneRows}</div>` : '<p class="dm-fine">No zones yet. Sync a connection, or check its token can read zones.</p>'}</section>
++<section aria-labelledby="dmp-conn-h"><h2 id="dmp-conn-h">Connections</h2><ul class="dmp-conns">${connections.map(connectionRow).join('')}</ul></section>
++${recent.length ? `<section aria-labelledby="dmp-recent-h"><h2 id="dmp-recent-h">Recent changes</h2><ul class="dmp-props">${recent.map(proposalCard).join('')}</ul></section>` : ''}`, { notice });
++}
++
++function connect({ error = null, values = {} } = {}) {
++ return page('Connect Cloudflare', `${head('<a href="/panel">Control panel</a>', 'Connect Cloudflare')}
++<div class="dmp-split">
++<form class="dmp-form" method="post" action="/panel/connect" id="dmp-connect" autocomplete="off">
++${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error)}</span></p>` : ''}
++<input type="hidden" name="provider" value="cloudflare">
++<label class="dmp-field"><span>A name for it</span><input name="label" maxlength="60" value="${esc(values.label || 'Cloudflare')}" required></label>
++<label class="dmp-field"><span>API token</span><input name="token" type="password" minlength="20" maxlength="400" required spellcheck="false" autocomplete="off" placeholder="Paste the token Cloudflare showed you"></label>
++<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('lock')}Connect</button><a class="dmp-btn dmp-btn--quiet" href="/panel">Cancel</a></p>
++</form>
++<aside class="dmp-help" aria-labelledby="dmp-help-h"><h2 id="dmp-help-h">Make a token that can do only this</h2>
++<ol><li>Open <a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer" target="_blank">API tokens</a> in Cloudflare and choose <b>Create token</b>.</li>
++<li>Start from <b>Edit zone DNS</b>. Add <b>Zone · Zone · Read</b> so Domains can list your zones.</li>
++<li>Under <b>Zone resources</b>, pick the zones you want here (or all of them).</li>
++<li>Create it and paste it on the left. Cloudflare shows it once.</li></ol>
++<p class="dmp-where">${kit.icon('shield')}<span>The token is stored as a secret in your own OpenVibe project, granted to OpenVibe.Domains only for calls to <b>api.cloudflare.com</b>. Domains keeps its name, never its value. Remove the connection and the secret goes with it.</span></p>
++</aside></div>`);
++}
++
++function recordFields(r = {}, { prefix = '' } = {}) {
++ const type = r.type || 'A';
++ return `<label class="dmp-field dmp-field--type"><span>Type</span><select name="${prefix}type">${TYPES.map((t) => `<option${t === type ? ' selected' : ''}>${t}</option>`).join('')}</select></label>
++<label class="dmp-field"><span>Name</span><input name="${prefix}name" value="${esc(r.name || '')}" placeholder="@ or www" required></label>
++<label class="dmp-field dmp-field--wide"><span>Value</span><input name="${prefix}content" value="${esc(r.content || '')}" required spellcheck="false"></label>
++<label class="dmp-field dmp-field--ttl"><span>TTL</span><input name="${prefix}ttl" inputmode="numeric" value="${esc(r.ttl == null ? 1 : r.ttl)}" title="1 is automatic; else 60 to 86 400 seconds"></label>
++<label class="dmp-field dmp-field--pri"><span>Priority</span><input name="${prefix}priority" inputmode="numeric" value="${esc(r.priority == null ? '' : r.priority)}" placeholder="MX, SRV"></label>
++<label class="dmp-check"><input type="checkbox" name="${prefix}proxied"${r.proxied ? ' checked' : ''}><span>Proxied</span></label>`;
++}
++
++function zone({ conn, zone: z, records = [], error = null, values = {} } = {}) {
++ const action = `/panel/z/${esc(conn.id)}/${esc(z.zone_id)}/propose`;
++ const rows = records.map((r) => `<li class="dmp-rec" id="dmp-rec-${esc(r.id)}">
++<div class="dmp-rec-main"><span class="dmp-type">${esc(r.type)}</span><b class="dmp-rec-name">${esc(r.name)}</b><code class="dmp-rec-val">${esc(r.content)}</code>
++<span class="dmp-sub">${r.ttl === 1 ? 'Auto TTL' : `TTL ${esc(r.ttl)}`}${r.priority != null ? ` · priority ${esc(r.priority)}` : ''}${r.proxied ? ' · proxied' : ''}</span></div>
++<div class="dmp-rec-act">
++<details class="dmp-edit"><summary class="dmp-btn">Change</summary>
++<form class="dmp-form dmp-form--row" method="post" action="${action}"><input type="hidden" name="op" value="update"><input type="hidden" name="record_id" value="${esc(r.id)}">
++${recordFields(r)}<label class="dmp-field dmp-field--wide"><span>Why (optional)</span><input name="why" maxlength="300"></label>
++<p class="dmp-actions"><button class="dm-btn" type="submit">Propose this change</button></p></form></details>
++<form method="post" action="${action}"><input type="hidden" name="op" value="delete"><input type="hidden" name="record_id" value="${esc(r.id)}"><button class="dmp-btn dmp-btn--quiet" type="submit">Remove</button></form>
++</div></li>`).join('');
++ return page(z.name, `${head(`<a href="/panel">Control panel</a> · ${esc(conn.label)}`, z.name)}
++<dl class="dm-facts"><div><dt>Records</dt><dd><b>${records.length}</b></dd></div><div><dt>Status</dt><dd>${esc(z.status || '—')}</dd></div><div><dt>Name servers</dt><dd class="dmp-ns">${(Array.isArray(z.name_servers) ? z.name_servers : []).map(esc).join('<br>') || '—'}</dd></div></dl>
++<section aria-labelledby="dmp-add-h"><h2 id="dmp-add-h">Add a record</h2>
++<p class="dm-fine">Nothing changes yet: this makes a proposal you approve on the next page.</p>
++${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error)}</span></p>` : ''}
++<form class="dmp-form dmp-form--row" method="post" action="${action}" id="dmp-add"><input type="hidden" name="op" value="create">
++${recordFields(values)}<label class="dmp-field dmp-field--wide"><span>Why (optional)</span><input name="why" maxlength="300" value="${esc(values.why || '')}"></label>
++<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('plus')}Propose</button></p></form></section>
++<section aria-labelledby="dmp-recs-h"><h2 id="dmp-recs-h">Records</h2>
++${records.length ? `<ul class="dmp-recs">${rows}</ul>` : '<p class="dm-fine">This zone has no records Cloudflare showed us.</p>'}</section>`);
++}
++
++function changeCard(c) {
++ const b = c.before;
++ const a = c.after;
++ const r = a || b || {};
++ const val = (x) => (x ? `<code>${esc(x.content)}</code><span class="dmp-sub">${x.ttl === 1 ? 'Auto TTL' : `TTL ${esc(x.ttl)}`}${x.priority != null ? ` · priority ${esc(x.priority)}` : ''}${x.proxied ? ' · proxied' : ''}</span>` : '');
++ return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span>
++<div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">${esc(r.type)}</span><b>${esc(r.name)}</b></p>
++<div class="dmp-diff">${b ? `<div class="dmp-before"><span class="dmp-sub">Now</span>${val(b)}</div>` : ''}${a ? `<div class="dmp-after"><span class="dmp-sub">${b ? 'After' : 'New'}</span>${val(a)}</div>` : '<div class="dmp-after"><span class="dmp-sub">After</span><em>Removed</em></div>'}</div></div></li>`;
++}
++
++function proposal({ p, notice = null } = {}) {
++ const rolledBackable = (p.status === 'applied' || p.status === 'failed') && p.can_roll_back;
++ const actions = p.status === 'pending'
++ ? `<form method="post" action="/panel/p/${esc(p.id)}/decide"><input type="hidden" name="decision" value="approve"><button class="dm-btn" type="submit">${kit.icon('check')}Approve and apply</button></form>
++<form method="post" action="/panel/p/${esc(p.id)}/decide"><input type="hidden" name="decision" value="decline"><button class="dmp-btn dmp-btn--quiet" type="submit">Decline</button></form>`
++ : rolledBackable ? `<form method="post" action="/panel/p/${esc(p.id)}/rollback" data-dmp-confirm="Undo what this applied?"><button class="dm-btn" type="submit">Roll back</button></form>` : '';
++ const by = String(p.created_by || '');
++ return page(p.title, `${head(`<a href="/panel">Control panel</a> · ${esc(p.domain)}`, p.title)}
++<p class="dm-status">${pill(p.status)}<span>${by.startsWith('agent:') ? `Asked by ${esc(by.slice(6))}` : 'You asked'} · ${when(p.created_at)}${p.status === 'pending' && p.expires_at ? ` · decide by ${when(p.expires_at)}` : ''}${p.applied_at ? ` · applied ${when(p.applied_at)}` : ''}${p.rolled_back_at ? ` · rolled back ${when(p.rolled_back_at)}` : ''}</span></p>
++${p.error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(p.error)}</span></p>` : ''}
++<section aria-labelledby="dmp-ch-h"><h2 id="dmp-ch-h">${p.changes.length === 1 ? 'The change' : `${p.changes.length} changes, in this order`}</h2><ol class="dmp-changes">${p.changes.map(changeCard).join('')}</ol></section>
++${p.why ? `<section aria-labelledby="dmp-why-h"><h2 id="dmp-why-h">Why</h2><p class="dmp-text">${esc(p.why)}</p></section>` : ''}
++${p.risk ? `<section aria-labelledby="dmp-risk-h"><h2 id="dmp-risk-h">What could break</h2><p class="dmp-text">${esc(p.risk)}</p></section>` : ''}
++${actions ? `<div class="dmp-decide">${actions}</div>` : ''}
++<p class="dm-fine">${p.status === 'pending' ? 'Approving applies the changes through Cloudflare\'s API, one by one. If a record changed since this was proposed, nothing more is applied and you can roll back what was.' : 'Every step is kept in your panel\'s audit.'}</p>`, { notice });
++}
++
++module.exports = { signedOut, home, connect, zone, proposal, describe };
ddiff --git a/test/helpers.js b/test/helpers.js
iindex bc39d04..94f8414 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -7,6 +7,8 @@
const http = require('http');
const configLib = require('../server/config');
const { createApp } = require('../server/app');
++const { createStore, MIGRATIONS } = require('../server/db');
++const { createTestDb } = require('openvibe-sdk/testing');
let failures = 0;
@@@ -25,13 +27,21 @@ function done() {
process.exit(failures ? 1 : 0);
}
--/** Boot the real app on an ephemeral port with injected host/tools clients. */
--async function boot({ config, host, tools, release } = {}) {
-- const cfg = config || configLib.load({
-- NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.domains', TRUST_PROXY: '1',
-- });
++/**
++ * Boot the real app on an ephemeral port with injected host/tools clients, a migrated test database (PGlite, or the
++ * PostgreSQL containers with DOMAINS_TEST_STORE=pg) and, for the control panel, a Network stand-in and providers.
++ *
++ * const t = await boot({ host, tools, network, providers, fetchImpl, now });
++ * await t.get(path, { method, headers, cookie, form, json, bearer, origin })
++ */
++async function boot({ config, host, tools, release, network = null, providers, fetchImpl, now } = {}) {
++ const env = { NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.domains', TRUST_PROXY: '1' };
++ if (network) Object.assign(env, { OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url, OV_OAUTH_CLIENT_ID: 'domains', OV_OAUTH_CLIENT_SECRET: 'domains-secret' });
++ const cfg = config || configLib.load(env);
const quiet = { log() {}, info() {}, warn() {}, error() {} };
-- const built = createApp({ config: cfg, host, tools, release, log: quiet });
++ const testDb = await createTestDb({ migrations: MIGRATIONS, store: process.env.DOMAINS_TEST_STORE || 'pglite', service: 'domains', max: 4 });
++ const store = createStore(testDb.db || testDb, { now });
++ const built = await createApp({ config: cfg, host, tools, release, log: quiet, store, providers, fetchImpl });
const server = await new Promise((resolve) => {
const s = http.createServer(built.app);
s.listen(0, '127.0.0.1', () => resolve(s));
@@@ -39,14 +49,26 @@ async function boot({ config, host, tools, release } = {}) {
const base = `http://127.0.0.1:${server.address().port}`;
async function get(path, opts = {}) {
-- const res = await fetch(base + path, { method: opts.method || 'GET', headers: opts.headers, redirect: 'manual' });
++ const headers = { ...(opts.headers || {}) };
++ let body;
++ if (opts.cookie) headers.cookie = opts.cookie;
++ if (opts.bearer) headers.authorization = `Bearer ${opts.bearer}`;
++ if (opts.origin !== undefined) { if (opts.origin) headers.origin = opts.origin; } else if (opts.form || opts.json) headers.origin = 'https://openvibe.domains';
++ if (opts.form) { body = new URLSearchParams(opts.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ if (opts.json !== undefined) { body = JSON.stringify(opts.json); headers['content-type'] = 'application/json'; }
++ const res = await fetch(base + path, { method: opts.method || (body !== undefined ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
const text = await res.text();
return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
}
return {
-- base, ctx: built.ctx, get,
-- async close() { await new Promise((resolve) => server.close(resolve)); },
++ base, ctx: built.ctx, get, store, network,
++ /** A person's session cookie (dom_at), as sign-in sets it. */
++ signIn: (user) => `dom_at=${network.userToken(user)}`,
++ async close() {
++ await new Promise((resolve) => server.close(resolve));
++ try { await (testDb.close ? testDb.close() : store.close()); } catch { /* already closed */ }
++ },
};
}
ddiff --git a/test/helpers/fake-cloudflare.js b/test/helpers/fake-cloudflare.js
nnew file mode 100644
iindex 0000000..99166fb
---- /dev/null
++++ b/test/helpers/fake-cloudflare.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * A stand-in for Cloudflare's v4 API behind createCloudflare(): an in-memory account answering the same paths, so the
++ * real client (server/panel/cloudflare.js) is what the tests drive. Only https://api.cloudflare.com is answered; any
++ * other origin is recorded as a leak.
++ *
++ * const cf = fakeCloudflare({ token: 'cf-good-token-…', zones: [{ id: 'z1', name: 'example.com' }] });
++ * createCloudflare({ fetchImpl: cf.fetch })
++ * cf.records('z1') / cf.setRecord('z1', id, patch) / cf.revoke() / cf.calls / cf.leaks
++ */
++const crypto = require('crypto');
++
++function fakeCloudflare({ token, zones = [] } = {}) {
++ const st = { token, revoked: false, zones: zones.map((z) => ({ status: 'active', name_servers: ['ada.ns.cloudflare.com', 'bob.ns.cloudflare.com'], plan: { name: 'Free Website' }, ...z })), records: new Map(), calls: [], leaks: [] };
++ for (const z of st.zones) st.records.set(z.id, []);
++ const ok = (result, info) => new Response(JSON.stringify({ success: true, errors: [], result, ...(info ? { result_info: info } : {}) }), { status: 200, headers: { 'content-type': 'application/json' } });
++ const err = (status, message) => new Response(JSON.stringify({ success: false, errors: [{ code: 1000, message }], result: null }), { status, headers: { 'content-type': 'application/json' } });
++
++ async function fetchImpl(input, init = {}) {
++ const u = new URL(String(input));
++ if (u.origin !== 'https://api.cloudflare.com') { st.leaks.push(String(input)); return err(400, 'wrong origin'); }
++ const auth = String((init.headers || {}).authorization || '');
++ const method = init.method || 'GET';
++ st.calls.push({ method, path: u.pathname, auth: auth === `Bearer ${st.token}` });
++ if (auth !== `Bearer ${st.token}` || st.revoked) return err(403, 'Invalid API Token');
++ const p = u.pathname.replace('/client/v4', '');
++ if (p === '/user/tokens/verify') return ok({ id: 'tok1', status: 'active' });
++ if (p === '/zones') return ok(st.zones, { page: 1, per_page: 50, total_pages: 1, count: st.zones.length });
++ const m = p.match(/^\/zones\/([^/]+)\/dns_records(?:\/([^/]+))?$/);
++ if (m) {
++ const list = st.records.get(decodeURIComponent(m[1]));
++ if (!list) return err(404, 'Zone not found');
++ const id = m[2] && decodeURIComponent(m[2]);
++ const body = init.body ? JSON.parse(init.body) : null;
++ if (!id && method === 'GET') return ok(list.map((r) => ({ ...r })), { page: 1, per_page: 100, total_pages: 1, count: list.length });
++ if (!id && method === 'POST') { const r = { id: crypto.randomBytes(16).toString('hex'), ttl: 1, ...body }; list.push(r); return ok({ ...r }); }
++ const at = list.findIndex((r) => r.id === id);
++ if (at < 0) return err(404, 'Record not found');
++ if (method === 'GET') return ok({ ...list[at] });
++ if (method === 'PUT') { list[at] = { id, ...body }; return ok({ ...list[at] }); }
++ if (method === 'DELETE') { list.splice(at, 1); return ok({ id }); }
++ }
++ return err(404, 'not found');
++ }
++
++ return {
++ fetch: fetchImpl,
++ state: st,
++ records: (zoneId) => st.records.get(zoneId).map((r) => ({ ...r })),
++ addRecord: (zoneId, r) => { const rec = { id: crypto.randomBytes(16).toString('hex'), ttl: 1, ...r }; st.records.get(zoneId).push(rec); return rec; },
++ setRecord: (zoneId, id, patch) => { const list = st.records.get(zoneId); const at = list.findIndex((r) => r.id === id); list[at] = { ...list[at], ...patch }; },
++ revoke: () => { st.revoked = true; },
++ get calls() { return st.calls; },
++ get leaks() { return st.leaks; },
++ };
++}
++
++module.exports = { fakeCloudflare };
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
nnew file mode 100644
iindex 0000000..0acdef6
---- /dev/null
++++ b/test/helpers/network.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network for the control panel's tests, with a real RS256 key pair:
++ *
++ * JWKS (/api/.well-known/jwks); /oauth/token (client_credentials and authorization_code for client `domains`);
++ * the person's projects and project secrets (/api/v1/projects…, ADR-056, Bearer user token);
++ * POST /internal/secrets/use (a service token): the value for a grant's purpose and origin, else 404, as Network does.
++ *
++ * const net = await startNetwork();
++ * const ada = net.addUser('ada'); net.userToken(ada) a person's session token
++ * net.serviceToken(['domains.actions.read']) Network's own token for openvibe.domains
++ * net.state.secrets Map "<project>/<env>/<name>" → { value, grants }
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(status === 204 ? '' : JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json)).catch((err) => json(500, { error: String(err && err.message) }));
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }) }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++const signJwt = (claims, key) => { const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`; return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), key).toString('base64url')}`; };
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const st = { users: new Map(), projects: new Map(), secrets: new Map(), requests: [], uses: [], down: false };
++ let issuer = null;
++ let nextId = 1;
++
++ const addUser = (username) => { const u = { id: nextId++, subject: ids.newId('user'), username, role: 'user' }; st.users.set(u.id, u); return u; };
++ const userToken = (u) => { const now = Math.floor(Date.now() / 1000); return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.username, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + 3600 }, privatePem); };
++ const serviceToken = (caps, { aud = 'openvibe.domains', sub = 'svc:network' } = {}) => { const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud: [aud], cap: caps, iat: now, exp: now + 300, jti: crypto.randomBytes(8).toString('hex') }, privatePem); };
++ const verified = (req) => {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const [a, b, c] = h.slice(7).split('.');
++ if (!c || !crypto.verify('RSA-SHA256', Buffer.from(`${a}.${b}`), publicPem, Buffer.from(c, 'base64url'))) return null;
++ return JSON.parse(Buffer.from(b, 'base64url').toString('utf8'));
++ };
++
++ const srv = await listen(async (req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ const p = url.pathname;
++ st.requests.push({ method: req.method, path: p, body: raw });
++ if (st.down) return json(503, { code: 'network.down', detail: 'Network is restarting' });
++ if (p === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (p === '/oauth/revoke') return json(200, {});
++ if (p === '/oauth/token' && req.method === 'POST') {
++ const body = Object.fromEntries(new URLSearchParams(raw));
++ if (body.client_id !== 'domains' || body.client_secret !== 'domains-secret') return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'client_credentials') {
++ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return json(200, { access_token: serviceToken(caps, { aud: body.audience || 'openvibe.network', sub: 'svc:domains' }), token_type: 'Bearer', expires_in: 300, scope: caps.join(' ') });
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++ }
++
++ // The person's projects and their secrets (Bearer user token only).
++ if (p.startsWith('/api/v1/projects')) {
++ const c = verified(req);
++ const u = c && st.users.get(c.sub);
++ if (!u) return json(401, { code: 'auth.required', detail: 'sign in' });
++ const parts = p.split('/').slice(4).map(decodeURIComponent); // ['', project?, 'secrets', env, name, 'grants', svc]
++ if (parts.length <= 1 || parts[0] === '') {
++ if (req.method === 'GET') return json(200, { projects: [...st.projects.values()].filter((x) => x.owner === u.subject).map((x) => ({ id: x.id, name: x.name, role: 'owner', archived_at: null })) });
++ if (req.method === 'POST') { const id = ids.newId('project'); const body = JSON.parse(raw || '{}'); st.projects.set(id, { id, name: body.name, owner: u.subject }); return json(201, { id, name: body.name, role: 'owner' }); }
++ }
++ const [project, kind, env, name, grants, svc] = parts;
++ const proj = st.projects.get(project);
++ if (!proj || proj.owner !== u.subject || kind !== 'secrets') return json(404, { code: 'project.not_found' });
++ const key = `${project}/${env}/${name}`;
++ if (!grants && req.method === 'PUT') { const body = JSON.parse(raw || '{}'); const old = st.secrets.get(key); st.secrets.set(key, { value: body.value, grants: old ? old.grants : {}, version: old ? old.version + 1 : 1 }); return json(200, { name, version: st.secrets.get(key).version }); }
++ if (!grants && req.method === 'DELETE') { if (!st.secrets.has(key)) return json(404, { code: 'secret.not_found' }); st.secrets.delete(key); return json(204); }
++ if (grants === 'grants' && req.method === 'PUT') { const s = st.secrets.get(key); if (!s) return json(404, { code: 'secret.not_found' }); s.grants[svc] = JSON.parse(raw || '{}'); return json(200, { service: svc }); }
++ return json(404, { code: 'route.not_found' });
++ }
++
++ if (p === '/internal/secrets/use' && req.method === 'POST') {
++ const c = verified(req);
++ if (!c || !String(c.sub).startsWith('svc:')) return json(401, { code: 'token.invalid' });
++ const caps = Array.isArray(c.cap) ? c.cap : [];
++ if (!caps.includes('network.secret.use')) return json(403, { code: 'capability.denied' });
++ const body = JSON.parse(raw || '{}');
++ st.uses.push({ service: c.sub, ...body });
++ const s = st.secrets.get(`${body.project_id}/${body.environment}/${body.name}`);
++ const g = s && s.grants[String(c.sub).slice(4)];
++ if (!g || !g.purposes.includes(body.purpose) || (body.purpose === 'reveal' && !g.origins.includes(body.origin))) return json(404, { code: 'secret.not_found' });
++ return json(200, { name: body.name, version: s.version, purpose: body.purpose, value: s.value });
++ }
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++ return { url: srv.url, state: st, addUser, userToken, serviceToken, close: srv.close };
++}
++
++module.exports = { startNetwork };
ddiff --git a/test/panel.test.js b/test/panel.test.js
nnew file mode 100644
iindex 0000000..48c2808
---- /dev/null
++++ b/test/panel.test.js
@@@ -0,0 +1,208 @@
++'use strict';
++/**
++ * The control panel (DOM-2) end to end, on a migrated test database with a Network stand-in (projects, secrets,
++ * network.secret.use) and a stand-in Cloudflare account behind the real client:
++ * connect stores the token as the person's Network project secret (granted for reveal to api.cloudflare.com only) and
++ * never in this database; a proposal changes nothing until approved; approving applies and keeps the rollback; rolling
++ * back undoes it; a record that moved is refused as stale; another site cannot post; Network's Action Center reads and
++ * decides with its own capabilities; a revoked token marks the connection broken; removing deletes the secret.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers');
++const { startNetwork } = require('./helpers/network');
++const { fakeCloudflare } = require('./helpers/fake-cloudflare');
++const { createCloudflare } = require('../server/panel/cloudflare');
++
++const CF_TOKEN = 'cf-test-token-0123456789abcdefABCDEF';
++const stub = { tlds: async () => ({ tlds: [] }), tld: async () => null, prices: async () => null, lists: async () => ({}), history: async () => null, check: async () => null };
++
++(async () => {
++ const network = await startNetwork();
++ const cf = fakeCloudflare({ token: CF_TOKEN, zones: [{ id: 'z1', name: 'example.com' }] });
++ const www = cf.addRecord('z1', { type: 'A', name: 'www.example.com', content: '192.0.2.1', proxied: true });
++ const t = await boot({ host: stub, tools: stub, network, providers: { cloudflare: createCloudflare({ fetchImpl: cf.fetch }) } });
++ const ada = network.addUser('ada');
++ const bob = network.addUser('bob');
++ const cookie = t.signIn(ada);
++ const db = t.store.db;
++ const locationOf = (r) => r.headers.get('location');
++ let conn = null;
++ let proposalId = null;
++
++ await check('signed out, /panel says what it is and asks to sign in; never cached', async () => {
++ const r = await t.get('/panel');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Sign in with OpenVibe/);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ const c = await t.get('/panel/connect');
++ assert.strictEqual(c.status, 303);
++ assert.match(locationOf(c), /^\/auth\/login\?next=%2Fpanel%2Fconnect/);
++ });
++
++ await check('a bad token is refused in words, and nothing is stored', async () => {
++ const r = await t.get('/panel/connect', { cookie, form: { provider: 'cloudflare', label: 'Main', token: 'cf-wrong-token-0123456789abcdef' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /Cloudflare refused this token/);
++ assert.strictEqual(network.state.secrets.size, 0);
++ assert.strictEqual(await db.value('SELECT count(*)::int FROM domains_connections'), 0);
++ });
++
++ await check('connecting stores the token as the person\'s project secret, granted for api.cloudflare.com only, and syncs the zones', async () => {
++ const r = await t.get('/panel/connect', { cookie, form: { provider: 'cloudflare', label: 'Main account', token: CF_TOKEN } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.strictEqual(locationOf(r), '/panel?done=connected');
++ conn = await db.maybe('SELECT * FROM domains_connections WHERE subject = $1', [ada.subject]);
++ assert.ok(conn && conn.status === 'active');
++ const [key, secret] = [...network.state.secrets.entries()][0];
++ assert.strictEqual(key, `${conn.project_id}/production/${conn.secret_name}`);
++ assert.strictEqual(secret.value, CF_TOKEN);
++ assert.deepStrictEqual(secret.grants.domains, { purposes: ['reveal'], origins: ['https://api.cloudflare.com'] });
++ // The token is nowhere in this service's database.
++ for (const table of ['domains_connections', 'domains_zones', 'domains_proposals', 'domains_audit']) {
++ const dump = JSON.stringify(await db.many(`SELECT * FROM ${table}`));
++ assert.ok(!dump.includes(CF_TOKEN), `${table} holds the token`);
++ }
++ const zones = await db.many('SELECT * FROM domains_zones WHERE connection_id = $1', [conn.id]);
++ assert.deepStrictEqual(zones.map((z) => z.name), ['example.com']);
++ const page = await t.get('/panel?done=connected', { cookie });
++ assert.match(page.text, /id="dmp-zone-z1"/);
++ assert.match(page.text, /Connected\. Your zones are below\./);
++ assert.deepStrictEqual(cf.leaks, []);
++ });
++
++ await check('a zone shows its records live; proposing adds nothing until it is approved', async () => {
++ const z = await t.get(`/panel/z/${conn.id}/z1`, { cookie });
++ assert.strictEqual(z.status, 200);
++ assert.match(z.text, /www\.example\.com/);
++ const before = cf.records('z1').length;
++ const r = await t.get(`/panel/z/${conn.id}/z1/propose`, { cookie, form: { op: 'create', type: 'TXT', name: '@', content: 'openvibe-proof=1', ttl: '1', why: 'Prove the zone is ours' } });
++ assert.strictEqual(r.status, 303, r.text);
++ proposalId = locationOf(r).split('/').pop();
++ assert.match(proposalId, /^dpr_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(cf.records('z1').length, before, 'nothing applied yet');
++ const p = await t.get(`/panel/p/${proposalId}`, { cookie });
++ assert.match(p.text, /Approve and apply/);
++ assert.match(p.text, /Add TXT example\.com|Add/);
++ const api = await t.get(`/api/v1/proposals/${proposalId}`, { bearer: network.userToken(ada) });
++ const v = contracts.validate('domains.proposal', api.json());
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.strictEqual(api.json().status, 'pending');
++ });
++
++ await check('another site cannot approve for a signed-in person', async () => {
++ const r = await t.get(`/panel/p/${proposalId}/decide`, { cookie, form: { decision: 'approve' }, origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403);
++ const row = await db.maybe('SELECT status FROM domains_proposals WHERE id = $1', [proposalId]);
++ assert.strictEqual(row.status, 'pending');
++ });
++
++ await check('approving applies it through Cloudflare and keeps the rollback; rolling back undoes it', async () => {
++ const r = await t.get(`/panel/p/${proposalId}/decide`, { cookie, form: { decision: 'approve' } });
++ assert.strictEqual(r.status, 303);
++ const added = cf.records('z1').find((x) => x.type === 'TXT');
++ assert.ok(added && added.content === 'openvibe-proof=1' && added.name === 'example.com');
++ let row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [proposalId]);
++ assert.strictEqual(row.status, 'applied');
++ assert.strictEqual(row.rollback.length, 1);
++ assert.strictEqual(row.rollback[0].op, 'delete');
++ const page = await t.get(`/panel/p/${proposalId}?done=approved`, { cookie });
++ assert.match(page.text, /Applied\./);
++ const back = await t.get(`/panel/p/${proposalId}/rollback`, { cookie, form: {} });
++ assert.strictEqual(back.status, 303);
++ assert.ok(!cf.records('z1').some((x) => x.type === 'TXT'), 'the record is gone again');
++ row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [proposalId]);
++ assert.strictEqual(row.status, 'rolled_back');
++ const audit = (await db.many('SELECT action FROM domains_audit WHERE proposal_id = $1 ORDER BY id', [proposalId])).map((x) => x.action);
++ assert.deepStrictEqual(audit, ['proposal.created', 'proposal.applied', 'proposal.rolled_back']);
++ });
++
++ await check('a record that moved since the proposal is refused as stale, and nothing more is applied', async () => {
++ const r = await t.get('/api/v1/proposals', { bearer: network.userToken(ada), json: { connection_id: conn.id, zone_id: 'z1', changes: [{ op: 'update', record_id: www.id, after: { content: '192.0.2.7' } }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ const id = r.json().id;
++ assert.strictEqual(r.json().changes[0].before.content, '192.0.2.1');
++ cf.setRecord('z1', www.id, { content: '198.51.100.9' }); // someone changed it in Cloudflare meanwhile
++ const d = await t.get(`/api/v1/proposals/${id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
++ assert.strictEqual(d.status, 200, d.text);
++ assert.strictEqual(d.json().status, 'failed');
++ assert.match(d.json().error, /changed since this was proposed/);
++ assert.strictEqual(cf.records('z1').find((x) => x.id === www.id).content, '198.51.100.9', 'left as someone set it');
++ });
++
++ await check('a change outside the zone, or one that changes nothing, is refused', async () => {
++ const out = await t.get('/api/v1/proposals', { bearer: network.userToken(ada), json: { connection_id: conn.id, zone_id: 'z1', changes: [{ op: 'create', after: { type: 'A', name: 'mail.other.org', content: '192.0.2.5' } }] } });
++ assert.strictEqual(out.status, 201, 'a bare name is placed inside the zone');
++ assert.strictEqual(out.json().changes[0].after.name, 'mail.other.org.example.com');
++ const bad = await t.get('/api/v1/proposals', { bearer: network.userToken(ada), json: { connection_id: conn.id, zone_id: 'z1', changes: [{ op: 'create', after: { type: 'A', name: 'x', content: 'not-an-ip' } }] } });
++ assert.strictEqual(bad.status, 422);
++ const noop = await t.get('/api/v1/proposals', { bearer: network.userToken(ada), json: { connection_id: conn.id, zone_id: 'z1', changes: [{ op: 'update', record_id: www.id, after: {} }] } });
++ assert.strictEqual(noop.status, 422);
++ assert.match(noop.json().detail, /changes nothing/);
++ });
++
++ await check('another person sees none of it', async () => {
++ const r = await t.get(`/api/v1/proposals/${proposalId}`, { bearer: network.userToken(bob) });
++ assert.strictEqual(r.status, 404);
++ const z = await t.get('/api/v1/panel/zones', { bearer: network.userToken(bob) });
++ assert.deepStrictEqual(z.json().zones, []);
++ });
++
++ await check('the Action Center reads with domains.actions.read and decides with domains.proposal.decide', async () => {
++ const fresh = await t.get('/api/v1/proposals', { bearer: network.userToken(ada), json: { connection_id: conn.id, zone_id: 'z1', title: 'Add a CAA record', changes: [{ op: 'create', after: { type: 'CAA', name: '@', content: '0 issue "letsencrypt.org"' } }] } });
++ const id = fresh.json().id;
++ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.proposal.decide']) });
++ assert.strictEqual(noCap.status, 403);
++ const asPerson = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.userToken(ada) });
++ assert.strictEqual(asPerson.status, 401);
++ const list = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.actions.read']) });
++ assert.strictEqual(list.status, 200, list.text);
++ const items = list.json().items;
++ for (const item of items) { const v = contracts.validate('common.action-item', item); assert.ok(v.valid, JSON.stringify(v.errors)); }
++ const mine = items.find((x) => x.id === id);
++ assert.deepStrictEqual([mine.bucket, mine.actions], ['waiting', ['approve', 'decline', 'open']]);
++ const bobs = await t.get(`/api/v1/actions?subject=${bob.subject}`, { bearer: network.serviceToken(['domains.actions.read']) });
++ assert.deepStrictEqual(bobs.json().items, []);
++ const decided = await t.get(`/api/v1/proposals/${id}/decide`, { bearer: network.serviceToken(['domains.proposal.decide']), headers: { 'x-ov-subject': ada.subject }, json: { decision: 'approve' }, origin: '' });
++ assert.strictEqual(decided.status, 200, decided.text);
++ assert.strictEqual(decided.json().bucket, 'done');
++ assert.ok(contracts.validate('common.action-item', decided.json()).valid);
++ assert.ok(cf.records('z1').some((x) => x.type === 'CAA'));
++ const row = await db.maybe('SELECT decided_by FROM domains_proposals WHERE id = $1', [id]);
++ assert.strictEqual(row.decided_by, 'service:network');
++ });
++
++ await check('a revoked token marks the connection broken, in words', async () => {
++ cf.revoke();
++ const r = await t.get(`/panel/c/${conn.id}/sync`, { cookie, form: {} });
++ assert.strictEqual(r.status, 303);
++ assert.match(locationOf(r), /problem=provider\.forbidden/);
++ const row = await db.maybe('SELECT status, last_error FROM domains_connections WHERE id = $1', [conn.id]);
++ assert.strictEqual(row.status, 'broken');
++ const page = await t.get('/panel', { cookie });
++ assert.match(page.text, /is-broken/);
++ });
++
++ await check('removing a connection deletes its secret at Network and expires what was waiting', async () => {
++ const pending = await db.value("SELECT count(*)::int FROM domains_proposals WHERE connection_id = $1 AND status = 'pending'", [conn.id]);
++ assert.ok(pending >= 1);
++ const r = await t.get(`/panel/c/${conn.id}/remove`, { cookie, form: {} });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(network.state.secrets.size, 0);
++ assert.strictEqual(await db.value("SELECT count(*)::int FROM domains_proposals WHERE connection_id = $1 AND status = 'pending'", [conn.id]), 0);
++ const row = await db.maybe('SELECT status FROM domains_connections WHERE id = $1', [conn.id]);
++ assert.strictEqual(row.status, 'removed');
++ });
++
++ await check('a stop mid-apply is marked failed at boot, for its person to check', async () => {
++ await db.exec("UPDATE domains_proposals SET status = 'applying' WHERE id = $1", [proposalId]);
++ assert.strictEqual(await t.ctx.panel.recover(), 1);
++ const row = await db.maybe('SELECT status, error FROM domains_proposals WHERE id = $1', [proposalId]);
++ assert.strictEqual(row.status, 'failed');
++ assert.match(row.error, /stopped while applying/);
++ });
++
++ await t.close();
++ await network.close();
++ done();
++})();
ddiff --git a/test/routes.test.js b/test/routes.test.js
iindex d9e1f13..0002edb 100644
---- a/test/routes.test.js
++++ b/test/routes.test.js
@@@ -234,7 +234,7 @@ function stubs() {
await check('health is ok; readiness is ready while both upstreams answer', async () => {
const health = await t.get('/api/health');
assert.strictEqual(health.status, 200);
-- assert.deepStrictEqual(health.json(), { status: 'ok', service: 'openvibe-domains', version: '0.1.0' });
++ assert.deepStrictEqual(health.json(), { status: 'ok', service: 'openvibe-domains', version: require('../package.json').version });
const ready = await t.get('/api/ready');
assert.strictEqual(ready.status, 200);
const json = ready.json();
@@@ -242,6 +242,7 @@ function stubs() {
assert.strictEqual(json.status, 'ready');
assert.strictEqual(json.checks.host.status, 'ok');
assert.strictEqual(json.checks.tools.status, 'ok');
++ assert.strictEqual(json.checks.db.status, 'ok', 'the panel\'s database is a required check');
});
await check('readiness is degraded, not 503, when Host does not answer', async () => {