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)
Changes
@@@ -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@@@ -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-28The service skeleton: a stateless read-only front over OpenVibe.Host's price catalog and OpenVibe.Tools' public@@@ -26,6 +26,21 @@ There is no database, no sign-in, no session and no event stream. Nothing is stoAll 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`).++## APIEverything under `/api/v1`, read-only, CORS-open (`Access-Control-Allow-Origin: *`), cached publicly for five@@@ -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,@@@ -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;nnew file mode 100644@@@ -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++);@@@ -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"++ }}}}@@@ -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"}}@@@ -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); }++}@@@ -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-varsnnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 || '',++ },};}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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,nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 },};nnew file mode 100644@@@ -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 };@@@ -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 */ }++ },};}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();@@@ -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 () => {
@@ -14,3 +14,16 @@ TRUST_PROXY=2
@@ -14,3 +14,16 @@ TRUST_PROXY=2
# The shared per-IP budget for /api/v1 and /search, and the data clients' timeout.
# The shared per-IP budget for /api/v1 and /search, and the data clients' timeout.
# DOMAINS_RATE_LIMIT_PER_MIN=120
# DOMAINS_RATE_LIMIT_PER_MIN=120
# DOMAINS_UPSTREAM_TIMEOUT_MS=8000
# 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
@@ -2,6 +2,20 @@
@@ -2,6 +2,20 @@
What changed in OpenVibe.Domains, newest first.
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
## 0.1.0 — 2026-10-28
The service skeleton: a stateless read-only front over OpenVibe.Host's price catalog and OpenVibe.Tools' public
The service skeleton: a stateless read-only front over OpenVibe.Host's price catalog and OpenVibe.Tools' public
@@ -26,6 +26,21 @@ There is no database, no sign-in, no session and no event stream. Nothing is sto
@@ -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`):
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`.
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
## API
Everything under `/api/v1`, read-only, CORS-open (`Access-Control-Allow-Origin: *`), cached publicly for five
Everything under `/api/v1`, read-only, CORS-open (`Access-Control-Allow-Origin: *`), cached publicly for five
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"repository": "OpenVibers/OpenVibe.Domains",
"repository": "OpenVibers/OpenVibe.Domains",
- "version": "0.1.0",
+ "version": "0.2.0",
"stage": "product",
"stage": "product",
"code": true,
"code": true,
"wave": "v1",
"wave": "v1",
@@ -25,14 +25,16 @@
@@ -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",
"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",
"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",
"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": [
"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",
"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 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 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",
"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,
"capabilitiesRegistered": false,
"serviceManifestRegistered": false,
"serviceManifestRegistered": false,
@@ -2,14 +2,17 @@
@@ -2,14 +2,17 @@
## openvibe.domains — Nginx configuration (REFERENCE)
## openvibe.domains — Nginx configuration (REFERENCE)
##
##
## Deployed to /etc/nginx/sites-available/openvibe.domains.conf (ov-vhost-install) — keep them in sync and reload
## 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.
## 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_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_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 {
server {
listen 80;
listen 80;
@@ -66,10 +69,24 @@ server {
@@ -66,10 +69,24 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
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 {
location /api/v1 {
- limit_except GET { deny all; }
+ limit_except GET POST { deny all; }
limit_req zone=domains_api burst=20 nodelay;
limit_req zone=domains_api burst=20 nodelay;
limit_req_status 429;
limit_req_status 429;
proxy_pass http://127.0.0.1:5070;
proxy_pass http://127.0.0.1:5070;
@@ -89,9 +106,19 @@ server {
@@ -89,9 +106,19 @@ server {
proxy_pass http://127.0.0.1:5070;
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) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5070/metrics) ──
location ~* ^/metrics(/|$) { return 404; }
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 ──────
# ── Pages, discovery, static assets, release.json ──────
location / {
location / {
proxy_pass http://127.0.0.1:5070;
proxy_pass http://127.0.0.1:5070;
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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
+);
@@ -1,26 +1,38 @@
@@ -1,26 +1,38 @@
{
{
"name": "openvibe-domains",
"name": "openvibe-domains",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-domains",
"name": "openvibe-domains",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
+ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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-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": {
"engines": {
"node": ">=22.0.0"
"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": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -171,6 +183,25 @@
@@ -171,6 +183,25 @@
"node": ">= 0.6"
"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": {
"node_modules/cookie-signature": {
"version": "1.0.7",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -770,9 +801,9 @@
@@ -770,9 +801,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -840,6 +871,134 @@
@@ -840,6 +871,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"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": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1067,6 +1226,15 @@
@@ -1067,6 +1226,15 @@
"url": "https://github.com/sponsors/ljharb"
"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": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1124,6 +1292,15 @@
@@ -1124,6 +1292,15 @@
"engines": {
"engines": {
"node": ">= 0.8"
"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"
+ }
}
}
}
}
}
}
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
"name": "openvibe-domains",
"name": "openvibe-domains",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"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",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -15,15 +15,20 @@
@@ -15,15 +15,20 @@
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
+ "cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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-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": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
@@ -128,3 +128,120 @@ html[data-ov-motion=reduced] .dm-bar span { transform: none !important; transiti
@@ -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 > 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 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; }
.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); }
+}
@@ -4,19 +4,23 @@
@@ -4,19 +4,23 @@
* OpenVibe.Domains — Express app factory.
* OpenVibe.Domains — Express app factory.
*
*
* /, /search, /tld/:tld, /d/:domain the pages (http/pages.js)
* /, /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
* /api/health, /api/ready liveness and truthful readiness
* /release.json, /release-metrics the release manifest (openvibe-shared/release)
* /release.json, /release-metrics the release manifest (openvibe-shared/release)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /shared/* this service's own pinned Frame files
* /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 path = require('path');
const express = require('express');
const express = require('express');
const helmet = require('helmet');
const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const serve = require('openvibe-shared/serve');
const serve = require('openvibe-shared/serve');
@@ -29,14 +33,24 @@ const { createTools } = require('./data/tools');
@@ -29,14 +33,24 @@ const { createTools } = require('./data/tools');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
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;
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 config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
@@ -44,7 +58,16 @@ function createApp(opts = {}) {
@@ -44,7 +58,16 @@ function createApp(opts = {}) {
const tools = opts.tools || createTools({ baseUrl: config.toolsUrl, fetchImpl, log, timeoutMs: config.timeoutMs });
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 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -58,7 +81,7 @@ function createApp(opts = {}) {
@@ -58,7 +81,7 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-domains', version: VERSION }));
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.get('/api/ready', readiness.handler);
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
@@ -76,6 +99,7 @@ function createApp(opts = {}) {
@@ -76,6 +99,7 @@ function createApp(opts = {}) {
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
baseUri: ["'self'"],
+ // Sign-in goes through Network's authorize page; a form never posts anywhere else.
formAction: ["'self'", 'https://openvibe.network'],
formAction: ["'self'", 'https://openvibe.network'],
},
},
},
},
@@ -85,6 +109,17 @@ function createApp(opts = {}) {
@@ -85,6 +109,17 @@ function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
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).
// 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('/shared', serve.handler());
app.use('/css', express.static(path.join(__dirname, '..', 'public', 'css'), { maxAge: '365d', immutable: true, fallthrough: false }));
app.use('/css', express.static(path.join(__dirname, '..', 'public', 'css'), { maxAge: '365d', immutable: true, fallthrough: false }));
@@ -108,6 +143,8 @@ function createApp(opts = {}) {
@@ -108,6 +143,8 @@ function createApp(opts = {}) {
app.use('/search', limiter);
app.use('/search', limiter);
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
+ const panelRoutes = createPanelRoutes({ config, panel, principal, log });
+ app.use('/api/v1', panelRoutes.api);
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => {
app.use('/api', (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
@@ -115,6 +152,7 @@ function createApp(opts = {}) {
@@ -115,6 +152,7 @@ function createApp(opts = {}) {
});
});
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
+ app.use(panelRoutes.pages);
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -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 };
@@ -20,6 +20,7 @@ function load(env = process.env) {
@@ -20,6 +20,7 @@ function load(env = process.env) {
const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.domains' : `http://localhost:${port}`));
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 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 toolsUrl = trim(env.OV_TOOLS_INTERNAL_URL || toolsManifest.internalOrigin || 'https://openvibe.tools');
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
return {
return {
service: 'domains',
service: 'domains',
@@ -30,12 +31,41 @@ function load(env = process.env) {
@@ -30,12 +31,41 @@ function load(env = process.env) {
isProduction,
isProduction,
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
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,
hostUrl,
toolsUrl,
toolsUrl,
// Per-IP budget for /api/v1 and /search (express-rate-limit, one window).
// 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)),
rateLimitPerMinute: Math.max(1, int(env.DOMAINS_RATE_LIMIT_PER_MIN, 120)),
timeoutMs: Math.max(100, int(env.DOMAINS_UPSTREAM_TIMEOUT_MS, 8000)),
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 || '',
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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 };
@@ -1,11 +1,13 @@
@@ -1,11 +1,13 @@
'use strict';
'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 { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
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
* The process stop (openvibe-sdk/service): stop taking connections, drain the requests in
@@ -19,16 +21,30 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
@@ -19,16 +21,30 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
exit,
exit,
signals,
signals,
deadlineMs: 10_000,
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() {
async function start() {
const { app, ctx } = await createApp();
const { app, ctx } = await createApp();
const { config } = ctx;
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, () => {
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})`);
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;
server.keepAliveTimeout = 65_000;
createLifecycle({ server, ctx });
createLifecycle({ server, ctx });
return { server, ctx };
return { server, ctx };
@@ -1,20 +1,22 @@
@@ -1,20 +1,22 @@
'use strict';
'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');
const { createReadiness } = require('openvibe-shared/ready');
-function createServiceReadiness({ host, tools, release = null }) {
+function createServiceReadiness({ host, tools, store = null, release = null }) {
return createReadiness({
return createReadiness({
service: 'domains',
service: 'domains',
release,
release,
checks: [
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',
name: 'host',
required: false,
required: false,
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,361 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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 };
@@ -52,8 +52,9 @@ const tldHref = (tld) => `/tld/${encodeURIComponent(String(tld).replace(/^\./, '
@@ -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 latest = (rows) => rows.map((r) => r.checked_at).filter(Boolean).sort().pop() || null;
const NAV = [
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: 'Every ending', href: '/tld', icon: 'fa-list' },
+ { label: 'Control panel', href: '/panel', icon: 'fa-server' },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
];
@@ -63,7 +64,12 @@ function layout({ title, description, canonical, robots = 'index, follow', body,
@@ -63,7 +64,12 @@ function layout({ title, description, canonical, robots = 'index, follow', body,
service: 'domains',
service: 'domains',
apiBase: NETWORK,
apiBase: NETWORK,
links: NAV.map((l) => ({ ...l, active: l.href === active })),
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 },
search: { page: '/search', label: NAME },
history: { type: 'page', title: title || NAME },
history: { type: 'page', title: title || NAME },
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -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 };
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
const http = require('http');
const http = require('http');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { createApp } = require('../server/app');
const { createApp } = require('../server/app');
+const { createStore, MIGRATIONS } = require('../server/db');
+const { createTestDb } = require('openvibe-sdk/testing');
let failures = 0;
let failures = 0;
@@ -25,13 +27,21 @@ function done() {
@@ -25,13 +27,21 @@ function done() {
process.exit(failures ? 1 : 0);
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 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 server = await new Promise((resolve) => {
const s = http.createServer(built.app);
const s = http.createServer(built.app);
s.listen(0, '127.0.0.1', () => resolve(s));
s.listen(0, '127.0.0.1', () => resolve(s));
@@ -39,14 +49,26 @@ async function boot({ config, host, tools, release } = {}) {
@@ -39,14 +49,26 @@ async function boot({ config, host, tools, release } = {}) {
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
async function get(path, opts = {}) {
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();
const text = await res.text();
return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
}
}
return {
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 */ }
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -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();
+})();
@@ -234,7 +234,7 @@ function stubs() {
@@ -234,7 +234,7 @@ function stubs() {
await check('health is ok; readiness is ready while both upstreams answer', async () => {
await check('health is ok; readiness is ready while both upstreams answer', async () => {
const health = await t.get('/api/health');
const health = await t.get('/api/health');
assert.strictEqual(health.status, 200);
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');
const ready = await t.get('/api/ready');
assert.strictEqual(ready.status, 200);
assert.strictEqual(ready.status, 200);
const json = ready.json();
const json = ready.json();
@@ -242,6 +242,7 @@ function stubs() {
@@ -242,6 +242,7 @@ function stubs() {
assert.strictEqual(json.status, 'ready');
assert.strictEqual(json.status, 'ready');
assert.strictEqual(json.checks.host.status, 'ok');
assert.strictEqual(json.checks.host.status, 'ok');
assert.strictEqual(json.checks.tools.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 () => {
await check('readiness is degraded, not 503, when Host does not answer', async () => {