openvibers/openvibe.wiki Commits
Wiki moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -18,6 +18,14 @@ NODE_ENV=development#WIKI_LIMITS_HOUR=3000# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh wiki: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.WIKI_DB_PATH=./data/wiki.db# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: wiki++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176@@@ -26,13 +26,13 @@ its publication state; the packages supply the mechanics.## Owns--The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PATH`):++The ten authority tables of §15.13, in Wiki's own PostgreSQL database (`ov_wiki` on the host's data role, ADR-035; schema in [migrations/](migrations/)):| Table | What | Mechanics ||---|---|---|| `wiki_spaces` | spaces: official / user, public / members / private | Wiki || `wiki_pages` | canonical page identity, slug, tree (`parent_id`), publication state, published revision | Wiki |--| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE abort in SQLite triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |++| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE refused by PL/pgSQL triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` || `wiki_page_links` | `[[links]]` of every revision (backlinks, red links) | Wiki || `wiki_page_redirects` | every historical path of a page or space (301; 410 when gone) | `openvibe-publishing/seo`, prefix `wiki_page` || `wiki_citations` | sources per (page, revision), append-only | `openvibe-publishing/citations`, prefix `wiki` |@@@ -260,18 +260,23 @@ fnm exec --using=22.22.1 npm installcp .env.example .envfnm exec --using=22.22.1 npm run seed # the official "OpenVibe" spacefnm exec --using=22.22.1 npm run dev # http://localhost:4800--fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases, no network++fnm exec --using=22.22.1 npm test # every test/*.test.js on PGlite (PostgreSQL in-process), no network++eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm run test:pg # the same through PgBouncer```++Without `DATABASE_URL`, development uses an embedded PGlite database in `data/pglite`.++Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit--[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database--`/var/lib/openvibe-wiki/wiki.db`, nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).++[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database `ov_wiki`++(`DATABASE_URL` through PgBouncer; migrations on `DATABASE_DIRECT_URL`), nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).## Depends on--- `openvibe-publishing` v0.4.0, `openvibe-contracts` v0.64.0, `openvibe-shared` v1.25.0 (chrome,-- release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.12.0 (auth, events-- outbox, per-actor limits) — pinned release tarballs.++- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).++- `openvibe-publishing` v1.0.0 (async PostgreSQL stores), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0++ (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.19.0 (db, auth, PostgreSQL++ events outbox, per-actor limits, testing) — pinned release tarballs.- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer ofthe index events). All but the Network key are optional at runtime and degrade to explicit failure@@@ -341,7 +346,10 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.Production deploys with `sudo ovhost deploy wiki` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.wiki`, install on a lockfile change, restart, wait for `/api/ready`).The unit is `openvibe-wiki.service` on `127.0.0.1:4800`, the env file `/etc/openvibe/wiki.env`. The database is--`/var/lib/openvibe-wiki/wiki.db`; nginx serves `openvibe.wiki` from++`ov_wiki` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh wiki` writes its settings);++the release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`: import, verification, and a rehearsal mode `--pglite`), run while the service is stopped;++the old `/var/lib/openvibe-wiki/wiki.db` stays read-only for 7 days as the rollback. nginx serves `openvibe.wiki` from[deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after therestart; afterwards `sudo ovhost rollback wiki --to <sha>`. Nothing blocks a rollback: the schema@@@ -4,12 +4,25 @@"code": true,"wave": "W16","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express, better-sqlite3",++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4800,"domain": "openvibe.wiki",-- "contracts": "openvibe-contracts v0.64.0",-- "packages": ["openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],-- "capabilities": ["wiki.space.create", "wiki.page.create", "wiki.page.read", "wiki.revision.propose", "wiki.revision.publish", "wiki.revision.revert", "wiki.citation.attach", "wiki.search.query"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-publishing v1.0.0",++ "openvibe-shared v1.25.0",++ "openvibe-sdk v0.19.0"++ ],++ "capabilities": [++ "wiki.space.create",++ "wiki.page.create",++ "wiki.page.read",++ "wiki.revision.propose",++ "wiki.revision.publish",++ "wiki.revision.revert",++ "wiki.citation.attach",++ "wiki.search.query"++ ],"capabilitiesRegistered": true,"deployed": true,"deployment": "Public at https://openvibe.wiki since 2026-09-23 (~03:00 UTC): deployed with `sudo ovhost deploy wiki`, /opt/openvibe.wiki, unit openvibe-wiki on 127.0.0.1:4800 behind the openvibe.wiki nginx vhost, principal wiki. On 2026-09-23 production held 1 space and the 10 seed pages (1 revision each), 0 person reviews, so every page was noindex and nothing public was in Search. Restore drill passed 2026-09-23.",@@@ -30,5 +43,6 @@"/api/v1 by capability","SDK outbox events and Search index documents","per-actor limits (openvibe-sdk/limits)"-- ]++ ],++ "database": "PostgreSQL ov_wiki (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"}@@@ -3,8 +3,8 @@ Description=OpenVibe.Wiki — wiki spaces, revisions, citations and discussion (# Network (openvibe-network) signs the tokens this service verifies. Wiki starts without it and# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.# Events, Community, Sources and Media are optional: each degrades to an explicit failure state.--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,347 @@++-- phase: expand++-- OpenVibe.Wiki on PostgreSQL (ADR-035, roadmap WS-X2): the ten authority tables of roadmap 15.13 as they were on++-- SQLite, typed for PostgreSQL (epoch milliseconds stay bigint; 0/1 flags stay integer so the API is unchanged),++-- then the openvibe-publishing stores and the openvibe-sdk outbox. Identifier columns sort like SQLite (COLLATE "C").++-- Generated once on 2026-09-28; never edited after it runs (a change is the next migration).++++CREATE TABLE wiki_spaces (++ id text COLLATE "C" PRIMARY KEY,++ slug text COLLATE "C" NOT NULL UNIQUE,++ name text NOT NULL,++ description text,++ kind text NOT NULL CHECK (kind IN ('official','user')),++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),++ owner text COLLATE "C" NOT NULL,++ created_by text NOT NULL,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ deleted_at bigint++);++++CREATE TABLE wiki_pages (++ id text COLLATE "C" PRIMARY KEY,++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),++ slug text COLLATE "C" NOT NULL,++ title text NOT NULL,++ parent_id text COLLATE "C" REFERENCES wiki_pages(id),++ position integer NOT NULL DEFAULT 0,++ state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),++ noindex integer NOT NULL DEFAULT 0,++ published_revision integer,++ published_at bigint,++ revision_published_at bigint,++ created_by text NOT NULL,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ UNIQUE (space_id, slug)++);++CREATE INDEX wiki_pages_parent ON wiki_pages (space_id, parent_id, position);++CREATE INDEX wiki_pages_children ON wiki_pages (parent_id) WHERE parent_id IS NOT NULL;++CREATE INDEX wiki_pages_updated ON wiki_pages (updated_at);++CREATE INDEX wiki_pages_recent ON wiki_pages (revision_published_at DESC, id) WHERE state = 'published';++++CREATE TABLE wiki_page_links (++ from_page_id text COLLATE "C" NOT NULL,++ from_revision integer NOT NULL,++ target_space text COLLATE "C" NOT NULL,++ target_slug text COLLATE "C" NOT NULL,++ label text,++ PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)++);++CREATE INDEX wiki_page_links_target ON wiki_page_links (target_space, target_slug);++++CREATE TABLE wiki_infobox_values (++ page_id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ position integer NOT NULL,++ key text COLLATE "C" NOT NULL,++ label text NOT NULL,++ type text NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),++ value_text text,++ value_number double precision,++ PRIMARY KEY (page_id, revision, key)++);++++CREATE TABLE wiki_permissions (++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),++ subject text COLLATE "C" NOT NULL,++ role text COLLATE "C" NOT NULL CHECK (role IN ('owner','editor','viewer')),++ granted_by text,++ granted_at bigint NOT NULL,++ PRIMARY KEY (space_id, subject)++);++CREATE INDEX wiki_permissions_subject ON wiki_permissions (subject);++++-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as the space or++-- role change, drained by server/integrations/projects-module.js.++CREATE TABLE wiki_module_dirty (++ subject text COLLATE "C" PRIMARY KEY,++ marked_at bigint NOT NULL++);++CREATE TABLE wiki_module_pushes (++ subject text COLLATE "C" PRIMARY KEY,++ hash text NOT NULL,++ pushed_at bigint NOT NULL++);++++CREATE TABLE wiki_watchers (++ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),++ subject text COLLATE "C" NOT NULL,++ created_at bigint NOT NULL,++ PRIMARY KEY (page_id, subject)++);++++CREATE TABLE wiki_ai_proposals (++ id text COLLATE "C" PRIMARY KEY,++ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),++ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),++ revision integer NOT NULL,++ base_revision integer NOT NULL,++ workflow_id text NOT NULL,++ run_id text NOT NULL,++ stub_provider integer NOT NULL DEFAULT 0,++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),++ proposed_by text NOT NULL,++ note text,++ reviewed_by text,++ review_note text,++ reviewed_at bigint,++ created_at bigint NOT NULL,++ UNIQUE (page_id, revision)++);++CREATE INDEX wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);++CREATE INDEX wiki_ai_proposals_page ON wiki_ai_proposals (page_id, created_at DESC);++++-- Who attached a Media object to a page, and what Media said about it at that moment. One row per attachment,++-- written with it and never changed.++CREATE TABLE wiki_attachment_origins (++ attachment_id bigint PRIMARY KEY,++ page_id text COLLATE "C" NOT NULL,++ media_id text COLLATE "C" NOT NULL,++ attached_by text NOT NULL,++ media_owner text,++ media_visibility text NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),++ attached_at bigint NOT NULL++);++CREATE INDEX wiki_attachment_origins_page ON wiki_attachment_origins (page_id);++++-- openvibe-publishing/revisions (prefix wiki_page)++CREATE TABLE IF NOT EXISTS wiki_page_revisions (++ id text PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL,++ number integer NOT NULL CHECK (number >= 1),++ parent_id text,++ parent_number integer,++ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),++ reverted_to integer,++ content text NOT NULL,++ fields jsonb NOT NULL DEFAULT '{}',++ meta jsonb NOT NULL DEFAULT '{}',++ content_hash text NOT NULL,++ author text,++ message text,++ created_at bigint NOT NULL++);++CREATE UNIQUE INDEX IF NOT EXISTS wiki_page_revisions_entity_num ON wiki_page_revisions (entity_id, number);++CREATE TABLE IF NOT EXISTS wiki_page_drafts (++ entity_id text COLLATE "C" NOT NULL,++ owner text COLLATE "C" NOT NULL,++ base_revision integer NOT NULL,++ content text NOT NULL,++ fields jsonb NOT NULL DEFAULT '{}',++ meta jsonb NOT NULL DEFAULT '{}',++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (entity_id, owner)++);++CREATE INDEX IF NOT EXISTS wiki_page_drafts_updated ON wiki_page_drafts (entity_id, updated_at DESC, owner);++CREATE TABLE IF NOT EXISTS wiki_page_revision_purges (++ entity_id text COLLATE "C" PRIMARY KEY,++ reason text NOT NULL,++ purged_by text,++ purged_at bigint NOT NULL++);++CREATE OR REPLACE FUNCTION wiki_page_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF TG_OP = 'UPDATE' THEN++ RAISE EXCEPTION 'wiki_page_revisions rows are immutable' USING ERRCODE = 'restrict_violation';++ END IF;++ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.entity_id) THEN++ RAISE EXCEPTION 'wiki_page_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE OR REPLACE TRIGGER wiki_page_revisions_no_update BEFORE UPDATE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();++CREATE OR REPLACE TRIGGER wiki_page_revisions_no_delete BEFORE DELETE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();++++-- openvibe-publishing/citations (prefix wiki)++CREATE TABLE IF NOT EXISTS wiki_citations (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL,++ revision integer NOT NULL CHECK (revision >= 1),++ anchor text,++ source_item_id text COLLATE "C",++ url text,++ title text,++ retrieved_at timestamptz,++ quote_text text,++ quote_start integer,++ quote_end integer,++ license_note text,++ carried_from bigint REFERENCES wiki_citations(id),++ attached_by text,++ attached_at bigint NOT NULL,++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)++);++CREATE INDEX IF NOT EXISTS wiki_citations_rev ON wiki_citations (entity_id, revision, id);++CREATE INDEX IF NOT EXISTS wiki_citations_source ON wiki_citations (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS wiki_citations_carried ON wiki_citations (carried_from) WHERE carried_from IS NOT NULL;++CREATE TABLE IF NOT EXISTS wiki_citation_purges (++ entity_id text COLLATE "C" PRIMARY KEY,++ reason text NOT NULL,++ purged_by text,++ purged_at bigint NOT NULL++);++CREATE OR REPLACE FUNCTION wiki_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF TG_OP = 'UPDATE' THEN++ RAISE EXCEPTION 'wiki_citations rows are immutable' USING ERRCODE = 'restrict_violation';++ END IF;++ IF NOT EXISTS (SELECT 1 FROM wiki_citation_purges WHERE entity_id = OLD.entity_id) THEN++ RAISE EXCEPTION 'wiki_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE OR REPLACE TRIGGER wiki_citations_no_update BEFORE UPDATE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();++CREATE OR REPLACE TRIGGER wiki_citations_no_delete BEFORE DELETE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();++++-- openvibe-publishing/seo (prefix wiki_page)++CREATE TABLE IF NOT EXISTS wiki_page_redirects (++ from_path text COLLATE "C" PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL,++ reason text,++ created_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS wiki_page_redirects_entity ON wiki_page_redirects (entity_id, created_at, from_path);++++-- openvibe-publishing/authorship (prefix wiki_page)++CREATE TABLE IF NOT EXISTS wiki_page_reviews (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ reviewer text NOT NULL,++ decision text NOT NULL CHECK (decision IN ('approved','rejected')),++ note text,++ reviewed_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS wiki_page_reviews_rev ON wiki_page_reviews (entity_id, revision, id);++CREATE INDEX IF NOT EXISTS wiki_page_reviews_entity ON wiki_page_reviews (entity_id, id);++CREATE OR REPLACE FUNCTION wiki_page_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF TG_OP = 'UPDATE' THEN++ RAISE EXCEPTION 'wiki_page_reviews rows are immutable' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE OR REPLACE TRIGGER wiki_page_reviews_no_update BEFORE UPDATE ON wiki_page_reviews FOR EACH ROW EXECUTE FUNCTION wiki_page_reviews_guard();++++-- openvibe-publishing/media (prefix wiki_page)++CREATE TABLE IF NOT EXISTS wiki_page_attachments (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ entity_id text COLLATE "C" NOT NULL,++ revision integer,++ media_id text COLLATE "C" NOT NULL,++ role text NOT NULL DEFAULT 'inline',++ variant text,++ alt text,++ caption text,++ position integer NOT NULL DEFAULT 0,++ state text NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),++ broken_reason text CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),++ checked_at bigint,++ created_at bigint NOT NULL,++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))++);++CREATE INDEX IF NOT EXISTS wiki_page_attachments_entity ON wiki_page_attachments (entity_id, position, id);++CREATE INDEX IF NOT EXISTS wiki_page_attachments_media ON wiki_page_attachments (media_id, entity_id);++++-- openvibe-publishing/discussion (prefix wiki)++CREATE TABLE IF NOT EXISTS wiki_discussion_refs (++ entity_id text COLLATE "C" PRIMARY KEY,++ thread_id text NOT NULL,++ ref jsonb NOT NULL,++ resolved_at bigint NOT NULL++);++++-- openvibe-publishing/schedule (prefix wiki)++CREATE TABLE IF NOT EXISTS wiki_schedule_jobs (++ id text COLLATE "C" PRIMARY KEY,++ idem_key text NOT NULL UNIQUE,++ entity_id text COLLATE "C" NOT NULL,++ action text NOT NULL CHECK (action IN ('publish','unpublish')),++ revision integer,++ run_at bigint NOT NULL,++ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),++ attempts integer NOT NULL DEFAULT 0,++ lease_owner text,++ lease_until bigint,++ last_error text,++ result jsonb,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_due ON wiki_schedule_jobs (run_at, id) WHERE status = 'pending';++CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_lease ON wiki_schedule_jobs (lease_until) WHERE status = 'running';++CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_entity ON wiki_schedule_jobs (entity_id, run_at, id);++++-- openvibe-publishing/index-hooks (prefix wiki)++CREATE TABLE IF NOT EXISTS wiki_index_revisions (++ owner text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ id text COLLATE "C" NOT NULL,++ revision integer NOT NULL,++ hash text NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (owner, type, id)++);++++++-- Immutable side tables (the SQLite RAISE(ABORT) triggers), as PL/pgSQL with the same messages.++CREATE FUNCTION wiki_rows_immutable() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ RAISE EXCEPTION '% rows are immutable', TG_TABLE_NAME USING ERRCODE = 'restrict_violation';++END++$$;++CREATE FUNCTION wiki_infobox_values_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN++ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id) THEN++ RAISE EXCEPTION 'wiki_infobox_values rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';++ END IF;++ RETURN OLD;++END++$$;++CREATE TRIGGER wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();++CREATE TRIGGER wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_infobox_values_no_delete();++CREATE TRIGGER wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();++CREATE TRIGGER wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS wiki_event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS wiki_event_outbox_due ON wiki_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS wiki_event_outbox_sent ON wiki_event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -14,15 +14,33 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.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.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -224,6 +242,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -306,6 +333,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -730,6 +766,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -794,12 +873,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -999,9 +1090,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.64.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",-- "integrity": "sha512-WoTx6IpmcWtKM+DMQDYv1qnZOO9wZJbG6wKd5VNjF7H5gOXsFDPIV9UWqf4FHK3dxrg2cVvQxOwTiPxsztqObA==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1015,15 +1106,15 @@}},"node_modules/openvibe-publishing": {-- "version": "0.4.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==","license": "MIT","engines": {"node": ">=20"},"peerDependencies": {-- "better-sqlite3": ">=11",++ "openvibe-sdk": ">=0.15.0","openvibe-shared": ">=1.5.0"},"peerDependenciesMeta": {@@@ -1033,12 +1124,32 @@}},"node_modules/openvibe-sdk": {-- "version": "0.12.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "version": "0.19.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",++ "integrity": "sha512-5hNT9KVxtkLeGNYirqY1+n939lga/8XPbmrtqISQTNKaXNsS1AHhf4TBwWe+ZAamuCHUADJZu5Ov3OyZLN0nIA==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {@@@ -1065,6 +1176,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "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.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "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/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1188,6 +1427,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1403,6 +1663,21 @@"simple-concat": "^1.0.0"}},++ "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/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1530,6 +1805,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "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"++ }}}}@@@ -8,9 +8,15 @@"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js","seed": "node scripts/seed.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "test:pg": "WIKI_TEST_STORE=pg node test/run.js",++ "migrate-to-postgres": "node scripts/migrate-to-postgres.js"},-- "keywords": ["openvibe", "wiki", "publishing"],++ "keywords": [++ "openvibe",++ "wiki",++ "publishing"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.Wiki",@@@ -20,12 +26,17 @@"dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",++ "pg": "^8.23.0"},"engines": {"node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}}nnew file mode 100644@@@ -0,0 +1,31 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Wiki's SQLite database (WIKI_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure++ * is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row++ * counts and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. In++ * production it runs once, from the new release's directory, as the service user with the service's++ * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.++ *++ * Every table keeps its name and columns. What the SQLite file holds that PostgreSQL does not take:++ * the SDK's SQLite outbox bookkeeping becomes the PostgreSQL outbox's (wiki_event_outbox: same rows; lease++ * columns start empty), and SQLite's own tables (sqlite_sequence) are not copied.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const { load } = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = load();++ runSqliteMigration({ service: 'wiki', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -7,14 +7,14 @@* npm run seed -- --draft # create as drafts, publish later from the page settings* npm run seed -- --file other.json*-- * Uses WIKI_DB_PATH like the server. Events for the new pages go to the outbox and are delivered++ * Uses DATABASE_URL (and DATABASE_DIRECT_URL to migrate) like the server. Events for the new pages go to the outbox and are delivered* by the server's relay (EVENTS_URL) the next time it runs.*/require('dotenv').config();const fs = require('fs');const path = require('path');const { load } = require('../server/config');--const { openDb, createStores } = require('../server/db');++const { openDb, migrate, createStores } = require('../server/db');const { createPlatform } = require('../server/integrations/platform');const { createWikiService } = require('../server/wiki/service');const { seed } = require('../server/wiki/seed');@@@ -23,10 +23,13 @@ const args = process.argv.slice(2);const fileArg = args.indexOf('--file');const file = fileArg >= 0 ? args[fileArg + 1] : path.join(__dirname, '..', 'seeds', 'openvibe.json');const config = load();--const db = openDb(config.dbPath);--const stores = createStores(db);--const platform = createPlatform({ config, db });--const svc = createWikiService({ db, stores, outbox: platform.outbox, config });--const out = seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });--console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${platform.outbox.pending()} event(s) waiting in the outbox`);--db.close();++(async () => {++ const db = openDb(config);++ await migrate(config, { serving: db });++ const stores = createStores(db);++ const platform = createPlatform({ config, db });++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config });++ const out = await seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });++ console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${await platform.outbox.pending()} event(s) waiting in the outbox`);++ await db.close();++})().catch((err) => { console.error(`[seed] ${err.message}`); process.exit(1); });@@@ -22,8 +22,10 @@ const { createActorLimits } = require('./http/actor-limits');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;--function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {++function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {const app = express();++ // Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.++ const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.@@@ -33,7 +35,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, raconst metrics = require('openvibe-shared/metrics').instrument(app, { service: 'wiki', release: release.release });metrics.registry.gauge({name: 'wiki_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],-- collect: () => [{ labels: { state: 'pending' }, value: platform.outbox.pending() }, { labels: { state: 'rejected' }, value: platform.outbox.rejected() }],++ collect: () => { const c = platform.outbox.counts(); return [{ labels: { state: 'pending' }, value: c.pending }, { labels: { state: 'rejected' }, value: c.rejected }]; },});app.use((req, res, next) => {@@@ -70,14 +72,21 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, raconst ready = createReadiness({service: 'wiki', release: release.release,checks: [-- { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'wiki_pages'").get().n === 1 || 'wiki tables missing' },++ // A real round trip, naming the store that answered (postgresql / pglite), and the schema present.++ { name: 'db', required: true, check: async () => {++ const r = await db.ready();++ if (!r.ok) return r.error;++ const n = await db.value("SELECT count(*)::int FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'wiki_pages'");++ return n === 1 ? { ok: true, detail: r.detail } : 'wiki tables missing (migrations did not run)';++ } },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{ name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },{ name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },{ name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },{ name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: Sources item citations are refused (URL citations work)' },{ name: 'media', required: false, check: () => platform.media.configured || 'not configured: attachments are not checked against Media' },],-- details: () => ({ outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() } }),++ details: async () => ({ outbox: await platform.outbox.refreshCounts() }),});app.get('/api/ready', ready.handler);@@@ -89,7 +98,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, raapp.use('/api/', limiter(60000, 300));// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).-- const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });++ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));@@@ -30,6 +30,10 @@ function load(env = process.env) {minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),},++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:wiki:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.WIKI_DB_PATH || './data/wiki.db',// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).@@@ -1,6 +1,11 @@'use strict';/**-- * Wiki's own SQLite database (one per service, WAL, created on boot, idempotent).++ * Wiki's own PostgreSQL database (ADR-035, roadmap WS-X2), through openvibe-sdk/db: async, pooled, one dialect.++ *++ * The schema is migrations/NNNN_*.sql (expand/migrate/contract, ADR-028), applied at boot on the owner's direct++ * connection (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in++ * transaction mode). Ambient transactions (openvibe-sdk ≥ 0.18): inside db.tx(fn), plain db calls — the prepared++ * statements in server/wiki/service.js and the openvibe-publishing stores — join the transaction.** The ten authority tables of roadmap §15.13:* wiki_spaces spaces (official editorial spaces and user spaces)@@@ -13,15 +18,16 @@* wiki_permissions space roles: owner / editor / viewer* wiki_watchers who watches a page (notified through an event, never email)* wiki_ai_proposals AI-authored draft revisions awaiting a person's decision++ * plus the Publishing helper tables under the same prefixes, wiki_attachment_origins, wiki_module_dirty/_pushes++ * and wiki_event_outbox (openvibe-sdk/events PostgreSQL outbox).*-- * The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,-- * wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,-- * wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.-- * wiki_attachment_origins records who attached each Media object and the read rights checked then.++ * openDb(config) the serving handle: DATABASE_URL; in development without it, an embedded PGlite++ * database in data/pglite (one process, nothing to install)++ * migrate(config, {serving}) apply migrations/ with the owner role (DATABASE_DIRECT_URL), then close it*/const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');const { createRevisionStore } = require('openvibe-publishing/revisions');const { createCitationStore } = require('openvibe-publishing/citations');const { createAttachmentStore } = require('openvibe-publishing/media');@@@ -31,183 +37,30 @@ const { createReviewLog } = require('openvibe-publishing/authorship');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');const seo = require('openvibe-publishing/seo');--const SCHEMA = `--CREATE TABLE IF NOT EXISTS wiki_spaces (-- id TEXT PRIMARY KEY,-- slug TEXT NOT NULL UNIQUE,-- name TEXT NOT NULL,-- description TEXT,-- kind TEXT NOT NULL CHECK (kind IN ('official','user')),-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),-- owner TEXT NOT NULL,-- created_by TEXT NOT NULL,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- deleted_at INTEGER--);----CREATE TABLE IF NOT EXISTS wiki_pages (-- id TEXT PRIMARY KEY,-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),-- slug TEXT NOT NULL,-- title TEXT NOT NULL,-- parent_id TEXT REFERENCES wiki_pages(id),-- position INTEGER NOT NULL DEFAULT 0,-- state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),-- noindex INTEGER NOT NULL DEFAULT 0,-- published_revision INTEGER,-- published_at INTEGER,-- revision_published_at INTEGER,-- created_by TEXT NOT NULL,-- created_at INTEGER NOT NULL,-- updated_at INTEGER NOT NULL,-- UNIQUE (space_id, slug)--);--CREATE INDEX IF NOT EXISTS wiki_pages_parent ON wiki_pages (space_id, parent_id, position);--CREATE INDEX IF NOT EXISTS wiki_pages_updated ON wiki_pages (updated_at);----CREATE TABLE IF NOT EXISTS wiki_page_links (-- from_page_id TEXT NOT NULL,-- from_revision INTEGER NOT NULL,-- target_space TEXT NOT NULL,-- target_slug TEXT NOT NULL,-- label TEXT,-- PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)--);--CREATE INDEX IF NOT EXISTS wiki_page_links_target ON wiki_page_links (target_space, target_slug);----CREATE TABLE IF NOT EXISTS wiki_infobox_values (-- page_id TEXT NOT NULL,-- revision INTEGER NOT NULL,-- position INTEGER NOT NULL,-- key TEXT NOT NULL,-- label TEXT NOT NULL,-- type TEXT NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),-- value_text TEXT,-- value_number REAL,-- PRIMARY KEY (page_id, revision, key)--);--CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values--BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are immutable'); END;--CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values--WHEN NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id)--BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are never deleted outside a recorded purge'); END;--CREATE TRIGGER IF NOT EXISTS wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links--BEGIN SELECT RAISE(ABORT, 'wiki_page_links rows are immutable'); END;----CREATE TABLE IF NOT EXISTS wiki_permissions (-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),-- subject TEXT NOT NULL,-- role TEXT NOT NULL CHECK (role IN ('owner','editor','viewer')),-- granted_by TEXT,-- granted_at INTEGER NOT NULL,-- PRIMARY KEY (space_id, subject)--);--CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);----CREATE TABLE IF NOT EXISTS wiki_module_dirty (-- subject TEXT PRIMARY KEY,-- marked_at INTEGER NOT NULL--);--CREATE TABLE IF NOT EXISTS wiki_module_pushes (-- subject TEXT PRIMARY KEY,-- hash TEXT NOT NULL,-- pushed_at INTEGER NOT NULL--);----CREATE TABLE IF NOT EXISTS wiki_watchers (-- page_id TEXT NOT NULL REFERENCES wiki_pages(id),-- subject TEXT NOT NULL,-- created_at INTEGER NOT NULL,-- PRIMARY KEY (page_id, subject)--);----CREATE TABLE IF NOT EXISTS wiki_ai_proposals (-- id TEXT PRIMARY KEY,-- page_id TEXT NOT NULL REFERENCES wiki_pages(id),-- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),-- revision INTEGER NOT NULL,-- base_revision INTEGER NOT NULL,-- workflow_id TEXT NOT NULL,-- run_id TEXT NOT NULL,-- stub_provider INTEGER NOT NULL DEFAULT 0,-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),-- proposed_by TEXT NOT NULL,-- note TEXT,-- reviewed_by TEXT,-- review_note TEXT,-- reviewed_at INTEGER,-- created_at INTEGER NOT NULL,-- UNIQUE (page_id, revision)--);--CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');--CREATE TABLE IF NOT EXISTS wiki_attachment_origins (-- attachment_id INTEGER PRIMARY KEY,-- page_id TEXT NOT NULL,-- media_id TEXT NOT NULL,-- attached_by TEXT NOT NULL,-- media_owner TEXT,-- media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),-- attached_at INTEGER NOT NULL--);--CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins--BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;--`;----function openDb(dbPath) {-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });-- const db = new Database(dbPath);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- return db;++function openDb(config, { registry, log = console } = {}) {++ 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 wiki)');++ log.warn(`[Wiki] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ return createDb({ pglite: DEV_PGLITE, service: 'wiki', registry, log });++ }++ return createDb({ url: config.db.url, service: 'wiki', registry, log });}--/**-- * Creates every table (package tables first: the infobox purge trigger references-- * wiki_page_revision_purges) and returns the package stores bound to this database.-- */--/**-- * Contracts 0.64.0 (WS-K task 8) adds the `vip` visibility. Tables created before it carry-- * CHECK (visibility IN ('public','members','private')); SQLite cannot ALTER a CHECK, and widening one-- * changes no stored row, so the table's CREATE statement is rewritten in place (SQLite's documented-- * writable_schema procedure, in one transaction with a schema_version bump), then integrity-checked.-- * Idempotent: a database already on the new text is left alone. Returns the tables changed.-- */--const OLD_VIS_CHECK = "CHECK (visibility IN ('public','members','private'))";--const NEW_VIS_CHECK = "CHECK (visibility IN ('public','members','vip','private'))";--function widenVisibilityChecks(db) {-- const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('wiki_spaces', 'wiki_pages')").all()-- .filter((r) => r.sql && r.sql.includes(OLD_VIS_CHECK));-- if (!rows.length) return [];-- db.unsafeMode(true);-- try {-- db.transaction(() => {-- const version = db.pragma('schema_version', { simple: true });-- db.pragma('writable_schema = ON');-- const set = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");-- for (const r of rows) set.run(r.sql.split(OLD_VIS_CHECK).join(NEW_VIS_CHECK), r.name);-- db.pragma(`schema_version = ${Number(version) + 1}`);-- db.pragma('writable_schema = OFF');-- })();-- } finally {-- db.unsafeMode(false);-- }-- const check = db.pragma('integrity_check', { simple: true });-- if (check !== 'ok') throw new Error(`wiki: integrity_check after widening the visibility CHECK: ${check}`);-- return rows.map((r) => r.name);++/** Apply pending migrations (several processes starting together are safe: the SDK takes an advisory lock). */++async function migrate(config, { serving = null, log = console } = {}) {++ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });++ 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: 'wiki-migrate', max: 1, log });++ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }}++/** The openvibe-publishing stores bound to this database (their tables are in migrations/). */function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });-- db.exec(SCHEMA);-- widenVisibilityChecks(db);return {revisions,citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),@@@ -220,4 +73,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =};}--module.exports = { openDb, createStores, widenVisibilityChecks, SCHEMA };++module.exports = { openDb, migrate, createStores, MIGRATIONS };@@@ -24,7 +24,7 @@* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages people read* (the per-address limits bound those).*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const FIRST_PARTY = /^svc:/;const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;@@@ -93,7 +93,7 @@ const BUDGETS = {* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the* per-address limits.*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {const refused = registry? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -101,6 +101,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.limits.minute, hour: config.limits.hour },actor: enabled ? actor : () => null,now,++ // Shared across processes and hosts on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is a subject id, a principal or an address, never a token.log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -104,51 +104,56 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {const B = (name) => limits.budget(name);const R = (fn, status) => run(fn, status, log);// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.-- const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());++ const prepare = (req, pairs) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs) : null);router.param('space', (req, res, next, ref) => {-- const space = svc.findSpace(ref);-- return space && !space.deleted_at ? prepare(req, [{ space }, ...svc.vipPagesOf(space.id).map((page) => ({ space, page }))], next) : next();++ (async () => {++ const space = await svc.findSpace(ref);++ if (space && !space.deleted_at) await prepare(req, [{ space }, ...(await svc.vipPagesOf(space.id)).map((page) => ({ space, page }))]);++ })().then(() => next(), next);});router.param('id', (req, res, next, id) => {-- const page = svc.pageById(id);-- const space = page && svc.spaceById(page.space_id);-- return space ? prepare(req, [{ space }, { space, page }], next) : next();++ (async () => {++ const page = await svc.pageById(id);++ const space = page && await svc.spaceById(page.space_id);++ if (space) await prepare(req, [{ space }, { space, page }]);++ })().then(() => next(), next);});-- const pageAndSpace = (id) => {-- const page = svc.pageById(id);++ const pageAndSpace = async (id) => {++ const page = await svc.pageById(id);if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');-- const space = svc.spaceById(page.space_id);++ const space = await svc.spaceById(page.space_id);return { page, space };};-- const readable = (req, id) => {-- const { page, space } = pageAndSpace(id);++ const readable = async (req, id) => {++ const { page, space } = await pageAndSpace(id);if (!svc.access.canReadPage(space, page, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');return { page, space };};++ const pageOut = async (page) => serializePage(page, svc, await svc.spaceById(page.space_id));// Spaces-- router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));-- router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));-- router.get('/spaces/:space', R((req) => {-- const space = svc.getSpace(req.params.space, req.actor);-- return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };++ router.get('/spaces', R(async (req) => ({ spaces: (await svc.listSpaces(req.actor)).map(serializeSpace) })));++ router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R(async (req) => ({ space: serializeSpace(await svc.createSpace(req.body || {}, req.actor)) }), 201));++ router.get('/spaces/:space', R(async (req) => {++ const space = await svc.getSpace(req.params.space, req.actor);++ return { space: serializeSpace(space), pages: serializeTree(await svc.tree(space, req.actor), svc, space) };}));-- router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));-- router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));-- router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));++ router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ space: serializeSpace(await svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));++ router.get('/spaces/:space/roles', R(async (req) => ({ roles: await svc.roles(req.params.space, req.actor) })));++ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ roles: await svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);-- const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);-- const space = svc.spaceById(out.page.space_id);-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };++ const out = await svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);++ const space = await svc.spaceById(out.page.space_id);++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };}, 201));router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {-- const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);++ const { space, bundle } = await svc.prepareImport(req.params.space, req.body, req.actor);await resolveBundleCitations(bundle, platform);-- const out = svc.importPages(space.id, bundle, req.actor);++ const out = await svc.importPages(space.id, bundle, req.actor);return {space: serializeSpace(out.space), published: out.published, skipped: out.skipped,created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),@@@ -156,9 +161,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {}, 201));// Pages-- router.get('/pages/:id', guard('wiki.page.read'), R((req) => {-- const { page, space } = readable(req, req.params.id);-- const v = svc.view(space, page, req.actor, { revision: int(req.query.revision) });++ router.get('/pages/:id', guard('wiki.page.read'), R(async (req) => {++ const { page, space } = await readable(req, req.params.id);++ const v = await svc.view(space, page, req.actor, { revision: int(req.query.revision) });return {page: serializePage(page, svc, space), revision: serializeRevision(v.revision),citations: v.citations.map(serializeCitation), infobox: v.infobox,@@@ -168,75 +173,76 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {discussion_thread: v.discussion ? v.discussion.threadId : null,};}));-- router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {++ router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {const b = req.body || {};-- let page = svc.pageById(req.params.id);++ let page = await svc.pageById(req.params.id);if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');-- if (b.slug !== undefined || b.parent_id !== undefined) page = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);-- if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;-- return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };++ if (b.slug !== undefined || b.parent_id !== undefined) page = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);++ if (b.visibility !== undefined || b.noindex !== undefined) page = (await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor)).page;++ return { page: await pageOut(page) };}));-- router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {-- const out = svc.deletePage(req.params.id, req.actor);-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };++ router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {++ const out = await svc.deletePage(req.params.id, req.actor);++ return { page: await pageOut(out.page) };}));-- router.get('/pages/:id/revisions', guard('wiki.page.read'), R((req) => {-- const { page, space } = readable(req, req.params.id);++ router.get('/pages/:id/revisions', guard('wiki.page.read'), R(async (req) => {++ const { page, space } = await readable(req, req.params.id);if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');-- return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };++ const list = await svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor });++ return { revisions: list.map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };}));router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);-- const out = svc.editPage(req.params.id, {++ const out = await svc.editPage(req.params.id, {expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,}, req.actor);-- const space = svc.spaceById(out.page.space_id);-- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };++ const space = await svc.spaceById(out.page.space_id);++ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };}, (out) => (out.created ? 201 : 200)));-- router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R((req) => {-- const { page, space } = readable(req, req.params.id);-- const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });++ router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {++ const { page, space } = await readable(req, req.params.id);++ const v = await svc.view(space, page, req.actor, { revision: int(req.params.n) });return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };}));-- router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R((req) => {-- const { page, space } = readable(req, req.params.id);++ router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R(async (req) => {++ const { page, space } = await readable(req, req.params.id);if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');-- return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };++ return { diff: await svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };}));-- router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R((req) => {-- const { page, space } = readable(req, req.params.id);-- svc.view(space, page, req.actor, { revision: int(req.params.n) });-- return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };++ router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R(async (req) => {++ const { page, space } = await readable(req, req.params.id);++ await svc.view(space, page, req.actor, { revision: int(req.params.n) });++ return { citations: (await svc.citationsOf(page, int(req.params.n))).map(serializeCitation) };}));router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {const cites = await resolveCitations((req.body || {}).citations, platform);-- return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };++ return { citations: (await svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor)).map(serializeCitation) };}, 201));-- router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {-- const { page, space } = pageAndSpace(req.params.id);++ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {++ const { page, space } = await pageAndSpace(req.params.id);const b = req.body || {};-- const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);++ const out = await svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };}, 201));-- router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {-- const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };++ router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {++ const out = await svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);++ return { page: await pageOut(out.page), action: out.action };}));-- router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {++ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {const b = req.body || {};-- const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);++ const out = await svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);return { job: out.job, created: out.created };}, (out) => (out.created ? 201 : 200)));-- router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {-- const out = svc.unpublish(req.params.id, req.actor);-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };++ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {++ const out = await svc.unpublish(req.params.id, req.actor);++ return { page: await pageOut(out.page), action: out.action };}));-- router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R((req) => {++ router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R(async (req) => {const b = req.body || {};-- const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);-- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };++ const out = await svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);++ return { page: await pageOut(out.page), revision: serializeRevision(out.revision), published: out.published };}, 201));router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {const b = req.body || {};@@@ -244,7 +250,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {return { attachment: serializeAttachment(a) };}, 201));router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {-- const { space } = pageAndSpace(req.params.id);++ const { space } = await pageAndSpace(req.params.id);if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };}));@@@ -255,16 +261,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {const b = req.body || {};const cites = await resolveCitations(b.citations, platform);const wf = b.workflow || {};-- const out = svc.propose({++ const out = await svc.propose({space: b.space, pageId: b.page_id || null, title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, citations: cites,workflow: { id: wf.id, runId: wf.run_id || wf.runId, version: wf.version, model: wf.model }, stubProvider: !!b.stub_provider,expectedRevision: b.expected_revision, note: b.note,}, req.actor);-- return { proposal: out.proposal, page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision) };++ return { proposal: out.proposal, page: await pageOut(out.page), revision: serializeRevision(out.revision) };}, 201));-- router.get('/proposals/:id', guard('wiki.page.read'), R((req) => {-- const p = svc.getProposal(req.params.id);-- if (!p || !svc.access.canEdit(svc.spaceById(p.space_id), req.actor)) {++ router.get('/proposals/:id', guard('wiki.page.read'), R(async (req) => {++ const p = await svc.getProposal(req.params.id);++ if (!p || !svc.access.canEdit(await svc.spaceById(p.space_id), req.actor)) {// Proposers may read their own proposal's state.if (!p || !req.actor || req.actor.kind !== 'service' || p.proposed_by !== req.actor.service) throw new svc.WikiError(404, 'proposal.not_found', 'No such proposal');}@@@ -275,8 +281,8 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);}));-- router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R((req) => ({-- results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),++ router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R(async (req) => ({++ results: (await svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit })).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),})));router.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));@@@ -28,14 +28,16 @@ function createMachine({ svc, config }) {}));});-- function pageEntries() {-- return svc.publishedPublic().map(({ space, page, decision }) => ({++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);++++ async function pageEntries() {++ return (await svc.publishedPublic()).map(({ space, page, decision }) => ({loc: svc.pageUrl(space, page), lastmod: page.revision_published_at, decision,}));}-- function spaceEntries() {-- return svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => {++ async function spaceEntries() {++ return (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => {const url = seo.canonicalUrl(origin, svc.spacePath(s));// A space index is navigation, not an article: no word or source minimum.const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });@@@ -43,22 +45,22 @@ function createMachine({ svc, config }) {});}-- router.get('/sitemap.xml', (_req, res) => {-- const pages = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP });++ router.get('/sitemap.xml', wrap(async (_req, res) => {++ const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));cache(res).type('application/xml').send(seo.sitemapIndex(maps));-- });-- router.get('/sitemaps/spaces.xml', (_req, res) => cache(res).type('application/xml').send(seo.sitemap(spaceEntries()).files[0]));-- router.get('/sitemaps/pages-:n.xml', (req, res) => {-- const files = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP }).files;++ }));++ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));++ router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {++ const files = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP }).files;const n = Number(req.params.n);if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');cache(res).type('application/xml').send(files[n - 1]);-- });++ }));-- function feedItems() {-- return svc.recentChanges(50).map(({ space, page, rev, decision }) => ({++ async function feedItems() {++ return (await svc.recentChanges(50)).map(({ space, page, rev, decision }) => ({id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,url: svc.pageUrl(space, page),title: rev.fields.title,@@@ -76,22 +78,22 @@ function createMachine({ svc, config }) {* change readers cannot see — or the Unix epoch when there is no public space at all. The feed is* linked from every page, so an empty one is a valid feed with zero entries, not a 404.*/-- function emptyFeedUpdated() {-- const times = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => s.updated_at);++ async function emptyFeedUpdated() {++ const times = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => s.updated_at);return new Date(times.length ? Math.max(...times) : 0).toISOString();}-- router.get('/feed.atom', (_req, res) => {-- const items = feedItems();-- const updated = items.some((i) => i.decision.listable) ? null : emptyFeedUpdated();++ router.get('/feed.atom', wrap(async (_req, res) => {++ const items = await feedItems();++ const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));-- });-- router.get('/feed.json', (_req, res) => {-- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));-- });++ }));++ router.get('/feed.json', wrap(async (_req, res) => {++ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));++ }));-- router.get('/llms.txt', (_req, res) => {-- const spaces = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public');++ router.get('/llms.txt', wrap(async (_req, res) => {++ const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({name: 'OpenVibe.Wiki',summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',@@@ -105,7 +107,7 @@ function createMachine({ svc, config }) {] },],}));-- });++ }));return router;}@@@ -49,11 +49,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.router.param('space', (req, res, next, ref) => {-- const space = svc.findSpace(ref);-- if (!space || space.deleted_at || !req.actor || !req.actor.subject) return next();-- const page = req.params.slug ? svc.findPage(space.id, req.params.slug) : null;-- const pairs = [{ space }, ...(page ? [{ space, page }] : svc.vipPagesOf(space.id).map((p) => ({ space, page: p })))];-- svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) }).then(() => next(), next);++ (async () => {++ const space = await svc.findSpace(ref);++ if (!space || space.deleted_at || !req.actor || !req.actor.subject) return;++ const page = req.params.slug ? await svc.findPage(space.id, req.params.slug) : null;++ const pairs = [{ space }, ...(page ? [{ space, page }] : (await svc.vipPagesOf(space.id)).map((p) => ({ space, page: p })))];++ await svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) });++ })().then(() => next(), next);});const origin = new URL(config.baseUrl).origin;@@@ -87,12 +89,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })});/** Resolve :space/:slug; follows history-aware redirects (301) and reports deleted pages (410). */-- function locate(req, res, suffix = '') {-- const space = svc.findSpace(req.params.space);-- const page = space && !space.deleted_at ? svc.findPage(space.id, req.params.slug) : null;++ async function locate(req, res, suffix = '') {++ const space = await svc.findSpace(req.params.space);++ const page = space && !space.deleted_at ? await svc.findPage(space.id, req.params.slug) : null;if (page && page.state === 'deleted') { gone(req, res); return null; }if (!page) {-- const r = svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);++ const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }notFound(req, res);@@@ -102,10 +104,10 @@ function createPages({ svc, viewers, platform, config, log = console, limits })return { space, page };}-- function locateSpace(req, res) {-- const space = svc.findSpace(req.params.space);++ async function locateSpace(req, res) {++ const space = await svc.findSpace(req.params.space);if (!space || space.deleted_at) {-- const r = svc.resolveRedirect(`/s/${req.params.space}`);++ const r = await svc.resolveRedirect(`/s/${req.params.space}`);if (r && r.status === 301) { res.redirect(301, r.location + req.path.replace(/^\/s\/[^/]+/, '')); return null; }if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }notFound(req, res);@@@ -117,9 +119,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })// ── Home, recent, search ─────────────────────────────────router.get('/', wrap(async (req, res) => {-- if (req.actor.subject) await svc.access.prepareVip(req.actor, svc.vipSpaces().map((space) => ({ space }))); // VIP spaces the viewer may see-- const spaces = svc.listSpaces(req.actor);-- const recent = svc.recentChanges(10);++ if (req.actor.subject) await svc.access.prepareVip(req.actor, (await svc.vipSpaces()).map((space) => ({ space }))); // VIP spaces the viewer may see++ const [spaces, recent] = await Promise.all([svc.listSpaces(req.actor), svc.recentChanges(10)]);send(req, res, 200, views.home({ spaces, recent, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {robots: 'index, follow', cache: 'public', active: 'home', path: '/',jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),@@@ -127,11 +128,11 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}));// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'wiki', siteName: 'OpenVibe.Wiki' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Wiki', robots: 'index, follow', cache: 'public', path: '/updates' }));-- router.get('/recent', (req, res) => send(req, res, 200, views.recentPage({ items: svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' }));-- router.get('/search', (req, res) => {++ router.get('/recent', wrap(async (req, res) => send(req, res, 200, views.recentPage({ items: await svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' })));++ router.get('/search', wrap(async (req, res) => {const query = String(req.query.q || '').slice(0, 200);-- send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });-- });++ send(req, res, 200, views.searchPage({ query, results: query ? await svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });++ }));// ── Spaces ───────────────────────────────────────────────router.get('/new-space', (req, res) => {@@@ -142,7 +143,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');const b = req.body || {};try {-- const space = svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);++ const space = await svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);} catch (err) {if (!err.status || err.status >= 500) throw err;@@@ -150,18 +151,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}}));-- router.get('/s/:space', (req, res) => {-- const space = locateSpace(req, res);++ router.get('/s/:space', wrap(async (req, res) => {++ const space = await locateSpace(req, res);if (!space) return;const canEdit = svc.access.canEdit(space, req.actor);-- const proposals = canEdit ? svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id) : [];++ const proposals = canEdit ? (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id) : [];const open = space.visibility === 'public';-- send(req, res, 200, views.spacePage({ space, tree: svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {++ send(req, res, 200, views.spacePage({ space, tree: await svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Wiki.`, path: svc.spacePath(space),robots: open ? 'index, follow' : 'noindex, nofollow', cache: open ? 'public' : null,jsonLd: open ? seo.structuredData.breadcrumbs([{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }]) : null,});-- });++ }));function editorsOnly(req, res, space) {if (svc.access.canEdit(space, req.actor)) return true;@@@ -170,31 +171,32 @@ function createPages({ svc, viewers, platform, config, log = console, limits })return false;}-- function parentsOf(space, actor) {++ async function parentsOf(space, actor) {const out = [];const walk = (nodes) => { for (const n of nodes) { out.push(n.page); walk(n.children); } };-- walk(svc.tree(space, actor));++ walk(await svc.tree(space, actor));return out;}-- router.get('/s/:space/new', (req, res) => {-- const space = locateSpace(req, res);++ router.get('/s/:space/new', wrap(async (req, res) => {++ const space = await locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;-- send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });-- });++ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });++ }));router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {-- const space = locateSpace(req, res);++ const space = await locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;const b = req.body || {};const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };-- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });++ const parents = await parentsOf(space, req.actor);++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });try {const infobox = content.parseInfoboxText(b.infobox);-- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));++ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));const cites = await resolveCitations(citationsFromForm(b), platform);-- const out = svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);-- if (b.op === 'publish') svc.publish(out.page.id, { revision: out.revision.number }, req.actor);++ const out = await svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);++ if (b.op === 'publish') await svc.publish(out.page.id, { revision: out.revision.number }, req.actor);res.redirect(303, views.wpath(space, out.page));} catch (err) {if (!err.status || err.status >= 500 && err.status !== 503) throw err;@@@ -202,26 +204,26 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}}));-- router.get('/s/:space/settings', (req, res) => {-- const space = locateSpace(req, res);++ router.get('/s/:space/settings', wrap(async (req, res) => {++ const space = await locateSpace(req, res);if (!space) return;if (!svc.access.canManage(space, req.actor)) return req.actor.subject ? errorPage(req, res, 403, 'Owners only', 'Only an owner of this space can change its settings.') : needSignIn(req, res, 'Sign in as an owner of this space.');-- send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });-- });++ send(req, res, 200, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });++ }));router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {-- const space = locateSpace(req, res);++ const space = await locateSpace(req, res);if (!space) return;const b = req.body || {};try {-- if (b.op === 'role') svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);-- else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }-- else { const s = svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }++ if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);++ else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }++ else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=1`);} catch (err) {if (!err.status || err.status >= 500) throw err;if (!svc.access.canManage(space, req.actor)) return errorPage(req, res, err.status, 'Owners only', err.message);-- send(req, res, err.status, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });++ send(req, res, err.status, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });}}));@@@ -234,13 +236,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');return false;}-- router.get('/s/:space/import', (req, res) => {-- const space = locateSpace(req, res);++ router.get('/s/:space/import', wrap(async (req, res) => {++ const space = await locateSpace(req, res);if (!space || !ownersOnly(req, res, space)) return;send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });-- });++ }));router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {-- const space = locateSpace(req, res);++ const space = await locateSpace(req, res);if (!space || !ownersOnly(req, res, space)) return;const b = req.body || {};const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };@@@ -249,9 +251,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })const parsed = importer.parseBundleText(values.bundle);const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });-- const { bundle } = svc.prepareImport(space.id, input, req.actor);++ const { bundle } = await svc.prepareImport(space.id, input, req.actor);await resolveBundleCitations(bundle, platform);-- const out = svc.importPages(space.id, bundle, req.actor);++ const out = await svc.importPages(space.id, bundle, req.actor);again(200, null, out);} catch (err) {if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;@@@ -259,19 +261,20 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}}));-- router.get('/s/:space/proposals', (req, res) => {-- const space = locateSpace(req, res);++ router.get('/s/:space/proposals', wrap(async (req, res) => {++ const space = await locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;-- const items = svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id).map((proposal) => ({ proposal, page: svc.pageById(proposal.page_id) }));++ const items = [];++ for (const proposal of (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id)) items.push({ proposal, page: await svc.pageById(proposal.page_id) });send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });-- });++ }));router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');-- const p = svc.getProposal(req.params.id);++ const p = await svc.getProposal(req.params.id);if (!p) return notFound(req, res);-- svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);-- const space = svc.spaceById(p.space_id);++ await svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);++ const space = await svc.spaceById(p.space_id);res.redirect(303, `/s/${encodeURIComponent(space.slug)}/proposals`);}));@@@ -307,13 +310,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })router.get('/w/:space/:slug', wrap(async (req, res) => {const json = /\.json$/.test(req.params.slug);if (json) req.params.slug = req.params.slug.slice(0, -5);-- const found = locate(req, res, json ? '.json' : '');++ const found = await locate(req, res, json ? '.json' : '');if (!found) return;const { space, page } = found;const rev = req.query.rev != null ? Number(req.query.rev) : undefined;if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);let v;-- try { v = svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }++ try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';const cache = open && v.isPublishedRevision ? 'public' : null;if (json) {@@@ -321,9 +324,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits }).set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));return;}-- const resolve = (t) => svc.resolveLink(t, req.actor);-- const bodyHtml = svc.renderRevision(space, v.revision, req.actor);-- const discussion = v.isPublishedRevision ? await discussionFor(space, page, req.actor) : { state: 'not_public' };++ // Infobox values of type page link to that page: resolved before the (synchronous) view renders.++ const infoboxLinks = new Map();++ for (const x of v.infobox) {++ if (x.type !== 'page' || infoboxLinks.has(x.value)) continue;++ infoboxLinks.set(x.value, await svc.resolveLink(content.parseTarget(x.value, space.slug) || { space: space.slug, slug: '-', title: x.value }, req.actor));++ }++ const [bodyHtml, discussion] = await Promise.all([++ svc.renderRevision(space, v.revision, req.actor),++ v.isPublishedRevision ? discussionFor(space, page, req.actor) : { state: 'not_public' },++ ]);const url = svc.pageUrl(space, page);const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];// The gate decides robots for the published revision; any other revision is noindex.@@@ -338,82 +348,82 @@ function createPages({ svc, viewers, platform, config, log = console, limits })seo.structuredData.breadcrumbs(crumbsLd),].filter(Boolean) : null;const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });-- send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => resolve(content.parseTarget(title, space.slug) || { space: space.slug, slug: '-', title }), flash: req.query.saved ? 'Saved.' : null }), {++ send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {head, path: svc.pagePath(space, page), cache,});}));-- router.get('/w/:space/:slug/history', (req, res) => {-- const found = locate(req, res, '/history');++ router.get('/w/:space/:slug/history', wrap(async (req, res) => {++ const found = await locate(req, res, '/history');if (!found) return;const { space, page } = found;-- send(req, res, 200, views.historyPage({ space, page, list: svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {++ send(req, res, 200, views.historyPage({ space, page, list: await svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {title: `History of ${page.title}`, robots: 'noindex, follow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,});-- });++ }));// The citation inspector: one revision's sources (?rev=N; the published one by default), with the// same read rules as the article.-- router.get('/w/:space/:slug/sources', (req, res) => {-- const found = locate(req, res, '/sources');++ router.get('/w/:space/:slug/sources', wrap(async (req, res) => {++ const found = await locate(req, res, '/sources');if (!found) return;const { space, page } = found;const rev = req.query.rev != null ? Number(req.query.rev) : undefined;if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);let inspected;-- try { inspected = svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }++ try { inspected = await svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }send(req, res, 200, views.sourcesPage({ space, page, ...inspected }), {title: `Sources of ${inspected.view.revision.fields.title || page.title}`, robots: 'noindex, follow',cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,});-- });++ }));-- router.get('/w/:space/:slug/compare', (req, res) => {-- const found = locate(req, res);++ router.get('/w/:space/:slug/compare', wrap(async (req, res) => {++ const found = await locate(req, res);if (!found) return;const a = Number(req.query.a), b = Number(req.query.b);if (!Number.isInteger(a) || !Number.isInteger(b)) return errorPage(req, res, 400, 'Pick two revisions', 'Choose a "from" and a "to" revision to compare.');res.redirect(303, `${views.wpath(found.space, found.page)}/diff/${Math.min(a, b)}/${Math.max(a, b)}`);-- });++ }));-- router.get('/w/:space/:slug/diff/:a/:b', (req, res) => {-- const found = locate(req, res, `/diff/${req.params.a}/${req.params.b}`);++ router.get('/w/:space/:slug/diff/:a/:b', wrap(async (req, res) => {++ const found = await locate(req, res, `/diff/${req.params.a}/${req.params.b}`);if (!found) return;const { space, page } = found;let diff;-- try { diff = svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }++ try { diff = await svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }send(req, res, 200, views.diffPage({ space, page, diff }), {title: `Changes to ${page.title}`, robots: 'noindex, nofollow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,});-- });++ }));// Editing a missing page (a red link) opens the new-page form with the title filled in.-- function editTarget(req, res) {-- const space = svc.findSpace(req.params.space);-- if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !svc.findPage(space.id, req.params.slug) && !svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {++ async function editTarget(req, res) {++ const space = await svc.findSpace(req.params.space);++ if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !await svc.findPage(space.id, req.params.slug) && !await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {res.redirect(303, `/s/${encodeURIComponent(space.slug)}/new?title=${encodeURIComponent(String(req.query.title || req.params.slug))}`);return null;}return locate(req, res, '/edit');}-- router.get('/w/:space/:slug/edit', (req, res) => {-- const found = editTarget(req, res);++ router.get('/w/:space/:slug/edit', wrap(async (req, res) => {++ const found = await editTarget(req, res);if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;// Start from the newest revision that is not an unapproved AI proposal; concurrency is// still checked against the head.-- const list = svc.history(page, { limit: 500 });++ const list = await svc.history(page, { limit: 500 });const headNumber = list[0].number;const start = list.find((r) => !r.proposal || r.proposal.status === 'approved') || list[0];-- const from = svc.view(space, page, req.actor, { revision: start.number });++ const from = await svc.view(space, page, req.actor, { revision: start.number });const note = start.number !== headNumber ? `The newest revision (${headNumber}) is an AI proposal that has not been approved; this form starts from revision ${start.number}.` : null;send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });-- });++ }));router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/edit');++ const found = await locate(req, res, '/edit');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;@@@ -422,15 +432,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits })const base = Number(b.base_revision) || expected;const keep = [].concat(b.keep_citation || []).map(String);let baseView = null;-- try { baseView = Number.isInteger(base) && base > 0 ? svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }++ try { baseView = Number.isInteger(base) && base > 0 ? await svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });try {const infobox = content.parseInfoboxText(b.infobox);-- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));++ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));const cites = await resolveCitations(citationsFromForm(b), platform);-- const out = svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);-- if (b.op === 'publish') svc.publish(page.id, { revision: out.revision.number }, req.actor);++ const out = await svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);++ if (b.op === 'publish') await svc.publish(page.id, { revision: out.revision.number }, req.actor);res.redirect(303, `${views.wpath(space, out.page)}${b.op === 'publish' ? '' : `?rev=${out.revision.number}`}`);} catch (err) {if (err.code === 'revision.conflict') return again(409, `Someone saved revision ${err.extra ? err.extra.current : ''} while you were editing. Your text is below; open the page in another tab, merge by hand, and save again.`);@@@ -439,52 +449,52 @@ function createPages({ svc, viewers, platform, config, log = console, limits })}}));-- router.get('/w/:space/:slug/revert', (req, res) => {-- const found = locate(req, res, '/revert');++ router.get('/w/:space/:slug/revert', wrap(async (req, res) => {++ const found = await locate(req, res, '/revert');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;const to = Number(req.query.to);-- const head = svc.history(page, { limit: 1 })[0].number;++ const head = (await svc.history(page, { limit: 1 }))[0].number;if (!Number.isInteger(to) || to < 1 || to > head) return notFound(req, res);send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });-- });++ }));router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/revert');++ const found = await locate(req, res, '/revert');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;const b = req.body || {};-- const out = svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);++ const out = await svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);res.redirect(303, out.published ? views.wpath(space, out.page) : `${views.wpath(space, out.page)}?rev=${out.revision.number}`);}));-- router.get('/w/:space/:slug/publish', (req, res) => {-- const found = locate(req, res, '/publish');++ router.get('/w/:space/:slug/publish', wrap(async (req, res) => {++ const found = await locate(req, res, '/publish');if (!found) return;if (!editorsOnly(req, res, found.space)) return;const rev = Number(req.query.rev);if (!Number.isInteger(rev)) return notFound(req, res);send(req, res, 200, views.confirmPublishPage({ ...found, rev }), { title: `Publish ${found.page.title}`, robots: 'noindex, nofollow' });-- });++ }));-- router.get('/w/:space/:slug/settings', (req, res) => {-- const found = locate(req, res, '/settings');++ router.get('/w/:space/:slug/settings', wrap(async (req, res) => {++ const found = await locate(req, res, '/settings');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;-- renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });-- });++ await renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });++ }));-- function renderPageSettings(req, res, space, page, status, extra = {}) {-- const view = svc.view(space, page, req.actor, {});-- const headNumber = svc.history(page, { limit: 1 })[0].number;-- send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });++ async function renderPageSettings(req, res, space, page, status, extra = {}) {++ const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);++ const headNumber = list[0].number;++ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });}router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/settings');++ const found = await locate(req, res, '/settings');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;@@@ -492,51 +502,51 @@ function createPages({ svc, viewers, platform, config, log = console, limits })const back = (p = page, q = 'saved=1') => res.redirect(303, `${views.wpath(space, p)}/settings?${q}`);try {switch (b.op) {-- case 'publish': svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));++ case 'publish': await svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));case 'schedule': {const at = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(String(b.run_at)) ? b.run_at : `${b.run_at}Z`);-- svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);++ await svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);return back();}-- case 'unpublish': svc.unpublish(page.id, req.actor); return back();-- case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }++ case 'unpublish': await svc.unpublish(page.id, req.actor); return back();++ case 'move': { const p = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();-- case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();++ case 'detach': await svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();case 'verify': {const results = await svc.verifyMedia(page.id, platform.media.resolve);const count = (k) => results.filter((r) => r.outcome === k).length;return back(page, `saved=verify&summary=${encodeURIComponent(`${count('available')} available, ${count('broken')} broken, ${count('check_failed')} not checked (Media did not answer)`)}`);}-- case 'visibility': svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();-- case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);++ case 'visibility': await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();++ case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); await svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');}} catch (err) {if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;-- renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });++ await renderPageSettings(req, res, space, await svc.pageById(page.id), err.status, { error: err.message });}}));router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/review');++ const found = await locate(req, res, '/review');if (!found) return;const { space, page } = found;if (!editorsOnly(req, res, space)) return;const b = req.body || {};-- svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);++ await svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);res.redirect(303, `${views.wpath(space, page)}/history`);}));router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/watch');++ const found = await locate(req, res, '/watch');if (!found) return;if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');-- svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');++ await svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');res.redirect(303, views.wpath(found.space, found.page));}));router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {-- const found = locate(req, res, '/discuss');++ const found = await locate(req, res, '/discuss');if (!found) return;const { space, page } = found;if (!req.actor.subject) return needSignIn(req, res, 'Sign in to comment.');@@@ -9,25 +9,30 @@* Events outbox relay, the Media attachment check) run only when asked (`workers: true`).*/const { load } = require('./config');--const { openDb, createStores } = require('./db');++const { openDb, migrate, createStores } = require('./db');const { createPlatform } = require('./integrations/platform');const { createWikiService } = require('./wiki/service');const { createKeyStore } = require('./auth/keys');const { createViewerResolver } = require('./auth/viewer');const { createApp } = require('./app');--async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {config = config || load();-- const db = openDb(config.dbPath);++ // PostgreSQL (ADR-035): migrate on the owner's direct connection, then serve on the pooled runtime role.++ // Tests hand in a migrated database of their own (test/db-helper.js).++ const db = givenDb || openDb(config, { log });++ if (!givenDb) await migrate(config, { serving: db, log });const stores = createStores(db, { now });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log });-- const reconciled = svc.reconcileIndex();++ const reconciled = await svc.reconcileIndex();if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);const keys = createKeyStore({ config, fetchImpl, log, publicKey });keys.ensure().catch(() => {});const viewers = createViewerResolver({ keys, config });-- const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl, limitsNow });++ const app = createApp({ config, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow });const timers = [];if (workers) {@@@ -44,6 +49,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetimers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));}if (platform.eventsConfigured) platform.outbox.start();++ timers.push(setInterval(() => platform.outbox.refreshCounts().catch(() => {}), 30 * 1000));// wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));@@@ -61,7 +67,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fefor (const t of timers) clearInterval(t);await platform.outbox.stop();if (server) await new Promise((resolve) => server.close(resolve));-- db.close();++ if (!givenDb) await db.close();++ if (valkey) await valkey.close().catch(() => {});}return { app, db, svc, stores, platform, keys, viewers, server, config, stop };}@@@ -10,7 +10,7 @@*/const { createClient } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');const { createDiscussionClient } = require('openvibe-publishing/discussion');class IntegrationError extends Error {@@@ -42,11 +42,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nulonWarning: (msg) => log.warn(`[Wiki] ${msg}`),});const events = createEventsClient(sdk, { source: 'wiki' });-- const outbox = createOutbox(db, {++ // The PostgreSQL outbox (openvibe-sdk/events): rows are written in the change's own transaction++ // (outbox.enqueue(db, …) joins the ambient transaction); several processes relay one table safely (leases).++ const outbox = createPgOutbox(db, {events, table: 'wiki_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,onError: (err) => log.warn(`[Wiki] event relay: ${err && err.message}`),});-- outbox.ensureSchema();++ // Counts for metrics (read synchronously at scrape time), refreshed by readiness and a timer.++ const outboxCounts = { pending: 0, rejected: 0 };++ outbox.refreshCounts = async () => {++ const [pending, rejected] = await Promise.all([outbox.pending(), outbox.rejected()]);++ Object.assign(outboxCounts, { pending, rejected });++ return { ...outboxCounts };++ };++ outbox.counts = () => ({ ...outboxCounts });async function getJson(url, audience, { headers = {}, timeoutMs = 5000 } = {}) {let res;@@@ -16,12 +16,12 @@ const NS = 'wiki.projects';const MAX_SPACES = 50;/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */--function summarize(db, subject) {-- const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,++async function summarize(db, subject) {++ const rows = await db.prepare(`SELECT s.slug, s.name, s.visibility,CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS roleFROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subjectWHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))-- ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });++ ORDER BY (CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 0 ELSE 1 END), lower(s.name), s.id`).all({ subject });const pub = rows.filter((r) => r.visibility === 'public');return {spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),@@@ -48,16 +48,16 @@ function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetchdraining = true;let written = 0;try {-- for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {++ for (const { subject, marked_at: markedAt } of await db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at, subject LIMIT ?').all(limit)) {const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);-- const data = summarize(db, subject);++ const data = await summarize(db, subject);const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);-- const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);-- if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }++ const last = await db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);++ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; await clear(); continue; }try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }-- db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)++ await db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());-- clear();++ await clear();stats.written++; written++;}} finally { draining = false; }@@@ -18,6 +18,10 @@* records the answers on the actor (actor.vipAllowed, actor.vipDenied). The synchronous checks below then* read them. Anything not prepared is not readable (fail closed), so VIP content never leaks into a list,* a link preview or a feed that did not ask.++ * Roles are read the same way (PostgreSQL, ADR-035): loadRoles(actor) reads every role the actor's subject holds++ * (one query, cached on the actor as actor.wikiRoles) before any check, so the checks stay synchronous and a++ * filter over pages never sees a promise. A check on an actor whose roles were not loaded throws (a bug, never++ * a silent refusal or grant). The service loads them at the top of every method that takes an actor.* Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,* review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as* owner of official spaces; they get no silent access to user spaces.@@@ -40,7 +44,27 @@ function effectiveVisibility(space, page) {}function createAccess(db, { vip = null } = {}) {-- const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');++ const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');++ const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');++++ /** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */++ async function loadRoles(actor, { fresh = false } = {}) {++ if (!actor || actor.kind === 'system' || !actor.subject) return actor;++ if (actor.wikiRoles && !fresh) return actor;++ const rows = await rolesOfSubject.all(actor.subject);++ Object.defineProperty(actor, 'wikiRoles', { value: new Map(rows.map((r) => [r.space_id, r.role])), writable: true, configurable: true, enumerable: false });++ return actor;++ }++++ /** Actors for subjects of one space (watchers), with that space's roles already loaded. */++ async function subjectActors(space) {++ const roles = new Map((await rolesOfSpace.all(space.id)).map((r) => [r.subject, r.role]));++ return (subject) => {++ const a = { kind: 'user', subject, staff: false };++ Object.defineProperty(a, 'wikiRoles', { value: new Map(roles.has(subject) ? [[space.id, roles.get(subject)]] : []), enumerable: false });++ return a;++ };++ }/** 'owner' | 'editor' | 'viewer' | null */function role(space, actor) {@@@ -50,8 +74,8 @@ function createAccess(db, { vip = null } = {}) {if (actor.kind === 'user' && actor.staff && space.kind === 'official') return 'owner';if (!subject) return null;if (space.owner === subject) return 'owner';-- const r = roleOf.get(space.id, subject);-- return r ? r.role : null;++ if (!actor.wikiRoles) throw new Error('wiki access: roles not loaded for this actor (await access.loadRoles(actor) first)');++ return actor.wikiRoles.get(space.id) || null;}function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }@@@ -68,6 +92,8 @@ function createAccess(db, { vip = null } = {}) {const api = {VISIBILITIES,role,++ loadRoles,++ subjectActors,isPerson,effectiveVisibility,canReadSpace(space, actor) {@@@ -121,9 +147,9 @@ function createAccess(db, { vip = null } = {}) {return { reason: (actor.vipDenied && actor.vipDenied.get(vipKey(space, page))) || (actor.subject ? 'denied' : 'not_signed_in'),owner: space.owner || null, joinUrl: vip ? vip.joinUrl(space.owner || null) : null };},-- /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */-- subjectCanRead(space, page, subject) {-- return api.canReadPage(space, page, { kind: 'user', subject, staff: false });++ /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. actorFor comes from subjectActors(space). */++ subjectCanRead(space, page, subject, actorFor) {++ return api.canReadPage(space, page, actorFor(subject));},};return api;@@@ -23,11 +23,11 @@ const IMPORTED = {},};--function seed(svc, data, { publish = true, log = console } = {}) {++async function seed(svc, data, { publish = true, log = console } = {}) {if (!data || !data.space || !Array.isArray(data.pages)) throw new TypeError('seed data needs { space, pages }');-- let space = svc.findSpace(data.space.slug);++ let space = await svc.findSpace(data.space.slug);if (!space) {-- space = svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);++ space = await svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);log.log(`[seed] created space ${space.slug}`);}const bySlug = new Map();@@@ -39,27 +39,27 @@ function seed(svc, data, { publish = true, log = console } = {}) {const next = [];for (const p of pending) {const slug = pageSlug(p.title);-- const existing = svc.findPage(space.id, slug);++ const existing = await svc.findPage(space.id, slug);if (existing) { bySlug.set(slug, existing); skipped.push(p.title); continue; }let parentId = null;if (p.parent) {-- const parent = bySlug.get(pageSlug(p.parent)) || svc.findPage(space.id, pageSlug(p.parent));++ const parent = bySlug.get(pageSlug(p.parent)) || await svc.findPage(space.id, pageSlug(p.parent));if (!parent) { next.push(p); continue; }parentId = parent.id;}-- const out = svc.createPage(space.id, {++ const out = await svc.createPage(space.id, {title: p.title, body: p.body, summary: p.summary || null, infobox: p.infobox || [], parentId,citations: (p.citations || []).map((c) => ({ url: c.url, title: c.title || null, retrievedAt: c.retrievedAt, quote: c.quote ? { text: c.quote } : null, licenseNote: c.licenseNote || null })),message: 'Seed import', authorship: IMPORTED,}, SYSTEM);-- if (publish) svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);-- bySlug.set(slug, svc.pageById(out.page.id));++ if (publish) await svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);++ bySlug.set(slug, await svc.pageById(out.page.id));created.push(p.title);}pending = next;}if (pending.length) throw new Error(`seed: parents not found for ${pending.map((p) => p.title).join(', ')}`);-- return { space: svc.findSpace(data.space.slug), created, skipped };++ return { space: await svc.findSpace(data.space.slug), created, skipped };}module.exports = { seed, SYSTEM, IMPORTED };@@@ -4,14 +4,16 @@* attachments, watchers and AI proposals. Wiki owns its publication state; the Publishing* packages supply the mechanics (revisions, citations, the gate, redirects, index hooks).*-- * Every write runs in one SQLite transaction together with the events it causes (transactional-- * outbox): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,++ * Every write runs in one PostgreSQL transaction together with the events it causes (transactional++ * outbox; ambient transactions, openvibe-sdk ≥ 0.18): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,* wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.* After the commit (never inside it), a page that stops or starts being public has its Community* discussion thread hidden or shown again (best effort).*-- * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).++ * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code). Every method that++ * reads or writes data is async (ADR-035); pure helpers (paths, linkRel) stay synchronous.*/++const { AsyncLocalStorage } = require('node:async_hooks');const seo = require('openvibe-publishing/seo');const hooks = require('openvibe-publishing/index-hooks');const authorship = require('openvibe-publishing/authorship');@@@ -73,25 +75,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =const access = createAccess(db, { vip });const origin = config.baseUrl;const policy = config.gate;++ // Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts, as++ // it did when each check read SQLite.++ const roles = (actor, opts = { fresh: true }) => access.loadRoles(actor, opts);-- // ── Queries ───────────────────────────────────────────────++ // ── Queries (PostgreSQL; better-sqlite3-shaped async statements, openvibe-sdk/db prepare) ──const q = {spaceById: db.prepare('SELECT * FROM wiki_spaces WHERE id = ?'),++ spacesByIds: db.prepare('SELECT * FROM wiki_spaces WHERE id = ANY(?)'),spaceBySlug: db.prepare('SELECT * FROM wiki_spaces WHERE slug = ?'),-- spaces: db.prepare('SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY kind = \'official\' DESC, name COLLATE NOCASE'),++ spaces: db.prepare("SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY (kind = 'official') DESC, lower(name), id"),insertSpace: db.prepare(`INSERT INTO wiki_spaces (id, slug, name, description, kind, visibility, owner, created_by, created_at, updated_at)VALUES (@id, @slug, @name, @description, @kind, @visibility, @owner, @created_by, @now, @now)`),pageById: db.prepare('SELECT * FROM wiki_pages WHERE id = ?'),pageBySlug: db.prepare('SELECT * FROM wiki_pages WHERE space_id = ? AND slug = ?'),-- pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),-- publishedOfSpace: db.prepare("SELECT id FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),++ pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),++ publishedOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),insertPage: db.prepare(`INSERT INTO wiki_pages (id, space_id, slug, title, parent_id, position, state, visibility, created_by, created_at, updated_at)VALUES (@id, @space_id, @slug, @title, @parent_id, @position, 'draft', @visibility, @created_by, @now, @now)`),-- children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),-- insertLink: db.prepare('INSERT OR IGNORE INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?)'),++ children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),++ insertLink: db.prepare('INSERT INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING'),linksOf: db.prepare('SELECT * FROM wiki_page_links WHERE from_page_id = ? AND from_revision = ? ORDER BY target_space, target_slug'),-- backlinks: db.prepare(`SELECT DISTINCT p.* FROM wiki_page_links l JOIN wiki_pages p ON p.id = l.from_page_id AND l.from_revision = p.published_revision-- WHERE l.target_space = ? AND l.target_slug = ? AND p.state = 'published' ORDER BY p.title COLLATE NOCASE LIMIT 200`),++ backlinks: db.prepare(`SELECT p.* FROM wiki_pages p++ WHERE p.state = 'published' AND EXISTS (SELECT 1 FROM wiki_page_links l WHERE l.from_page_id = p.id AND l.from_revision = p.published_revision++ AND l.target_space = ? AND l.target_slug = ?)++ ORDER BY lower(p.title), p.id LIMIT 200`),insertInfobox: db.prepare(`INSERT INTO wiki_infobox_values (page_id, revision, position, key, label, type, value_text, value_number)VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),infoboxOf: db.prepare('SELECT * FROM wiki_infobox_values WHERE page_id = ? AND revision = ? ORDER BY position'),@@@ -100,20 +108,20 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),// wiki.projects (Network user module): who needs their record written again.-- dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),++ dirtySubject: db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at'),dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)-- SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'-- UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'++ SELECT subject, @t::bigint FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'++ UNION SELECT owner, @t::bigint FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),-- watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),++ watch: db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'),unwatch: db.prepare('DELETE FROM wiki_watchers WHERE page_id = ? AND subject = ?'),-- isWatching: db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?'),++ isWatching: db.prepare('SELECT 1 AS yes FROM wiki_watchers WHERE page_id = ? AND subject = ?'),proposal: db.prepare('SELECT * FROM wiki_ai_proposals WHERE id = ?'),proposalByRev: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? AND revision = ?'),-- proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC'),-- pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC LIMIT 200"),++ proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC, id DESC'),++ pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC, id DESC LIMIT 200"),insertProposal: db.prepare(`INSERT INTO wiki_ai_proposals (id, page_id, space_id, revision, base_revision, workflow_id, run_id, stub_provider, status, proposed_by, note, created_at)VALUES (@id, @page_id, @space_id, @revision, @base_revision, @workflow_id, @run_id, @stub_provider, 'pending', @proposed_by, @note, @now)`),reviewProposal: db.prepare("UPDATE wiki_ai_proposals SET status = @status, reviewed_by = @by, review_note = @note, reviewed_at = @now WHERE id = @id AND status = 'pending'"),@@@ -122,15 +130,24 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_idWHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'-- ORDER BY p.id`),++ ORDER BY p.id LIMIT 50000`),insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)VALUES (?, ?, ?, ?, ?, ?, ?)`),originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),++ // Case-insensitive, as SQLite's LIKE was for ASCII.search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_idJOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision-- WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)-- AND (p.title LIKE @like ESCAPE '\\' OR r.content LIKE @like ESCAPE '\\')-- ORDER BY (p.title LIKE @like ESCAPE '\\') DESC, p.revision_published_at DESC LIMIT 200`),++ WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space::text IS NULL OR s.slug = @space)++ AND (p.title ILIKE @like ESCAPE '\' OR r.content ILIKE @like ESCAPE '\')++ ORDER BY (p.title ILIKE @like ESCAPE '\') DESC, p.revision_published_at DESC, p.id LIMIT 200`),++ setPublished: db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = @n, title = @title, published_at = COALESCE(published_at, @t),++ revision_published_at = CASE WHEN published_revision IS NOT DISTINCT FROM @n AND state = 'published' THEN revision_published_at ELSE @t END,++ updated_at = @t WHERE id = @id`),++ setState: db.prepare('UPDATE wiki_pages SET state = ?, updated_at = ? WHERE id = ?'),++ touch: db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?'),++ vipPages: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' ORDER BY id LIMIT 50"),++ pageIds: db.prepare('SELECT id FROM wiki_pages ORDER BY id'),++ attachedEntities: db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments ORDER BY entity_id'),};// ── Paths and small helpers ───────────────────────────────@@@ -139,29 +156,23 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };-- // Work that must wait until the write has committed (never inside the transaction). Queued by-- // afterCommit() and run once the outermost tx() returns; dropped when the transaction throws.-- let commitQueue = null;-- function tx(fn) {-- if (commitQueue) return db.transaction(fn)();-- commitQueue = [];-- let out;-- try {-- out = db.transaction(fn)();-- } catch (err) {-- commitQueue = null;-- throw err;-- }-- const queue = commitQueue;-- commitQueue = null;++ // Work that must wait until the write has committed (never inside the transaction). Queued by afterCommit()++ // in the running request's own queue (AsyncLocalStorage: concurrent requests never share one), run once the++ // outermost tx() has committed, dropped when it throws. A tx() inside a tx() is a savepoint.++ const commits = new AsyncLocalStorage();++ async function tx(fn) {++ if (commits.getStore()) return db.tx(() => fn());++ const queue = [];++ const out = await commits.run(queue, () => db.tx(() => fn()));for (const job of queue) {-- try { job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }++ try { await job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }}return out;}function afterCommit(job) {-- if (commitQueue) commitQueue.push(job);-- else job();++ const queue = commits.getStore();++ if (queue) queue.push(job);++ else Promise.resolve().then(job).catch((err) => log.warn(`[Wiki] after-commit: ${err && err.message}`));}/**@@@ -172,8 +183,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =*/function followThread(pageId, visibility) {if (!community || typeof community.setThreadVisibility !== 'function') return;-- afterCommit(() => {-- const known = discussions.get(pageId);++ afterCommit(async () => {++ const known = await discussions.get(pageId);if (!known) return;Promise.resolve().then(() => community.setThreadVisibility(known.threadId, visibility))@@@ -193,14 +204,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =return actor.subject;}-- function spaceOrFail(idOrSlug) {-- const s = /^spc_/.test(String(idOrSlug)) ? q.spaceById.get(idOrSlug) : q.spaceBySlug.get(String(idOrSlug));++ async function spaceOrFail(idOrSlug) {++ const s = /^spc_/.test(String(idOrSlug)) ? await q.spaceById.get(idOrSlug) : await q.spaceBySlug.get(String(idOrSlug));if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');return s;}-- function pageOrFail(id) {-- const p = PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) : null;++ async function pageOrFail(id) {++ const p = PAGE_ID_RE.test(String(id)) ? await q.pageById.get(id) : null;if (!p) fail(404, 'page.not_found', 'No such page');return p;}@@@ -239,26 +250,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* A renamed (or deleted) space's old slug keeps answering with its redirect (or 410): nobody but* that space may take it, or the old links would serve someone else's pages.*/-- function checkSlugNotRetired(slug, spaceId = null) {-- const r = svc.resolveRedirect(`/s/${slug}`);++ async function checkSlugNotRetired(slug, spaceId = null) {++ const r = await svc.resolveRedirect(`/s/${slug}`);if (r && r.entityId !== spaceId) fail(409, 'space.slug_retired', `The space slug "${slug}" belonged to another space and still leads there`);}-- function wrapContentError(fn) {-- try { return fn(); } catch (err) {-- if (err instanceof content.ContentError) fail(422, err.code, err.message);-- if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);-- if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);-- throw err;-- }++ function contentError(err) {++ if (err instanceof content.ContentError) fail(422, err.code, err.message);++ if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);++ if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);++ throw err;}++ function wrapContentError(fn) { try { return fn(); } catch (err) { return contentError(err); } }++ async function wrapStore(fn) { try { return await fn(); } catch (err) { return contentError(err); } }// ── Events ───────────────────────────────────────────────-- function emit(envelope) { return outbox.enqueue(envelope); }++ function emit(envelope) { return outbox.enqueue(db, envelope); }-- function spaceEvent(space, actor, change) {-- q.dirtySpace.run({ id: space.id, t: now() });-- emit({++ async function spaceEvent(space, actor, change) {++ await q.dirtySpace.run({ id: space.id, t: now() });++ await emit({event_type: 'wiki.space.updated',actor: hooks.subjectRef(actorId(actor)),subject: { type: 'space', id: space.id },@@@ -273,7 +284,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =function revisionEvent(space, page, rev, actor, extra = {}) {const rec = rev.meta && rev.meta.authorship;// A new revision is a draft until it is published: the event is internal.-- emit({++ return emit({event_type: 'wiki.revision.created',actor: hooks.subjectRef(actorId(actor)),subject: { type: 'page', id: page.id, revision: rev.number },@@@ -287,12 +298,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =}/** One event per change listing the watchers who may read the page (never the actor). */-- function notifyWatchers(space, page, action, revision, actor) {++ async function notifyWatchers(space, page, action, revision, actor) {const who = actorId(actor);-- const recipients = q.watchers.all(page.id).map((r) => r.subject)-- .filter((s) => s !== who && access.subjectCanRead(space, page.state === 'deleted' ? { ...page, state: 'published' } : page, s));++ const subjects = (await q.watchers.all(page.id)).map((r) => r.subject).filter((s) => s !== who);++ if (!subjects.length) return;++ const actorFor = await access.subjectActors(space);++ const readable = page.state === 'deleted' ? { ...page, state: 'published' } : page;++ const recipients = subjects.filter((s) => access.subjectCanRead(space, readable, s, actorFor));if (!recipients.length) return;-- emit({++ await emit({event_type: 'wiki.watch.triggered',actor: hooks.subjectRef(who),subject: { type: 'page', id: page.id, revision: revision || 0 },@@@ -302,27 +316,29 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =}// ── The gate, index documents and publication events ─────-- function gateFacts(space, page, rev) {-- const text = ssr.markdownToText(rev.content);++ /** Gate facts from facts already read (no query of its own). */++ function gateFactsWith(space, page, rev, citationCount, review) {const rec = rev.meta && rev.meta.authorship;return {state: space.deleted_at ? 'deleted' : page.state,visibility: GATE_VIS[access.effectiveVisibility(space, page)],canonicalUrl: pageUrl(space, page),-- text,-- citationCount: citations.forRevision(page.id, rev.number).length,++ text: ssr.markdownToText(rev.content),++ citationCount,noindex: !!page.noindex,-- ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),++ ...(rec ? authorshipGateFacts(rec, review) : {}),};}---- function decide(space, page, rev) {-- return seo.evaluate(gateFacts(space, page, rev), { policy, now: now() });++ async function gateFacts(space, page, rev) {++ const [cites, review] = await Promise.all([citations.forRevision(page.id, rev.number), reviews.latest(page.id, rev.number)]);++ return gateFactsWith(space, page, rev, cites.length, review);}++ const decideWith = (space, page, rev, cites, review) => seo.evaluate(gateFactsWith(space, page, rev, cites.length, review), { policy, now: now() });++ async function decide(space, page, rev) { return seo.evaluate(await gateFacts(space, page, rev), { policy, now: now() }); }-- function snapshot(page) {++ async function snapshot(page) {if (!page) return null;-- const space = q.spaceById.get(page.space_id);++ const space = await q.spaceById.get(page.space_id);return { state: space.deleted_at ? 'deleted' : page.state, visibility: access.effectiveVisibility(space, page), revision: page.published_revision };}@@@ -332,15 +348,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* product event when the publication state moved. Both go to the outbox in the caller's transaction.* When the page stops (or starts) being public, its discussion thread follows after the commit.*/-- function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {-- const page = q.pageById.get(pageId);-- const space = q.spaceById.get(page.space_id);++ async function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {++ const page = await q.pageById.get(pageId);++ const space = await q.spaceById.get(page.space_id);const eff = access.effectiveVisibility(space, page);const state = space.deleted_at ? 'deleted' : page.state;-- const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;-- const decision = rev && state === 'published' ? decide(space, page, rev) : null;-- const cites = rev ? citations.forRevision(page.id, rev.number) : [];-- const doc = sequencer.stamp(hooks.buildIndexDocument({++ const rev = page.published_revision ? await revisions.get(page.id, page.published_revision) : null;++ const cites = rev ? await citations.forRevision(page.id, rev.number) : [];++ const decision = rev && state === 'published' ? decideWith(space, page, rev, cites, await reviews.latest(page.id, rev.number)) : null;++ const doc = await sequencer.stamp(db, hooks.buildIndexDocument({owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],// Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.deleted: eff !== 'public' || !decision || !decision.listable,@@@ -352,7 +368,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =publishedAt: page.published_at, updatedAt: page.revision_published_at,}));const sentBefore = before ? before.indexRevision : null;-- if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));++ if (doc.revision !== sentBefore) await emit(hooks.indexEvent({ document: doc, now: now() }));const after = { state, visibility: eff, revision: page.published_revision };const wasPublic = Boolean(before) && before.state === 'published' && before.visibility === 'public';@@@ -368,45 +384,47 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =// its state, not a Search document.evDoc = { owner: 'wiki', type: 'page', id: page.id, revision: doc.revision, deleted: false, visibility: GATE_VIS[eff], canonical_url: pageUrl(space, page), publication_state: 'published', indexability: decision ? hooks.searchIndexability(decision) : null };}-- emit(hooks.publicationEvent({++ await emit(hooks.publicationEvent({product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0,actor: hooks.subjectRef(actorId(actor)), document: evDoc, decision, now: now(),extra: { space: space.slug, slug: page.slug, visibility: eff },}));-- notifyWatchers(space, page, action, page.published_revision, actor);++ await notifyWatchers(space, page, action, page.published_revision, actor);}return { action, indexRevision: doc.revision, deleted: doc.deleted };}-- function before(page) {-- const s = snapshot(page);-- return s ? { ...s, indexRevision: sequencer.current('wiki', 'page', page.id) } : null;++ async function before(page) {++ const s = await snapshot(page);++ return s ? { ...s, indexRevision: await sequencer.current('wiki', 'page', page.id) } : null;}// ── Side tables written with each revision ───────────────-- function writeRevisionExtras(space, page, rev, infobox) {-- for (const l of content.extractLinks(rev.content, space.slug)) q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);-- infobox.forEach((e, i) => q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type,-- e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null));++ async function writeRevisionExtras(space, page, rev, infobox) {++ for (const l of content.extractLinks(rev.content, space.slug)) await q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);++ for (const [i, e] of infobox.entries()) {++ await q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type, e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null);++ }}-- function infoboxOf(pageId, revision) {-- return q.infoboxOf.all(pageId, revision).map((r) => ({++ async function infoboxOf(pageId, revision) {++ return (await q.infoboxOf.all(pageId, revision)).map((r) => ({key: r.key, label: r.label, type: r.type,value: r.type === 'number' ? r.value_number : r.type === 'boolean' ? r.value_text === 'true' : r.value_text,}));}-- function attachCitationList(page, revisionNumber, list, actor) {++ async function attachCitationList(page, revisionNumber, list, actor) {if (!list || !list.length) return [];-- return wrapContentError(() => citations.attachMany(page.id, revisionNumber, list.map((c) => {++ const items = list.map((c) => {if (!c || (!c.url && !c.sourceItemId)) fail(422, 'citation.no_source', 'A citation needs a URL or a Sources item id');if (c.url && !c.sourceItemId && !c.retrievedAt) fail(422, 'citation.retrieved_at_required', `When was ${c.url} read? A URL citation needs retrievedAt`);return { ...c, attachedBy: actorId(actor) };-- })));++ });++ return wrapStore(() => citations.attachMany(page.id, revisionNumber, items));}-- function carryCitations(page, from, to, keep, actor) {++ async function carryCitations(page, from, to, keep, actor) {if (!from || keep === 'none' || (Array.isArray(keep) && !keep.length)) return [];const ids = keep === 'all' || keep == null ? null : keep.map(Number).filter(Number.isInteger);return citations.carryForward({ entityId: page.id, fromRevision: from, toRevision: to, ids, attachedBy: actorId(actor) });@@@ -417,86 +435,101 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =}/** A new page with its first revision (permission checks and authorship are the caller's). */-- function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {++ async function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {const cleanTitle = checkTitle(title);const slug = wrapContentError(() => content.pageSlug(cleanTitle));const cleanSummary = checkSummary(summary);const text = checkBody(body == null ? '' : body);const box = wrapContentError(() => content.normalizeInfobox(infobox));const vis = checkVisibility(visibility, 'public', space);-- return tx(() => {-- const existing = q.pageBySlug.get(space.id, slug);++ return tx(async () => {++ const existing = await q.pageBySlug.get(space.id, slug);if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });-- if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }++ if (parentId) { const par = await q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }const t = now();const id = pageIdNew(t);-- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });-- redirects.release(`/w/${space.slug}/${slug}`);-- const { revision } = wrapContentError(() => revisions.create({++ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });++ await redirects.release(`/w/${space.slug}/${slug}`);++ const { revision } = await wrapStore(() => revisions.create({entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },meta: { authorship: rec }, author: actorId(actor), message,}));-- const page = q.pageById.get(id);-- writeRevisionExtras(space, page, revision, box);-- attachCitationList(page, revision.number, cites, actor);-- revisionEvent(space, page, revision, actor);-- if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);++ const page = await q.pageById.get(id);++ await writeRevisionExtras(space, page, revision, box);++ await attachCitationList(page, revision.number, cites, actor);++ await revisionEvent(space, page, revision, actor);++ if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);return { page, revision };});}// ── Publication (internal: no permission checks) ─────────-- function publishRevision(page, revisionNumber, actor) {-- const rev = revisions.get(page.id, revisionNumber);++ async function publishRevision(page, revisionNumber, actor) {++ const rev = await revisions.get(page.id, revisionNumber);if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);const rec = rev.meta.authorship;if (rec) {-- const ok = authorship.canPublish(rec, reviews.latest(page.id, revisionNumber));++ const ok = authorship.canPublish(rec, await reviews.latest(page.id, revisionNumber));if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');}-- const b = before(page);-- const t = now();-- db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = ?, title = ?, published_at = COALESCE(published_at, ?),-- revision_published_at = CASE WHEN published_revision IS ? AND state = 'published' THEN revision_published_at ELSE ? END, updated_at = ? WHERE id = ?`)-- .run(revisionNumber, rev.fields.title || page.title, t, revisionNumber, t, t, page.id);++ const b = await before(page);++ await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });return sync(b, page.id, actor);}/*** Readers who are not editors see the published revision and the history before it, never-- * newer drafts and never an AI proposal a person has not approved.++ * newer drafts and never an AI proposal a person has not approved. `proposal` is the one filed++ * for revision n, when the caller already read it (undefined: read here).*/-- function revisionVisible(space, page, n, actor) {++ async function revisionVisible(space, page, n, actor, proposal) {if (access.canEdit(space, actor)) return true;if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;-- const p = q.proposalByRev.get(page.id, n);++ const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;return !p || p.status === 'approved';}++ /** { space, page, rev, decision } for published pages, in four queries whatever their number (no N+1). */++ async function expandPublished(pages) {++ if (!pages.length) return [];++ const spaces = new Map((await q.spacesByIds.all([...new Set(pages.map((p) => p.space_id))])).map((s) => [s.id, s]));++ const refs = pages.map((p) => ({ entityId: p.id, revision: p.published_revision }));++ const [revs, cites, revws] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs)]);++ const out = [];++ pages.forEach((page, i) => {++ const space = spaces.get(page.space_id);++ if (!space || !revs[i]) return;++ out.push({ space, page, rev: revs[i], decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });++ });++ return out;++ }++// ── Public API ───────────────────────────────────────────const svc = {-- access, spacePath, pagePath, pageUrl, WikiError,++ access, spacePath, pagePath, pageUrl, WikiError, loadRoles: roles,// Spaces ----------------------------------------------------------------------------- listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },++ async listSpaces(actor) { await roles(actor); return (await q.spaces.all()).filter((s) => access.canReadSpace(s, actor)); },/** VIP spaces (for preparing a viewer's VIP answers before a list renders; WS-K task 8). */-- vipSpaces() { return q.spaces.all().filter((s) => s.visibility === 'vip').slice(0, 50); },++ async vipSpaces() { return (await q.spaces.all()).filter((s) => s.visibility === 'vip').slice(0, 50); },/** A space's VIP-only pages (its tree), at most 50. */-- vipPagesOf(spaceId) { return db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' LIMIT 50").all(spaceId); },++ vipPagesOf(spaceId) { return q.vipPages.all(spaceId); },-- getSpace(idOrSlug, actor) {-- const s = spaceOrFail(idOrSlug);++ async getSpace(idOrSlug, actor) {++ await roles(actor);++ const s = await spaceOrFail(idOrSlug);if (!access.canReadSpace(s, actor)) fail(404, 'space.not_found', 'No such space');return s;},/** Raw lookup for redirects and routing (no permission check; callers decide). */-- findSpace(slug) { return q.spaceBySlug.get(String(slug)) || null; },-- findPage(spaceId, slug) { return q.pageBySlug.get(spaceId, String(slug)) || null; },-- pageById(id) { return PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) || null : null; },-- spaceById(id) { return q.spaceById.get(id) || null; },++ async findSpace(slug) { return (await q.spaceBySlug.get(String(slug))) || null; },++ async findPage(spaceId, slug) { return (await q.pageBySlug.get(spaceId, String(slug))) || null; },++ async pageById(id) { return PAGE_ID_RE.test(String(id)) ? (await q.pageById.get(id)) || null : null; },++ async spaceById(id) { return (await q.spaceById.get(id)) || null; },-- createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {++ async createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {++ await roles(actor);const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 120);if (!cleanName) fail(422, 'space.invalid_name', 'A space needs a name');let cleanSlug;@@@ -512,26 +545,30 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =}if (kind === 'user') requirePerson(actor);const owner = kind === 'user' ? actor.subject : (actor && actor.kind === 'service' ? actor.service : 'svc:wiki');-- return tx(() => {-- if (q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);-- checkSlugNotRetired(cleanSlug);++ const space = await tx(async () => {++ if (await q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);++ await checkSlugNotRetired(cleanSlug);const t = now();const id = spaceIdNew(t);-- q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });-- if (/^usr_/.test(owner)) q.putRole.run(id, owner, 'owner', actorId(actor), t);-- redirects.release(`/s/${cleanSlug}`);-- const space = q.spaceById.get(id);-- spaceEvent(space, actor, 'created');-- return space;++ await q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });++ if (/^usr_/.test(owner)) await q.putRole.run(id, owner, 'owner', actorId(actor), t);++ await redirects.release(`/s/${cleanSlug}`);++ const made = await q.spaceById.get(id);++ await spaceEvent(made, actor, 'created');++ return made;});++ await roles(actor, { fresh: true });++ return space;},-- updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {-- const space = spaceOrFail(idOrSlug);++ async updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {++ await roles(actor);++ const space = await spaceOrFail(idOrSlug);if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can change it');-- return tx(() => {-- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));-- const snaps = new Map(pages.map((p) => [p.id, before(p)]));++ return tx(async () => {++ const pages = await q.publishedOfSpace.all(space.id);++ const snaps = new Map();++ for (const p of pages) snaps.set(p.id, await before(p));const t = now();const next = {name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,@@@ -541,59 +578,72 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =};if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');if (next.slug !== space.slug) {-- if (q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);-- checkSlugNotRetired(next.slug, space.id);-- redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });-- for (const p of q.pagesOfSpace.all(space.id)) redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });++ if (await q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);++ await checkSlugNotRetired(next.slug, space.id);++ await redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });++ for (const p of await q.pagesOfSpace.all(space.id)) await redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });}-- db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')++ await db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?').run(next.name, next.description, next.visibility, next.slug, t, space.id);-- const updated = q.spaceById.get(space.id);-- spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));-- for (const p of pages) sync(snaps.get(p.id), p.id, actor);++ const updated = await q.spaceById.get(space.id);++ await spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));++ for (const p of pages) await sync(snaps.get(p.id), p.id, actor);return updated;});},-- deleteSpace(idOrSlug, actor) {-- const space = spaceOrFail(idOrSlug);++ async deleteSpace(idOrSlug, actor) {++ await roles(actor);++ const space = await spaceOrFail(idOrSlug);if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can delete it');-- return tx(() => {-- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));-- const snaps = new Map(pages.map((p) => [p.id, before(p)]));-- db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);-- for (const p of pages) { scheduler.cancelPending(p.id); sync(snaps.get(p.id), p.id, actor); }-- spaceEvent(q.spaceById.get(space.id), actor, 'deleted');++ return tx(async () => {++ const pages = await q.publishedOfSpace.all(space.id);++ const snaps = new Map();++ for (const p of pages) snaps.set(p.id, await before(p));++ await db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);++ for (const p of pages) { await scheduler.cancelPending(p.id); await sync(snaps.get(p.id), p.id, actor); }++ await spaceEvent(await q.spaceById.get(space.id), actor, 'deleted');return { deleted: true };});},-- roles(idOrSlug, actor) {-- const space = spaceOrFail(idOrSlug);++ async roles(idOrSlug, actor) {++ await roles(actor);++ const space = await spaceOrFail(idOrSlug);if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner sees the role list');-- return q.roles.all(space.id).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));++ return (await q.roles.all(space.id)).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));},-- setRole(idOrSlug, subject, role, actor) {-- const space = spaceOrFail(idOrSlug);++ async setRole(idOrSlug, subject, role, actor) {++ await roles(actor);++ const space = await spaceOrFail(idOrSlug);if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner can change roles');if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) fail(422, 'wiki.invalid_subject', 'Roles go to people: a usr_… subject id');if (role != null && !['owner', 'editor', 'viewer'].includes(role)) fail(422, 'wiki.invalid_role', 'role is owner, editor or viewer (or null to remove)');-- return tx(() => {-- const current = q.roles.all(space.id).find((r) => r.subject === subject);-- const losingOwner = current && current.role === 'owner' && role !== 'owner';-- if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');-- if (role == null) q.delRole.run(space.id, subject);-- else q.putRole.run(space.id, subject, role, actorId(actor), now());-- q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list-- spaceEvent(space, actor, 'roles');-- return svc.roles(space.id, actor);-- });++ try {++ return await tx(async () => {++ const current = (await q.roles.all(space.id)).find((r) => r.subject === subject);++ const losingOwner = current && current.role === 'owner' && role !== 'owner';++ if (losingOwner && space.kind === 'user' && (await q.owners.get(space.id)).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');++ if (role == null) await q.delRole.run(space.id, subject);++ else await q.putRole.run(space.id, subject, role, actorId(actor), now());++ await q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list++ await spaceEvent(space, actor, 'roles');++ // As before: the list is read with the roles just written, so an owner who hands their own++ // role away is refused (403) and the change rolls back.++ await roles(actor, { fresh: true });++ return svc.roles(space.id, actor);++ });++ } catch (err) {++ await roles(actor, { fresh: true }).catch(() => {}); // the cached roles must not keep a rolled-back change++ throw err;++ }},// Pages ------------------------------------------------------------------------------ tree(space, actor) {-- const pages = q.pagesOfSpace.all(space.id).filter((p) => access.canReadPage(space, p, actor));++ async tree(space, actor) {++ await roles(actor);++ const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));const byParent = new Map();for (const p of pages) {const k = p.parent_id && pages.some((x) => x.id === p.parent_id) ? p.parent_id : null;@@@ -604,8 +654,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =return build(null, 0);},-- createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {-- const space = spaceOrFail(spaceIdOrSlug);++ async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {++ await roles(actor);++ const space = await spaceOrFail(spaceIdOrSlug);if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');// Only this service's own import path (the seed) may state another authorship; people are human authors.const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);@@@ -619,8 +670,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the* caller cannot read is "not found", exactly like everywhere else.*/-- prepareImport(spaceIdOrSlug, input, actor) {-- const space = spaceOrFail(spaceIdOrSlug);++ async prepareImport(spaceIdOrSlug, input, actor) {++ await roles(actor);++ const space = await spaceOrFail(spaceIdOrSlug);if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');requirePerson(actor);@@@ -637,14 +689,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing* imported) or "skip" (left alone, reported).*/-- importPages(spaceIdOrSlug, bundle, actor) {-- const space = spaceOrFail(spaceIdOrSlug);++ async importPages(spaceIdOrSlug, bundle, actor) {++ await roles(actor);++ const space = await spaceOrFail(spaceIdOrSlug);if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');const who = requirePerson(actor);const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;-- return tx(() => {-- const existing = bundle.pages.map((p) => ({ p, page: q.pageBySlug.get(space.id, p.slug) })).filter((x) => x.page);++ return tx(async () => {++ const existing = [];++ for (const p of bundle.pages) { const page = await q.pageBySlug.get(space.id, p.slug); if (page) existing.push({ p, page }); }if (existing.length && bundle.onExisting !== 'skip') {fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });}@@@ -661,13 +715,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =let parentId = null;if (p.parentSlug) {if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }-- const parent = made.get(p.parentSlug) || q.pageBySlug.get(space.id, p.parentSlug);++ const parent = made.get(p.parentSlug) || await q.pageBySlug.get(space.id, p.parentSlug);if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);parentId = parent.id;}let out;try {-- out = insertPage(space, { ...p, parentId }, actor, rec);++ out = await insertPage(space, { ...p, parentId }, actor, rec);} catch (err) {if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);throw err;@@@ -678,8 +732,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);pending = next;}-- if (bundle.publish) for (const c of created) publishRevision(q.pageById.get(c.page.id), c.revision.number, actor);-- return { space, created: created.map((c) => ({ page: q.pageById.get(c.page.id), revision: c.revision })), skipped, published: !!bundle.publish };++ if (bundle.publish) for (const c of created) await publishRevision(await q.pageById.get(c.page.id), c.revision.number, actor);++ const pagesNow = [];++ for (const c of created) pagesNow.push({ page: await q.pageById.get(c.page.id), revision: c.revision });++ return { space, created: pagesNow, skipped, published: !!bundle.publish };});},@@@ -688,109 +744,115 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* forward, an array keeps only those citation ids, 'none' drops them all (they stay on* the older revision). citations: new ones for this revision.*/-- editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can edit it');const rec = humanRecord(actor);if (!Number.isInteger(expectedRevision)) fail(422, 'revision.expected_required', 'expectedRevision (the revision you edited) is required');-- return tx(() => {-- const head = revisions.head(page.id);++ return tx(async () => {++ const head = await revisions.head(page.id);if (!head) fail(404, 'revision.not_found', 'This page has no revision');const text = body == null ? head.content : checkBody(body);const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };-- const { revision, created } = wrapContentError(() => revisions.create({++ const { revision, created } = await wrapStore(() => revisions.create({entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,}));if (!created) return { page, revision, created: false };-- writeRevisionExtras(space, page, revision, box);++ await writeRevisionExtras(space, page, revision, box);// Citations come from the revision the editor started from (the head unless the// head is an AI proposal the editor chose not to build on).-- const from = baseRevision != null && revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;-- carryCitations(page, from, revision.number, keepCitations, actor);-- attachCitationList(page, revision.number, cites, actor);-- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);-- revisionEvent(space, page, revision, actor);-- notifyWatchers(space, page, 'revised', revision.number, actor);-- return { page: q.pageById.get(page.id), revision, created: true };++ const from = baseRevision != null && await revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;++ await carryCitations(page, from, revision.number, keepCitations, actor);++ await attachCitationList(page, revision.number, cites, actor);++ await q.touch.run(now(), page.id);++ await revisionEvent(space, page, revision, actor);++ await notifyWatchers(space, page, 'revised', revision.number, actor);++ return { page: await q.pageById.get(page.id), revision, created: true };});},/** Revert = a new revision copying an old one (content, fields, citations, infobox). */-- revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can revert');const who = requirePerson(actor);-- return tx(() => {-- const target = revisions.get(page.id, Number(toRevision));++ return tx(async () => {++ const target = await revisions.get(page.id, Number(toRevision));if (!target) fail(404, 'revision.not_found', `No revision ${toRevision}`);const trec = target.meta.authorship;// Restoring AI text keeps the AI workflow on record: the reverting person is accountable (hybrid).const rec = trec && (trec.mode === 'ai' || trec.mode === 'hybrid') && trec.workflow? authorship.record({ mode: 'hybrid', authors: [who], workflow: trec.workflow }): authorship.record({ mode: 'human', authors: [who] });-- const { revision } = wrapContentError(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));-- writeRevisionExtras(space, page, revision, target.fields.infobox || []);-- carryCitations(page, target.number, revision.number, 'all', actor);-- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);-- revisionEvent(space, page, revision, actor, { reverted_to: target.number });-- const livePage = q.pageById.get(page.id);++ const { revision } = await wrapStore(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));++ await writeRevisionExtras(space, page, revision, target.fields.infobox || []);++ await carryCitations(page, target.number, revision.number, 'all', actor);++ await q.touch.run(now(), page.id);++ await revisionEvent(space, page, revision, actor, { reverted_to: target.number });++ const livePage = await q.pageById.get(page.id);const shouldPublish = publish == null ? livePage.state === 'published' : !!publish;-- if (shouldPublish) publishRevision(livePage, revision.number, actor);-- else notifyWatchers(space, livePage, 'revised', revision.number, actor);-- return { page: q.pageById.get(page.id), revision, published: shouldPublish };++ if (shouldPublish) await publishRevision(livePage, revision.number, actor);++ else await notifyWatchers(space, livePage, 'revised', revision.number, actor);++ return { page: await q.pageById.get(page.id), revision, published: shouldPublish };});},-- publish(pageId, { revision } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async publish(pageId, { revision } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can publish');if (!actor || actor.kind !== 'system') requirePerson(actor);-- return tx(() => {-- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);-- const out = publishRevision(q.pageById.get(page.id), n, actor);-- return { page: q.pageById.get(page.id), ...out };++ return tx(async () => {++ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);++ const out = await publishRevision(await q.pageById.get(page.id), n, actor);++ return { page: await q.pageById.get(page.id), ...out };});},-- unpublish(pageId, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async unpublish(pageId, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can unpublish');if (page.state !== 'published') fail(409, 'page.not_published', 'The page is not published');-- return tx(() => {-- const b = before(page);-- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);-- scheduler.cancelPending(page.id, 'unpublish');-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };++ return tx(async () => {++ const b = await before(page);++ await q.setState.run('unpublished', now(), page.id);++ await scheduler.cancelPending(page.id, 'unpublish');++ const out = await sync(b, page.id, actor);++ return { page: await q.pageById.get(page.id), ...out };});},/** Idempotent: the same (page, revision, time) returns the existing job. */-- schedulePublish(pageId, { revision, runAt } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async schedulePublish(pageId, { revision, runAt } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can schedule');requirePerson(actor);const at = typeof runAt === 'number' ? runAt : Date.parse(runAt);if (!Number.isFinite(at)) fail(422, 'schedule.invalid_time', 'runAt must be an ISO 8601 time');-- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);-- if (!revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);-- return tx(() => {-- const { job, created } = scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });++ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);++ if (!await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);++ return tx(async () => {++ const { job, created } = await scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });if (page.state === 'draft' || page.state === 'unpublished') {-- const b = before(page);-- db.prepare("UPDATE wiki_pages SET state = 'scheduled', updated_at = ? WHERE id = ?").run(now(), page.id);-- sync(b, page.id, actor);++ const b = await before(page);++ await q.setState.run('scheduled', now(), page.id);++ await sync(b, page.id, actor);}-- return { job, created, page: q.pageById.get(page.id) };++ return { job, created, page: await q.pageById.get(page.id) };});},@@@ -800,130 +862,159 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =async runSchedule({ worker = config.workerId } = {}) {return scheduler.runDue({worker,-- handler: (job) => tx(() => {-- const page = q.pageById.get(job.entityId);++ handler: (job) => tx(async () => {++ const page = await q.pageById.get(job.entityId);if (!page || page.state === 'deleted') return { skipped: 'deleted' };-- const space = q.spaceById.get(page.space_id);++ const space = await q.spaceById.get(page.space_id);if (!space || space.deleted_at) return { skipped: 'space_deleted' };if (job.action === 'publish') {if (page.state === 'published' && page.published_revision === job.revision) return { noop: true };-- publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });++ await publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });return { published: job.revision };}if (page.state !== 'published') return { noop: true };-- const b = before(page);-- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);-- sync(b, page.id, { kind: 'system', service: 'svc:wiki' });++ const b = await before(page);++ await q.setState.run('unpublished', now(), page.id);++ await sync(b, page.id, { kind: 'system', service: 'svc:wiki' });return { unpublished: true };}),});},-- movePage(pageId, { slug, title, parentId } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async movePage(pageId, { slug, title, parentId } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can move pages');-- return tx(() => {-- const b = before(page);++ return tx(async () => {++ const b = await before(page);let nextSlug = page.slug;if (slug != null || title != null) nextSlug = slug != null ? String(slug).trim().toLowerCase() : wrapContentError(() => content.pageSlug(checkTitle(title)));if (!content.PAGE_SLUG_RE.test(nextSlug)) fail(422, 'page.invalid_slug', 'A page slug is lowercase letters, digits and dashes');if (nextSlug !== page.slug) {-- const clash = q.pageBySlug.get(space.id, nextSlug);++ const clash = await q.pageBySlug.get(space.id, nextSlug);if (clash) fail(409, 'page.slug_taken', `A page "${nextSlug}" already exists in this space`);-- redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);++ await redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);}let parent = page.parent_id;if (parentId !== undefined) {parent = parentId || null;-- for (let p = parent ? q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? q.pageById.get(p.parent_id) : null, i++) {++ for (let p = parent ? await q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? await q.pageById.get(p.parent_id) : null, i++) {if (p.id === page.id || i > 50) fail(422, 'page.invalid_parent', 'A page cannot be moved under itself');if (p.space_id !== space.id) fail(422, 'page.invalid_parent', 'The parent must be a page of the same space');}}-- db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);-- sync(b, page.id, actor);++ await db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);++ await sync(b, page.id, actor);return q.pageById.get(page.id);});},-- setPageVisibility(pageId, { visibility, noindex } = {}, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');-- return tx(() => {-- const b = before(page);-- db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')++ return tx(async () => {++ const b = await before(page);++ await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?').run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };++ const out = await sync(b, page.id, actor);++ return { page: await q.pageById.get(page.id), ...out };});},-- deletePage(pageId, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async deletePage(pageId, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space can delete pages');if (page.state === 'deleted') return { page, action: null };-- return tx(() => {-- const b = before(page);-- db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), page.id);-- db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);-- scheduler.cancelPending(page.id);-- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };++ return tx(async () => {++ const b = await before(page);++ await q.setState.run('deleted', now(), page.id);++ await db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);++ await scheduler.cancelPending(page.id);++ const out = await sync(b, page.id, actor);++ return { page: await q.pageById.get(page.id), ...out };});},// Reading --------------------------------------------------------------------------/** The page as `actor` may see it; revision defaults to the published one (editors: head of a draft). */-- view(space, page, actor, { revision } = {}) {++ async view(space, page, actor, { revision } = {}) {++ await roles(actor);if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');const canEdit = access.canEdit(space, actor);-- let n = revision == null ? (page.state === 'published' ? page.published_revision : revisions.headNumber(page.id)) : Number(revision);-- if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');-- const rev = revisions.get(page.id, n);++ let n = revision == null ? (page.state === 'published' ? page.published_revision : await revisions.headNumber(page.id)) : Number(revision);++ const proposal = Number.isInteger(n) ? await q.proposalByRev.get(page.id, n) : undefined;++ if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor, proposal || null)) fail(404, 'revision.not_found', 'No such revision');++ const rev = await revisions.get(page.id, n);if (!rev) fail(404, 'revision.not_found', 'No such revision');n = rev.number;-- const review = reviews.latest(page.id, n);++ const [review, cites, infobox, links, atts, children, parentRow, backlinks, discussion, watching, proposals, jobs, headNumber] = await Promise.all([++ reviews.latest(page.id, n),++ citations.forRevision(page.id, n),++ infoboxOf(page.id, n),++ q.linksOf.all(page.id, n),++ svc.attachmentsOf(page.id),++ q.children.all(page.id),++ page.parent_id ? q.pageById.get(page.parent_id) : null,++ svc.backlinks(space, page, actor),++ discussions.get(page.id),++ actor && actor.subject ? q.isWatching.get(page.id, actor.subject) : null,++ canEdit ? q.proposalsOfPage.all(page.id) : [],++ canEdit ? scheduler.jobs(page.id) : [],++ canEdit ? revisions.headNumber(page.id) : null,++ ]);const rec = rev.meta.authorship || null;-- const decision = page.state === 'published' && n === page.published_revision ? decide(space, page, rev) : seo.evaluate({ ...gateFacts(space, page, rev), state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });++ const facts = gateFactsWith(space, page, rev, cites.length, review);++ const decision = page.state === 'published' && n === page.published_revision++ ? seo.evaluate(facts, { policy, now: now() })++ : seo.evaluate({ ...facts, state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });return {space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),isPublishedRevision: n === page.published_revision && page.state === 'published',authorship: rec, review, disclosure: rec ? disclosureFor(rec, review) : null,needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),-- proposal: q.proposalByRev.get(page.id, n) || null,-- infobox: infoboxOf(page.id, n),-- citations: citations.forRevision(page.id, n),-- links: q.linksOf.all(page.id, n),-- attachments: svc.attachmentsOf(page.id),-- children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),-- parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,-- backlinks: svc.backlinks(space, page, actor),-- discussion: discussions.get(page.id),-- watching: actor && actor.subject ? !!q.isWatching.get(page.id, actor.subject) : false,-- pendingProposals: canEdit ? q.proposalsOfPage.all(page.id).filter((p) => p.status === 'pending') : [],-- jobs: canEdit ? scheduler.jobs(page.id).filter((j) => j.status === 'pending' || j.status === 'running') : [],-- headNumber: canEdit ? revisions.headNumber(page.id) : null,++ proposal: proposal || null,++ infobox,++ citations: cites,++ links,++ attachments: atts,++ children: children.filter((c) => access.canReadPage(space, c, actor)),++ parent: parentRow && access.canReadPage(space, parentRow, actor) ? parentRow : null,++ backlinks,++ discussion,++ watching: !!watching,++ pendingProposals: proposals.filter((p) => p.status === 'pending'),++ jobs: jobs.filter((j) => j.status === 'pending' || j.status === 'running'),++ headNumber,};},-- backlinks(space, page, actor) {-- return q.backlinks.all(space.slug, page.slug).filter((p) => p.id !== page.id).map((p) => ({ page: p, space: q.spaceById.get(p.space_id) }))-- .filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));++ async backlinks(space, page, actor) {++ await roles(actor);++ const rows = (await q.backlinks.all(space.slug, page.slug)).filter((p) => p.id !== page.id);++ if (!rows.length) return [];++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));++ return rows.map((p) => ({ page: p, space: spaces.get(p.space_id) })).filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));},/** [[link]] target → { href, exists }. Pages the actor cannot read look missing. */-- resolveLink({ space: spaceSlug, slug, title }, actor) {-- const space = q.spaceBySlug.get(spaceSlug);++ async resolveLink({ space: spaceSlug, slug, title }, actor) {++ await roles(actor);++ const space = await q.spaceBySlug.get(spaceSlug);if (!space || space.deleted_at || !access.canReadSpace(space, actor)) return { href: null, exists: false };-- let page = q.pageBySlug.get(space.id, slug);++ let page = await q.pageBySlug.get(space.id, slug);if (!page) {-- const r = redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: (id) => { const p = q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(q.spaceById.get(p.space_id), p) : null; } });-- if (r && r.status === 301) { const moved = q.pageById.get(r.entityId); if (moved) page = moved; }++ const r = await redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: async (id) => { const p = await q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(await q.spaceById.get(p.space_id), p) : null; } });++ if (r && r.status === 301) { const moved = await q.pageById.get(r.entityId); if (moved) page = moved; }}-- if (page && page.state === 'published' && access.canReadPage(q.spaceById.get(page.space_id), page, actor)) {-- return { href: pagePath(q.spaceById.get(page.space_id), page), exists: true };++ const pageSpace = page ? (page.space_id === space.id ? space : await q.spaceById.get(page.space_id)) : null;++ if (page && page.state === 'published' && access.canReadPage(pageSpace, page, actor)) {++ return { href: pagePath(pageSpace, page), exists: true };}if (!page && access.canEdit(space, actor)) return { href: `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(slug)}/edit?title=${encodeURIComponent(title || slug)}`, exists: false };return { href: null, exists: false };@@@ -932,84 +1023,82 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =/*** Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":* nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces-- * are edited by staff, who vouch for their links.++ * are edited by staff, who vouch for their links. Every [[link]] is resolved first (the++ * renderer itself is synchronous).*/-- renderRevision(space, rev, actor) {-- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });++ async renderRevision(space, rev, actor) {++ const targets = content.extractLinks(rev.content, space.slug);++ const resolved = new Map();++ for (const t of targets) resolved.set(`${t.space}\u0000${t.slug}`, await svc.resolveLink(t, actor));++ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => resolved.get(`${t.space}\u0000${t.slug}`) || { href: null, exists: false }, rel: svc.linkRel(space) });},linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },-- history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {-- const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));-- const space = q.spaceById.get(page.space_id);-- return revisions.list(page.id, { limit, before: cursor }).filter((r) => revisionVisible(space, page, r.number, actor)).map((r) => ({-- ...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,-- review: reviews.latest(page.id, r.number),++ async history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {++ await roles(actor);++ const [props, space, list] = await Promise.all([q.proposalsOfPage.all(page.id), q.spaceById.get(page.space_id), revisions.list(page.id, { limit, before: cursor })]);++ const byRev = new Map(props.map((p) => [p.revision, p]));++ const visible = [];++ for (const r of list) if (await revisionVisible(space, page, r.number, actor, byRev.get(r.number) || null)) visible.push(r);++ const refs = visible.map((r) => ({ entityId: page.id, revision: r.number }));++ const [revws, cites] = await Promise.all([reviews.latestMany(refs), citations.forRevisions(refs)]);++ return visible.map((r, i) => ({++ ...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,++ review: revws[i],aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),-- needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, reviews.latest(page.id, r.number)),-- citationCount: citations.forRevision(page.id, r.number).length,++ needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, revws[i]),++ citationCount: (cites[i] || []).length,}));},-- diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {-- const space = q.spaceById.get(page.space_id);-- for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');-- return wrapContentError(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));++ async diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {++ await roles(actor);++ const space = await q.spaceById.get(page.space_id);++ for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');++ return wrapStore(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));},resolveRedirect(path) {return redirects.resolve(path, {-- currentPath: (id) => {-- if (/^spc_/.test(id)) { const s = q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }-- const p = q.pageById.get(id);++ currentPath: async (id) => {++ if (/^spc_/.test(id)) { const s = await q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }++ const p = await q.pageById.get(id);if (!p || p.state === 'deleted') return null;-- const s = q.spaceById.get(p.space_id);++ const s = await q.spaceById.get(p.space_id);return s && !s.deleted_at ? pagePath(s, p) : null;},});},-- search(text, actor, { space = null, limit = 20 } = {}) {++ async search(text, actor, { space = null, limit = 20 } = {}) {++ await roles(actor);const words = String(text || '').trim().slice(0, 200);if (!words) return [];const like = `%${words.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;-- const out = [];-- for (const p of q.search.all({ like, space })) {-- const s = q.spaceById.get(p.space_id);-- if (!access.canReadPage(s, p, actor)) continue;-- const rev = revisions.get(p.id, p.published_revision);-- out.push({ page: p, space: s, summary: rev.fields.summary || ssr.markdownToText(rev.content, 200) });-- if (out.length >= Math.min(50, Math.max(1, Number(limit) || 20))) break;-- }-- return out;++ const rows = await q.search.all({ like, space: space || null });++ if (!rows.length) return [];++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));++ const max = Math.min(50, Math.max(1, Number(limit) || 20));++ const hits = rows.filter((p) => access.canReadPage(spaces.get(p.space_id), p, actor)).slice(0, max);++ const revs = await revisions.getMany(hits.map((p) => ({ entityId: p.id, revision: p.published_revision })));++ return hits.map((p, i) => ({ page: p, space: spaces.get(p.space_id), summary: revs[i] ? (revs[i].fields.summary || ssr.markdownToText(revs[i].content, 200)) : '' }));},// Discovery (public, indexable only) ------------------------------------------------- publishedPublic() {-- return q.allPublishedPublic.all().map((page) => {-- const space = q.spaceById.get(page.space_id);-- const rev = revisions.get(page.id, page.published_revision);-- return { space, page, rev, decision: decide(space, page, rev) };-- });-- },++ async publishedPublic() { return expandPublished(await q.allPublishedPublic.all()); },-- recentChanges(limit = 50) {-- return q.recent.all(Math.min(200, Math.max(1, limit))).map((page) => {-- const space = q.spaceById.get(page.space_id);-- const rev = revisions.get(page.id, page.published_revision);-- return { space, page, rev, decision: decide(space, page, rev) };-- });-- },++ async recentChanges(limit = 50) { return expandPublished(await q.recent.all(Math.min(200, Math.max(1, limit)))); },// Citations and media ----------------------------------------------------------------/** Citations go on an unpublished head revision (a published revision's sources are fixed). */-- attachCitations(pageId, revisionNumber, list, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async attachCitations(pageId, revisionNumber, list, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can cite');const n = Number(revisionNumber);-- if (n !== revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');++ if (n !== await revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');if (page.state === 'published' && page.published_revision === n) fail(409, 'citation.revision_published', 'That revision is published; its sources are fixed. Edit the page to add sources to a new revision');return tx(() => attachCitationList(page, n, list, actor));},@@@ -1023,21 +1112,22 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* read rules as the article (view() refuses what the actor may not see); an origin revision* the actor may not read is not named.*/-- citationInspector(space, page, actor, { revision } = {}) {-- const v = svc.view(space, page, actor, { revision });++ async citationInspector(space, page, actor, { revision } = {}) {++ const v = await svc.view(space, page, actor, { revision });const n = v.revision.number;-- const readable = svc.history(page, { limit: 500, actor });++ const readable = await svc.history(page, { limit: 500, actor });const visible = new Set(readable.map((r) => r.number));const lineage = (c) => c.carriedFrom || c.id;const prev = readable.find((r) => r.number < n) || null;-- const prevCites = prev ? citations.forRevision(page.id, prev.number) : [];++ const prevCites = prev ? await citations.forRevision(page.id, prev.number) : [];const prevLines = new Set(prevCites.map(lineage));-- const items = v.citations.map((c) => {-- const first = c.carriedFrom ? citations.get(c.carriedFrom) : c;++ const items = [];++ for (const c of v.citations) {++ const first = c.carriedFrom ? await citations.get(c.carriedFrom) : c;const shown = first && visible.has(first.revision) ? first : null;const change = prevLines.has(lineage(c)) ? 'kept' : (c.carriedFrom ? 'restored' : 'new');-- return { ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null };-- });++ items.push({ ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null });++ }const here = new Set(v.citations.map(lineage));return {view: v, citations: items,@@@ -1075,8 +1165,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* nothing is attached unchecked.*/async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');const who = actor && actor.kind === 'system' ? null : requirePerson(actor);@@@ -1092,26 +1183,28 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);-- return tx(() => {-- const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));-- q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());-- attachments.markAvailable(id);-- return svc.attachmentsOf(page.id).find((a) => a.id === att.id);++ return tx(async () => {++ const att = await wrapStore(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));++ await q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());++ await attachments.markAvailable(id);++ return (await svc.attachmentsOf(page.id)).find((a) => a.id === att.id);});},/** A page's attachments, each with who attached it (null for rows older than the record). */-- attachmentsOf(pageId) {-- const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));-- return attachments.list(pageId).map((a) => {-- const o = origins.get(a.id);++ async attachmentsOf(pageId) {++ const [origins, list] = await Promise.all([q.originsOf.all(pageId), attachments.list(pageId)]);++ const byId = new Map(origins.map((o) => [o.attachment_id, o]));++ return list.map((a) => {++ const o = byId.get(a.id);return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };});},-- detachMedia(pageId, attachmentId, actor) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async detachMedia(pageId, attachmentId, actor) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can detach media');return attachments.detach(page.id, attachmentId);},@@@ -1122,7 +1215,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =async verifyMedia(pageId, resolve) { return attachments.verify(pageId, { resolve }); },async verifyAllMedia(resolve) {-- const ids = db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments').all().map((r) => r.entity_id);++ const ids = (await q.attachedEntities.all()).map((r) => r.entity_id);const out = [];for (const id of ids) out.push(...await attachments.verify(id, { resolve }));return out;@@@ -1131,7 +1224,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =// Discussion -------------------------------------------------------------------------/** The Community thread of a public, published page (resolved once, then stored as a reference). */async discussionThread(space, page, client) {-- const known = discussions.get(page.id);++ const known = await discussions.get(page.id);if (known) return known.threadId;if (page.state !== 'published' || access.effectiveVisibility(space, page) !== 'public') return null;const out = await discussions.threadFor(page.id, { service: 'wiki', type: 'page', id: page.id, label: page.title.slice(0, 200) }, { client });@@@ -1139,12 +1232,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =},// Watchers ----------------------------------------------------------------------------- watch(pageId, actor, on = true) {-- const page = pageOrFail(pageId);-- const space = spaceOrFail(page.space_id);++ async watch(pageId, actor, on = true) {++ await roles(actor);++ const page = await pageOrFail(pageId);++ const space = await spaceOrFail(page.space_id);const who = requirePerson(actor);if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');-- if (on) q.watch.run(page.id, who, now()); else q.unwatch.run(page.id, who);++ if (on) await q.watch.run(page.id, who, now()); else await q.unwatch.run(page.id, who);return { watching: on };},@@@ -1154,12 +1248,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.*/reconcileIndex() {-- return tx(() => {++ return tx(async () => {let sent = 0;-- for (const { id } of db.prepare('SELECT id FROM wiki_pages ORDER BY id').all()) {-- const b = before(q.pageById.get(id));++ for (const { id } of await q.pageIds.all()) {++ const b = await before(await q.pageById.get(id));if (b.indexRevision == null) continue; // never sent: nothing in Search to correct-- const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });++ const out = await sync(b, id, { kind: 'system', service: 'svc:wiki' });if (out.indexRevision !== b.indexRevision) sent++;}return { sent };@@@ -1172,26 +1266,27 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* gate is re-evaluated, and a changed Search document goes out with wiki.page.updated.* Pending AI proposals are reviewed through reviewProposal instead.*/-- reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {-- const space = spaceOrFail(spaceIdOrSlug);-- const page = q.pageBySlug.get(space.id, String(slug));++ async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {++ await roles(actor);++ const space = await spaceOrFail(spaceIdOrSlug);++ const page = await q.pageBySlug.get(space.id, String(slug));if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');if (!access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');const who = requirePerson(actor);if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');const n = Number(revisionNumber);-- if (!Number.isInteger(n) || !revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);-- const prop = q.proposalByRev.get(page.id, n);++ if (!Number.isInteger(n) || !await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);++ const prop = await q.proposalByRev.get(page.id, n);if (prop && prop.status === 'pending') fail(409, 'review.proposal_pending', 'This revision is a pending AI proposal: approve or reject the proposal', { proposal_id: prop.id });-- return tx(() => {-- const b = before(page);-- const review = wrapContentError(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));++ return tx(async () => {++ const b = await before(page);++ const review = await wrapStore(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));const out = page.state === 'published' && page.published_revision === n-- ? sync(b, page.id, actor, { updatedIfIndexChanged: true })++ ? await sync(b, page.id, actor, { updatedIfIndexChanged: true }): { action: null, indexRevision: b ? b.indexRevision : null };-- const fresh = q.pageById.get(page.id);-- const rev = revisions.get(page.id, n);-- const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? decide(space, fresh, rev) : null;++ const fresh = await q.pageById.get(page.id);++ const rev = await revisions.get(page.id, n);++ const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? await decide(space, fresh, rev) : null;return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };});},@@@ -1202,76 +1297,80 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =* (workflow + run id) and a pending proposal; nothing is published until a person approves.* pageId null + title = propose a new page (created as a draft).*/-- propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {++ async propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {// A proposal may target any space, private ones included, and never needs a person: only// first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');++ await roles(actor);const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));const cleanSummary = checkSummary(summary);const text = checkBody(body == null ? '' : body);const box = wrapContentError(() => content.normalizeInfobox(infobox));-- return tx(() => {-- let page = pageId ? pageOrFail(pageId) : null;-- const space = page ? spaceOrFail(page.space_id) : spaceOrFail(spaceIdOrSlug);++ return tx(async () => {++ let page = pageId ? await pageOrFail(pageId) : null;++ const space = page ? await spaceOrFail(page.space_id) : await spaceOrFail(spaceIdOrSlug);const t = now();let base = 0;if (!page) {const cleanTitle = checkTitle(title);const slug = wrapContentError(() => content.pageSlug(cleanTitle));-- if (q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);++ if (await q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);const id = pageIdNew(t);-- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });-- page = q.pageById.get(id);++ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });++ page = await q.pageById.get(id);} else {if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');-- base = revisions.headNumber(page.id);++ base = await revisions.headNumber(page.id);if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });}-- const head = base ? revisions.head(page.id) : null;++ const head = base ? await revisions.head(page.id) : null;const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };-- const { revision } = wrapContentError(() => revisions.create({++ const { revision } = await wrapStore(() => revisions.create({entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,}));-- writeRevisionExtras(space, page, revision, box);-- attachCitationList(page, revision.number, cites, actor);++ await writeRevisionExtras(space, page, revision, box);++ await attachCitationList(page, revision.number, cites, actor);const id = proposalIdNew(t);-- q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });-- revisionEvent(space, page, revision, actor, { proposal_id: id });-- notifyWatchers(space, page, 'proposed', revision.number, actor);-- return { proposal: q.proposal.get(id), page: q.pageById.get(page.id), revision };++ await q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });++ await revisionEvent(space, page, revision, actor, { proposal_id: id });++ await notifyWatchers(space, page, 'proposed', revision.number, actor);++ return { proposal: await q.proposal.get(id), page: await q.pageById.get(page.id), revision };});},-- proposals(pageId) { return q.proposalsOfPage.all(pageFor(pageId).id); },-- pendingProposals(actor) {-- return q.pendingProposals.all().filter((p) => access.canEdit(q.spaceById.get(p.space_id), actor));++ async proposals(pageId) { return q.proposalsOfPage.all((await pageOrFail(pageId)).id); },++ async pendingProposals(actor) {++ await roles(actor);++ const rows = await q.pendingProposals.all();++ if (!rows.length) return [];++ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));++ return rows.filter((p) => access.canEdit(spaces.get(p.space_id), actor));},-- getProposal(id) { return q.proposal.get(String(id)) || null; },++ async getProposal(id) { return (await q.proposal.get(String(id))) || null; },/** A person approves (and by default publishes) or rejects an AI proposal. */-- reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {-- const p = q.proposal.get(String(id));++ async reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {++ await roles(actor);++ const p = await q.proposal.get(String(id));if (!p) fail(404, 'proposal.not_found', 'No such proposal');-- const space = spaceOrFail(p.space_id);++ const space = await spaceOrFail(p.space_id);if (!access.canEdit(space, actor)) fail(403, 'proposal.forbidden', 'Only editors of this space review proposals');const who = requirePerson(actor);if (decision !== 'approved' && decision !== 'rejected') fail(422, 'proposal.invalid_decision', 'decision is approved or rejected');if (p.status !== 'pending') fail(409, 'proposal.already_reviewed', `This proposal was already ${p.status}`);-- return tx(() => {-- reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });-- q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });++ return tx(async () => {++ await reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });++ await q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });let published = null;-- if (decision === 'approved' && publish) published = publishRevision(q.pageById.get(p.page_id), p.revision, actor);-- return { proposal: q.proposal.get(p.id), published: !!published };++ if (decision === 'approved' && publish) published = await publishRevision(await q.pageById.get(p.page_id), p.revision, actor);++ return { proposal: await q.proposal.get(p.id), published: !!published };});},};-- function pageFor(id) { return pageOrFail(id); }--return svc;}@@@ -52,19 +52,19 @@ const { actor, serviceItself } = require('../server/http/actor-limits');// an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past itclock = Date.UTC(2026, 8, 27, 12, 5, 0);const owner = { kind: 'user', subject: alice, staff: false };-- const space = h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);-- const { page } = h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);-- h.svc.setRole(space.id, bob, 'editor', owner);-- const head = () => h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;++ const space = await h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);++ const { page } = await h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);++ await h.svc.setRole(space.id, bob, 'editor', owner);++ const head = async () => (await h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;for (let i = 0; i < 30; i++) {-- r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: head(), body: `${H.LONG} Edit ${i}.` } });++ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: await head(), body: `${H.LONG} Edit ${i}.` } });assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);}-- const stored = head();++ const stored = await head();r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);-- assert.strictEqual(head(), stored, 'nothing stored');++ assert.strictEqual(await head(), stored, 'nothing stored');r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);@@@ -16,24 +16,24 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');const owner = { kind: 'user', subject: H.subject(), staff: false };const cookie = H.cookieFor(H.userToken({ subject: owner.subject }));try {-- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);-- const { page } = h.svc.createPage(space.id, {++ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);++ const { page } = await h.svc.createPage(space.id, {title: 'Rye', body: H.LONG,citations: [{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01T00:00:00Z', licenseNote: 'CC BY 4.0', quote: { text: 'Rye has less gluten.' } },{ url: 'https://old.example/bread', title: 'Old bread book', retrievedAt: '2026-08-15T00:00:00Z' },],}, owner);-- h.svc.publish(page.id, {}, owner);-- const r1 = h.svc.citationsOf(page, 1);++ await h.svc.publish(page.id, {}, owner);++ const r1 = await h.svc.citationsOf(page, 1);// Revision 2 keeps the first source, drops the second and adds a third.-- h.svc.editPage(page.id, {++ await h.svc.editPage(page.id, {expectedRevision: 1, body: `${H.LONG} Sourdough helps.`, keepCitations: [r1[0].id],citations: [{ url: 'https://sour.example/dough', title: 'Sourdough notes', retrievedAt: '2026-09-10T12:00:00Z' }],}, owner);-- h.svc.publish(page.id, {}, owner);++ await h.svc.publish(page.id, {}, owner);// Revision 3 is an unpublished draft citing something not public yet.-- h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);++ await h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);// Linked from the article and the history page.let r = await H.req(h, 'GET', '/w/bread/rye');@@@ -71,28 +71,28 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');assert.ok(r.text.includes('secret.example/draft'));// A renamed page redirects to its inspector.-- h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);++ await h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');assert.strictEqual(r.status, 301);assert.strictEqual(r.headers.get('location'), '/w/bread/rye-bread/sources?rev=1');// Same read rules as the article: private pages, members spaces, drafts and deleted pages.-- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);++ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);r = await H.req(h, 'GET', '/w/bread/rye-bread/sources');assert.strictEqual(r.status, 404);assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');assert.ok(!r.text.includes('flour.example'));assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie })).status, 200, 'the owner still inspects it');-- h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);-- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);++ await h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);++ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources')).status, 404, 'anonymous: members space');r = await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie: H.cookieFor(H.userToken()) });assert.strictEqual(r.status, 200, 'any signed-in account reads a members space');assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');-- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);-- const draft = h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner).page;++ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);++ const draft = (await h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner)).page;assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 404, 'a draft page');-- h.svc.deletePage(draft.id, owner);++ await h.svc.deletePage(draft.id, owner);assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 410);console.log('citation inspector ok');} finally {@@@ -38,13 +38,13 @@ for (const bad of ['Site | url | javascript:alert(1)', 'N | number | lots', 'D |const h = await H.boot();const me = { kind: 'user', subject: H.subject() };try {-- const s = h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);-- const { page } = h.svc.createPage(s.id, {++ const s = await h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);++ const { page } = await h.svc.createPage(s.id, {title: '<script>alert(1)</script> title', body: `Body ${H.LONG}`, summary: '"><script>alert(2)</script>',infobox: [{ label: '<i>L</i>', type: 'text', value: '<img src=x onerror=alert(3)>' }],citations: [{ url: 'https://example.org/"><script>', title: '<script>alert(4)</script>', retrievedAt: '2026-09-01' }],}, me);-- h.svc.publish(page.id, {}, me);++ await h.svc.publish(page.id, {}, me);const r = await H.req(h, 'GET', `/w/xss/${page.slug}`);assert.strictEqual(r.status, 200);const body = r.text.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, '').replace(/<script>\s*window\.__OV_PAGE[\s\S]*?<\/script>/, '').replace(/<script>\(function\(\)\{try\{var r=localStorage[\s\S]*?<\/script>/, '');nnew file mode 100644@@@ -0,0 +1,13 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * WIKI_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers (openvibe-sdk scripts/test-services.sh++ * up), with roles and a schema of this run's own, shaped as OpenVibe.Host's roles/data/add-service.sh makes them.++ */++const { createTestDb, createTestValkey, pgAvailable, valkeyAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../server/db');++++const testDb = ({ store = process.env.WIKI_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'wiki', max });++const testValkey = () => createTestValkey({ prefix: 'wiki' });++++module.exports = { testDb, testValkey, pgAvailable, valkeyAvailable };@@@ -35,13 +35,13 @@ const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];assert.strictEqual(r.status, 403, `${type} read as the victim`);}}-- assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get().n, 0);++ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get()).n, 0);// An app acts for its on_behalf_of person (the header is optional and must match).const appTok = token(APP, 'app', { on_behalf_of: appUser });r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(h.svc.findSpace('app-user').owner, appUser);++ assert.strictEqual((await h.svc.findSpace('app-user')).owner, appUser);r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });assert.strictEqual(r.status, 201, r.text);r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });@@@ -25,7 +25,7 @@ const H = require('./helpers');const vis = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);if (vis && method === 'PUT') {const headers = Object.fromEntries(new Headers(init.headers || {}).entries());-- calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction });++ calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction() });if (communityDown) return json(503, { code: 'unavailable' });return json(200, { thread: { id: Number(vis[1]), visibility: JSON.parse(init.body).visibility } });}@@@ -58,7 +58,7 @@ const H = require('./helpers');// Rendering the public page resolves its thread (id 1).r = await H.req(h, 'GET', '/w/talk/discussed');assert.strictEqual(r.status, 200);-- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '1');++ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '1');// Unpublished: hidden. After the commit, with a moderate token.r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });@@@ -115,7 +115,7 @@ const H = require('./helpers');assert.strictEqual(r.status, 200, r.text);await settle(7);assert.strictEqual(calls.length, 7);-- assert.strictEqual(h.svc.pageById(pageId).state, 'unpublished');++ assert.strictEqual((await h.svc.pageById(pageId)).state, 'unpublished');communityDown = false;r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });await settle(8);@@@ -60,27 +60,27 @@ function atom(text) {assert.deepStrictEqual(f.entries, []);// Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const seeded = h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM).page;-- h.svc.publish(seeded.id, {}, SYSTEM);-- const hidden = h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner).page;-- h.svc.publish(hidden.id, {}, owner);-- h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);-- const secret = h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);-- const inSecret = h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner).page;-- h.svc.publish(inSecret.id, {}, owner);++ const seeded = (await h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM)).page;++ await h.svc.publish(seeded.id, {}, SYSTEM);++ const hidden = (await h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner)).page;++ await h.svc.publish(hidden.id, {}, owner);++ await h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);++ const secret = await h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);++ const inSecret = (await h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner)).page;++ await h.svc.publish(inSecret.id, {}, owner);r = await H.req(h, 'GET', '/feed.atom');assert.strictEqual(r.status, 200);f = atom(r.text);assert.deepStrictEqual(f.entries, []);-- assert.strictEqual(f.updated, new Date(h.svc.findSpace('notes').updated_at).toISOString(), 'the last change to a public space');++ assert.strictEqual(f.updated, new Date((await h.svc.findSpace('notes')).updated_at).toISOString(), 'the last change to a public space');assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);// A listable page: one entry, and the feed's <updated> is that entry's.-- const open = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;-- h.svc.publish(open.id, {}, owner);++ const open = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;++ await h.svc.publish(open.id, {}, owner);f = atom((await H.req(h, 'GET', '/feed.atom')).text);assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);assert.strictEqual(f.updated, f.entries[0].updated);@@@ -10,6 +10,7 @@ const path = require('path');const { serviceAuth } = require('openvibe-contracts');const { load } = require('../server/config');const { start } = require('../server/index');++const { testDb } = require('./db-helper');const ISSUER = 'https://network.test';const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });@@@ -32,14 +33,20 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', exp = 30return serviceAuth.signServiceToken({ iss: ISSUER, sub: sub || `svc:${client}`, actor_type: actorType, aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...app, ...extra }, privateKey);}--const quiet = { log() {}, warn() {}, error() {} };++const quiet = process.env.WIKI_TEST_LOG ? console : { log() {}, warn() {}, error() {} };/*** A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls* (Community, Sources, Media, Events, Network token endpoint).*/--async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {++/**++ * A running Wiki on a database of its own (PGlite by default; WIKI_TEST_STORE=pg: the PostgreSQL + PgBouncer++ * containers). opts.db: boot on an existing handle (a restart keeps the data); h.stop() closes what boot opened.++ */++async function boot({ env = {}, fetch: fetchImpl, dbPath, db: givenDb = null, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));++ const owned = givenDb ? null : await testDb();++ const db = givenDb || owned.db;const config = load({NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',WIKI_DB_PATH: dbPath || path.join(dir, 'wiki.db'),@@@ -47,12 +54,13 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,...env,});const h = await start({-- config, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,++ config, db, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },});const base = `http://127.0.0.1:${h.server.address().port}`;-- return { ...h, base, dir, dbPath: config.dbPath };++ const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };++ return { ...h, stop, base, dir, dbPath: config.dbPath, closeDb: owned ? owned.close : async () => {} };}async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {@@@ -77,8 +85,8 @@ async function req(h, method, p, { token, body, form, headers = {}, cookie } = {const cookieFor = (token) => `ov_token=${token}`;/** Every envelope in the outbox, oldest first. */--function outbox(h) {-- return h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));++async function outbox(h) {++ return (await h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));}const LONG = 'This page has enough words to pass the thin content rule of the indexability gate, which the tests set to twenty words so that a short paragraph is enough for a published page to be indexable.';@@@ -9,7 +9,7 @@const assert = require('assert');const H = require('./helpers');--const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;++const count = async (h, table) => (await h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).n;(async () => {const h = await H.boot();@@@ -33,7 +33,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gawait H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });// Only an owner, acting as a person, imports.-- const pagesBefore = count(h, 'wiki_pages');++ const pagesBefore = await count(h, 'wiki_pages');r = await imp(tokEditor, bundle);assert.strictEqual(r.status, 403, 'an editor is not an owner');assert.strictEqual(r.json.code, 'import.forbidden');@@@ -43,7 +43,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gassert.strictEqual(r.status, 403, 'a service acting as itself is no owner');r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');-- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore);++ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore);// Invalid and oversized bundles are refused before anything is written.const refusals = [@@@ -73,12 +73,12 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gr = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });assert.strictEqual(r.status, 413, 'a bundle over 2 MB');assert.strictEqual(r.json.code, 'request.too_large');-- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');++ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');// Atomicity: the third page is invalid (an infobox value that is not a number; a parent that// is nowhere) → none of the pages, revisions, citations or events exist afterwards.-- const snapshot = () => ['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t));-- const before = snapshot();++ const snapshot = async () => Promise.all(['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t)));++ const before = await snapshot();for (const broken of [{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },@@@ -87,7 +87,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g]) {r = await imp(tokOwner, broken);assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);-- assert.deepStrictEqual(snapshot(), before, `all or nothing: ${r.json.code}`);++ assert.deepStrictEqual(await snapshot(), before, `all or nothing: ${r.json.code}`);}// Success: drafts, attributed to the importer, links and citations recorded, events emitted.@@@ -95,19 +95,19 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gassert.strictEqual(r.status, 201, r.text);assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');assert.strictEqual(r.json.published, false);-- const space = h.svc.findSpace('bakery');-- const rye = h.svc.findPage(space.id, 'rye');-- const bread = h.svc.findPage(space.id, 'bread');++ const space = await h.svc.findSpace('bakery');++ const rye = await h.svc.findPage(space.id, 'rye');++ const bread = await h.svc.findPage(space.id, 'bread');assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');-- assert.strictEqual(h.svc.findPage(space.id, 'private-notes').visibility, 'private');-- const rev = h.stores.revisions.get(rye.id, 1);++ assert.strictEqual((await h.svc.findPage(space.id, 'private-notes')).visibility, 'private');++ const rev = await h.stores.revisions.get(rye.id, 1);assert.strictEqual(rev.author, owner);assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });-- assert.strictEqual(h.svc.citationsOf(rye, 1)[0].url, 'https://flour.example/rye');-- assert.ok(h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');-- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));++ assert.strictEqual((await h.svc.citationsOf(rye, 1))[0].url, 'https://flour.example/rye');++ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');++ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));assert.strictEqual(created.length, 2);-- assert.ok(h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');++ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');// Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });@@@ -121,18 +121,18 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gr = await imp(tokOwner, again);assert.strictEqual(r.status, 409);assert.strictEqual(r.json.code, 'import.pages_exist');-- assert.strictEqual(h.svc.findPage(space.id, 'spelt'), null);++ assert.strictEqual(await h.svc.findPage(space.id, 'spelt'), null);r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });assert.strictEqual(r.status, 201, r.text);assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);-- assert.strictEqual(h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');++ assert.strictEqual(await h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');// Published through the import: readable, indexed like any page (upsert event).assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);-- const spelt = h.svc.findPage(space.id, 'spelt');++ const spelt = await h.svc.findPage(space.id, 'spelt');assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));// AI-assisted bundles stay noindex until a person reviews each page.r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });@@@ -160,8 +160,8 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).gassert.strictEqual(r.status, 403);r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });assert.strictEqual(r.status, 403, 'cross-site form posts are refused');-- assert.strictEqual(h.svc.findPage(space.id, 'nope'), null);-- assert.strictEqual(h.svc.findPage(space.id, 'csrf'), null);++ assert.strictEqual(await h.svc.findPage(space.id, 'nope'), null);++ assert.strictEqual(await h.svc.findPage(space.id, 'csrf'), null);console.log('import ok');} finally {await h.stop();@@@ -76,7 +76,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';sourcesUp = false;r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Down', body: H.LONG, citations: [{ source_item_id: ITEM }] } });assert.strictEqual(r.status, 503);-- assert.strictEqual(h.svc.findPage(h.svc.findSpace('mt').id, 'down'), null, 'nothing is written without the source');++ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('mt')).id, 'down'), null, 'nothing is written without the source');sourcesUp = true;await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });@@@ -108,8 +108,8 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';const resolves = calls.filter((c) => c.url === '/api/v1/comments/threads/resolve');assert.strictEqual(resolves.length, 1);assert.deepStrictEqual(JSON.parse(resolves[0].body).ref, { service: 'wiki', type: 'page', id: pageId, label: 'Cited' });-- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '7');-- assert.deepStrictEqual(h.db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');++ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '7');++ assert.deepStrictEqual((await h.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'wiki_discussion_refs'").all()).map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');r = await H.req(h, 'POST', '/w/mt/cited/discuss', { cookie: H.cookieFor(tok), form: { message: 'Thanks' } });assert.strictEqual(r.status, 303);const post = calls.find((c) => c.url === '/api/v1/comments/threads/7/comments');@@@ -119,11 +119,11 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';assert.strictEqual(r.status, 401, 'anonymous visitors do not post through Wiki');// Events: the relay publishes the outbox with the service token.-- const pending = h.platform.outbox.pending();++ const pending = await h.platform.outbox.pending();assert.ok(pending > 0);const flushed = await h.platform.outbox.flush();assert.strictEqual(flushed.sent, pending);-- assert.strictEqual(h.platform.outbox.pending(), 0);++ assert.strictEqual(await h.platform.outbox.pending(), 0);const ev = calls.filter((c) => c.host === 'events.internal');assert.ok(ev.length >= 1);assert.strictEqual(ev[0].headers.authorization, 'Bearer stub-token');@@@ -55,12 +55,12 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;assert.strictEqual(r.json.attachment.state, 'available');assert.strictEqual(r.json.attachment.attached_by, A);assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');-- const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);++ const origins = await h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);assert.deepStrictEqual(origins, [{ media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },{ media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },]);-- assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);r = await attach(tokA, pageId, MED(4));assert.strictEqual(r.status, 409);assert.strictEqual(r.json.code, 'media.not_ready');@@@ -76,7 +76,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;assert.strictEqual(r.status, 201, r.text);r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2);// 3. B cannot attach an object only A can read — the same answer as an id that does not exist.const refused = await attach(tokB, pageId, MED(3));@@@ -106,7 +106,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;assert.strictEqual(r.status, 503);mediaUp = true;await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');-- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');++ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2, 'nothing refused was attached');// 4. Media deletes one object and makes the other private: after the check, the public page// shows the explicit states and no image.@@@ -35,7 +35,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'preview', title: 'Chess', body, infobox: 'Players | number | 2\nInvented | text | unknown' } });assert.strictEqual(r.status, 200);assert.ok(r.text.includes('Preview (not saved)'));-- assert.strictEqual(h.svc.findPage(h.svc.findSpace('board-games').id, 'chess'), null);++ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('board-games')).id, 'chess'), null);// Invalid infobox rows come back with the text intact.r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'save', title: 'Chess', body, infobox: 'Players | number | two' } });assert.strictEqual(r.status, 422);@@@ -73,10 +73,10 @@ const H = require('./helpers');// Staff get no silent access to a private user space.assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes', { cookie: H.cookieFor(tok(staff, 'admin')) })).status, 404);// A watcher who loses access is not notified.-- h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);-- h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());-- h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });-- const watch = H.outbox(h).filter((e) => e.event_type === 'wiki.watch.triggered').pop();++ await h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);++ await h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());++ await h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });++ const watch = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.watch.triggered').pop();assert.ok(watch.payload.recipients.includes(viewer));assert.ok(!watch.payload.recipients.includes(stranger), 'no notification about a page the person cannot read');assert.ok(!watch.payload.recipients.includes(editor), 'the actor is not notified of their own change');@@@ -147,7 +147,7 @@ const H = require('./helpers');assert.ok(shown.text.includes('Proposed by a workflow'));assert.ok(shown.text.includes('AI-generated'), 'the AI disclosure is shown at the item');assert.ok(shown.text.includes('reviewed by a person'));-- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;++ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;assert.strictEqual(doc.authorship, 'ai_generated');assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === 'run_01'));r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'rejected' } });@@@ -163,7 +163,7 @@ const H = require('./helpers');const p2 = r.json.proposal;r = await H.req(h, 'POST', `/api/v1/proposals/${p2.id}/review`, { token: tok(editor), body: { decision: 'rejected', note: 'not sourced' } });assert.strictEqual(r.json.published, false);-- assert.strictEqual(h.svc.pageById(p2.page_id).state, 'draft');++ assert.strictEqual((await h.svc.pageById(p2.page_id)).state, 'draft');assert.strictEqual((await H.req(h, 'GET', '/w/garden/peppers')).status, 404);console.log('permissions ok');} finally {@@@ -22,29 +22,29 @@ const { summarize, createProjectsModule } = require('../server/integrations/projreturn { ok: true, status: 201, json: async () => ({}) };};const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });-- const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);++ const dirty = async () => (await h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all()).map((r) => r.subject);try {let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });assert.strictEqual(r.status, 201, r.text);r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });assert.strictEqual(r.status, 201, r.text);-- assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');++ assert.deepStrictEqual(await dirty(), [ann], 'creating a space marks its owner');r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });assert.strictEqual(r.status, 200, r.text);r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });-- assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });-- assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');++ assert.deepStrictEqual(await summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });++ assert.deepStrictEqual(await summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });++ assert.ok(modules.validateData('wiki.projects', await summarize(h.db, ann)).valid, 'matches the namespace schema');assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());-- assert.deepStrictEqual(dirty(), [], 'every mark cleared');++ assert.deepStrictEqual(await dirty(), [], 'every mark cleared');// Bob loses his role: he is marked although he is no longer in the space's list.r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });assert.strictEqual(r.status, 200, r.text);-- assert.ok(dirty().includes(bob));++ assert.ok((await dirty()).includes(bob));await projects.drain();assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');@@@ -55,7 +55,7 @@ const { summarize, createProjectsModule } = require('../server/integrations/projawait projects.drain();assert.strictEqual(puts.length, before + 1);assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');-- h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);++ await h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);await projects.drain();assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');assert.strictEqual(projects.stats().unchanged >= 2, true);@@@ -19,25 +19,25 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeconst staff = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject();const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });try {-- seed(h.svc, data, { log: H.quiet });-- const space = h.svc.findSpace('openvibe');++ await seed(h.svc, data, { log: H.quiet });++ const space = await h.svc.findSpace('openvibe');// A row seeded before the flag existed: recognised by its label alone.-- const legacy = h.svc.createPage(space.id, {++ const legacy = await h.svc.createPage(space.id, {title: 'Legacy import', body: `${data.pages[1].body}`, citations: [{ url: data.pages[1].citations[0].url, retrievedAt: '2026-09-22T12:00:00Z' }],authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },}, SYSTEM);-- h.svc.publish(legacy.page.id, {}, SYSTEM);-- assert.strictEqual(h.stores.revisions.get(legacy.page.id, 1).meta.authorship.importedFrom.aiAssisted, undefined);-- assert.deepStrictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);++ await h.svc.publish(legacy.page.id, {}, SYSTEM);++ assert.strictEqual((await h.stores.revisions.get(legacy.page.id, 1)).meta.authorship.importedFrom.aiAssisted, undefined);++ assert.deepStrictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);-- h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });-- h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });-- const page = h.svc.findPage(space.id, 'openvibe-search');++ await h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });++ await h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });++ const page = await h.svc.findPage(space.id, 'openvibe-search');const n = page.published_revision;const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;-- const indexEvents = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));-- const updates = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');-- const before = { index: indexEvents().length, updates: updates().length };++ const indexEvents = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));++ const updates = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');++ const before = { index: (await indexEvents()).length, updates: (await updates()).length };// Refused: anonymous, a signed-in stranger, a viewer, a service acting as itself, a service without the capability.assert.strictEqual((await H.req(h, 'POST', url, { body: { decision: 'approved' } })).status, 403);@@@ -50,8 +50,8 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeassert.strictEqual((await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'maybe' } })).status, 422);assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/99/review`, { token: tok(editor), body: { decision: 'approved' } })).status, 404);// Nothing changed.-- assert.strictEqual(h.stores.reviews.history(page.id).length, 0);-- assert.deepStrictEqual({ index: indexEvents().length, updates: updates().length }, before);++ assert.strictEqual((await h.stores.reviews.history(page.id)).length, 0);++ assert.deepStrictEqual({ index: (await indexEvents()).length, updates: (await updates()).length }, before);assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));// The history view offers the review form to editors only.@@@ -67,18 +67,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'oper = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(editor)), form: { revision: String(n), decision: 'approved', note: 'Checked against the README' } });assert.strictEqual(r.status, 303);assert.strictEqual(r.headers.get('location'), '/w/openvibe/openvibe-search/history');-- const reviews = h.stores.reviews.history(page.id);++ const reviews = await h.stores.reviews.history(page.id);assert.strictEqual(reviews.length, 1);assert.deepStrictEqual([reviews[0].reviewer, reviews[0].decision, reviews[0].note], [editor, 'approved', 'Checked against the README']);// Indexable now; the Search document went out exactly once, with wiki.page.updated.-- const idx = indexEvents().slice(before.index);++ const idx = (await indexEvents()).slice(before.index);assert.strictEqual(idx.length, 1);assert.strictEqual(idx[0].event_type, 'wiki.index_document.upserted');assert.strictEqual(idx[0].payload.indexability.decision, 'index');-- assert.ok(idx[0].subject.revision > indexEvents()[before.index - 1].subject.revision, 'the sequencer bumped the index revision');++ assert.ok(idx[0].subject.revision > (await indexEvents())[before.index - 1].subject.revision, 'the sequencer bumped the index revision');assert.ok(contracts.validate('search.index-document@1', idx[0].payload).valid);-- const upd = updates().slice(before.updates);++ const upd = (await updates()).slice(before.updates);assert.strictEqual(upd.length, 1);assert.strictEqual(upd[0].visibility, 'public');assert.strictEqual(upd[0].payload.indexability.decision, 'index');@@@ -97,30 +97,30 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeassert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-events'));// A second identical approval records the review but sends nothing new (same document).-- const count = indexEvents().length;++ const count = (await indexEvents()).length;r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });assert.strictEqual(r.status, 201, r.text);assert.strictEqual(r.json.indexable, true);assert.strictEqual(r.json.action, null);-- assert.strictEqual(indexEvents().length, count);++ assert.strictEqual((await indexEvents()).length, count);// "Needs changes" takes it out again: tombstone + wiki.page.updated, gone from the sitemap.r = await H.req(h, 'POST', url, { token: tok(staff, 'admin'), body: { decision: 'rejected', note: 'Status is out of date' } });assert.strictEqual(r.status, 201, r.text);assert.strictEqual(r.json.indexable, false);assert.deepStrictEqual(r.json.reasons, ['ai_generated_unreviewed']);-- assert.strictEqual(indexEvents().pop().event_type, 'wiki.index_document.deleted');-- assert.strictEqual(updates().length, before.updates + 2);++ assert.strictEqual((await indexEvents()).pop().event_type, 'wiki.index_document.deleted');++ assert.strictEqual((await updates()).length, before.updates + 2);assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));assert.ok((await H.req(h, 'GET', '/w/openvibe/openvibe-search')).text.includes('Not yet reviewed by a person.'));// The legacy-labelled row is reviewable the same way.-- h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });-- assert.strictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.indexable, true);++ await h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });++ assert.strictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.indexable, true);// Human revisions and AI proposals are unaffected: a pending proposal goes through its own review.-- const prop = h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });-- assert.throws(() => h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');++ const prop = await h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });++ await assert.rejects(async () => await h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');console.log('review ok');} finally {await h.stop();@@@ -131,21 +131,22 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope(async () => {const { seed: seedFn } = require('../server/wiki/seed');const h1 = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });-- const dbPath = h1.dbPath;-- seedFn(h1.svc, data, { log: H.quiet });-- const page = h1.svc.findPage(h1.svc.findSpace('openvibe').id, 'openvibe-network');++ const shared = h1.db;++ await seedFn(h1.svc, data, { log: H.quiet });++ const page = await h1.svc.findPage((await h1.svc.findSpace('openvibe')).id, 'openvibe-network');// Simulate the host: Search was last sent an indexable document for this page.-- const old = h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id).revision;-- h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);-- await h1.stop();-- const h2 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });-- const ev = H.outbox(h2).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();++ const old = (await h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id)).revision;++ await h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);++ await h1.stop({ keepDb: true });++ const h2 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });++ const ev = (await H.outbox(h2)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');assert.ok(ev.subject.revision > old);-- const n = H.outbox(h2).length;-- await h2.stop();-- const h3 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });-- assert.strictEqual(H.outbox(h3).length, n, 'a second boot sends nothing');++ const n = (await H.outbox(h2)).length;++ await h2.stop({ keepDb: true });++ const h3 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });++ assert.strictEqual((await H.outbox(h3)).length, n, 'a second boot sends nothing');await h3.stop();++ await h1.closeDb();console.log('reconcile ok');})().catch((err) => { console.error(err); process.exit(1); });@@@ -10,12 +10,12 @@ const H = require('./helpers');const h = await H.boot();const alice = { kind: 'user', subject: H.subject(), staff: false };try {-- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);++ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);const src1 = { url: 'https://example.org/rye', title: 'Rye notes', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass.' } };const src2 = { url: 'https://example.org/wheat', title: 'Wheat notes', retrievedAt: '2026-09-21T10:00:00Z' };// r1: created with a citation and a typed infobox-- const { page, revision: r1 } = h.svc.createPage(space.id, {++ const { page, revision: r1 } = await h.svc.createPage(space.id, {title: 'Rye bread', body: `# Rye bread\n\n${H.LONG}`, citations: [src1],infobox: [{ label: 'Flour', type: 'text', value: 'rye' }, { label: 'Hydration', type: 'number', value: '78' }, { label: 'Sourdough', type: 'boolean', value: 'yes' }],}, alice);@@@ -23,11 +23,11 @@ const H = require('./helpers');assert.strictEqual(page.state, 'draft');// publish r1-- h.svc.publish(page.id, { revision: 1 }, alice);-- assert.strictEqual(h.svc.pageById(page.id).published_revision, 1);++ await h.svc.publish(page.id, { revision: 1 }, alice);++ assert.strictEqual((await h.svc.pageById(page.id)).published_revision, 1);// r2: edit drops src1, adds src2, changes the infobox-- const e2 = h.svc.editPage(page.id, {++ const e2 = await h.svc.editPage(page.id, {expectedRevision: 1, body: `# Rye bread\n\nA different text. ${H.LONG}`, citations: [src2], keepCitations: 'none',infobox: [{ label: 'Flour', type: 'text', value: 'rye and wheat' }],}, alice);@@@ -35,20 +35,20 @@ const H = require('./helpers');assert.strictEqual(e2.created, true);// Optimistic concurrency: an edit based on r1 is a 412 conflict.-- assert.throws(() => h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');++ await assert.rejects(async () => await h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');// An identical edit creates nothing.-- const same = h.svc.editPage(page.id, { expectedRevision: 2 }, alice);++ const same = await h.svc.editPage(page.id, { expectedRevision: 2 }, alice);assert.strictEqual(same.created, false);// Citations stay with their revision.-- const c1 = h.svc.citationsOf(page, 1);-- const c2 = h.svc.citationsOf(page, 2);++ const c1 = await h.svc.citationsOf(page, 1);++ const c2 = await h.svc.citationsOf(page, 2);assert.deepStrictEqual(c1.map((c) => c.url), ['https://example.org/rye']);assert.deepStrictEqual(c2.map((c) => c.url), ['https://example.org/wheat']);assert.strictEqual(c1[0].retrievedAt, '2026-09-20T10:00:00.000Z');assert.deepStrictEqual(c1[0].quote, { text: 'Rye is a grass.', start: null, end: null });// The published page (still r1) shows r1's sources, not r2's.-- const pub = h.svc.view(space, h.svc.pageById(page.id), alice, {});++ const pub = await h.svc.view(space, await h.svc.pageById(page.id), alice, {});assert.strictEqual(pub.revision.number, 1);assert.deepStrictEqual(pub.citations.map((c) => c.url), ['https://example.org/rye']);assert.deepStrictEqual(pub.infobox, [@@@ -58,40 +58,40 @@ const H = require('./helpers');]);// Citations cannot be attached to the published revision after the fact.-- assert.throws(() => h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);++ await assert.rejects(async () => await h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);// ...but can be to the unpublished head.-- h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);-- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2);++ await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);++ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2);// A URL citation without a retrieval time is refused (never invented).-- assert.throws(() => h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');++ await assert.rejects(async () => await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');// Publish r2, then diff r1 → r2-- h.svc.publish(page.id, { revision: 2 }, alice);-- const d = h.svc.diff(h.svc.pageById(page.id), 1, 2);++ await h.svc.publish(page.id, { revision: 2 }, alice);++ const d = await h.svc.diff(await h.svc.pageById(page.id), 1, 2);assert.ok(d.content.ops.some((o) => o.op === 'insert' && /different/.test(o.text)));assert.ok(d.fields.some((f) => f.field === 'infobox'));// Revert to r1 = r3, a copy of r1 with r1's citations carried forward; history is untouched.-- const rv = h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);++ const rv = await h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);assert.strictEqual(rv.revision.number, 3);assert.strictEqual(rv.revision.kind, 'revert');assert.strictEqual(rv.revision.revertedTo, 1);-- assert.strictEqual(rv.revision.content, h.stores.revisions.get(page.id, 1).content);++ assert.strictEqual(rv.revision.content, (await h.stores.revisions.get(page.id, 1)).content);assert.strictEqual(rv.published, true, 'a revert of a published page is published');-- const c3 = h.svc.citationsOf(page, 3);++ const c3 = await h.svc.citationsOf(page, 3);assert.deepStrictEqual(c3.map((c) => c.url), ['https://example.org/rye']);assert.strictEqual(c3[0].carriedFrom, c1[0].id);-- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2, 'r2 keeps its own citations');-- assert.deepStrictEqual(h.svc.view(space, h.svc.pageById(page.id), alice, {}).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);++ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2, 'r2 keeps its own citations');++ assert.deepStrictEqual((await h.svc.view(space, await h.svc.pageById(page.id), alice, {})).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);// Lineage: r3 → r2 → r1 by parent pointers.-- assert.deepStrictEqual(h.stores.revisions.lineage(page.id).map((r) => r.number), [3, 2, 1]);++ assert.deepStrictEqual((await h.stores.revisions.lineage(page.id)).map((r) => r.number), [3, 2, 1]);// Immutability is enforced by the database, not by convention.-- assert.throws(() => h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);-- assert.throws(() => h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);-- assert.throws(() => h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);-- assert.throws(() => h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);-- assert.throws(() => h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);++ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);++ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);++ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);// The same flow over HTTP: history lists every revision, the diff page renders.const hist = await H.req(h, 'GET', `/w/bread/rye-bread/history`);@@@ -108,7 +108,7 @@ const H = require('./helpers');assert.ok(old.text.includes('content="noindex, nofollow"'));// Every revision produced a wiki.revision.created event.-- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);++ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);assert.deepStrictEqual(created, [1, 2, 3]);console.log('revisions ok');} finally {@@@ -12,18 +12,18 @@ const H = require('./helpers');let clock = Date.parse('2026-10-01T08:00:00Z');const now = () => clock;const h1 = await H.boot({ now });-- const dbPath = h1.dbPath;++ const shared = h1.db;const editor = { kind: 'user', subject: H.subject(), staff: false };-- const space = h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);-- const { page } = h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);++ const space = await h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);++ const { page } = await h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);const runAt = '2026-10-01T09:00:00Z';-- const a = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);-- const b = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);++ const a = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);++ const b = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);assert.strictEqual(a.created, true);assert.strictEqual(b.created, false, 'the same schedule twice is one job');assert.strictEqual(a.job.id, b.job.id);-- assert.strictEqual(h1.svc.pageById(page.id).state, 'scheduled');++ assert.strictEqual((await h1.svc.pageById(page.id)).state, 'scheduled');assert.strictEqual((await H.req(h1, 'GET', '/w/diary/launch-notes')).status, 404, 'scheduled is not public yet');// Not due yet: nothing happens.@@@ -32,12 +32,12 @@ const H = require('./helpers');// Due: worker w1 claims it and "crashes" before completing (lease taken, never completed).clock = Date.parse('2026-10-01T09:00:01Z');-- const claimed = h1.stores.scheduler.claim({ worker: 'w1' });++ const claimed = await h1.stores.scheduler.claim({ worker: 'w1' });assert.strictEqual(claimed.length, 1);-- await h1.stop();++ await h1.stop({ keepDb: true });// A new process on the same database. Inside the lease nothing runs twice.-- const h2 = await H.boot({ now, dbPath });++ const h2 = await H.boot({ now, db: shared });out = await h2.svc.runSchedule({ worker: 'w2' });assert.strictEqual(out.done.length, 0, 'the lease still belongs to w1');// After the lease expires the new worker publishes.@@@ -45,41 +45,42 @@ const H = require('./helpers');out = await h2.svc.runSchedule({ worker: 'w2' });assert.strictEqual(out.done.length, 1);assert.deepStrictEqual(out.done[0].result, { published: 1 });-- let p = h2.svc.pageById(page.id);++ let p = await h2.svc.pageById(page.id);assert.strictEqual(p.state, 'published');assert.strictEqual(p.published_revision, 1);assert.strictEqual(p.published_at, clock);const publishedAt = p.published_at;assert.strictEqual((await H.req(h2, 'GET', '/w/diary/launch-notes')).status, 200);-- const count = (h) => {-- const all = H.outbox(h).filter((e) => e.subject.id === page.id);++ const count = async (h) => {++ const all = (await H.outbox(h)).filter((e) => e.subject.id === page.id);return { published: all.filter((e) => e.event_type === 'wiki.page.published').length, index: all.filter((e) => e.event_type === 'wiki.index_document.upserted').length };};-- const once = count(h2);++ const once = (await count(h2));assert.deepStrictEqual(once, { published: 1, index: 1 });// Re-running the same job (a replay after a crash between publish and complete) is a no-op.const replay = await h2.stores.scheduler.runDue({ worker: 'w3', handler: async () => ({}) }); // nothing due any moreassert.strictEqual(replay.done.length, 0);-- h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });++ await h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });clock += 1000;out = await h2.svc.runSchedule({ worker: 'w3' });assert.deepStrictEqual(out.done[0].result, { noop: true });-- assert.deepStrictEqual(count(h2), once, 'no second publication event and no new index revision');-- p = h2.svc.pageById(page.id);++ assert.deepStrictEqual((await count(h2)), once, 'no second publication event and no new index revision');++ p = await h2.svc.pageById(page.id);assert.strictEqual(p.published_at, publishedAt, 'the first publication time is kept');-- await h2.stop();++ await h2.stop({ keepDb: true });// A third restart: still exactly one publication.-- const h3 = await H.boot({ now, dbPath });++ const h3 = await H.boot({ now, db: shared });out = await h3.svc.runSchedule({ worker: 'w4' });assert.strictEqual(out.done.length, 0);-- assert.deepStrictEqual(count(h3), once);++ assert.deepStrictEqual((await count(h3)), once);// Deleting a page cancels its pending jobs.-- h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);-- h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });-- assert.ok(h3.svc.jobs(page.id).filter((j) => j.status === 'pending').length === 0);++ await h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);++ await h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });++ assert.ok((await h3.svc.jobs(page.id)).filter((j) => j.status === 'pending').length === 0);await h3.stop();++ await h1.closeDb();console.log('schedule ok');})().catch((err) => { console.error(err); process.exit(1); });@@@ -23,22 +23,22 @@ const H = require('./helpers');const ann = { kind: 'user', subject: H.subject(), staff: false };const bob = { kind: 'user', subject: H.subject(), staff: false };const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const annSpace = h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);-- const annPage = h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann).page;-- h.svc.publish(annPage.id, {}, ann);-- const annPrivate = h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann).page;-- const bobSpace = h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);-- const bobPage = h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob).page;-- h.svc.publish(bobPage.id, {}, bob);-- const proposal = h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });++ const annSpace = await h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);++ const annPage = (await h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann)).page;++ await h.svc.publish(annPage.id, {}, ann);++ const annPrivate = (await h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann)).page;++ const bobSpace = await h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);++ const bobPage = (await h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob)).page;++ await h.svc.publish(bobPage.id, {}, bob);++ const proposal = await h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });const proposalId = proposal.id || (proposal.proposal && proposal.proposal.id);const bobTok = H.userToken({ subject: bob.subject, username: 'bob' });const annTok = H.userToken({ subject: ann.subject, username: 'ann' });-- const tables = h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'wiki_%' AND name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all().map((r) => r.name);-- const snapshot = () => Object.fromEntries(tables.map((t) => [t, h.db.prepare(`SELECT * FROM ${t}`).all()]));-- const same = (before, label) => {-- const after = snapshot();++ const tables = (await h.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'wiki_%' AND table_name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all()).map((r) => r.name);++ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (t) => [t, await h.db.prepare(`SELECT * FROM ${t}`).all()])));++ const same = async (before, label) => {++ const after = await snapshot();for (const t of tables) assert.deepStrictEqual(after[t], before[t], `${label}: ${t} changed`);};const api = (method, p, body, token = bobTok) => H.req(h, method, `/api/v1${p}`, { token, body });@@@ -46,7 +46,7 @@ const H = require('./helpers');const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status) || (r.status === 303 && !/\/w\/ann-space/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);await check('API: Bob, owner of his own space, cannot touch Ann\'s page or space by id', async () => {-- const before = snapshot();++ const before = await snapshot();for (const id of [annPage.id, annPrivate.id]) {refused(await api('PATCH', `/pages/${id}`, { title: 'pwned', slug: 'pwned', visibility: 'public', noindex: true }), `PATCH ${id}`);refused(await api('DELETE', `/pages/${id}`), `DELETE ${id}`);@@@ -68,11 +68,11 @@ const H = require('./helpers');refused(await api('POST', `/proposals/${proposalId}/review`, { decision: 'approve' }), 'review Ann\'s proposal');refused(await api('GET', `/pages/${annPrivate.id}`), 'read the private page');refused(await api('GET', `/proposals/${proposalId}`), 'read Ann\'s proposal');-- same(before, 'API');++ await same(before, 'API');});await check('forms: Bob cannot post to Ann\'s pages, nor reach them through his own space\'s slug', async () => {-- const before = snapshot();++ const before = await snapshot();for (const sp of ['ann-space', 'bob-space']) {for (const slug of ['ann-page', 'ann-private']) {refused(await form(`/w/${sp}/${slug}/edit`, { body: `${H.LONG} pwned`, summary: 'x', expected_revision: '1' }), `edit ${sp}/${slug}`);@@@ -85,7 +85,7 @@ const H = require('./helpers');refused(await form('/s/ann-space/new', { title: 'Bob in Ann', body: H.LONG }), 'new page in Ann\'s space');refused(await form('/s/ann-space/import', { bundle: '{}' }), 'import form');refused(await form(`/proposals/${proposalId}`, { decision: 'approve' }), 'proposal form');-- same(before, 'forms');++ await same(before, 'forms');const r = await H.req(h, 'GET', '/w/bob-space/ann-private', { cookie: H.cookieFor(bobTok) });assert.strictEqual(r.status, 404, 'Ann\'s slug under Bob\'s space is not her page');});@@@ -94,9 +94,9 @@ const H = require('./helpers');assert.strictEqual((await api('PATCH', '/spaces/ann-space', { name: 'Ann Space 2' }, annTok)).status, 200);const f = await form('/s/ann-space/settings', { name: 'Ann Space 3', visibility: 'public' }, annTok);assert.ok([200, 303].includes(f.status), `the settings form works for its owner: ${f.status} ${f.text.slice(0, 160)}`);-- assert.strictEqual(h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get().name, 'Ann Space 3');++ assert.strictEqual((await h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get()).name, 'Ann Space 3');assert.strictEqual((await api('GET', `/pages/${annPrivate.id}`, undefined, annTok)).status, 200);-- const cur = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id).n;++ const cur = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id)).n;const r = await api('POST', `/pages/${annPage.id}/revisions`, { expected_revision: cur, body: `${H.LONG} by ann`, citations: cite }, annTok);assert.ok([200, 201].includes(r.status), `${r.status} ${r.text.slice(0, 200)}`);});@@@ -33,23 +33,23 @@ const { getPaths, crawl } = require('./security-crawl');let failures = 0;const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };try {-- const vault = h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);-- h.svc.setRole(vault.id, friend, 'viewer', owner);-- const hidden = h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner).page;-- h.svc.publish(hidden.id, {}, owner);++ const vault = await h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);++ await h.svc.setRole(vault.id, friend, 'viewer', owner);++ const hidden = (await h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner)).page;++ await h.svc.publish(hidden.id, {}, owner);-- const open = h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);-- const control = h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner).page;-- h.svc.publish(control.id, {}, owner);++ const open = await h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);++ const control = (await h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner)).page;++ await h.svc.publish(control.id, {}, owner);// Restricted from the start (a page that was public once was legitimately public then).-- const priv = h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner).page;-- h.svc.publish(priv.id, {}, owner);-- const members = h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner).page;-- h.svc.publish(members.id, {}, owner);-- const draft = h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner).page;++ const priv = (await h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner)).page;++ await h.svc.publish(priv.id, {}, owner);++ const members = (await h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner)).page;++ await h.svc.publish(members.id, {}, owner);++ const draft = (await h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner)).page;// A published page with a second, unpublished revision.-- h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);-- const proposal = h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });++ await h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);++ const proposal = await h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });const pageIds = [hidden.id, priv.id, draft.id, members.id, proposal.id || proposal.proposal && proposal.proposal.id];const ownerTok = H.userToken({ subject: owner.subject, username: 'owner' });@@@ -91,7 +91,7 @@ const { getPaths, crawl } = require('./security-crawl');});await check('public events and Search index documents carry none of it (internal events are first-party only)', async () => {-- const text = JSON.stringify(H.outbox(h).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));++ const text = JSON.stringify((await H.outbox(h)).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));assert.ok(text.includes('open-control-words') || text.includes('Open Page'), 'the public page is there (control)');const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);assert.deepStrictEqual(hits, []);@@@ -37,10 +37,10 @@ const SECRETS = {const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };try {const owner = { kind: 'user', subject: H.subject(), staff: false };-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const page = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;-- h.svc.publish(page.id, {}, owner);++ const page = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;++ await h.svc.publish(page.id, {}, owner);const people = {anonymous: null,user: H.userToken({ subject: H.subject(), username: 'someone' }),@@@ -89,7 +89,7 @@ const SECRETS = {});await check('the events outbox carries neither', async () => {-- const text = JSON.stringify(H.outbox(h));++ const text = JSON.stringify(await H.outbox(h));for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);});} finally {@@@ -34,19 +34,19 @@ const INTERNAL = [try {const owner = { kind: 'user', subject: H.subject(), staff: false };const tok = H.userToken({ subject: owner.subject, username: 'owner' });-- const space = h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);++ const space = await h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);const okCite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const page = h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner).page;-- h.svc.publish(page.id, {}, owner);++ const page = (await h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner)).page;++ await h.svc.publish(page.id, {}, owner);await check('internal citation URLs are refused or stored, never fetched, through every path that handles them', async () => {for (const url of INTERNAL) {// Through the API (attach to a revision), the service (a new revision) and the edit form.await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/1/citations`, { token: tok, body: { citations: [{ url, retrieved_at: '2026-09-20T00:00:00Z' }] } });-- try { h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }++ try { await h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }}-- const n = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;-- try { h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }++ const n = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;++ try { await h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }for (const p of ['/w/refs/cited-page', '/w/refs/cited-page/sources', '/w/refs/cited-page/history', `/api/v1/pages/${page.id}`, `/api/v1/pages/${page.id}/revisions/${n}/citations`,`/api/v1/pages/${page.id}/revisions/1/citations`, '/feed.atom', '/feed.json', '/llms.txt', '/sitemaps/pages-1.xml']) {await H.req(h, 'GET', p, { cookie: H.cookieFor(tok) });@@@ -11,15 +11,15 @@ const H = require('./helpers');const owner = { kind: 'user', subject: H.subject(), staff: false };const mallory = { kind: 'user', subject: H.subject(), staff: false };try {-- const space = h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);-- const { page } = h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);-- h.svc.publish(page.id, {}, owner);-- h.svc.updateSpace(space.id, { slug: 'guide' }, owner);++ const space = await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);++ const { page } = await h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);++ await h.svc.publish(page.id, {}, owner);++ await h.svc.updateSpace(space.id, { slug: 'guide' }, owner);// Another person cannot take the retired slug, by creating a space or by renaming theirs.-- assert.throws(() => h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');-- const own = h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);-- assert.throws(() => h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');++ await assert.rejects(async () => await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');++ const own = await h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);++ await assert.rejects(async () => await h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');const r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.userToken({ subject: mallory.subject }), body: { name: 'Handbook', slug: 'handbook' } });assert.strictEqual(r.status, 409);@@@ -28,7 +28,7 @@ const H = require('./helpers');assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).headers.get('location'), '/w/guide/onboarding');// The space itself may take its old slug back.-- h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);++ await h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).status, 200);console.log('security: ok');} finally {@@@ -35,15 +35,15 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeconst h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });try {-- const first = seed(h.svc, data, { log: H.quiet });++ const first = await seed(h.svc, data, { log: H.quiet });assert.strictEqual(first.created.length, data.pages.length);-- const again = seed(h.svc, data, { log: H.quiet });++ const again = await seed(h.svc, data, { log: H.quiet });assert.strictEqual(again.created.length, 0, 'idempotent');assert.strictEqual(again.skipped.length, data.pages.length);-- const space = h.svc.findSpace('openvibe');++ const space = await h.svc.findSpace('openvibe');assert.strictEqual(space.kind, 'official');-- const published = h.svc.publishedPublic();++ const published = await h.svc.publishedPublic();assert.strictEqual(published.length, data.pages.length);// Generated (AI-assisted) text: published and readable, but noindex until a person reviews it.for (const { page, decision } of published) {@@@ -51,7 +51,7 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeassert.strictEqual(decision.indexable, false, page.title);assert.strictEqual(decision.listable, false, page.title);assert.deepStrictEqual(decision.codes, ['ai_generated_unreviewed'], `${page.title}: only the review is missing (${decision.codes.join(', ')})`);-- assert.strictEqual(h.stores.revisions.get(page.id, page.published_revision).meta.authorship.importedFrom.aiAssisted, true);++ assert.strictEqual((await h.stores.revisions.get(page.id, page.published_revision)).meta.authorship.importedFrom.aiAssisted, true);}for (const p of data.pages) {const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);@@@ -67,17 +67,17 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeassert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');assert.ok(!atomFeed.text.includes('<entry>'));assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);-- const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));++ const idx = (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.'));assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');// The official space is not editable by an ordinary signed-in person, but is by staff.const person = { kind: 'user', subject: H.subject(), staff: false };const staff = { kind: 'user', subject: H.subject(), staff: true };-- assert.throws(() => h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);-- assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);++ await assert.rejects(async () => await h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);++ assert.ok((await h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff)).page);// After a person's review the page is indexable; its Search document records the imported authorship.const first1 = published[0].page;-- h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);-- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;++ await h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);++ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;assert.strictEqual(doc.id, first1.id);assert.strictEqual(doc.authorship, 'imported');assert.strictEqual(doc.indexability.decision, 'index');@@@ -23,9 +23,9 @@ const { SYSTEM } = require('../server/wiki/seed');const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];try {// Community space: every outbound link is nofollow ugc.-- const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });-- const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;-- h.svc.publish(up.id, {}, { kind: 'user', subject: me });++ const user = await h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });++ const up = (await h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me })).page;++ await h.svc.publish(up.id, {}, { kind: 'user', subject: me });let html = (await H.req(h, 'GET', '/w/community/links')).text;const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));@@@ -37,9 +37,9 @@ const { SYSTEM } = require('../server/wiki/seed');assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));// Official space: staff vouch for their links.-- const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);-- const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;-- h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });++ const off = await h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);++ const op = (await h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true })).page;++ await h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });html = (await H.req(h, 'GET', '/w/official/links')).text;assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));@@@ -53,7 +53,7 @@ const { SYSTEM } = require('../server/wiki/seed');}let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });assert.strictEqual(r.status, 422);-- assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');++ await assert.rejects(async () => await h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });assert.strictEqual(r.status, 201);assert.strictEqual(r.json.revision.summary, 'One short sentence.');@@@ -66,7 +66,7 @@ const { SYSTEM } = require('../server/wiki/seed');assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);assert.strictEqual(r.json.code, 'proposal.service_only');}-- assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);++ assert.strictEqual(await h.svc.findPage(user.id, 'planted'), null);r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });assert.strictEqual(r.status, 201, r.text);await h.stop();@@@ -78,10 +78,10 @@ const { SYSTEM } = require('../server/wiki/seed');assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);const owner = { kind: 'user', subject: H.subject() };-- const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);-- const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;-- limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);-- limited.svc.publish(pg.id, {}, owner);++ const sp = await limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);++ const pg = (await limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner)).page;++ await limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);++ await limited.svc.publish(pg.id, {}, owner);for (let i = 0; i < 15; i++) {assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);@@@ -13,27 +13,16 @@ const STRANGER = H.subject();const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';(async () => {-- // Migration: a database made before `vip` (the old CHECK) is widened in place; rows stay; idempotent.++ // The schema: `vip` is a visibility PostgreSQL accepts, and anything else is still refused by the CHECK++ // (on SQLite a database made before `vip` was widened in place; migrations/0001 has it from the start).{-- const fs = require('fs');-- const os = require('os');-- const path = require('path');-- const { openDb, createStores, widenVisibilityChecks, SCHEMA } = require('../server/db');-- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-vipmig-')), 'wiki.db');-- const old = openDb(file);-- old.exec(SCHEMA.split("CHECK (visibility IN ('public','members','vip','private'))").join("CHECK (visibility IN ('public','members','private'))"));-- old.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);-- assert.throws(() => old.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run(), /CHECK constraint/);-- old.close();-- const db = openDb(file);-- createStores(db);-- db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();-- assert.strictEqual(db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get().visibility, 'vip', 'widened; the row kept');-- assert.throws(() => db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /CHECK constraint/, 'still checked');-- assert.deepStrictEqual(widenVisibilityChecks(db), [], 'idempotent');-- assert.strictEqual(db.pragma('integrity_check', { simple: true }), 'ok');-- db.close();-- fs.rmSync(path.dirname(file), { recursive: true, force: true });++ const { testDb } = require('./db-helper');++ const { db, close } = await testDb();++ await db.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);++ await db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();++ assert.strictEqual((await db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get()).visibility, 'vip');++ await assert.rejects(db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /check constraint/i, 'still checked');++ await close();}const asks = [];@@@ -51,14 +40,14 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';const cookie = (sub) => H.cookieFor(H.userToken({ subject: sub }));try {const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const club = h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);-- const secret = h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner).page;-- h.svc.publish(secret.id, {}, owner);-- const open = h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);-- const perk = h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner).page;-- h.svc.publish(perk.id, {}, owner);-- const pub = h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner).page;-- h.svc.publish(pub.id, {}, owner);++ const club = await h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);++ const secret = (await h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner)).page;++ await h.svc.publish(secret.id, {}, owner);++ const open = await h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);++ const perk = (await h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner)).page;++ await h.svc.publish(perk.id, {}, owner);++ const pub = (await h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner)).page;++ await h.svc.publish(pub.id, {}, owner);// Anonymous: the join prompt (403, never publicly cached), no body.let r = await H.req(h, 'GET', '/w/club/club-secret');@@@ -118,7 +107,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('club-secret'));assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('club-secret'));assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('perk-page'));-- const evs = H.outbox(h);++ const evs = await H.outbox(h);assert.ok(!evs.some((e) => e.event_type === 'wiki.index_document.upserted' && (e.subject.id === secret.id || e.subject.id === perk.id)), 'no Search document');assert.ok(evs.some((e) => e.event_type === 'wiki.page.published' && e.subject.id === secret.id && e.payload.visibility === 'vip'));@@@ -130,7 +119,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';vipDown = false;// Official spaces have no VIP owner.-- assert.throws(() => h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);++ await assert.rejects(async () => await h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);} finally {await h.stop();}@@@ -9,33 +9,33 @@ const assert = require('assert');const contracts = require('openvibe-contracts');const H = require('./helpers');--function lastIndexEvent(h, pageId) {-- return H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();++async function lastIndexEvent(h, pageId) {++ return (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();}(async () => {const h = await H.boot();const owner = { kind: 'user', subject: H.subject(), staff: false };try {-- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);++ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];-- const { page } = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);-- const other = h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner).page;++ const { page } = await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);++ const other = (await h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner)).page;// Draft: not in sitemap, feed or index; direct URL is 404 for anonymous visitors.let r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');assert.ok(!r.text.includes('/w/notes/open-page'));assert.strictEqual((await H.req(h, 'GET', '/w/notes/open-page')).status, 404);-- assert.strictEqual(lastIndexEvent(h, page.id), undefined, 'a draft never reaches Search');++ assert.strictEqual((await lastIndexEvent(h, page.id)), undefined, 'a draft never reaches Search');// Published: sitemap, feeds, index upsert, public cache.-- h.svc.publish(page.id, {}, owner);-- h.svc.publish(other.id, {}, owner);++ await h.svc.publish(page.id, {}, owner);++ await h.svc.publish(other.id, {}, owner);r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');assert.ok(r.text.includes('http://wiki.test/w/notes/open-page'));assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/notes/open-page'));assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/notes/open-page'));-- let ev = lastIndexEvent(h, page.id);++ let ev = (await lastIndexEvent(h, page.id));assert.strictEqual(ev.event_type, 'wiki.index_document.upserted');assert.strictEqual(ev.payload.visibility, 'public');assert.strictEqual(ev.payload.indexability.decision, 'index');@@@ -48,9 +48,9 @@ function lastIndexEvent(h, pageId) {assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');// Private page: leaves sitemap, feeds and Search (tombstone at a higher revision), 404 + private for anonymous.-- const before = lastIndexEvent(h, page.id).payload.revision;-- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);-- ev = lastIndexEvent(h, page.id);++ const before = (await lastIndexEvent(h, page.id)).payload.revision;++ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);++ ev = (await lastIndexEvent(h, page.id));assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');assert.ok(ev.payload.revision > before, 'the tombstone outranks the last document');assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('open-page'));@@@ -70,47 +70,47 @@ function lastIndexEvent(h, pageId) {// Search (the wiki's own) does not return it to anonymous callers.assert.ok(!(await H.req(h, 'GET', '/api/v1/search?q=Open')).json.results.some((x) => x.id === page.id));// The product event for a non-public change is internal.-- const upd = H.outbox(h).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();++ const upd = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();assert.strictEqual(upd.visibility, 'internal');// Space goes members-only: its public pages leave the index too.-- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');++ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 404, 'anonymous cannot read a members page');r = await H.req(h, 'GET', '/w/notes/second-page', { cookie: H.cookieFor(H.userToken()) });assert.strictEqual(r.status, 200, 'any signed-in account reads a members page');assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');assert.ok(!(await H.req(h, 'GET', '/sitemaps/spaces.xml')).text.includes('/s/notes'));-- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.upserted', 'back in the index when public again');++ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.upserted', 'back in the index when public again');// Rename: every old address 301s to the current one (chains collapse).-- h.svc.movePage(other.id, { slug: 'second' }, owner);-- h.svc.movePage(other.id, { slug: 'third' }, owner);++ await h.svc.movePage(other.id, { slug: 'second' }, owner);++ await h.svc.movePage(other.id, { slug: 'third' }, owner);r = await H.req(h, 'GET', '/w/notes/second-page/history');assert.strictEqual(r.status, 301);assert.strictEqual(r.headers.get('location'), '/w/notes/third/history');r = await H.req(h, 'GET', '/w/notes/second');assert.strictEqual(r.headers.get('location'), '/w/notes/third');-- ev = lastIndexEvent(h, other.id);++ ev = (await lastIndexEvent(h, other.id));assert.strictEqual(ev.payload.canonical_url, 'http://wiki.test/w/notes/third');// A renamed space redirects its pages as well.-- h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);++ await h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);r = await H.req(h, 'GET', '/w/notes/third');assert.strictEqual(r.status, 301);assert.strictEqual(r.headers.get('location'), '/w/jottings/third');assert.strictEqual((await H.req(h, 'GET', '/s/notes')).headers.get('location'), '/s/jottings');// Delete: 410 at the current and the old addresses, tombstone, gone from sitemap.-- h.svc.deletePage(other.id, owner);++ await h.svc.deletePage(other.id, owner);assert.strictEqual((await H.req(h, 'GET', '/w/jottings/third')).status, 410);assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 410);-- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');-- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));++ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');++ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('third'));// Contracts: every envelope and every Search document validates.-- const all = H.outbox(h);++ const all = await H.outbox(h);for (const env of all) {const v = contracts.validate('events.event-envelope@1', env);assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);
@@ -18,6 +18,14 @@ NODE_ENV=development
@@ -18,6 +18,14 @@ NODE_ENV=development
#WIKI_LIMITS_HOUR=3000
#WIKI_LIMITS_HOUR=3000
# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
# Store (SQLite, WAL). Production: /var/lib/openvibe-wiki/wiki.db (systemd StateDirectory).
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh wiki: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
WIKI_DB_PATH=./data/wiki.db
WIKI_DB_PATH=./data/wiki.db
# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
# Identity — OpenVibe.Network signs user JWTs (SSO) and service tokens. The public key is fetched
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: wiki
contracts-service: wiki
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -26,13 +26,13 @@ its publication state; the packages supply the mechanics.
@@ -26,13 +26,13 @@ its publication state; the packages supply the mechanics.
## Owns
## Owns
-The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PATH`):
+The ten authority tables of §15.13, in Wiki's own PostgreSQL database (`ov_wiki` on the host's data role, ADR-035; schema in [migrations/](migrations/)):
| Table | What | Mechanics |
| Table | What | Mechanics |
|---|---|---|
|---|---|---|
| `wiki_spaces` | spaces: official / user, public / members / private | Wiki |
| `wiki_spaces` | spaces: official / user, public / members / private | Wiki |
| `wiki_pages` | canonical page identity, slug, tree (`parent_id`), publication state, published revision | Wiki |
| `wiki_pages` | canonical page identity, slug, tree (`parent_id`), publication state, published revision | Wiki |
-| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE abort in SQLite triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |
+| `wiki_page_revisions` | immutable revisions (UPDATE/DELETE refused by PL/pgSQL triggers) | `openvibe-publishing/revisions`, prefix `wiki_page` |
| `wiki_page_links` | `[[links]]` of every revision (backlinks, red links) | Wiki |
| `wiki_page_links` | `[[links]]` of every revision (backlinks, red links) | Wiki |
| `wiki_page_redirects` | every historical path of a page or space (301; 410 when gone) | `openvibe-publishing/seo`, prefix `wiki_page` |
| `wiki_page_redirects` | every historical path of a page or space (301; 410 when gone) | `openvibe-publishing/seo`, prefix `wiki_page` |
| `wiki_citations` | sources per (page, revision), append-only | `openvibe-publishing/citations`, prefix `wiki` |
| `wiki_citations` | sources per (page, revision), append-only | `openvibe-publishing/citations`, prefix `wiki` |
@@ -260,18 +260,23 @@ fnm exec --using=22.22.1 npm install
@@ -260,18 +260,23 @@ fnm exec --using=22.22.1 npm install
cp .env.example .env
cp .env.example .env
fnm exec --using=22.22.1 npm run seed # the official "OpenVibe" space
fnm exec --using=22.22.1 npm run seed # the official "OpenVibe" space
fnm exec --using=22.22.1 npm run dev # http://localhost:4800
fnm exec --using=22.22.1 npm run dev # http://localhost:4800
-fnm exec --using=22.22.1 npm test # every test/*.test.js on temp databases, no network
+fnm exec --using=22.22.1 npm test # every test/*.test.js on PGlite (PostgreSQL in-process), no network
+eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm run test:pg # the same through PgBouncer
```
```
+Without `DATABASE_URL`, development uses an embedded PGlite database in `data/pglite`.
+
Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
-[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database
-`/var/lib/openvibe-wiki/wiki.db`, nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
+[deploy/systemd/openvibe-wiki.service](deploy/systemd/openvibe-wiki.service), database `ov_wiki`
+(`DATABASE_URL` through PgBouncer; migrations on `DATABASE_DIRECT_URL`), nginx [deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
## Depends on
## Depends on
-- `openvibe-publishing` v0.4.0, `openvibe-contracts` v0.64.0, `openvibe-shared` v1.25.0 (chrome,
- release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.12.0 (auth, events
- outbox, per-actor limits) — pinned release tarballs.
+- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
+ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
+- `openvibe-publishing` v1.0.0 (async PostgreSQL stores), `openvibe-contracts` v0.76.0, `openvibe-shared` v1.25.0
+ (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.19.0 (db, auth, PostgreSQL
+ events outbox, per-actor limits, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
the index events). All but the Network key are optional at runtime and degrade to explicit failure
the index events). All but the Network key are optional at runtime and degrade to explicit failure
@@ -341,7 +346,10 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.
@@ -341,7 +346,10 @@ references and the gaps that remain: [docs/threat-review.md](docs/threat-review.
Production deploys with `sudo ovhost deploy wiki` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy wiki` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.wiki`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.wiki`, install on a lockfile change, restart, wait for `/api/ready`).
The unit is `openvibe-wiki.service` on `127.0.0.1:4800`, the env file `/etc/openvibe/wiki.env`. The database is
The unit is `openvibe-wiki.service` on `127.0.0.1:4800`, the env file `/etc/openvibe/wiki.env`. The database is
-`/var/lib/openvibe-wiki/wiki.db`; nginx serves `openvibe.wiki` from
+`ov_wiki` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh wiki` writes its settings);
+the release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`: import, verification, and a rehearsal mode `--pglite`), run while the service is stopped;
+the old `/var/lib/openvibe-wiki/wiki.db` stays read-only for 7 days as the rollback. nginx serves `openvibe.wiki` from
[deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
[deploy/nginx/openvibe.wiki.conf](deploy/nginx/openvibe.wiki.conf).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback wiki --to <sha>`. Nothing blocks a rollback: the schema
restart; afterwards `sudo ovhost rollback wiki --to <sha>`. Nothing blocks a rollback: the schema
@@ -4,12 +4,25 @@
@@ -4,12 +4,25 @@
"code": true,
"code": true,
"wave": "W16",
"wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, better-sqlite3",
+ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"port": 4800,
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
- "contracts": "openvibe-contracts v0.64.0",
- "packages": ["openvibe-publishing v0.4.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
- "capabilities": ["wiki.space.create", "wiki.page.create", "wiki.page.read", "wiki.revision.propose", "wiki.revision.publish", "wiki.revision.revert", "wiki.citation.attach", "wiki.search.query"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-publishing v1.0.0",
+ "openvibe-shared v1.25.0",
+ "openvibe-sdk v0.19.0"
+ ],
+ "capabilities": [
+ "wiki.space.create",
+ "wiki.page.create",
+ "wiki.page.read",
+ "wiki.revision.propose",
+ "wiki.revision.publish",
+ "wiki.revision.revert",
+ "wiki.citation.attach",
+ "wiki.search.query"
+ ],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"deployed": true,
"deployed": true,
"deployment": "Public at https://openvibe.wiki since 2026-09-23 (~03:00 UTC): deployed with `sudo ovhost deploy wiki`, /opt/openvibe.wiki, unit openvibe-wiki on 127.0.0.1:4800 behind the openvibe.wiki nginx vhost, principal wiki. On 2026-09-23 production held 1 space and the 10 seed pages (1 revision each), 0 person reviews, so every page was noindex and nothing public was in Search. Restore drill passed 2026-09-23.",
"deployment": "Public at https://openvibe.wiki since 2026-09-23 (~03:00 UTC): deployed with `sudo ovhost deploy wiki`, /opt/openvibe.wiki, unit openvibe-wiki on 127.0.0.1:4800 behind the openvibe.wiki nginx vhost, principal wiki. On 2026-09-23 production held 1 space and the 10 seed pages (1 revision each), 0 person reviews, so every page was noindex and nothing public was in Search. Restore drill passed 2026-09-23.",
@@ -30,5 +43,6 @@
@@ -30,5 +43,6 @@
"/api/v1 by capability",
"/api/v1 by capability",
"SDK outbox events and Search index documents",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
- ]
+ ],
+ "database": "PostgreSQL ov_wiki (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"
}
}
@@ -3,8 +3,8 @@ Description=OpenVibe.Wiki — wiki spaces, revisions, citations and discussion (
@@ -3,8 +3,8 @@ Description=OpenVibe.Wiki — wiki spaces, revisions, citations and discussion (
# Network (openvibe-network) signs the tokens this service verifies. Wiki starts without it and
# Network (openvibe-network) signs the tokens this service verifies. Wiki starts without it and
# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
# keeps retrying the JWKS fetch: public pages work, sign-in and token calls answer 503 meanwhile.
# Events, Community, Sources and Media are optional: each degrades to an explicit failure state.
# Events, Community, Sources and Media are optional: each degrades to an explicit failure state.
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,347 @@
@@ -0,0 +1,347 @@
+-- phase: expand
+-- OpenVibe.Wiki on PostgreSQL (ADR-035, roadmap WS-X2): the ten authority tables of roadmap 15.13 as they were on
+-- SQLite, typed for PostgreSQL (epoch milliseconds stay bigint; 0/1 flags stay integer so the API is unchanged),
+-- then the openvibe-publishing stores and the openvibe-sdk outbox. Identifier columns sort like SQLite (COLLATE "C").
+-- Generated once on 2026-09-28; never edited after it runs (a change is the next migration).
+
+CREATE TABLE wiki_spaces (
+ id text COLLATE "C" PRIMARY KEY,
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ name text NOT NULL,
+ description text,
+ kind text NOT NULL CHECK (kind IN ('official','user')),
+ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
+ owner text COLLATE "C" NOT NULL,
+ created_by text NOT NULL,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ deleted_at bigint
+);
+
+CREATE TABLE wiki_pages (
+ id text COLLATE "C" PRIMARY KEY,
+ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
+ slug text COLLATE "C" NOT NULL,
+ title text NOT NULL,
+ parent_id text COLLATE "C" REFERENCES wiki_pages(id),
+ position integer NOT NULL DEFAULT 0,
+ state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
+ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
+ noindex integer NOT NULL DEFAULT 0,
+ published_revision integer,
+ published_at bigint,
+ revision_published_at bigint,
+ created_by text NOT NULL,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ UNIQUE (space_id, slug)
+);
+CREATE INDEX wiki_pages_parent ON wiki_pages (space_id, parent_id, position);
+CREATE INDEX wiki_pages_children ON wiki_pages (parent_id) WHERE parent_id IS NOT NULL;
+CREATE INDEX wiki_pages_updated ON wiki_pages (updated_at);
+CREATE INDEX wiki_pages_recent ON wiki_pages (revision_published_at DESC, id) WHERE state = 'published';
+
+CREATE TABLE wiki_page_links (
+ from_page_id text COLLATE "C" NOT NULL,
+ from_revision integer NOT NULL,
+ target_space text COLLATE "C" NOT NULL,
+ target_slug text COLLATE "C" NOT NULL,
+ label text,
+ PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)
+);
+CREATE INDEX wiki_page_links_target ON wiki_page_links (target_space, target_slug);
+
+CREATE TABLE wiki_infobox_values (
+ page_id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ position integer NOT NULL,
+ key text COLLATE "C" NOT NULL,
+ label text NOT NULL,
+ type text NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),
+ value_text text,
+ value_number double precision,
+ PRIMARY KEY (page_id, revision, key)
+);
+
+CREATE TABLE wiki_permissions (
+ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
+ subject text COLLATE "C" NOT NULL,
+ role text COLLATE "C" NOT NULL CHECK (role IN ('owner','editor','viewer')),
+ granted_by text,
+ granted_at bigint NOT NULL,
+ PRIMARY KEY (space_id, subject)
+);
+CREATE INDEX wiki_permissions_subject ON wiki_permissions (subject);
+
+-- People whose wiki.projects user module (Network) needs writing again: marked in the same transaction as the space or
+-- role change, drained by server/integrations/projects-module.js.
+CREATE TABLE wiki_module_dirty (
+ subject text COLLATE "C" PRIMARY KEY,
+ marked_at bigint NOT NULL
+);
+CREATE TABLE wiki_module_pushes (
+ subject text COLLATE "C" PRIMARY KEY,
+ hash text NOT NULL,
+ pushed_at bigint NOT NULL
+);
+
+CREATE TABLE wiki_watchers (
+ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),
+ subject text COLLATE "C" NOT NULL,
+ created_at bigint NOT NULL,
+ PRIMARY KEY (page_id, subject)
+);
+
+CREATE TABLE wiki_ai_proposals (
+ id text COLLATE "C" PRIMARY KEY,
+ page_id text COLLATE "C" NOT NULL REFERENCES wiki_pages(id),
+ space_id text COLLATE "C" NOT NULL REFERENCES wiki_spaces(id),
+ revision integer NOT NULL,
+ base_revision integer NOT NULL,
+ workflow_id text NOT NULL,
+ run_id text NOT NULL,
+ stub_provider integer NOT NULL DEFAULT 0,
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
+ proposed_by text NOT NULL,
+ note text,
+ reviewed_by text,
+ review_note text,
+ reviewed_at bigint,
+ created_at bigint NOT NULL,
+ UNIQUE (page_id, revision)
+);
+CREATE INDEX wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
+CREATE INDEX wiki_ai_proposals_page ON wiki_ai_proposals (page_id, created_at DESC);
+
+-- Who attached a Media object to a page, and what Media said about it at that moment. One row per attachment,
+-- written with it and never changed.
+CREATE TABLE wiki_attachment_origins (
+ attachment_id bigint PRIMARY KEY,
+ page_id text COLLATE "C" NOT NULL,
+ media_id text COLLATE "C" NOT NULL,
+ attached_by text NOT NULL,
+ media_owner text,
+ media_visibility text NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
+ attached_at bigint NOT NULL
+);
+CREATE INDEX wiki_attachment_origins_page ON wiki_attachment_origins (page_id);
+
+-- openvibe-publishing/revisions (prefix wiki_page)
+CREATE TABLE IF NOT EXISTS wiki_page_revisions (
+ id text PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL,
+ number integer NOT NULL CHECK (number >= 1),
+ parent_id text,
+ parent_number integer,
+ kind text NOT NULL CHECK (kind IN ('edit','revert','import')),
+ reverted_to integer,
+ content text NOT NULL,
+ fields jsonb NOT NULL DEFAULT '{}',
+ meta jsonb NOT NULL DEFAULT '{}',
+ content_hash text NOT NULL,
+ author text,
+ message text,
+ created_at bigint NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS wiki_page_revisions_entity_num ON wiki_page_revisions (entity_id, number);
+CREATE TABLE IF NOT EXISTS wiki_page_drafts (
+ entity_id text COLLATE "C" NOT NULL,
+ owner text COLLATE "C" NOT NULL,
+ base_revision integer NOT NULL,
+ content text NOT NULL,
+ fields jsonb NOT NULL DEFAULT '{}',
+ meta jsonb NOT NULL DEFAULT '{}',
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (entity_id, owner)
+);
+CREATE INDEX IF NOT EXISTS wiki_page_drafts_updated ON wiki_page_drafts (entity_id, updated_at DESC, owner);
+CREATE TABLE IF NOT EXISTS wiki_page_revision_purges (
+ entity_id text COLLATE "C" PRIMARY KEY,
+ reason text NOT NULL,
+ purged_by text,
+ purged_at bigint NOT NULL
+);
+CREATE OR REPLACE FUNCTION wiki_page_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ RAISE EXCEPTION 'wiki_page_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.entity_id) THEN
+ RAISE EXCEPTION 'wiki_page_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE OR REPLACE TRIGGER wiki_page_revisions_no_update BEFORE UPDATE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();
+CREATE OR REPLACE TRIGGER wiki_page_revisions_no_delete BEFORE DELETE ON wiki_page_revisions FOR EACH ROW EXECUTE FUNCTION wiki_page_revisions_guard();
+
+-- openvibe-publishing/citations (prefix wiki)
+CREATE TABLE IF NOT EXISTS wiki_citations (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL CHECK (revision >= 1),
+ anchor text,
+ source_item_id text COLLATE "C",
+ url text,
+ title text,
+ retrieved_at timestamptz,
+ quote_text text,
+ quote_start integer,
+ quote_end integer,
+ license_note text,
+ carried_from bigint REFERENCES wiki_citations(id),
+ attached_by text,
+ attached_at bigint NOT NULL,
+ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
+);
+CREATE INDEX IF NOT EXISTS wiki_citations_rev ON wiki_citations (entity_id, revision, id);
+CREATE INDEX IF NOT EXISTS wiki_citations_source ON wiki_citations (source_item_id, entity_id, revision, id) WHERE source_item_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS wiki_citations_carried ON wiki_citations (carried_from) WHERE carried_from IS NOT NULL;
+CREATE TABLE IF NOT EXISTS wiki_citation_purges (
+ entity_id text COLLATE "C" PRIMARY KEY,
+ reason text NOT NULL,
+ purged_by text,
+ purged_at bigint NOT NULL
+);
+CREATE OR REPLACE FUNCTION wiki_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ RAISE EXCEPTION 'wiki_citations rows are immutable' USING ERRCODE = 'restrict_violation';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM wiki_citation_purges WHERE entity_id = OLD.entity_id) THEN
+ RAISE EXCEPTION 'wiki_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE OR REPLACE TRIGGER wiki_citations_no_update BEFORE UPDATE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();
+CREATE OR REPLACE TRIGGER wiki_citations_no_delete BEFORE DELETE ON wiki_citations FOR EACH ROW EXECUTE FUNCTION wiki_citations_guard();
+
+-- openvibe-publishing/seo (prefix wiki_page)
+CREATE TABLE IF NOT EXISTS wiki_page_redirects (
+ from_path text COLLATE "C" PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL,
+ reason text,
+ created_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS wiki_page_redirects_entity ON wiki_page_redirects (entity_id, created_at, from_path);
+
+-- openvibe-publishing/authorship (prefix wiki_page)
+CREATE TABLE IF NOT EXISTS wiki_page_reviews (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ reviewer text NOT NULL,
+ decision text NOT NULL CHECK (decision IN ('approved','rejected')),
+ note text,
+ reviewed_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS wiki_page_reviews_rev ON wiki_page_reviews (entity_id, revision, id);
+CREATE INDEX IF NOT EXISTS wiki_page_reviews_entity ON wiki_page_reviews (entity_id, id);
+CREATE OR REPLACE FUNCTION wiki_page_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF TG_OP = 'UPDATE' THEN
+ RAISE EXCEPTION 'wiki_page_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE OR REPLACE TRIGGER wiki_page_reviews_no_update BEFORE UPDATE ON wiki_page_reviews FOR EACH ROW EXECUTE FUNCTION wiki_page_reviews_guard();
+
+-- openvibe-publishing/media (prefix wiki_page)
+CREATE TABLE IF NOT EXISTS wiki_page_attachments (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ entity_id text COLLATE "C" NOT NULL,
+ revision integer,
+ media_id text COLLATE "C" NOT NULL,
+ role text NOT NULL DEFAULT 'inline',
+ variant text,
+ alt text,
+ caption text,
+ position integer NOT NULL DEFAULT 0,
+ state text NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
+ broken_reason text CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
+ checked_at bigint,
+ created_at bigint NOT NULL,
+ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
+);
+CREATE INDEX IF NOT EXISTS wiki_page_attachments_entity ON wiki_page_attachments (entity_id, position, id);
+CREATE INDEX IF NOT EXISTS wiki_page_attachments_media ON wiki_page_attachments (media_id, entity_id);
+
+-- openvibe-publishing/discussion (prefix wiki)
+CREATE TABLE IF NOT EXISTS wiki_discussion_refs (
+ entity_id text COLLATE "C" PRIMARY KEY,
+ thread_id text NOT NULL,
+ ref jsonb NOT NULL,
+ resolved_at bigint NOT NULL
+);
+
+-- openvibe-publishing/schedule (prefix wiki)
+CREATE TABLE IF NOT EXISTS wiki_schedule_jobs (
+ id text COLLATE "C" PRIMARY KEY,
+ idem_key text NOT NULL UNIQUE,
+ entity_id text COLLATE "C" NOT NULL,
+ action text NOT NULL CHECK (action IN ('publish','unpublish')),
+ revision integer,
+ run_at bigint NOT NULL,
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
+ attempts integer NOT NULL DEFAULT 0,
+ lease_owner text,
+ lease_until bigint,
+ last_error text,
+ result jsonb,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_due ON wiki_schedule_jobs (run_at, id) WHERE status = 'pending';
+CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_lease ON wiki_schedule_jobs (lease_until) WHERE status = 'running';
+CREATE INDEX IF NOT EXISTS wiki_schedule_jobs_entity ON wiki_schedule_jobs (entity_id, run_at, id);
+
+-- openvibe-publishing/index-hooks (prefix wiki)
+CREATE TABLE IF NOT EXISTS wiki_index_revisions (
+ owner text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ id text COLLATE "C" NOT NULL,
+ revision integer NOT NULL,
+ hash text NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (owner, type, id)
+);
+
+
+-- Immutable side tables (the SQLite RAISE(ABORT) triggers), as PL/pgSQL with the same messages.
+CREATE FUNCTION wiki_rows_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ RAISE EXCEPTION '% rows are immutable', TG_TABLE_NAME USING ERRCODE = 'restrict_violation';
+END
+$$;
+CREATE FUNCTION wiki_infobox_values_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id) THEN
+ RAISE EXCEPTION 'wiki_infobox_values rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
+ END IF;
+ RETURN OLD;
+END
+$$;
+CREATE TRIGGER wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
+CREATE TRIGGER wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values FOR EACH ROW EXECUTE FUNCTION wiki_infobox_values_no_delete();
+CREATE TRIGGER wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
+CREATE TRIGGER wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins FOR EACH ROW EXECUTE FUNCTION wiki_rows_immutable();
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS wiki_event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS wiki_event_outbox_due ON wiki_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS wiki_event_outbox_sent ON wiki_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -14,15 +14,33 @@
@@ -14,15 +14,33 @@
"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",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.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.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"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",
@@ -224,6 +242,15 @@
@@ -224,6 +242,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -306,6 +333,15 @@
@@ -306,6 +333,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -730,6 +766,49 @@
@@ -730,6 +766,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -794,12 +873,24 @@
@@ -794,12 +873,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -999,9 +1090,9 @@
@@ -999,9 +1090,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.64.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
- "integrity": "sha512-WoTx6IpmcWtKM+DMQDYv1qnZOO9wZJbG6wKd5VNjF7H5gOXsFDPIV9UWqf4FHK3dxrg2cVvQxOwTiPxsztqObA==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1015,15 +1106,15 @@
@@ -1015,15 +1106,15 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"node_modules/openvibe-publishing": {
- "version": "0.4.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "integrity": "sha512-zipRyxNSb39fTk6ZtXfCygWgxSRVj/fOVGc31hynBANM5JCwErX/dYKsE/XmdYGlId29E5O3MyZLohgfocBP4w==",
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-g4mU0PPtI4y38NcsdppEbrXTrCYp0rPmIYWyxLxh4GpEYhn48jMWq2+cMQA2uwZCeeZlB3UiXVrQNQyQLiliwg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
},
},
"peerDependencies": {
"peerDependencies": {
- "better-sqlite3": ">=11",
+ "openvibe-sdk": ">=0.15.0",
"openvibe-shared": ">=1.5.0"
"openvibe-shared": ">=1.5.0"
},
},
"peerDependenciesMeta": {
"peerDependenciesMeta": {
@@ -1033,12 +1124,32 @@
@@ -1033,12 +1124,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.19.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
+ "integrity": "sha512-5hNT9KVxtkLeGNYirqY1+n939lga/8XPbmrtqISQTNKaXNsS1AHhf4TBwWe+ZAamuCHUADJZu5Ov3OyZLN0nIA==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
@@ -1065,6 +1176,134 @@
@@ -1065,6 +1176,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "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.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "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/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1188,6 +1427,27 @@
@@ -1188,6 +1427,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1403,6 +1663,21 @@
@@ -1403,6 +1663,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "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/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"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",
@@ -1530,6 +1805,15 @@
@@ -1530,6 +1805,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "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"
+ }
}
}
}
}
}
}
@@ -8,9 +8,15 @@
@@ -8,9 +8,15 @@
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"seed": "node scripts/seed.js",
"seed": "node scripts/seed.js",
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "test:pg": "WIKI_TEST_STORE=pg node test/run.js",
+ "migrate-to-postgres": "node scripts/migrate-to-postgres.js"
},
},
- "keywords": ["openvibe", "wiki", "publishing"],
+ "keywords": [
+ "openvibe",
+ "wiki",
+ "publishing"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.Wiki",
"repository": "github:OpenVibers/OpenVibe.Wiki",
@@ -20,12 +26,17 @@
@@ -20,12 +26,17 @@
"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",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.19.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
+ "pg": "^8.23.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Wiki's SQLite database (WIKI_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure
+ * is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row
+ * counts and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. In
+ * production it runs once, from the new release's directory, as the service user with the service's
+ * environment, while the service is stopped (the write freeze), before the PostgreSQL release starts.
+ *
+ * Every table keeps its name and columns. What the SQLite file holds that PostgreSQL does not take:
+ * the SDK's SQLite outbox bookkeeping becomes the PostgreSQL outbox's (wiki_event_outbox: same rows; lease
+ * columns start empty), and SQLite's own tables (sqlite_sequence) are not copied.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const { load } = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = load();
+ runSqliteMigration({ service: 'wiki', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -7,14 +7,14 @@
@@ -7,14 +7,14 @@
* npm run seed -- --draft # create as drafts, publish later from the page settings
* npm run seed -- --draft # create as drafts, publish later from the page settings
* npm run seed -- --file other.json
* npm run seed -- --file other.json
*
*
- * Uses WIKI_DB_PATH like the server. Events for the new pages go to the outbox and are delivered
+ * Uses DATABASE_URL (and DATABASE_DIRECT_URL to migrate) like the server. Events for the new pages go to the outbox and are delivered
* by the server's relay (EVENTS_URL) the next time it runs.
* by the server's relay (EVENTS_URL) the next time it runs.
*/
*/
require('dotenv').config();
require('dotenv').config();
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { load } = require('../server/config');
const { load } = require('../server/config');
-const { openDb, createStores } = require('../server/db');
+const { openDb, migrate, createStores } = require('../server/db');
const { createPlatform } = require('../server/integrations/platform');
const { createPlatform } = require('../server/integrations/platform');
const { createWikiService } = require('../server/wiki/service');
const { createWikiService } = require('../server/wiki/service');
const { seed } = require('../server/wiki/seed');
const { seed } = require('../server/wiki/seed');
@@ -23,10 +23,13 @@ const args = process.argv.slice(2);
@@ -23,10 +23,13 @@ const args = process.argv.slice(2);
const fileArg = args.indexOf('--file');
const fileArg = args.indexOf('--file');
const file = fileArg >= 0 ? args[fileArg + 1] : path.join(__dirname, '..', 'seeds', 'openvibe.json');
const file = fileArg >= 0 ? args[fileArg + 1] : path.join(__dirname, '..', 'seeds', 'openvibe.json');
const config = load();
const config = load();
-const db = openDb(config.dbPath);
-const stores = createStores(db);
-const platform = createPlatform({ config, db });
-const svc = createWikiService({ db, stores, outbox: platform.outbox, config });
-const out = seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });
-console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${platform.outbox.pending()} event(s) waiting in the outbox`);
-db.close();
+(async () => {
+ const db = openDb(config);
+ await migrate(config, { serving: db });
+ const stores = createStores(db);
+ const platform = createPlatform({ config, db });
+ const svc = createWikiService({ db, stores, outbox: platform.outbox, config });
+ const out = await seed(svc, JSON.parse(fs.readFileSync(file, 'utf8')), { publish: !args.includes('--draft') });
+ console.log(`[seed] space ${out.space.slug}: ${out.created.length} created, ${out.skipped.length} already there; ${await platform.outbox.pending()} event(s) waiting in the outbox`);
+ await db.close();
+})().catch((err) => { console.error(`[seed] ${err.message}`); process.exit(1); });
@@ -22,8 +22,10 @@ const { createActorLimits } = require('./http/actor-limits');
@@ -22,8 +22,10 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-function createApp({ config, svc, viewers, platform, keys, db, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
+function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null }) {
const app = express();
const app = express();
+ // Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
+ const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
// One W3C trace across services (openvibe-shared/trace): calls made while serving a request carry its traceparent.
@@ -33,7 +35,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -33,7 +35,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'wiki', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'wiki', release: release.release });
metrics.registry.gauge({
metrics.registry.gauge({
name: 'wiki_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
name: 'wiki_event_outbox', help: 'Events in the outbox by state', labelNames: ['state'],
- collect: () => [{ labels: { state: 'pending' }, value: platform.outbox.pending() }, { labels: { state: 'rejected' }, value: platform.outbox.rejected() }],
+ collect: () => { const c = platform.outbox.counts(); return [{ labels: { state: 'pending' }, value: c.pending }, { labels: { state: 'rejected' }, value: c.rejected }]; },
});
});
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -70,14 +72,21 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -70,14 +72,21 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
const ready = createReadiness({
const ready = createReadiness({
service: 'wiki', release: release.release,
service: 'wiki', release: release.release,
checks: [
checks: [
- { name: 'db', required: true, check: () => db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'wiki_pages'").get().n === 1 || 'wiki tables missing' },
+ // A real round trip, naming the store that answered (postgresql / pglite), and the schema present.
+ { name: 'db', required: true, check: async () => {
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ const n = await db.value("SELECT count(*)::int FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'wiki_pages'");
+ return n === 1 ? { ok: true, detail: r.detail } : 'wiki tables missing (migrations did not run)';
+ } },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{ name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },
{ name: 'network_jwks', required: false, check: () => { if (keys.loaded()) return true; keys.ensure().catch(() => {}); return 'Network signing key not loaded yet: sign-in and token calls answer 503'; } },
{ name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
{ name: 'events_relay', required: false, check: () => (platform.eventsConfigured ? true : 'EVENTS_URL or the service principal is not configured: events wait in the outbox') },
{ name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
{ name: 'community', required: false, check: () => platform.community.configured || 'not configured: discussions show as unavailable' },
{ name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: Sources item citations are refused (URL citations work)' },
{ name: 'sources', required: false, check: () => platform.sources.configured || 'not configured: Sources item citations are refused (URL citations work)' },
{ name: 'media', required: false, check: () => platform.media.configured || 'not configured: attachments are not checked against Media' },
{ name: 'media', required: false, check: () => platform.media.configured || 'not configured: attachments are not checked against Media' },
],
],
- details: () => ({ outbox: { pending: platform.outbox.pending(), rejected: platform.outbox.rejected() } }),
+ details: async () => ({ outbox: await platform.outbox.refreshCounts() }),
});
});
app.get('/api/ready', ready.handler);
app.get('/api/ready', ready.handler);
@@ -89,7 +98,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -89,7 +98,7 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
app.use('/api/', limiter(60000, 300));
app.use('/api/', limiter(60000, 300));
// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
- const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits });
+ const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });
app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@ -30,6 +30,10 @@ function load(env = process.env) {
@@ -30,6 +30,10 @@ function load(env = process.env) {
minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),
minute: Math.max(1, int(env.WIKI_LIMITS_MINUTE, 120)),
hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.WIKI_LIMITS_HOUR, 3000)),
},
},
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:wiki:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.WIKI_DB_PATH || './data/wiki.db',
dbPath: env.WIKI_DB_PATH || './data/wiki.db',
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
// Identity: OpenVibe.Network signs user JWTs (SSO) and service tokens (client credentials).
@@ -1,6 +1,11 @@
@@ -1,6 +1,11 @@
'use strict';
'use strict';
/**
/**
- * Wiki's own SQLite database (one per service, WAL, created on boot, idempotent).
+ * Wiki's own PostgreSQL database (ADR-035, roadmap WS-X2), through openvibe-sdk/db: async, pooled, one dialect.
+ *
+ * The schema is migrations/NNNN_*.sql (expand/migrate/contract, ADR-028), applied at boot on the owner's direct
+ * connection (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
+ * transaction mode). Ambient transactions (openvibe-sdk ≥ 0.18): inside db.tx(fn), plain db calls — the prepared
+ * statements in server/wiki/service.js and the openvibe-publishing stores — join the transaction.
*
*
* The ten authority tables of roadmap §15.13:
* The ten authority tables of roadmap §15.13:
* wiki_spaces spaces (official editorial spaces and user spaces)
* wiki_spaces spaces (official editorial spaces and user spaces)
@@ -13,15 +18,16 @@
@@ -13,15 +18,16 @@
* wiki_permissions space roles: owner / editor / viewer
* wiki_permissions space roles: owner / editor / viewer
* wiki_watchers who watches a page (notified through an event, never email)
* wiki_watchers who watches a page (notified through an event, never email)
* wiki_ai_proposals AI-authored draft revisions awaiting a person's decision
* wiki_ai_proposals AI-authored draft revisions awaiting a person's decision
+ * plus the Publishing helper tables under the same prefixes, wiki_attachment_origins, wiki_module_dirty/_pushes
+ * and wiki_event_outbox (openvibe-sdk/events PostgreSQL outbox).
*
*
- * The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
- * wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
- * wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
- * wiki_attachment_origins records who attached each Media object and the read rights checked then.
+ * openDb(config) the serving handle: DATABASE_URL; in development without it, an embedded PGlite
+ * database in data/pglite (one process, nothing to install)
+ * migrate(config, {serving}) apply migrations/ with the owner role (DATABASE_DIRECT_URL), then close it
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createRevisionStore } = require('openvibe-publishing/revisions');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createCitationStore } = require('openvibe-publishing/citations');
const { createAttachmentStore } = require('openvibe-publishing/media');
const { createAttachmentStore } = require('openvibe-publishing/media');
@@ -31,183 +37,30 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
@@ -31,183 +37,30 @@ const { createReviewLog } = require('openvibe-publishing/authorship');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS wiki_spaces (
- id TEXT PRIMARY KEY,
- slug TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- description TEXT,
- kind TEXT NOT NULL CHECK (kind IN ('official','user')),
- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
- owner TEXT NOT NULL,
- created_by TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- deleted_at INTEGER
-);
-
-CREATE TABLE IF NOT EXISTS wiki_pages (
- id TEXT PRIMARY KEY,
- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
- slug TEXT NOT NULL,
- title TEXT NOT NULL,
- parent_id TEXT REFERENCES wiki_pages(id),
- position INTEGER NOT NULL DEFAULT 0,
- state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
- noindex INTEGER NOT NULL DEFAULT 0,
- published_revision INTEGER,
- published_at INTEGER,
- revision_published_at INTEGER,
- created_by TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- UNIQUE (space_id, slug)
-);
-CREATE INDEX IF NOT EXISTS wiki_pages_parent ON wiki_pages (space_id, parent_id, position);
-CREATE INDEX IF NOT EXISTS wiki_pages_updated ON wiki_pages (updated_at);
-
-CREATE TABLE IF NOT EXISTS wiki_page_links (
- from_page_id TEXT NOT NULL,
- from_revision INTEGER NOT NULL,
- target_space TEXT NOT NULL,
- target_slug TEXT NOT NULL,
- label TEXT,
- PRIMARY KEY (from_page_id, from_revision, target_space, target_slug)
-);
-CREATE INDEX IF NOT EXISTS wiki_page_links_target ON wiki_page_links (target_space, target_slug);
-
-CREATE TABLE IF NOT EXISTS wiki_infobox_values (
- page_id TEXT NOT NULL,
- revision INTEGER NOT NULL,
- position INTEGER NOT NULL,
- key TEXT NOT NULL,
- label TEXT NOT NULL,
- type TEXT NOT NULL CHECK (type IN ('text','number','date','url','boolean','page','media')),
- value_text TEXT,
- value_number REAL,
- PRIMARY KEY (page_id, revision, key)
-);
-CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_update BEFORE UPDATE ON wiki_infobox_values
-BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are immutable'); END;
-CREATE TRIGGER IF NOT EXISTS wiki_infobox_values_no_delete BEFORE DELETE ON wiki_infobox_values
-WHEN NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.page_id)
-BEGIN SELECT RAISE(ABORT, 'wiki_infobox_values rows are never deleted outside a recorded purge'); END;
-CREATE TRIGGER IF NOT EXISTS wiki_page_links_no_update BEFORE UPDATE ON wiki_page_links
-BEGIN SELECT RAISE(ABORT, 'wiki_page_links rows are immutable'); END;
-
-CREATE TABLE IF NOT EXISTS wiki_permissions (
- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
- subject TEXT NOT NULL,
- role TEXT NOT NULL CHECK (role IN ('owner','editor','viewer')),
- granted_by TEXT,
- granted_at INTEGER NOT NULL,
- PRIMARY KEY (space_id, subject)
-);
-CREATE INDEX IF NOT EXISTS wiki_permissions_subject ON wiki_permissions (subject);
-
-CREATE TABLE IF NOT EXISTS wiki_module_dirty (
- subject TEXT PRIMARY KEY,
- marked_at INTEGER NOT NULL
-);
-CREATE TABLE IF NOT EXISTS wiki_module_pushes (
- subject TEXT PRIMARY KEY,
- hash TEXT NOT NULL,
- pushed_at INTEGER NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS wiki_watchers (
- page_id TEXT NOT NULL REFERENCES wiki_pages(id),
- subject TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- PRIMARY KEY (page_id, subject)
-);
-
-CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
- id TEXT PRIMARY KEY,
- page_id TEXT NOT NULL REFERENCES wiki_pages(id),
- space_id TEXT NOT NULL REFERENCES wiki_spaces(id),
- revision INTEGER NOT NULL,
- base_revision INTEGER NOT NULL,
- workflow_id TEXT NOT NULL,
- run_id TEXT NOT NULL,
- stub_provider INTEGER NOT NULL DEFAULT 0,
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
- proposed_by TEXT NOT NULL,
- note TEXT,
- reviewed_by TEXT,
- review_note TEXT,
- reviewed_at INTEGER,
- created_at INTEGER NOT NULL,
- UNIQUE (page_id, revision)
-);
-CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
-CREATE TABLE IF NOT EXISTS wiki_attachment_origins (
- attachment_id INTEGER PRIMARY KEY,
- page_id TEXT NOT NULL,
- media_id TEXT NOT NULL,
- attached_by TEXT NOT NULL,
- media_owner TEXT,
- media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
- attached_at INTEGER NOT NULL
-);
-CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins
-BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;
-`;
-
-function openDb(dbPath) {
- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
- const db = new Database(dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- return db;
+function openDb(config, { registry, log = console } = {}) {
+ 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 wiki)');
+ log.warn(`[Wiki] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ return createDb({ pglite: DEV_PGLITE, service: 'wiki', registry, log });
+ }
+ return createDb({ url: config.db.url, service: 'wiki', registry, log });
}
}
-/**
- * Creates every table (package tables first: the infobox purge trigger references
- * wiki_page_revision_purges) and returns the package stores bound to this database.
- */
-/**
- * Contracts 0.64.0 (WS-K task 8) adds the `vip` visibility. Tables created before it carry
- * CHECK (visibility IN ('public','members','private')); SQLite cannot ALTER a CHECK, and widening one
- * changes no stored row, so the table's CREATE statement is rewritten in place (SQLite's documented
- * writable_schema procedure, in one transaction with a schema_version bump), then integrity-checked.
- * Idempotent: a database already on the new text is left alone. Returns the tables changed.
- */
-const OLD_VIS_CHECK = "CHECK (visibility IN ('public','members','private'))";
-const NEW_VIS_CHECK = "CHECK (visibility IN ('public','members','vip','private'))";
-function widenVisibilityChecks(db) {
- const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('wiki_spaces', 'wiki_pages')").all()
- .filter((r) => r.sql && r.sql.includes(OLD_VIS_CHECK));
- if (!rows.length) return [];
- db.unsafeMode(true);
- try {
- db.transaction(() => {
- const version = db.pragma('schema_version', { simple: true });
- db.pragma('writable_schema = ON');
- const set = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
- for (const r of rows) set.run(r.sql.split(OLD_VIS_CHECK).join(NEW_VIS_CHECK), r.name);
- db.pragma(`schema_version = ${Number(version) + 1}`);
- db.pragma('writable_schema = OFF');
- })();
- } finally {
- db.unsafeMode(false);
- }
- const check = db.pragma('integrity_check', { simple: true });
- if (check !== 'ok') throw new Error(`wiki: integrity_check after widening the visibility CHECK: ${check}`);
- return rows.map((r) => r.name);
+/** Apply pending migrations (several processes starting together are safe: the SDK takes an advisory lock). */
+async function migrate(config, { serving = null, log = console } = {}) {
+ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });
+ 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: 'wiki-migrate', max: 1, log });
+ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
}
}
+/** The openvibe-publishing stores bound to this database (their tables are in migrations/). */
function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
- db.exec(SCHEMA);
- widenVisibilityChecks(db);
return {
return {
revisions,
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
@@ -220,4 +73,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
@@ -220,4 +73,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
};
};
}
}
-module.exports = { openDb, createStores, widenVisibilityChecks, SCHEMA };
+module.exports = { openDb, migrate, createStores, MIGRATIONS };
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages people read
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages people read
* (the per-address limits bound those).
* (the per-address limits bound those).
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@ -93,7 +93,7 @@ const BUDGETS = {
@@ -93,7 +93,7 @@ const BUDGETS = {
* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
* enabled=false (createApp's rateLimits=false, tests only) counts nobody, as it turns off the
* per-address limits.
* per-address limits.
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'wiki_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -101,6 +101,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -101,6 +101,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.limits.minute, hour: config.limits.hour },
limits: { minute: config.limits.minute, hour: config.limits.hour },
actor: enabled ? actor : () => null,
actor: enabled ? actor : () => null,
now,
now,
+ // Shared across processes and hosts on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -104,51 +104,56 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -104,51 +104,56 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
const B = (name) => limits.budget(name);
const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
const R = (fn, status) => run(fn, status, log);
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
- const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());
+ const prepare = (req, pairs) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs) : null);
router.param('space', (req, res, next, ref) => {
router.param('space', (req, res, next, ref) => {
- const space = svc.findSpace(ref);
- return space && !space.deleted_at ? prepare(req, [{ space }, ...svc.vipPagesOf(space.id).map((page) => ({ space, page }))], next) : next();
+ (async () => {
+ const space = await svc.findSpace(ref);
+ if (space && !space.deleted_at) await prepare(req, [{ space }, ...(await svc.vipPagesOf(space.id)).map((page) => ({ space, page }))]);
+ })().then(() => next(), next);
});
});
router.param('id', (req, res, next, id) => {
router.param('id', (req, res, next, id) => {
- const page = svc.pageById(id);
- const space = page && svc.spaceById(page.space_id);
- return space ? prepare(req, [{ space }, { space, page }], next) : next();
+ (async () => {
+ const page = await svc.pageById(id);
+ const space = page && await svc.spaceById(page.space_id);
+ if (space) await prepare(req, [{ space }, { space, page }]);
+ })().then(() => next(), next);
});
});
- const pageAndSpace = (id) => {
- const page = svc.pageById(id);
+ const pageAndSpace = async (id) => {
+ const page = await svc.pageById(id);
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
- const space = svc.spaceById(page.space_id);
+ const space = await svc.spaceById(page.space_id);
return { page, space };
return { page, space };
};
};
- const readable = (req, id) => {
- const { page, space } = pageAndSpace(id);
+ const readable = async (req, id) => {
+ const { page, space } = await pageAndSpace(id);
if (!svc.access.canReadPage(space, page, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (!svc.access.canReadPage(space, page, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
return { page, space };
return { page, space };
};
};
+ const pageOut = async (page) => serializePage(page, svc, await svc.spaceById(page.space_id));
// Spaces
// Spaces
- router.get('/spaces', R((req) => ({ spaces: svc.listSpaces(req.actor).map(serializeSpace) })));
- router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R((req) => ({ space: serializeSpace(svc.createSpace(req.body || {}, req.actor)) }), 201));
- router.get('/spaces/:space', R((req) => {
- const space = svc.getSpace(req.params.space, req.actor);
- return { space: serializeSpace(space), pages: serializeTree(svc.tree(space, req.actor), svc, space) };
+ router.get('/spaces', R(async (req) => ({ spaces: (await svc.listSpaces(req.actor)).map(serializeSpace) })));
+ router.post('/spaces', guard('wiki.space.create'), B('wiki.space.create'), R(async (req) => ({ space: serializeSpace(await svc.createSpace(req.body || {}, req.actor)) }), 201));
+ router.get('/spaces/:space', R(async (req) => {
+ const space = await svc.getSpace(req.params.space, req.actor);
+ return { space: serializeSpace(space), pages: serializeTree(await svc.tree(space, req.actor), svc, space) };
}));
}));
- router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ space: serializeSpace(svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
- router.get('/spaces/:space/roles', R((req) => ({ roles: svc.roles(req.params.space, req.actor) })));
- router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R((req) => ({ roles: svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
+ router.patch('/spaces/:space', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ space: serializeSpace(await svc.updateSpace(req.params.space, req.body || {}, req.actor)) })));
+ router.get('/spaces/:space/roles', R(async (req) => ({ roles: await svc.roles(req.params.space, req.actor) })));
+ router.put('/spaces/:space/roles/:subject', guard('wiki.space.create'), B('wiki.space.update'), R(async (req) => ({ roles: await svc.setRole(req.params.space, req.params.subject, (req.body || {}).role == null ? null : String(req.body.role), req.actor) })));
router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
router.post('/spaces/:space/pages', guard('wiki.page.create'), B('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
- const out = svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
- const space = svc.spaceById(out.page.space_id);
- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
+ const out = await svc.createPage(req.params.space, { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message }, req.actor);
+ const space = await svc.spaceById(out.page.space_id);
+ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, 201));
}, 201));
router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
router.post('/spaces/:space/import', guard('wiki.page.create'), B('wiki.page.import'), R(async (req) => {
- const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);
+ const { space, bundle } = await svc.prepareImport(req.params.space, req.body, req.actor);
await resolveBundleCitations(bundle, platform);
await resolveBundleCitations(bundle, platform);
- const out = svc.importPages(space.id, bundle, req.actor);
+ const out = await svc.importPages(space.id, bundle, req.actor);
return {
return {
space: serializeSpace(out.space), published: out.published, skipped: out.skipped,
space: serializeSpace(out.space), published: out.published, skipped: out.skipped,
created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),
created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),
@@ -156,9 +161,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -156,9 +161,9 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
}, 201));
}, 201));
// Pages
// Pages
- router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
- const { page, space } = readable(req, req.params.id);
- const v = svc.view(space, page, req.actor, { revision: int(req.query.revision) });
+ router.get('/pages/:id', guard('wiki.page.read'), R(async (req) => {
+ const { page, space } = await readable(req, req.params.id);
+ const v = await svc.view(space, page, req.actor, { revision: int(req.query.revision) });
return {
return {
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
citations: v.citations.map(serializeCitation), infobox: v.infobox,
citations: v.citations.map(serializeCitation), infobox: v.infobox,
@@ -168,75 +173,76 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -168,75 +173,76 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
discussion_thread: v.discussion ? v.discussion.threadId : null,
discussion_thread: v.discussion ? v.discussion.threadId : null,
};
};
}));
}));
- router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
+ router.patch('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
- let page = svc.pageById(req.params.id);
+ let page = await svc.pageById(req.params.id);
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (!page) throw new svc.WikiError(404, 'page.not_found', 'No such page');
- if (b.slug !== undefined || b.parent_id !== undefined) page = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);
- if (b.visibility !== undefined || b.noindex !== undefined) page = svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor).page;
- return { page: serializePage(page, svc, svc.spaceById(page.space_id)) };
+ if (b.slug !== undefined || b.parent_id !== undefined) page = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id }, req.actor);
+ if (b.visibility !== undefined || b.noindex !== undefined) page = (await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex }, req.actor)).page;
+ return { page: await pageOut(page) };
}));
}));
- router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R((req) => {
- const out = svc.deletePage(req.params.id, req.actor);
- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)) };
+ router.delete('/pages/:id', guard('wiki.page.create'), B('wiki.page.update'), R(async (req) => {
+ const out = await svc.deletePage(req.params.id, req.actor);
+ return { page: await pageOut(out.page) };
}));
}));
- router.get('/pages/:id/revisions', guard('wiki.page.read'), R((req) => {
- const { page, space } = readable(req, req.params.id);
+ router.get('/pages/:id/revisions', guard('wiki.page.read'), R(async (req) => {
+ const { page, space } = await readable(req, req.params.id);
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
- return { revisions: svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor }).map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
+ const list = await svc.history(page, { limit: req.query.limit, before: req.query.before, actor: req.actor });
+ return { revisions: list.map((r) => ({ ...serializeRevision(r), body: undefined, published: r.published, citation_count: r.citationCount, proposal: r.proposal ? { id: r.proposal.id, status: r.proposal.status } : null })) };
}));
}));
router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {
router.post('/pages/:id/revisions', guard('wiki.page.create'), B('wiki.page.edit'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
- const out = svc.editPage(req.params.id, {
+ const out = await svc.editPage(req.params.id, {
expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,
expectedRevision: int(b.expected_revision), title: b.title, body: b.body, summary: b.summary, infobox: b.infobox,
citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
citations: cites, keepCitations: b.keep_citations === undefined ? 'all' : b.keep_citations, message: b.message,
}, req.actor);
}, req.actor);
- const space = svc.spaceById(out.page.space_id);
- return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
+ const space = await svc.spaceById(out.page.space_id);
+ return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), created: out.created, citations: (await svc.citationsOf(out.page, out.revision.number)).map(serializeCitation) };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R((req) => {
- const { page, space } = readable(req, req.params.id);
- const v = svc.view(space, page, req.actor, { revision: int(req.params.n) });
+ router.get('/pages/:id/revisions/:n', guard('wiki.page.read'), R(async (req) => {
+ const { page, space } = await readable(req, req.params.id);
+ const v = await svc.view(space, page, req.actor, { revision: int(req.params.n) });
return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
return { revision: serializeRevision(v.revision), citations: v.citations.map(serializeCitation), infobox: v.infobox };
}));
}));
- router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R((req) => {
- const { page, space } = readable(req, req.params.id);
+ router.get('/pages/:id/diff', guard('wiki.page.read'), B('wiki.page.diff'), R(async (req) => {
+ const { page, space } = await readable(req, req.params.id);
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
if (page.state !== 'published' && !svc.access.canEdit(space, req.actor)) throw new svc.WikiError(404, 'page.not_found', 'No such page');
- return { diff: svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
+ return { diff: await svc.diff(page, int(req.query.from), int(req.query.to), { mode: req.query.mode, actor: req.actor }) };
}));
}));
- router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R((req) => {
- const { page, space } = readable(req, req.params.id);
- svc.view(space, page, req.actor, { revision: int(req.params.n) });
- return { citations: svc.citationsOf(page, int(req.params.n)).map(serializeCitation) };
+ router.get('/pages/:id/revisions/:n/citations', guard('wiki.page.read'), R(async (req) => {
+ const { page, space } = await readable(req, req.params.id);
+ await svc.view(space, page, req.actor, { revision: int(req.params.n) });
+ return { citations: (await svc.citationsOf(page, int(req.params.n))).map(serializeCitation) };
}));
}));
router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {
router.post('/pages/:id/revisions/:n/citations', guard('wiki.citation.attach'), B('wiki.citation.attach'), R(async (req) => {
const cites = await resolveCitations((req.body || {}).citations, platform);
const cites = await resolveCitations((req.body || {}).citations, platform);
- return { citations: svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor).map(serializeCitation) };
+ return { citations: (await svc.attachCitations(req.params.id, int(req.params.n), cites, req.actor)).map(serializeCitation) };
}, 201));
}, 201));
- router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
- const { page, space } = pageAndSpace(req.params.id);
+ router.post('/pages/:id/revisions/:n/review', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
+ const { page, space } = await pageAndSpace(req.params.id);
const b = req.body || {};
const b = req.body || {};
- const out = svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
+ const out = await svc.reviewRevision(space.id, page.slug, int(req.params.n), { decision: b.decision, note: b.note == null ? null : String(b.note) }, req.actor);
return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
return { review: out.review, page: serializePage(out.page, svc, space), action: out.action, indexable: out.indexable, reasons: out.reasons };
}, 201));
}, 201));
- router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
- const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
+ router.post('/pages/:id/publish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
+ const out = await svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
+ return { page: await pageOut(out.page), action: out.action };
}));
}));
- router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
+ router.post('/pages/:id/schedule', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
- const out = svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
+ const out = await svc.schedulePublish(req.params.id, { revision: int(b.revision), runAt: b.run_at }, req.actor);
return { job: out.job, created: out.created };
return { job: out.job, created: out.created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R((req) => {
- const out = svc.unpublish(req.params.id, req.actor);
- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
+ router.post('/pages/:id/unpublish', guard('wiki.revision.publish'), B('wiki.revision.publish'), R(async (req) => {
+ const out = await svc.unpublish(req.params.id, req.actor);
+ return { page: await pageOut(out.page), action: out.action };
}));
}));
- router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R((req) => {
+ router.post('/pages/:id/revert', guard('wiki.revision.revert'), B('wiki.page.edit'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
- const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
- return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
+ const out = await svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
+ return { page: await pageOut(out.page), revision: serializeRevision(out.revision), published: out.published };
}, 201));
}, 201));
router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
router.post('/pages/:id/media', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
@@ -244,7 +250,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -244,7 +250,7 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
return { attachment: serializeAttachment(a) };
return { attachment: serializeAttachment(a) };
}, 201));
}, 201));
router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
router.post('/pages/:id/media/verify', guard('wiki.page.create'), B('wiki.media.attach'), R(async (req) => {
- const { space } = pageAndSpace(req.params.id);
+ const { space } = await pageAndSpace(req.params.id);
if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
if (!svc.access.canEdit(space, req.actor)) throw new svc.WikiError(403, 'page.forbidden', 'Only editors of this space can check media');
return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
return { results: await svc.verifyMedia(req.params.id, platform.media.resolve) };
}));
}));
@@ -255,16 +261,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -255,16 +261,16 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
const b = req.body || {};
const b = req.body || {};
const cites = await resolveCitations(b.citations, platform);
const cites = await resolveCitations(b.citations, platform);
const wf = b.workflow || {};
const wf = b.workflow || {};
- const out = svc.propose({
+ const out = await svc.propose({
space: b.space, pageId: b.page_id || null, title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, citations: cites,
space: b.space, pageId: b.page_id || null, title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, citations: cites,
workflow: { id: wf.id, runId: wf.run_id || wf.runId, version: wf.version, model: wf.model }, stubProvider: !!b.stub_provider,
workflow: { id: wf.id, runId: wf.run_id || wf.runId, version: wf.version, model: wf.model }, stubProvider: !!b.stub_provider,
expectedRevision: b.expected_revision, note: b.note,
expectedRevision: b.expected_revision, note: b.note,
}, req.actor);
}, req.actor);
- return { proposal: out.proposal, page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision) };
+ return { proposal: out.proposal, page: await pageOut(out.page), revision: serializeRevision(out.revision) };
}, 201));
}, 201));
- router.get('/proposals/:id', guard('wiki.page.read'), R((req) => {
- const p = svc.getProposal(req.params.id);
- if (!p || !svc.access.canEdit(svc.spaceById(p.space_id), req.actor)) {
+ router.get('/proposals/:id', guard('wiki.page.read'), R(async (req) => {
+ const p = await svc.getProposal(req.params.id);
+ if (!p || !svc.access.canEdit(await svc.spaceById(p.space_id), req.actor)) {
// Proposers may read their own proposal's state.
// Proposers may read their own proposal's state.
if (!p || !req.actor || req.actor.kind !== 'service' || p.proposed_by !== req.actor.service) throw new svc.WikiError(404, 'proposal.not_found', 'No such proposal');
if (!p || !req.actor || req.actor.kind !== 'service' || p.proposed_by !== req.actor.service) throw new svc.WikiError(404, 'proposal.not_found', 'No such proposal');
}
}
@@ -275,8 +281,8 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
@@ -275,8 +281,8 @@ function createApi({ svc, viewers, platform, config, log = console, limits }) {
return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
return svc.reviewProposal(req.params.id, { decision: b.decision, note: b.note, publish: b.publish !== false }, req.actor);
}));
}));
- router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R((req) => ({
- results: svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit }).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
+ router.get('/search', guard('wiki.search.query'), B('wiki.search.query'), R(async (req) => ({
+ results: (await svc.search(req.query.q, req.actor, { space: req.query.space || null, limit: req.query.limit })).map((r) => ({ ...serializePage(r.page, svc, r.space), summary: r.summary })),
})));
})));
router.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
@@ -28,14 +28,16 @@ function createMachine({ svc, config }) {
@@ -28,14 +28,16 @@ function createMachine({ svc, config }) {
}));
}));
});
});
- function pageEntries() {
- return svc.publishedPublic().map(({ space, page, decision }) => ({
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
+
+ async function pageEntries() {
+ return (await svc.publishedPublic()).map(({ space, page, decision }) => ({
loc: svc.pageUrl(space, page), lastmod: page.revision_published_at, decision,
loc: svc.pageUrl(space, page), lastmod: page.revision_published_at, decision,
}));
}));
}
}
- function spaceEntries() {
- return svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => {
+ async function spaceEntries() {
+ return (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => {
const url = seo.canonicalUrl(origin, svc.spacePath(s));
const url = seo.canonicalUrl(origin, svc.spacePath(s));
// A space index is navigation, not an article: no word or source minimum.
// A space index is navigation, not an article: no word or source minimum.
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
@@ -43,22 +45,22 @@ function createMachine({ svc, config }) {
@@ -43,22 +45,22 @@ function createMachine({ svc, config }) {
});
});
}
}
- router.get('/sitemap.xml', (_req, res) => {
- const pages = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP });
+ router.get('/sitemap.xml', wrap(async (_req, res) => {
+ const pages = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP });
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
const maps = [{ loc: `${origin}/sitemaps/spaces.xml` }];
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
pages.files.forEach((_f, i) => maps.push({ loc: `${origin}/sitemaps/pages-${i + 1}.xml` }));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
cache(res).type('application/xml').send(seo.sitemapIndex(maps));
- });
- router.get('/sitemaps/spaces.xml', (_req, res) => cache(res).type('application/xml').send(seo.sitemap(spaceEntries()).files[0]));
- router.get('/sitemaps/pages-:n.xml', (req, res) => {
- const files = seo.sitemap(pageEntries(), { maxUrls: PER_SITEMAP }).files;
+ }));
+ router.get('/sitemaps/spaces.xml', wrap(async (_req, res) => cache(res).type('application/xml').send(seo.sitemap(await spaceEntries()).files[0])));
+ router.get('/sitemaps/pages-:n.xml', wrap(async (req, res) => {
+ const files = seo.sitemap(await pageEntries(), { maxUrls: PER_SITEMAP }).files;
const n = Number(req.params.n);
const n = Number(req.params.n);
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
if (!Number.isInteger(n) || n < 1 || n > files.length) return res.status(404).type('text/plain').send('Not found');
cache(res).type('application/xml').send(files[n - 1]);
cache(res).type('application/xml').send(files[n - 1]);
- });
+ }));
- function feedItems() {
- return svc.recentChanges(50).map(({ space, page, rev, decision }) => ({
+ async function feedItems() {
+ return (await svc.recentChanges(50)).map(({ space, page, rev, decision }) => ({
id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
id: `tag:openvibe.wiki,2026:page/${page.id}/revision/${page.published_revision}`,
url: svc.pageUrl(space, page),
url: svc.pageUrl(space, page),
title: rev.fields.title,
title: rev.fields.title,
@@ -76,22 +78,22 @@ function createMachine({ svc, config }) {
@@ -76,22 +78,22 @@ function createMachine({ svc, config }) {
* change readers cannot see — or the Unix epoch when there is no public space at all. The feed is
* change readers cannot see — or the Unix epoch when there is no public space at all. The feed is
* linked from every page, so an empty one is a valid feed with zero entries, not a 404.
* linked from every page, so an empty one is a valid feed with zero entries, not a 404.
*/
*/
- function emptyFeedUpdated() {
- const times = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => s.updated_at);
+ async function emptyFeedUpdated() {
+ const times = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => s.updated_at);
return new Date(times.length ? Math.max(...times) : 0).toISOString();
return new Date(times.length ? Math.max(...times) : 0).toISOString();
}
}
- router.get('/feed.atom', (_req, res) => {
- const items = feedItems();
- const updated = items.some((i) => i.decision.listable) ? null : emptyFeedUpdated();
+ router.get('/feed.atom', wrap(async (_req, res) => {
+ const items = await feedItems();
+ const updated = items.some((i) => i.decision.listable) ? null : await emptyFeedUpdated();
cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
- });
- router.get('/feed.json', (_req, res) => {
- cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));
- });
+ }));
+ router.get('/feed.json', wrap(async (_req, res) => {
+ cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, await feedItems())));
+ }));
- router.get('/llms.txt', (_req, res) => {
- const spaces = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public');
+ router.get('/llms.txt', wrap(async (_req, res) => {
+ const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public');
cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
cache(res, 3600).type('text/plain').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Wiki',
name: 'OpenVibe.Wiki',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
summary: 'Wiki spaces of the OpenVibe network: page trees, immutable revisions, citations attached to the revision that used them, infoboxes and internal links.',
@@ -105,7 +107,7 @@ function createMachine({ svc, config }) {
@@ -105,7 +107,7 @@ function createMachine({ svc, config }) {
] },
] },
],
],
}));
}));
- });
+ }));
return router;
return router;
}
}
@@ -49,11 +49,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -49,11 +49,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
// VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
// URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
router.param('space', (req, res, next, ref) => {
router.param('space', (req, res, next, ref) => {
- const space = svc.findSpace(ref);
- if (!space || space.deleted_at || !req.actor || !req.actor.subject) return next();
- const page = req.params.slug ? svc.findPage(space.id, req.params.slug) : null;
- const pairs = [{ space }, ...(page ? [{ space, page }] : svc.vipPagesOf(space.id).map((p) => ({ space, page: p })))];
- svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) }).then(() => next(), next);
+ (async () => {
+ const space = await svc.findSpace(ref);
+ if (!space || space.deleted_at || !req.actor || !req.actor.subject) return;
+ const page = req.params.slug ? await svc.findPage(space.id, req.params.slug) : null;
+ const pairs = [{ space }, ...(page ? [{ space, page }] : (await svc.vipPagesOf(space.id)).map((p) => ({ space, page: p })))];
+ await svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) });
+ })().then(() => next(), next);
});
});
const origin = new URL(config.baseUrl).origin;
const origin = new URL(config.baseUrl).origin;
@@ -87,12 +89,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -87,12 +89,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
});
});
/** Resolve :space/:slug; follows history-aware redirects (301) and reports deleted pages (410). */
/** Resolve :space/:slug; follows history-aware redirects (301) and reports deleted pages (410). */
- function locate(req, res, suffix = '') {
- const space = svc.findSpace(req.params.space);
- const page = space && !space.deleted_at ? svc.findPage(space.id, req.params.slug) : null;
+ async function locate(req, res, suffix = '') {
+ const space = await svc.findSpace(req.params.space);
+ const page = space && !space.deleted_at ? await svc.findPage(space.id, req.params.slug) : null;
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (page && page.state === 'deleted') { gone(req, res); return null; }
if (!page) {
if (!page) {
- const r = svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
+ const r = await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`);
if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
if (r && r.status === 301) { res.set('Cache-Control', 'public, max-age=300').redirect(301, r.location + suffix + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
notFound(req, res);
@@ -102,10 +104,10 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -102,10 +104,10 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
return { space, page };
return { space, page };
}
}
- function locateSpace(req, res) {
- const space = svc.findSpace(req.params.space);
+ async function locateSpace(req, res) {
+ const space = await svc.findSpace(req.params.space);
if (!space || space.deleted_at) {
if (!space || space.deleted_at) {
- const r = svc.resolveRedirect(`/s/${req.params.space}`);
+ const r = await svc.resolveRedirect(`/s/${req.params.space}`);
if (r && r.status === 301) { res.redirect(301, r.location + req.path.replace(/^\/s\/[^/]+/, '')); return null; }
if (r && r.status === 301) { res.redirect(301, r.location + req.path.replace(/^\/s\/[^/]+/, '')); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
if ((r && r.status === 410) || (space && space.deleted_at)) { gone(req, res); return null; }
notFound(req, res);
notFound(req, res);
@@ -117,9 +119,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -117,9 +119,8 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// ── Home, recent, search ─────────────────────────────────
// ── Home, recent, search ─────────────────────────────────
router.get('/', wrap(async (req, res) => {
router.get('/', wrap(async (req, res) => {
- if (req.actor.subject) await svc.access.prepareVip(req.actor, svc.vipSpaces().map((space) => ({ space }))); // VIP spaces the viewer may see
- const spaces = svc.listSpaces(req.actor);
- const recent = svc.recentChanges(10);
+ if (req.actor.subject) await svc.access.prepareVip(req.actor, (await svc.vipSpaces()).map((space) => ({ space }))); // VIP spaces the viewer may see
+ const [spaces, recent] = await Promise.all([svc.listSpaces(req.actor), svc.recentChanges(10)]);
send(req, res, 200, views.home({ spaces, recent, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
send(req, res, 200, views.home({ spaces, recent, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/',
robots: 'index, follow', cache: 'public', active: 'home', path: '/',
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),
@@ -127,11 +128,11 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -127,11 +128,11 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}));
}));
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'wiki', siteName: 'OpenVibe.Wiki' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Wiki', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'wiki', siteName: 'OpenVibe.Wiki' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Wiki', robots: 'index, follow', cache: 'public', path: '/updates' }));
- router.get('/recent', (req, res) => send(req, res, 200, views.recentPage({ items: svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' }));
- router.get('/search', (req, res) => {
+ router.get('/recent', wrap(async (req, res) => send(req, res, 200, views.recentPage({ items: await svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' })));
+ router.get('/search', wrap(async (req, res) => {
const query = String(req.query.q || '').slice(0, 200);
const query = String(req.query.q || '').slice(0, 200);
- send(req, res, 200, views.searchPage({ query, results: query ? svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });
- });
+ send(req, res, 200, views.searchPage({ query, results: query ? await svc.search(query, req.actor) : [] }), { title: query ? `Search: ${query}` : 'Search', robots: 'noindex, follow', query, active: 'search' });
+ }));
// ── Spaces ───────────────────────────────────────────────
// ── Spaces ───────────────────────────────────────────────
router.get('/new-space', (req, res) => {
router.get('/new-space', (req, res) => {
@@ -142,7 +143,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -142,7 +143,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in with your OpenVibe account to start a space.');
const b = req.body || {};
const b = req.body || {};
try {
try {
- const space = svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);
+ const space = await svc.createSpace({ name: b.name, slug: b.slug || null, description: b.description || null, visibility: b.visibility, kind: b.official === '1' && req.actor.staff ? 'official' : 'user' }, req.actor);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) throw err;
if (!err.status || err.status >= 500) throw err;
@@ -150,18 +151,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -150,18 +151,18 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}
}));
}));
- router.get('/s/:space', (req, res) => {
- const space = locateSpace(req, res);
+ router.get('/s/:space', wrap(async (req, res) => {
+ const space = await locateSpace(req, res);
if (!space) return;
if (!space) return;
const canEdit = svc.access.canEdit(space, req.actor);
const canEdit = svc.access.canEdit(space, req.actor);
- const proposals = canEdit ? svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id) : [];
+ const proposals = canEdit ? (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id) : [];
const open = space.visibility === 'public';
const open = space.visibility === 'public';
- send(req, res, 200, views.spacePage({ space, tree: svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {
+ send(req, res, 200, views.spacePage({ space, tree: await svc.tree(space, req.actor), canEdit, canManage: svc.access.canManage(space, req.actor), proposals }), {
title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Wiki.`, path: svc.spacePath(space),
title: space.name, description: space.description || `${space.name}: a space on OpenVibe.Wiki.`, path: svc.spacePath(space),
robots: open ? 'index, follow' : 'noindex, nofollow', cache: open ? 'public' : null,
robots: open ? 'index, follow' : 'noindex, nofollow', cache: open ? 'public' : null,
jsonLd: open ? seo.structuredData.breadcrumbs([{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }]) : null,
jsonLd: open ? seo.structuredData.breadcrumbs([{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }]) : null,
});
});
- });
+ }));
function editorsOnly(req, res, space) {
function editorsOnly(req, res, space) {
if (svc.access.canEdit(space, req.actor)) return true;
if (svc.access.canEdit(space, req.actor)) return true;
@@ -170,31 +171,32 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -170,31 +171,32 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
return false;
return false;
}
}
- function parentsOf(space, actor) {
+ async function parentsOf(space, actor) {
const out = [];
const out = [];
const walk = (nodes) => { for (const n of nodes) { out.push(n.page); walk(n.children); } };
const walk = (nodes) => { for (const n of nodes) { out.push(n.page); walk(n.children); } };
- walk(svc.tree(space, actor));
+ walk(await svc.tree(space, actor));
return out;
return out;
}
}
- router.get('/s/:space/new', (req, res) => {
- const space = locateSpace(req, res);
+ router.get('/s/:space/new', wrap(async (req, res) => {
+ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
- send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
- });
+ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
+ }));
router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
- const space = locateSpace(req, res);
+ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents: parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
+ const parents = await parentsOf(space, req.actor);
+ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
try {
try {
const infobox = content.parseInfoboxText(b.infobox);
const infobox = content.parseInfoboxText(b.infobox);
- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
+ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
const cites = await resolveCitations(citationsFromForm(b), platform);
const cites = await resolveCitations(citationsFromForm(b), platform);
- const out = svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);
- if (b.op === 'publish') svc.publish(out.page.id, { revision: out.revision.number }, req.actor);
+ const out = await svc.createPage(space.id, { title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, parentId: b.parent_id || null, visibility: b.visibility, citations: cites, message: b.message || null }, req.actor);
+ if (b.op === 'publish') await svc.publish(out.page.id, { revision: out.revision.number }, req.actor);
res.redirect(303, views.wpath(space, out.page));
res.redirect(303, views.wpath(space, out.page));
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500 && err.status !== 503) throw err;
if (!err.status || err.status >= 500 && err.status !== 503) throw err;
@@ -202,26 +204,26 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -202,26 +204,26 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}
}));
}));
- router.get('/s/:space/settings', (req, res) => {
- const space = locateSpace(req, res);
+ router.get('/s/:space/settings', wrap(async (req, res) => {
+ const space = await locateSpace(req, res);
if (!space) return;
if (!space) return;
if (!svc.access.canManage(space, req.actor)) return req.actor.subject ? errorPage(req, res, 403, 'Owners only', 'Only an owner of this space can change its settings.') : needSignIn(req, res, 'Sign in as an owner of this space.');
if (!svc.access.canManage(space, req.actor)) return req.actor.subject ? errorPage(req, res, 403, 'Owners only', 'Only an owner of this space can change its settings.') : needSignIn(req, res, 'Sign in as an owner of this space.');
- send(req, res, 200, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
- });
+ send(req, res, 200, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), flash: req.query.saved ? 'Saved.' : null }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
+ }));
router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {
router.post('/s/:space/settings', B('wiki.space.update'), form, wrap(async (req, res) => {
- const space = locateSpace(req, res);
+ const space = await locateSpace(req, res);
if (!space) return;
if (!space) return;
const b = req.body || {};
const b = req.body || {};
try {
try {
- if (b.op === 'role') svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
- else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
- else { const s = svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
+ if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
+ else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
+ else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=1`);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=1`);
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) throw err;
if (!err.status || err.status >= 500) throw err;
if (!svc.access.canManage(space, req.actor)) return errorPage(req, res, err.status, 'Owners only', err.message);
if (!svc.access.canManage(space, req.actor)) return errorPage(req, res, err.status, 'Owners only', err.message);
- send(req, res, err.status, views.spaceSettingsPage({ space, roles: svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
+ send(req, res, err.status, views.spaceSettingsPage({ space, roles: await svc.roles(space.id, req.actor), error: err.message }), { title: `Settings of ${space.name}`, robots: 'noindex, nofollow' });
}
}
}));
}));
@@ -234,13 +236,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -234,13 +236,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');
else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');
return false;
return false;
}
}
- router.get('/s/:space/import', (req, res) => {
- const space = locateSpace(req, res);
+ router.get('/s/:space/import', wrap(async (req, res) => {
+ const space = await locateSpace(req, res);
if (!space || !ownersOnly(req, res, space)) return;
if (!space || !ownersOnly(req, res, space)) return;
send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
- });
+ }));
router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {
router.post('/s/:space/import', B('wiki.page.import'), importForm, wrap(async (req, res) => {
- const space = locateSpace(req, res);
+ const space = await locateSpace(req, res);
if (!space || !ownersOnly(req, res, space)) return;
if (!space || !ownersOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };
const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };
@@ -249,9 +251,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -249,9 +251,9 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const parsed = importer.parseBundleText(values.bundle);
const parsed = importer.parseBundleText(values.bundle);
const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };
const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };
Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });
Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });
- const { bundle } = svc.prepareImport(space.id, input, req.actor);
+ const { bundle } = await svc.prepareImport(space.id, input, req.actor);
await resolveBundleCitations(bundle, platform);
await resolveBundleCitations(bundle, platform);
- const out = svc.importPages(space.id, bundle, req.actor);
+ const out = await svc.importPages(space.id, bundle, req.actor);
again(200, null, out);
again(200, null, out);
} catch (err) {
} catch (err) {
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
@@ -259,19 +261,20 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -259,19 +261,20 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}
}));
}));
- router.get('/s/:space/proposals', (req, res) => {
- const space = locateSpace(req, res);
+ router.get('/s/:space/proposals', wrap(async (req, res) => {
+ const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
- const items = svc.pendingProposals(req.actor).filter((p) => p.space_id === space.id).map((proposal) => ({ proposal, page: svc.pageById(proposal.page_id) }));
+ const items = [];
+ for (const proposal of (await svc.pendingProposals(req.actor)).filter((p) => p.space_id === space.id)) items.push({ proposal, page: await svc.pageById(proposal.page_id) });
send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.proposalsPage({ space, items }), { title: `AI proposals in ${space.name}`, robots: 'noindex, nofollow' });
- });
+ }));
router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {
router.post('/proposals/:id', B('wiki.revision.publish'), form, wrap(async (req, res) => {
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to review proposals.');
- const p = svc.getProposal(req.params.id);
+ const p = await svc.getProposal(req.params.id);
if (!p) return notFound(req, res);
if (!p) return notFound(req, res);
- svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);
- const space = svc.spaceById(p.space_id);
+ await svc.reviewProposal(p.id, { decision: (req.body || {}).decision, publish: true }, req.actor);
+ const space = await svc.spaceById(p.space_id);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/proposals`);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/proposals`);
}));
}));
@@ -307,13 +310,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -307,13 +310,13 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
router.get('/w/:space/:slug', wrap(async (req, res) => {
router.get('/w/:space/:slug', wrap(async (req, res) => {
const json = /\.json$/.test(req.params.slug);
const json = /\.json$/.test(req.params.slug);
if (json) req.params.slug = req.params.slug.slice(0, -5);
if (json) req.params.slug = req.params.slug.slice(0, -5);
- const found = locate(req, res, json ? '.json' : '');
+ const found = await locate(req, res, json ? '.json' : '');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
let v;
let v;
- try { v = svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
+ try { v = await svc.view(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
const open = page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public';
const cache = open && v.isPublishedRevision ? 'public' : null;
const cache = open && v.isPublishedRevision ? 'public' : null;
if (json) {
if (json) {
@@ -321,9 +324,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -321,9 +324,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
.set('X-Robots-Tag', v.isPublishedRevision ? seo.xRobotsTag(v.decision) : 'noindex, nofollow').json(articleData(v, space, page));
return;
return;
}
}
- const resolve = (t) => svc.resolveLink(t, req.actor);
- const bodyHtml = svc.renderRevision(space, v.revision, req.actor);
- const discussion = v.isPublishedRevision ? await discussionFor(space, page, req.actor) : { state: 'not_public' };
+ // Infobox values of type page link to that page: resolved before the (synchronous) view renders.
+ const infoboxLinks = new Map();
+ for (const x of v.infobox) {
+ if (x.type !== 'page' || infoboxLinks.has(x.value)) continue;
+ infoboxLinks.set(x.value, await svc.resolveLink(content.parseTarget(x.value, space.slug) || { space: space.slug, slug: '-', title: x.value }, req.actor));
+ }
+ const [bodyHtml, discussion] = await Promise.all([
+ svc.renderRevision(space, v.revision, req.actor),
+ v.isPublishedRevision ? discussionFor(space, page, req.actor) : { state: 'not_public' },
+ ]);
const url = svc.pageUrl(space, page);
const url = svc.pageUrl(space, page);
const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];
const crumbsLd = [{ name: 'Wiki', url: `${config.baseUrl}/` }, { name: space.name, url: `${config.baseUrl}${svc.spacePath(space)}` }, { name: v.revision.fields.title, url }];
// The gate decides robots for the published revision; any other revision is noindex.
// The gate decides robots for the published revision; any other revision is noindex.
@@ -338,82 +348,82 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -338,82 +348,82 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
seo.structuredData.breadcrumbs(crumbsLd),
seo.structuredData.breadcrumbs(crumbsLd),
].filter(Boolean) : null;
].filter(Boolean) : null;
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
const head = seo.metaTags({ decision, title: `${v.revision.fields.title} · OpenVibe.Wiki`, description, siteName: 'OpenVibe.Wiki', type: 'article', jsonLd });
- send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => resolve(content.parseTarget(title, space.slug) || { space: space.slug, slug: '-', title }), flash: req.query.saved ? 'Saved.' : null }), {
+ send(req, res, 200, views.articlePage(v, { html: bodyHtml, discussion, actor: req.actor, mediaUrl: (id) => platform.media.publicUrl(id), resolve: (title) => infoboxLinks.get(title) || { href: null, exists: false }, flash: req.query.saved ? 'Saved.' : null }), {
head, path: svc.pagePath(space, page), cache,
head, path: svc.pagePath(space, page), cache,
});
});
}));
}));
- router.get('/w/:space/:slug/history', (req, res) => {
- const found = locate(req, res, '/history');
+ router.get('/w/:space/:slug/history', wrap(async (req, res) => {
+ const found = await locate(req, res, '/history');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
- send(req, res, 200, views.historyPage({ space, page, list: svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {
+ send(req, res, 200, views.historyPage({ space, page, list: await svc.history(page, { limit: 500, actor: req.actor }), canEdit: svc.access.canEdit(space, req.actor) }), {
title: `History of ${page.title}`, robots: 'noindex, follow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
title: `History of ${page.title}`, robots: 'noindex, follow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
});
- });
+ }));
// The citation inspector: one revision's sources (?rev=N; the published one by default), with the
// The citation inspector: one revision's sources (?rev=N; the published one by default), with the
// same read rules as the article.
// same read rules as the article.
- router.get('/w/:space/:slug/sources', (req, res) => {
- const found = locate(req, res, '/sources');
+ router.get('/w/:space/:slug/sources', wrap(async (req, res) => {
+ const found = await locate(req, res, '/sources');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
let inspected;
let inspected;
- try { inspected = svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
+ try { inspected = await svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
send(req, res, 200, views.sourcesPage({ space, page, ...inspected }), {
send(req, res, 200, views.sourcesPage({ space, page, ...inspected }), {
title: `Sources of ${inspected.view.revision.fields.title || page.title}`, robots: 'noindex, follow',
title: `Sources of ${inspected.view.revision.fields.title || page.title}`, robots: 'noindex, follow',
cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
});
- });
+ }));
- router.get('/w/:space/:slug/compare', (req, res) => {
- const found = locate(req, res);
+ router.get('/w/:space/:slug/compare', wrap(async (req, res) => {
+ const found = await locate(req, res);
if (!found) return;
if (!found) return;
const a = Number(req.query.a), b = Number(req.query.b);
const a = Number(req.query.a), b = Number(req.query.b);
if (!Number.isInteger(a) || !Number.isInteger(b)) return errorPage(req, res, 400, 'Pick two revisions', 'Choose a "from" and a "to" revision to compare.');
if (!Number.isInteger(a) || !Number.isInteger(b)) return errorPage(req, res, 400, 'Pick two revisions', 'Choose a "from" and a "to" revision to compare.');
res.redirect(303, `${views.wpath(found.space, found.page)}/diff/${Math.min(a, b)}/${Math.max(a, b)}`);
res.redirect(303, `${views.wpath(found.space, found.page)}/diff/${Math.min(a, b)}/${Math.max(a, b)}`);
- });
+ }));
- router.get('/w/:space/:slug/diff/:a/:b', (req, res) => {
- const found = locate(req, res, `/diff/${req.params.a}/${req.params.b}`);
+ router.get('/w/:space/:slug/diff/:a/:b', wrap(async (req, res) => {
+ const found = await locate(req, res, `/diff/${req.params.a}/${req.params.b}`);
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
let diff;
let diff;
- try { diff = svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }
+ try { diff = await svc.diff(page, req.params.a, req.params.b, { mode: req.query.mode, actor: req.actor }); } catch (err) { if (err.status === 404 || err.status === 422) return notFound(req, res); throw err; }
send(req, res, 200, views.diffPage({ space, page, diff }), {
send(req, res, 200, views.diffPage({ space, page, diff }), {
title: `Changes to ${page.title}`, robots: 'noindex, nofollow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
title: `Changes to ${page.title}`, robots: 'noindex, nofollow', cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
});
});
- });
+ }));
// Editing a missing page (a red link) opens the new-page form with the title filled in.
// Editing a missing page (a red link) opens the new-page form with the title filled in.
- function editTarget(req, res) {
- const space = svc.findSpace(req.params.space);
- if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !svc.findPage(space.id, req.params.slug) && !svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {
+ async function editTarget(req, res) {
+ const space = await svc.findSpace(req.params.space);
+ if (space && !space.deleted_at && svc.access.canReadSpace(space, req.actor) && !await svc.findPage(space.id, req.params.slug) && !await svc.resolveRedirect(`/w/${req.params.space}/${req.params.slug}`)) {
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/new?title=${encodeURIComponent(String(req.query.title || req.params.slug))}`);
res.redirect(303, `/s/${encodeURIComponent(space.slug)}/new?title=${encodeURIComponent(String(req.query.title || req.params.slug))}`);
return null;
return null;
}
}
return locate(req, res, '/edit');
return locate(req, res, '/edit');
}
}
- router.get('/w/:space/:slug/edit', (req, res) => {
- const found = editTarget(req, res);
+ router.get('/w/:space/:slug/edit', wrap(async (req, res) => {
+ const found = await editTarget(req, res);
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
// Start from the newest revision that is not an unapproved AI proposal; concurrency is
// Start from the newest revision that is not an unapproved AI proposal; concurrency is
// still checked against the head.
// still checked against the head.
- const list = svc.history(page, { limit: 500 });
+ const list = await svc.history(page, { limit: 500 });
const headNumber = list[0].number;
const headNumber = list[0].number;
const start = list.find((r) => !r.proposal || r.proposal.status === 'approved') || list[0];
const start = list.find((r) => !r.proposal || r.proposal.status === 'approved') || list[0];
- const from = svc.view(space, page, req.actor, { revision: start.number });
+ const from = await svc.view(space, page, req.actor, { revision: start.number });
const note = start.number !== headNumber ? `The newest revision (${headNumber}) is an AI proposal that has not been approved; this form starts from revision ${start.number}.` : null;
const note = start.number !== headNumber ? `The newest revision (${headNumber}) is an AI proposal that has not been approved; this form starts from revision ${start.number}.` : null;
send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.editPage({ space, page, values: views.formValuesFromRevision(from.revision, page), citations: from.citations, baseRevision: start.number, expectedRevision: headNumber, error: note }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
- });
+ }));
router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/edit', B('wiki.page.edit'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/edit');
+ const found = await locate(req, res, '/edit');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
@@ -422,15 +432,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -422,15 +432,15 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const base = Number(b.base_revision) || expected;
const base = Number(b.base_revision) || expected;
const keep = [].concat(b.keep_citation || []).map(String);
const keep = [].concat(b.keep_citation || []).map(String);
let baseView = null;
let baseView = null;
- try { baseView = Number.isInteger(base) && base > 0 ? svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
+ try { baseView = Number.isInteger(base) && base > 0 ? await svc.view(space, page, req.actor, { revision: base }) : null; } catch { baseView = null; }
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, message: b.message, keep };
const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, page, values, error, preview, citations: baseView ? baseView.citations : [], baseRevision: base, expectedRevision: expected }), { title: `Editing ${page.title}`, robots: 'noindex, nofollow' });
try {
try {
const infobox = content.parseInfoboxText(b.infobox);
const infobox = content.parseInfoboxText(b.infobox);
- if (b.op === 'preview') return again(200, null, svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
+ if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
const cites = await resolveCitations(citationsFromForm(b), platform);
const cites = await resolveCitations(citationsFromForm(b), platform);
- const out = svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);
- if (b.op === 'publish') svc.publish(page.id, { revision: out.revision.number }, req.actor);
+ const out = await svc.editPage(page.id, { expectedRevision: expected, baseRevision: base, title: b.title, body: String(b.body || ''), summary: b.summary || null, infobox, citations: cites, keepCitations: keep, message: b.message || null }, req.actor);
+ if (b.op === 'publish') await svc.publish(page.id, { revision: out.revision.number }, req.actor);
res.redirect(303, `${views.wpath(space, out.page)}${b.op === 'publish' ? '' : `?rev=${out.revision.number}`}`);
res.redirect(303, `${views.wpath(space, out.page)}${b.op === 'publish' ? '' : `?rev=${out.revision.number}`}`);
} catch (err) {
} catch (err) {
if (err.code === 'revision.conflict') return again(409, `Someone saved revision ${err.extra ? err.extra.current : ''} while you were editing. Your text is below; open the page in another tab, merge by hand, and save again.`);
if (err.code === 'revision.conflict') return again(409, `Someone saved revision ${err.extra ? err.extra.current : ''} while you were editing. Your text is below; open the page in another tab, merge by hand, and save again.`);
@@ -439,52 +449,52 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -439,52 +449,52 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
}
}
}));
}));
- router.get('/w/:space/:slug/revert', (req, res) => {
- const found = locate(req, res, '/revert');
+ router.get('/w/:space/:slug/revert', wrap(async (req, res) => {
+ const found = await locate(req, res, '/revert');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
const to = Number(req.query.to);
const to = Number(req.query.to);
- const head = svc.history(page, { limit: 1 })[0].number;
+ const head = (await svc.history(page, { limit: 1 }))[0].number;
if (!Number.isInteger(to) || to < 1 || to > head) return notFound(req, res);
if (!Number.isInteger(to) || to < 1 || to > head) return notFound(req, res);
send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.revertPage({ space, page, to, head }), { title: `Revert ${page.title}`, robots: 'noindex, nofollow' });
- });
+ }));
router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/revert', B('wiki.page.edit'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/revert');
+ const found = await locate(req, res, '/revert');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
- const out = svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);
+ const out = await svc.revert(page.id, { toRevision: Number(b.to), expectedRevision: Number(b.expected_revision), message: b.message || null }, req.actor);
res.redirect(303, out.published ? views.wpath(space, out.page) : `${views.wpath(space, out.page)}?rev=${out.revision.number}`);
res.redirect(303, out.published ? views.wpath(space, out.page) : `${views.wpath(space, out.page)}?rev=${out.revision.number}`);
}));
}));
- router.get('/w/:space/:slug/publish', (req, res) => {
- const found = locate(req, res, '/publish');
+ router.get('/w/:space/:slug/publish', wrap(async (req, res) => {
+ const found = await locate(req, res, '/publish');
if (!found) return;
if (!found) return;
if (!editorsOnly(req, res, found.space)) return;
if (!editorsOnly(req, res, found.space)) return;
const rev = Number(req.query.rev);
const rev = Number(req.query.rev);
if (!Number.isInteger(rev)) return notFound(req, res);
if (!Number.isInteger(rev)) return notFound(req, res);
send(req, res, 200, views.confirmPublishPage({ ...found, rev }), { title: `Publish ${found.page.title}`, robots: 'noindex, nofollow' });
send(req, res, 200, views.confirmPublishPage({ ...found, rev }), { title: `Publish ${found.page.title}`, robots: 'noindex, nofollow' });
- });
+ }));
- router.get('/w/:space/:slug/settings', (req, res) => {
- const found = locate(req, res, '/settings');
+ router.get('/w/:space/:slug/settings', wrap(async (req, res) => {
+ const found = await locate(req, res, '/settings');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
- renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });
- });
+ await renderPageSettings(req, res, space, page, 200, { flash: req.query.saved ? (req.query.saved === 'verify' ? `Checked attachments against OpenVibe.Media: ${req.query.summary || ''}` : 'Saved.') : null });
+ }));
- function renderPageSettings(req, res, space, page, status, extra = {}) {
- const view = svc.view(space, page, req.actor, {});
- const headNumber = svc.history(page, { limit: 1 })[0].number;
- send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents: parentsOf(space, req.actor), canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
+ async function renderPageSettings(req, res, space, page, status, extra = {}) {
+ const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);
+ const headNumber = list[0].number;
+ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
}
}
router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/settings');
+ const found = await locate(req, res, '/settings');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
@@ -492,51 +502,51 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -492,51 +502,51 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
const back = (p = page, q = 'saved=1') => res.redirect(303, `${views.wpath(space, p)}/settings?${q}`);
const back = (p = page, q = 'saved=1') => res.redirect(303, `${views.wpath(space, p)}/settings?${q}`);
try {
try {
switch (b.op) {
switch (b.op) {
- case 'publish': svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));
+ case 'publish': await svc.publish(page.id, { revision: Number(b.revision) }, req.actor); return res.redirect(303, views.wpath(space, page));
case 'schedule': {
case 'schedule': {
const at = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(String(b.run_at)) ? b.run_at : `${b.run_at}Z`);
const at = Date.parse(/Z|[+-]\d\d:?\d\d$/.test(String(b.run_at)) ? b.run_at : `${b.run_at}Z`);
- svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);
+ await svc.schedulePublish(page.id, { revision: Number(b.revision), runAt: at }, req.actor);
return back();
return back();
}
}
- case 'unpublish': svc.unpublish(page.id, req.actor); return back();
- case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
+ case 'unpublish': await svc.unpublish(page.id, req.actor); return back();
+ case 'move': { const p = await svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();
case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();
- case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
+ case 'detach': await svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
case 'verify': {
case 'verify': {
const results = await svc.verifyMedia(page.id, platform.media.resolve);
const results = await svc.verifyMedia(page.id, platform.media.resolve);
const count = (k) => results.filter((r) => r.outcome === k).length;
const count = (k) => results.filter((r) => r.outcome === k).length;
return back(page, `saved=verify&summary=${encodeURIComponent(`${count('available')} available, ${count('broken')} broken, ${count('check_failed')} not checked (Media did not answer)`)}`);
return back(page, `saved=verify&summary=${encodeURIComponent(`${count('available')} available, ${count('broken')} broken, ${count('check_failed')} not checked (Media did not answer)`)}`);
}
}
- case 'visibility': svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();
- case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
+ case 'visibility': await svc.setPageVisibility(page.id, { visibility: b.visibility, noindex: b.noindex === '1' }, req.actor); return back();
+ case 'delete': if (b.confirm !== 'yes') throw new svc.WikiError(422, 'page.confirm', 'Tick the box to confirm'); await svc.deletePage(page.id, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}`);
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
}
}
} catch (err) {
} catch (err) {
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
- renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
+ await renderPageSettings(req, res, space, await svc.pageById(page.id), err.status, { error: err.message });
}
}
}));
}));
router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/review', B('wiki.revision.publish'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/review');
+ const found = await locate(req, res, '/review');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!editorsOnly(req, res, space)) return;
if (!editorsOnly(req, res, space)) return;
const b = req.body || {};
const b = req.body || {};
- svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);
+ await svc.reviewRevision(space.id, page.slug, Number(b.revision), { decision: b.decision, note: b.note ? String(b.note).slice(0, 2000) : null }, req.actor);
res.redirect(303, `${views.wpath(space, page)}/history`);
res.redirect(303, `${views.wpath(space, page)}/history`);
}));
}));
router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/watch', B('wiki.page.watch'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/watch');
+ const found = await locate(req, res, '/watch');
if (!found) return;
if (!found) return;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to watch pages.');
- svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');
+ await svc.watch(found.page.id, req.actor, (req.body || {}).on !== '0');
res.redirect(303, views.wpath(found.space, found.page));
res.redirect(303, views.wpath(found.space, found.page));
}));
}));
router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/discuss', B('wiki.discussion.comment'), form, wrap(async (req, res) => {
- const found = locate(req, res, '/discuss');
+ const found = await locate(req, res, '/discuss');
if (!found) return;
if (!found) return;
const { space, page } = found;
const { space, page } = found;
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to comment.');
if (!req.actor.subject) return needSignIn(req, res, 'Sign in to comment.');
@@ -9,25 +9,30 @@
@@ -9,25 +9,30 @@
* Events outbox relay, the Media attachment check) run only when asked (`workers: true`).
* Events outbox relay, the Media attachment check) run only when asked (`workers: true`).
*/
*/
const { load } = require('./config');
const { load } = require('./config');
-const { openDb, createStores } = require('./db');
+const { openDb, migrate, createStores } = require('./db');
const { createPlatform } = require('./integrations/platform');
const { createPlatform } = require('./integrations/platform');
const { createWikiService } = require('./wiki/service');
const { createWikiService } = require('./wiki/service');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
const { createApp } = require('./app');
-async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
+async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null } = {}) {
config = config || load();
config = config || load();
- const db = openDb(config.dbPath);
+ // PostgreSQL (ADR-035): migrate on the owner's direct connection, then serve on the pooled runtime role.
+ // Tests hand in a migrated database of their own (test/db-helper.js).
+ const db = givenDb || openDb(config, { log });
+ if (!givenDb) await migrate(config, { serving: db, log });
const stores = createStores(db, { now });
const stores = createStores(db, { now });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log });
const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log });
- const reconciled = svc.reconcileIndex();
+ const reconciled = await svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
const viewers = createViewerResolver({ keys, config });
- const app = createApp({ config, svc, viewers, platform, keys, db, log, rateLimits, fetchImpl, limitsNow });
+ const app = createApp({ config, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow });
const timers = [];
const timers = [];
if (workers) {
if (workers) {
@@ -44,6 +49,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -44,6 +49,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
timers.push(setInterval(() => svc.verifyAllMedia(platform.media.resolve).catch((err) => log.warn(`[Wiki] media check: ${err.message}`)), config.mediaVerifyIntervalMs));
}
}
if (platform.eventsConfigured) platform.outbox.start();
if (platform.eventsConfigured) platform.outbox.start();
+ timers.push(setInterval(() => platform.outbox.refreshCounts().catch(() => {}), 30 * 1000));
// wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.
// wiki.projects on Network (Contracts 0.41.0): people whose spaces or roles changed, every minute.
const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });
const projects = require('./integrations/projects-module').createProjectsModule({ db, config, tokens: platform.tokenClient, fetchImpl, now, log });
if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));
if (projects.enabled) timers.push(setInterval(() => projects.drain().catch((err) => log.warn(`[Wiki] wiki.projects: ${err.message}`)), 60 * 1000));
@@ -61,7 +67,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -61,7 +67,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
for (const t of timers) clearInterval(t);
for (const t of timers) clearInterval(t);
await platform.outbox.stop();
await platform.outbox.stop();
if (server) await new Promise((resolve) => server.close(resolve));
if (server) await new Promise((resolve) => server.close(resolve));
- db.close();
+ if (!givenDb) await db.close();
+ if (valkey) await valkey.close().catch(() => {});
}
}
return { app, db, svc, stores, platform, keys, viewers, server, config, stop };
return { app, db, svc, stores, platform, keys, viewers, server, config, stop };
}
}
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const { createDiscussionClient } = require('openvibe-publishing/discussion');
const { createDiscussionClient } = require('openvibe-publishing/discussion');
class IntegrationError extends Error {
class IntegrationError extends Error {
@@ -42,11 +42,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -42,11 +42,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
onWarning: (msg) => log.warn(`[Wiki] ${msg}`),
onWarning: (msg) => log.warn(`[Wiki] ${msg}`),
});
});
const events = createEventsClient(sdk, { source: 'wiki' });
const events = createEventsClient(sdk, { source: 'wiki' });
- const outbox = createOutbox(db, {
+ // The PostgreSQL outbox (openvibe-sdk/events): rows are written in the change's own transaction
+ // (outbox.enqueue(db, …) joins the ambient transaction); several processes relay one table safely (leases).
+ const outbox = createPgOutbox(db, {
events, table: 'wiki_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
events, table: 'wiki_event_outbox', intervalMs: config.eventsRelayIntervalMs, now,
onError: (err) => log.warn(`[Wiki] event relay: ${err && err.message}`),
onError: (err) => log.warn(`[Wiki] event relay: ${err && err.message}`),
});
});
- outbox.ensureSchema();
+ // Counts for metrics (read synchronously at scrape time), refreshed by readiness and a timer.
+ const outboxCounts = { pending: 0, rejected: 0 };
+ outbox.refreshCounts = async () => {
+ const [pending, rejected] = await Promise.all([outbox.pending(), outbox.rejected()]);
+ Object.assign(outboxCounts, { pending, rejected });
+ return { ...outboxCounts };
+ };
+ outbox.counts = () => ({ ...outboxCounts });
async function getJson(url, audience, { headers = {}, timeoutMs = 5000 } = {}) {
async function getJson(url, audience, { headers = {}, timeoutMs = 5000 } = {}) {
let res;
let res;
@@ -16,12 +16,12 @@ const NS = 'wiki.projects';
@@ -16,12 +16,12 @@ const NS = 'wiki.projects';
const MAX_SPACES = 50;
const MAX_SPACES = 50;
/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */
/** The record for one person: { spaces: [{ slug, name, role }], private_count }. */
-function summarize(db, subject) {
- const rows = db.prepare(`SELECT s.slug, s.name, s.visibility,
+async function summarize(db, subject) {
+ const rows = await db.prepare(`SELECT s.slug, s.name, s.visibility,
CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role
CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 'owner' ELSE p.role END AS role
FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject
FROM wiki_spaces s LEFT JOIN wiki_permissions p ON p.space_id = s.id AND p.subject = @subject
WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))
WHERE s.deleted_at IS NULL AND (s.owner = @subject OR p.role IN ('owner', 'editor'))
- ORDER BY role = 'owner' DESC, s.name COLLATE NOCASE`).all({ subject });
+ ORDER BY (CASE WHEN p.role = 'owner' OR s.owner = @subject THEN 0 ELSE 1 END), lower(s.name), s.id`).all({ subject });
const pub = rows.filter((r) => r.visibility === 'public');
const pub = rows.filter((r) => r.visibility === 'public');
return {
return {
spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),
spaces: pub.slice(0, MAX_SPACES).map((r) => ({ slug: r.slug, name: String(r.name).slice(0, 120), role: r.role })),
@@ -48,16 +48,16 @@ function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch
@@ -48,16 +48,16 @@ function createProjectsModule({ db, config, tokens, fetchImpl = globalThis.fetch
draining = true;
draining = true;
let written = 0;
let written = 0;
try {
try {
- for (const { subject, marked_at: markedAt } of db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at LIMIT ?').all(limit)) {
+ for (const { subject, marked_at: markedAt } of await db.prepare('SELECT subject, marked_at FROM wiki_module_dirty ORDER BY marked_at, subject LIMIT ?').all(limit)) {
const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);
const clear = () => db.prepare('DELETE FROM wiki_module_dirty WHERE subject = ? AND marked_at = ?').run(subject, markedAt);
- const data = summarize(db, subject);
+ const data = await summarize(db, subject);
const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
const hash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 32);
- const last = db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
- if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; clear(); continue; }
+ const last = await db.prepare('SELECT hash FROM wiki_module_pushes WHERE subject = ?').get(subject);
+ if ((last && last.hash === hash) || (!last && !data.spaces.length && !data.private_count)) { stats.unchanged++; await clear(); continue; }
try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }
try { await put(subject, data); } catch (err) { stats.failed++; stats.lastError = err.message; continue; }
- db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
+ await db.prepare(`INSERT INTO wiki_module_pushes (subject, hash, pushed_at) VALUES (?, ?, ?)
ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());
ON CONFLICT (subject) DO UPDATE SET hash = excluded.hash, pushed_at = excluded.pushed_at`).run(subject, hash, now());
- clear();
+ await clear();
stats.written++; written++;
stats.written++; written++;
}
}
} finally { draining = false; }
} finally { draining = false; }
@@ -18,6 +18,10 @@
@@ -18,6 +18,10 @@
* records the answers on the actor (actor.vipAllowed, actor.vipDenied). The synchronous checks below then
* records the answers on the actor (actor.vipAllowed, actor.vipDenied). The synchronous checks below then
* read them. Anything not prepared is not readable (fail closed), so VIP content never leaks into a list,
* read them. Anything not prepared is not readable (fail closed), so VIP content never leaks into a list,
* a link preview or a feed that did not ask.
* a link preview or a feed that did not ask.
+ * Roles are read the same way (PostgreSQL, ADR-035): loadRoles(actor) reads every role the actor's subject holds
+ * (one query, cached on the actor as actor.wikiRoles) before any check, so the checks stay synchronous and a
+ * filter over pages never sees a promise. A check on an actor whose roles were not loaded throws (a bug, never
+ * a silent refusal or grant). The service loads them at the top of every method that takes an actor.
* Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,
* Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,
* review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as
* review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as
* owner of official spaces; they get no silent access to user spaces.
* owner of official spaces; they get no silent access to user spaces.
@@ -40,7 +44,27 @@ function effectiveVisibility(space, page) {
@@ -40,7 +44,27 @@ function effectiveVisibility(space, page) {
}
}
function createAccess(db, { vip = null } = {}) {
function createAccess(db, { vip = null } = {}) {
- const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');
+ const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');
+ const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');
+
+ /** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */
+ async function loadRoles(actor, { fresh = false } = {}) {
+ if (!actor || actor.kind === 'system' || !actor.subject) return actor;
+ if (actor.wikiRoles && !fresh) return actor;
+ const rows = await rolesOfSubject.all(actor.subject);
+ Object.defineProperty(actor, 'wikiRoles', { value: new Map(rows.map((r) => [r.space_id, r.role])), writable: true, configurable: true, enumerable: false });
+ return actor;
+ }
+
+ /** Actors for subjects of one space (watchers), with that space's roles already loaded. */
+ async function subjectActors(space) {
+ const roles = new Map((await rolesOfSpace.all(space.id)).map((r) => [r.subject, r.role]));
+ return (subject) => {
+ const a = { kind: 'user', subject, staff: false };
+ Object.defineProperty(a, 'wikiRoles', { value: new Map(roles.has(subject) ? [[space.id, roles.get(subject)]] : []), enumerable: false });
+ return a;
+ };
+ }
/** 'owner' | 'editor' | 'viewer' | null */
/** 'owner' | 'editor' | 'viewer' | null */
function role(space, actor) {
function role(space, actor) {
@@ -50,8 +74,8 @@ function createAccess(db, { vip = null } = {}) {
@@ -50,8 +74,8 @@ function createAccess(db, { vip = null } = {}) {
if (actor.kind === 'user' && actor.staff && space.kind === 'official') return 'owner';
if (actor.kind === 'user' && actor.staff && space.kind === 'official') return 'owner';
if (!subject) return null;
if (!subject) return null;
if (space.owner === subject) return 'owner';
if (space.owner === subject) return 'owner';
- const r = roleOf.get(space.id, subject);
- return r ? r.role : null;
+ if (!actor.wikiRoles) throw new Error('wiki access: roles not loaded for this actor (await access.loadRoles(actor) first)');
+ return actor.wikiRoles.get(space.id) || null;
}
}
function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
@@ -68,6 +92,8 @@ function createAccess(db, { vip = null } = {}) {
@@ -68,6 +92,8 @@ function createAccess(db, { vip = null } = {}) {
const api = {
const api = {
VISIBILITIES,
VISIBILITIES,
role,
role,
+ loadRoles,
+ subjectActors,
isPerson,
isPerson,
effectiveVisibility,
effectiveVisibility,
canReadSpace(space, actor) {
canReadSpace(space, actor) {
@@ -121,9 +147,9 @@ function createAccess(db, { vip = null } = {}) {
@@ -121,9 +147,9 @@ function createAccess(db, { vip = null } = {}) {
return { reason: (actor.vipDenied && actor.vipDenied.get(vipKey(space, page))) || (actor.subject ? 'denied' : 'not_signed_in'),
return { reason: (actor.vipDenied && actor.vipDenied.get(vipKey(space, page))) || (actor.subject ? 'denied' : 'not_signed_in'),
owner: space.owner || null, joinUrl: vip ? vip.joinUrl(space.owner || null) : null };
owner: space.owner || null, joinUrl: vip ? vip.joinUrl(space.owner || null) : null };
},
},
- /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */
- subjectCanRead(space, page, subject) {
- return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
+ /** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. actorFor comes from subjectActors(space). */
+ subjectCanRead(space, page, subject, actorFor) {
+ return api.canReadPage(space, page, actorFor(subject));
},
},
};
};
return api;
return api;
@@ -23,11 +23,11 @@ const IMPORTED = {
@@ -23,11 +23,11 @@ const IMPORTED = {
},
},
};
};
-function seed(svc, data, { publish = true, log = console } = {}) {
+async function seed(svc, data, { publish = true, log = console } = {}) {
if (!data || !data.space || !Array.isArray(data.pages)) throw new TypeError('seed data needs { space, pages }');
if (!data || !data.space || !Array.isArray(data.pages)) throw new TypeError('seed data needs { space, pages }');
- let space = svc.findSpace(data.space.slug);
+ let space = await svc.findSpace(data.space.slug);
if (!space) {
if (!space) {
- space = svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
+ space = await svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
log.log(`[seed] created space ${space.slug}`);
log.log(`[seed] created space ${space.slug}`);
}
}
const bySlug = new Map();
const bySlug = new Map();
@@ -39,27 +39,27 @@ function seed(svc, data, { publish = true, log = console } = {}) {
@@ -39,27 +39,27 @@ function seed(svc, data, { publish = true, log = console } = {}) {
const next = [];
const next = [];
for (const p of pending) {
for (const p of pending) {
const slug = pageSlug(p.title);
const slug = pageSlug(p.title);
- const existing = svc.findPage(space.id, slug);
+ const existing = await svc.findPage(space.id, slug);
if (existing) { bySlug.set(slug, existing); skipped.push(p.title); continue; }
if (existing) { bySlug.set(slug, existing); skipped.push(p.title); continue; }
let parentId = null;
let parentId = null;
if (p.parent) {
if (p.parent) {
- const parent = bySlug.get(pageSlug(p.parent)) || svc.findPage(space.id, pageSlug(p.parent));
+ const parent = bySlug.get(pageSlug(p.parent)) || await svc.findPage(space.id, pageSlug(p.parent));
if (!parent) { next.push(p); continue; }
if (!parent) { next.push(p); continue; }
parentId = parent.id;
parentId = parent.id;
}
}
- const out = svc.createPage(space.id, {
+ const out = await svc.createPage(space.id, {
title: p.title, body: p.body, summary: p.summary || null, infobox: p.infobox || [], parentId,
title: p.title, body: p.body, summary: p.summary || null, infobox: p.infobox || [], parentId,
citations: (p.citations || []).map((c) => ({ url: c.url, title: c.title || null, retrievedAt: c.retrievedAt, quote: c.quote ? { text: c.quote } : null, licenseNote: c.licenseNote || null })),
citations: (p.citations || []).map((c) => ({ url: c.url, title: c.title || null, retrievedAt: c.retrievedAt, quote: c.quote ? { text: c.quote } : null, licenseNote: c.licenseNote || null })),
message: 'Seed import', authorship: IMPORTED,
message: 'Seed import', authorship: IMPORTED,
}, SYSTEM);
}, SYSTEM);
- if (publish) svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);
- bySlug.set(slug, svc.pageById(out.page.id));
+ if (publish) await svc.publish(out.page.id, { revision: out.revision.number }, SYSTEM);
+ bySlug.set(slug, await svc.pageById(out.page.id));
created.push(p.title);
created.push(p.title);
}
}
pending = next;
pending = next;
}
}
if (pending.length) throw new Error(`seed: parents not found for ${pending.map((p) => p.title).join(', ')}`);
if (pending.length) throw new Error(`seed: parents not found for ${pending.map((p) => p.title).join(', ')}`);
- return { space: svc.findSpace(data.space.slug), created, skipped };
+ return { space: await svc.findSpace(data.space.slug), created, skipped };
}
}
module.exports = { seed, SYSTEM, IMPORTED };
module.exports = { seed, SYSTEM, IMPORTED };
@@ -4,14 +4,16 @@
@@ -4,14 +4,16 @@
* attachments, watchers and AI proposals. Wiki owns its publication state; the Publishing
* attachments, watchers and AI proposals. Wiki owns its publication state; the Publishing
* packages supply the mechanics (revisions, citations, the gate, redirects, index hooks).
* packages supply the mechanics (revisions, citations, the gate, redirects, index hooks).
*
*
- * Every write runs in one SQLite transaction together with the events it causes (transactional
- * outbox): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
+ * Every write runs in one PostgreSQL transaction together with the events it causes (transactional
+ * outbox; ambient transactions, openvibe-sdk ≥ 0.18): wiki.space.updated, wiki.revision.created, wiki.page.published|updated|unpublished|deleted,
* wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.
* wiki.watch.triggered, and the Search index events wiki.index_document.upserted|deleted.
* After the commit (never inside it), a page that stops or starts being public has its Community
* After the commit (never inside it), a page that stops or starts being public has its Community
* discussion thread hidden or shown again (best effort).
* discussion thread hidden or shown again (best effort).
*
*
- * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code).
+ * Methods take an actor (server/wiki/access.js) and throw WikiError (status + stable code). Every method that
+ * reads or writes data is async (ADR-035); pure helpers (paths, linkRel) stay synchronous.
*/
*/
+const { AsyncLocalStorage } = require('node:async_hooks');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
@@ -73,25 +75,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -73,25 +75,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const access = createAccess(db, { vip });
const access = createAccess(db, { vip });
const origin = config.baseUrl;
const origin = config.baseUrl;
const policy = config.gate;
const policy = config.gate;
+ // Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts, as
+ // it did when each check read SQLite.
+ const roles = (actor, opts = { fresh: true }) => access.loadRoles(actor, opts);
- // ── Queries ───────────────────────────────────────────────
+ // ── Queries (PostgreSQL; better-sqlite3-shaped async statements, openvibe-sdk/db prepare) ──
const q = {
const q = {
spaceById: db.prepare('SELECT * FROM wiki_spaces WHERE id = ?'),
spaceById: db.prepare('SELECT * FROM wiki_spaces WHERE id = ?'),
+ spacesByIds: db.prepare('SELECT * FROM wiki_spaces WHERE id = ANY(?)'),
spaceBySlug: db.prepare('SELECT * FROM wiki_spaces WHERE slug = ?'),
spaceBySlug: db.prepare('SELECT * FROM wiki_spaces WHERE slug = ?'),
- spaces: db.prepare('SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY kind = \'official\' DESC, name COLLATE NOCASE'),
+ spaces: db.prepare("SELECT * FROM wiki_spaces WHERE deleted_at IS NULL ORDER BY (kind = 'official') DESC, lower(name), id"),
insertSpace: db.prepare(`INSERT INTO wiki_spaces (id, slug, name, description, kind, visibility, owner, created_by, created_at, updated_at)
insertSpace: db.prepare(`INSERT INTO wiki_spaces (id, slug, name, description, kind, visibility, owner, created_by, created_at, updated_at)
VALUES (@id, @slug, @name, @description, @kind, @visibility, @owner, @created_by, @now, @now)`),
VALUES (@id, @slug, @name, @description, @kind, @visibility, @owner, @created_by, @now, @now)`),
pageById: db.prepare('SELECT * FROM wiki_pages WHERE id = ?'),
pageById: db.prepare('SELECT * FROM wiki_pages WHERE id = ?'),
pageBySlug: db.prepare('SELECT * FROM wiki_pages WHERE space_id = ? AND slug = ?'),
pageBySlug: db.prepare('SELECT * FROM wiki_pages WHERE space_id = ? AND slug = ?'),
- pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
- publishedOfSpace: db.prepare("SELECT id FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),
+ pagesOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),
+ publishedOfSpace: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND state IN ('published','unpublished','scheduled','draft')"),
insertPage: db.prepare(`INSERT INTO wiki_pages (id, space_id, slug, title, parent_id, position, state, visibility, created_by, created_at, updated_at)
insertPage: db.prepare(`INSERT INTO wiki_pages (id, space_id, slug, title, parent_id, position, state, visibility, created_by, created_at, updated_at)
VALUES (@id, @space_id, @slug, @title, @parent_id, @position, 'draft', @visibility, @created_by, @now, @now)`),
VALUES (@id, @space_id, @slug, @title, @parent_id, @position, 'draft', @visibility, @created_by, @now, @now)`),
- children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state != 'deleted' ORDER BY position, title COLLATE NOCASE"),
- insertLink: db.prepare('INSERT OR IGNORE INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?)'),
+ children: db.prepare("SELECT * FROM wiki_pages WHERE parent_id = ? AND state <> 'deleted' ORDER BY position, lower(title), id"),
+ insertLink: db.prepare('INSERT INTO wiki_page_links (from_page_id, from_revision, target_space, target_slug, label) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING'),
linksOf: db.prepare('SELECT * FROM wiki_page_links WHERE from_page_id = ? AND from_revision = ? ORDER BY target_space, target_slug'),
linksOf: db.prepare('SELECT * FROM wiki_page_links WHERE from_page_id = ? AND from_revision = ? ORDER BY target_space, target_slug'),
- backlinks: db.prepare(`SELECT DISTINCT p.* FROM wiki_page_links l JOIN wiki_pages p ON p.id = l.from_page_id AND l.from_revision = p.published_revision
- WHERE l.target_space = ? AND l.target_slug = ? AND p.state = 'published' ORDER BY p.title COLLATE NOCASE LIMIT 200`),
+ backlinks: db.prepare(`SELECT p.* FROM wiki_pages p
+ WHERE p.state = 'published' AND EXISTS (SELECT 1 FROM wiki_page_links l WHERE l.from_page_id = p.id AND l.from_revision = p.published_revision
+ AND l.target_space = ? AND l.target_slug = ?)
+ ORDER BY lower(p.title), p.id LIMIT 200`),
insertInfobox: db.prepare(`INSERT INTO wiki_infobox_values (page_id, revision, position, key, label, type, value_text, value_number)
insertInfobox: db.prepare(`INSERT INTO wiki_infobox_values (page_id, revision, position, key, label, type, value_text, value_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
infoboxOf: db.prepare('SELECT * FROM wiki_infobox_values WHERE page_id = ? AND revision = ? ORDER BY position'),
infoboxOf: db.prepare('SELECT * FROM wiki_infobox_values WHERE page_id = ? AND revision = ? ORDER BY position'),
@@ -100,20 +108,20 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -100,20 +108,20 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
ON CONFLICT (space_id, subject) DO UPDATE SET role = excluded.role, granted_by = excluded.granted_by, granted_at = excluded.granted_at`),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
delRole: db.prepare('DELETE FROM wiki_permissions WHERE space_id = ? AND subject = ?'),
// wiki.projects (Network user module): who needs their record written again.
// wiki.projects (Network user module): who needs their record written again.
- dirtySubject: db.prepare("INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at"),
+ dirtySubject: db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, ?) ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at'),
dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)
dirtySpace: db.prepare(`INSERT INTO wiki_module_dirty (subject, marked_at)
- SELECT subject, @t FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
- UNION SELECT owner, @t FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
+ SELECT subject, @t::bigint FROM wiki_permissions WHERE space_id = @id AND substr(subject, 1, 4) = 'usr_'
+ UNION SELECT owner, @t::bigint FROM wiki_spaces WHERE id = @id AND substr(owner, 1, 4) = 'usr_'
ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),
ON CONFLICT (subject) DO UPDATE SET marked_at = excluded.marked_at`),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
owners: db.prepare("SELECT COUNT(*) AS n FROM wiki_permissions WHERE space_id = ? AND role = 'owner'"),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
watchers: db.prepare('SELECT subject FROM wiki_watchers WHERE page_id = ? ORDER BY subject'),
- watch: db.prepare('INSERT OR IGNORE INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)'),
+ watch: db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'),
unwatch: db.prepare('DELETE FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
unwatch: db.prepare('DELETE FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
- isWatching: db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
+ isWatching: db.prepare('SELECT 1 AS yes FROM wiki_watchers WHERE page_id = ? AND subject = ?'),
proposal: db.prepare('SELECT * FROM wiki_ai_proposals WHERE id = ?'),
proposal: db.prepare('SELECT * FROM wiki_ai_proposals WHERE id = ?'),
proposalByRev: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? AND revision = ?'),
proposalByRev: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? AND revision = ?'),
- proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC'),
- pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC LIMIT 200"),
+ proposalsOfPage: db.prepare('SELECT * FROM wiki_ai_proposals WHERE page_id = ? ORDER BY created_at DESC, id DESC'),
+ pendingProposals: db.prepare("SELECT * FROM wiki_ai_proposals WHERE status = 'pending' ORDER BY created_at DESC, id DESC LIMIT 200"),
insertProposal: db.prepare(`INSERT INTO wiki_ai_proposals (id, page_id, space_id, revision, base_revision, workflow_id, run_id, stub_provider, status, proposed_by, note, created_at)
insertProposal: db.prepare(`INSERT INTO wiki_ai_proposals (id, page_id, space_id, revision, base_revision, workflow_id, run_id, stub_provider, status, proposed_by, note, created_at)
VALUES (@id, @page_id, @space_id, @revision, @base_revision, @workflow_id, @run_id, @stub_provider, 'pending', @proposed_by, @note, @now)`),
VALUES (@id, @page_id, @space_id, @revision, @base_revision, @workflow_id, @run_id, @stub_provider, 'pending', @proposed_by, @note, @now)`),
reviewProposal: db.prepare("UPDATE wiki_ai_proposals SET status = @status, reviewed_by = @by, review_note = @note, reviewed_at = @now WHERE id = @id AND status = 'pending'"),
reviewProposal: db.prepare("UPDATE wiki_ai_proposals SET status = @status, reviewed_by = @by, review_note = @note, reviewed_at = @now WHERE id = @id AND status = 'pending'"),
@@ -122,15 +130,24 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -122,15 +130,24 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
- ORDER BY p.id`),
+ ORDER BY p.id LIMIT 50000`),
insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)
insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`),
VALUES (?, ?, ?, ?, ?, ?, ?)`),
originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),
originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),
+ // Case-insensitive, as SQLite's LIKE was for ASCII.
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
- WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
- AND (p.title LIKE @like ESCAPE '\\' OR r.content LIKE @like ESCAPE '\\')
- ORDER BY (p.title LIKE @like ESCAPE '\\') DESC, p.revision_published_at DESC LIMIT 200`),
+ WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space::text IS NULL OR s.slug = @space)
+ AND (p.title ILIKE @like ESCAPE '\' OR r.content ILIKE @like ESCAPE '\')
+ ORDER BY (p.title ILIKE @like ESCAPE '\') DESC, p.revision_published_at DESC, p.id LIMIT 200`),
+ setPublished: db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = @n, title = @title, published_at = COALESCE(published_at, @t),
+ revision_published_at = CASE WHEN published_revision IS NOT DISTINCT FROM @n AND state = 'published' THEN revision_published_at ELSE @t END,
+ updated_at = @t WHERE id = @id`),
+ setState: db.prepare('UPDATE wiki_pages SET state = ?, updated_at = ? WHERE id = ?'),
+ touch: db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?'),
+ vipPages: db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' ORDER BY id LIMIT 50"),
+ pageIds: db.prepare('SELECT id FROM wiki_pages ORDER BY id'),
+ attachedEntities: db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments ORDER BY entity_id'),
};
};
// ── Paths and small helpers ───────────────────────────────
// ── Paths and small helpers ───────────────────────────────
@@ -139,29 +156,23 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -139,29 +156,23 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const pageUrl = (space, page) => seo.canonicalUrl(origin, pagePath(space, page));
const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
const fail = (status, code, message, extra) => { throw new WikiError(status, code, message, extra); };
- // Work that must wait until the write has committed (never inside the transaction). Queued by
- // afterCommit() and run once the outermost tx() returns; dropped when the transaction throws.
- let commitQueue = null;
- function tx(fn) {
- if (commitQueue) return db.transaction(fn)();
- commitQueue = [];
- let out;
- try {
- out = db.transaction(fn)();
- } catch (err) {
- commitQueue = null;
- throw err;
- }
- const queue = commitQueue;
- commitQueue = null;
+ // Work that must wait until the write has committed (never inside the transaction). Queued by afterCommit()
+ // in the running request's own queue (AsyncLocalStorage: concurrent requests never share one), run once the
+ // outermost tx() has committed, dropped when it throws. A tx() inside a tx() is a savepoint.
+ const commits = new AsyncLocalStorage();
+ async function tx(fn) {
+ if (commits.getStore()) return db.tx(() => fn());
+ const queue = [];
+ const out = await commits.run(queue, () => db.tx(() => fn()));
for (const job of queue) {
for (const job of queue) {
- try { job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }
+ try { await job(); } catch (err) { log.warn(`[Wiki] after-commit: ${err && err.message}`); }
}
}
return out;
return out;
}
}
function afterCommit(job) {
function afterCommit(job) {
- if (commitQueue) commitQueue.push(job);
- else job();
+ const queue = commits.getStore();
+ if (queue) queue.push(job);
+ else Promise.resolve().then(job).catch((err) => log.warn(`[Wiki] after-commit: ${err && err.message}`));
}
}
/**
/**
@@ -172,8 +183,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -172,8 +183,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
*/
*/
function followThread(pageId, visibility) {
function followThread(pageId, visibility) {
if (!community || typeof community.setThreadVisibility !== 'function') return;
if (!community || typeof community.setThreadVisibility !== 'function') return;
- afterCommit(() => {
- const known = discussions.get(pageId);
+ afterCommit(async () => {
+ const known = await discussions.get(pageId);
if (!known) return;
if (!known) return;
Promise.resolve()
Promise.resolve()
.then(() => community.setThreadVisibility(known.threadId, visibility))
.then(() => community.setThreadVisibility(known.threadId, visibility))
@@ -193,14 +204,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -193,14 +204,14 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return actor.subject;
return actor.subject;
}
}
- function spaceOrFail(idOrSlug) {
- const s = /^spc_/.test(String(idOrSlug)) ? q.spaceById.get(idOrSlug) : q.spaceBySlug.get(String(idOrSlug));
+ async function spaceOrFail(idOrSlug) {
+ const s = /^spc_/.test(String(idOrSlug)) ? await q.spaceById.get(idOrSlug) : await q.spaceBySlug.get(String(idOrSlug));
if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');
if (!s || s.deleted_at) fail(404, 'space.not_found', 'No such space');
return s;
return s;
}
}
- function pageOrFail(id) {
- const p = PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) : null;
+ async function pageOrFail(id) {
+ const p = PAGE_ID_RE.test(String(id)) ? await q.pageById.get(id) : null;
if (!p) fail(404, 'page.not_found', 'No such page');
if (!p) fail(404, 'page.not_found', 'No such page');
return p;
return p;
}
}
@@ -239,26 +250,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -239,26 +250,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* A renamed (or deleted) space's old slug keeps answering with its redirect (or 410): nobody but
* A renamed (or deleted) space's old slug keeps answering with its redirect (or 410): nobody but
* that space may take it, or the old links would serve someone else's pages.
* that space may take it, or the old links would serve someone else's pages.
*/
*/
- function checkSlugNotRetired(slug, spaceId = null) {
- const r = svc.resolveRedirect(`/s/${slug}`);
+ async function checkSlugNotRetired(slug, spaceId = null) {
+ const r = await svc.resolveRedirect(`/s/${slug}`);
if (r && r.entityId !== spaceId) fail(409, 'space.slug_retired', `The space slug "${slug}" belonged to another space and still leads there`);
if (r && r.entityId !== spaceId) fail(409, 'space.slug_retired', `The space slug "${slug}" belonged to another space and still leads there`);
}
}
- function wrapContentError(fn) {
- try { return fn(); } catch (err) {
- if (err instanceof content.ContentError) fail(422, err.code, err.message);
- if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);
- if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);
- throw err;
- }
+ function contentError(err) {
+ if (err instanceof content.ContentError) fail(422, err.code, err.message);
+ if (err && err.name === 'PublishingError') fail(err.status || 400, err.code, err.message, err.expected !== undefined ? { expected: err.expected, current: err.current } : undefined);
+ if (err instanceof TypeError && /citation|url|quote|retrievedAt|media|role|variant|workflow|authors|mode/i.test(err.message)) fail(422, 'wiki.invalid_input', err.message);
+ throw err;
}
}
+ function wrapContentError(fn) { try { return fn(); } catch (err) { return contentError(err); } }
+ async function wrapStore(fn) { try { return await fn(); } catch (err) { return contentError(err); } }
// ── Events ───────────────────────────────────────────────
// ── Events ───────────────────────────────────────────────
- function emit(envelope) { return outbox.enqueue(envelope); }
+ function emit(envelope) { return outbox.enqueue(db, envelope); }
- function spaceEvent(space, actor, change) {
- q.dirtySpace.run({ id: space.id, t: now() });
- emit({
+ async function spaceEvent(space, actor, change) {
+ await q.dirtySpace.run({ id: space.id, t: now() });
+ await emit({
event_type: 'wiki.space.updated',
event_type: 'wiki.space.updated',
actor: hooks.subjectRef(actorId(actor)),
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'space', id: space.id },
subject: { type: 'space', id: space.id },
@@ -273,7 +284,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -273,7 +284,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
function revisionEvent(space, page, rev, actor, extra = {}) {
function revisionEvent(space, page, rev, actor, extra = {}) {
const rec = rev.meta && rev.meta.authorship;
const rec = rev.meta && rev.meta.authorship;
// A new revision is a draft until it is published: the event is internal.
// A new revision is a draft until it is published: the event is internal.
- emit({
+ return emit({
event_type: 'wiki.revision.created',
event_type: 'wiki.revision.created',
actor: hooks.subjectRef(actorId(actor)),
actor: hooks.subjectRef(actorId(actor)),
subject: { type: 'page', id: page.id, revision: rev.number },
subject: { type: 'page', id: page.id, revision: rev.number },
@@ -287,12 +298,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -287,12 +298,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
}
/** One event per change listing the watchers who may read the page (never the actor). */
/** One event per change listing the watchers who may read the page (never the actor). */
- function notifyWatchers(space, page, action, revision, actor) {
+ async function notifyWatchers(space, page, action, revision, actor) {
const who = actorId(actor);
const who = actorId(actor);
- const recipients = q.watchers.all(page.id).map((r) => r.subject)
- .filter((s) => s !== who && access.subjectCanRead(space, page.state === 'deleted' ? { ...page, state: 'published' } : page, s));
+ const subjects = (await q.watchers.all(page.id)).map((r) => r.subject).filter((s) => s !== who);
+ if (!subjects.length) return;
+ const actorFor = await access.subjectActors(space);
+ const readable = page.state === 'deleted' ? { ...page, state: 'published' } : page;
+ const recipients = subjects.filter((s) => access.subjectCanRead(space, readable, s, actorFor));
if (!recipients.length) return;
if (!recipients.length) return;
- emit({
+ await emit({
event_type: 'wiki.watch.triggered',
event_type: 'wiki.watch.triggered',
actor: hooks.subjectRef(who),
actor: hooks.subjectRef(who),
subject: { type: 'page', id: page.id, revision: revision || 0 },
subject: { type: 'page', id: page.id, revision: revision || 0 },
@@ -302,27 +316,29 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -302,27 +316,29 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
}
// ── The gate, index documents and publication events ─────
// ── The gate, index documents and publication events ─────
- function gateFacts(space, page, rev) {
- const text = ssr.markdownToText(rev.content);
+ /** Gate facts from facts already read (no query of its own). */
+ function gateFactsWith(space, page, rev, citationCount, review) {
const rec = rev.meta && rev.meta.authorship;
const rec = rev.meta && rev.meta.authorship;
return {
return {
state: space.deleted_at ? 'deleted' : page.state,
state: space.deleted_at ? 'deleted' : page.state,
visibility: GATE_VIS[access.effectiveVisibility(space, page)],
visibility: GATE_VIS[access.effectiveVisibility(space, page)],
canonicalUrl: pageUrl(space, page),
canonicalUrl: pageUrl(space, page),
- text,
- citationCount: citations.forRevision(page.id, rev.number).length,
+ text: ssr.markdownToText(rev.content),
+ citationCount,
noindex: !!page.noindex,
noindex: !!page.noindex,
- ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
+ ...(rec ? authorshipGateFacts(rec, review) : {}),
};
};
}
}
-
- function decide(space, page, rev) {
- return seo.evaluate(gateFacts(space, page, rev), { policy, now: now() });
+ async function gateFacts(space, page, rev) {
+ const [cites, review] = await Promise.all([citations.forRevision(page.id, rev.number), reviews.latest(page.id, rev.number)]);
+ return gateFactsWith(space, page, rev, cites.length, review);
}
}
+ const decideWith = (space, page, rev, cites, review) => seo.evaluate(gateFactsWith(space, page, rev, cites.length, review), { policy, now: now() });
+ async function decide(space, page, rev) { return seo.evaluate(await gateFacts(space, page, rev), { policy, now: now() }); }
- function snapshot(page) {
+ async function snapshot(page) {
if (!page) return null;
if (!page) return null;
- const space = q.spaceById.get(page.space_id);
+ const space = await q.spaceById.get(page.space_id);
return { state: space.deleted_at ? 'deleted' : page.state, visibility: access.effectiveVisibility(space, page), revision: page.published_revision };
return { state: space.deleted_at ? 'deleted' : page.state, visibility: access.effectiveVisibility(space, page), revision: page.published_revision };
}
}
@@ -332,15 +348,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -332,15 +348,15 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* product event when the publication state moved. Both go to the outbox in the caller's transaction.
* product event when the publication state moved. Both go to the outbox in the caller's transaction.
* When the page stops (or starts) being public, its discussion thread follows after the commit.
* When the page stops (or starts) being public, its discussion thread follows after the commit.
*/
*/
- function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
- const page = q.pageById.get(pageId);
- const space = q.spaceById.get(page.space_id);
+ async function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
+ const page = await q.pageById.get(pageId);
+ const space = await q.spaceById.get(page.space_id);
const eff = access.effectiveVisibility(space, page);
const eff = access.effectiveVisibility(space, page);
const state = space.deleted_at ? 'deleted' : page.state;
const state = space.deleted_at ? 'deleted' : page.state;
- const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
- const decision = rev && state === 'published' ? decide(space, page, rev) : null;
- const cites = rev ? citations.forRevision(page.id, rev.number) : [];
- const doc = sequencer.stamp(hooks.buildIndexDocument({
+ const rev = page.published_revision ? await revisions.get(page.id, page.published_revision) : null;
+ const cites = rev ? await citations.forRevision(page.id, rev.number) : [];
+ const decision = rev && state === 'published' ? decideWith(space, page, rev, cites, await reviews.latest(page.id, rev.number)) : null;
+ const doc = await sequencer.stamp(db, hooks.buildIndexDocument({
owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],
owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],
// Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.
// Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.
deleted: eff !== 'public' || !decision || !decision.listable,
deleted: eff !== 'public' || !decision || !decision.listable,
@@ -352,7 +368,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -352,7 +368,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
publishedAt: page.published_at, updatedAt: page.revision_published_at,
publishedAt: page.published_at, updatedAt: page.revision_published_at,
}));
}));
const sentBefore = before ? before.indexRevision : null;
const sentBefore = before ? before.indexRevision : null;
- if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
+ if (doc.revision !== sentBefore) await emit(hooks.indexEvent({ document: doc, now: now() }));
const after = { state, visibility: eff, revision: page.published_revision };
const after = { state, visibility: eff, revision: page.published_revision };
const wasPublic = Boolean(before) && before.state === 'published' && before.visibility === 'public';
const wasPublic = Boolean(before) && before.state === 'published' && before.visibility === 'public';
@@ -368,45 +384,47 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -368,45 +384,47 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// its state, not a Search document.
// its state, not a Search document.
evDoc = { owner: 'wiki', type: 'page', id: page.id, revision: doc.revision, deleted: false, visibility: GATE_VIS[eff], canonical_url: pageUrl(space, page), publication_state: 'published', indexability: decision ? hooks.searchIndexability(decision) : null };
evDoc = { owner: 'wiki', type: 'page', id: page.id, revision: doc.revision, deleted: false, visibility: GATE_VIS[eff], canonical_url: pageUrl(space, page), publication_state: 'published', indexability: decision ? hooks.searchIndexability(decision) : null };
}
}
- emit(hooks.publicationEvent({
+ await emit(hooks.publicationEvent({
product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0,
product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0,
actor: hooks.subjectRef(actorId(actor)), document: evDoc, decision, now: now(),
actor: hooks.subjectRef(actorId(actor)), document: evDoc, decision, now: now(),
extra: { space: space.slug, slug: page.slug, visibility: eff },
extra: { space: space.slug, slug: page.slug, visibility: eff },
}));
}));
- notifyWatchers(space, page, action, page.published_revision, actor);
+ await notifyWatchers(space, page, action, page.published_revision, actor);
}
}
return { action, indexRevision: doc.revision, deleted: doc.deleted };
return { action, indexRevision: doc.revision, deleted: doc.deleted };
}
}
- function before(page) {
- const s = snapshot(page);
- return s ? { ...s, indexRevision: sequencer.current('wiki', 'page', page.id) } : null;
+ async function before(page) {
+ const s = await snapshot(page);
+ return s ? { ...s, indexRevision: await sequencer.current('wiki', 'page', page.id) } : null;
}
}
// ── Side tables written with each revision ───────────────
// ── Side tables written with each revision ───────────────
- function writeRevisionExtras(space, page, rev, infobox) {
- for (const l of content.extractLinks(rev.content, space.slug)) q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);
- infobox.forEach((e, i) => q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type,
- e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null));
+ async function writeRevisionExtras(space, page, rev, infobox) {
+ for (const l of content.extractLinks(rev.content, space.slug)) await q.insertLink.run(page.id, rev.number, l.space, l.slug, l.label);
+ for (const [i, e] of infobox.entries()) {
+ await q.insertInfobox.run(page.id, rev.number, i, e.key, e.label, e.type, e.type === 'number' ? null : String(e.value), e.type === 'number' ? e.value : null);
+ }
}
}
- function infoboxOf(pageId, revision) {
- return q.infoboxOf.all(pageId, revision).map((r) => ({
+ async function infoboxOf(pageId, revision) {
+ return (await q.infoboxOf.all(pageId, revision)).map((r) => ({
key: r.key, label: r.label, type: r.type,
key: r.key, label: r.label, type: r.type,
value: r.type === 'number' ? r.value_number : r.type === 'boolean' ? r.value_text === 'true' : r.value_text,
value: r.type === 'number' ? r.value_number : r.type === 'boolean' ? r.value_text === 'true' : r.value_text,
}));
}));
}
}
- function attachCitationList(page, revisionNumber, list, actor) {
+ async function attachCitationList(page, revisionNumber, list, actor) {
if (!list || !list.length) return [];
if (!list || !list.length) return [];
- return wrapContentError(() => citations.attachMany(page.id, revisionNumber, list.map((c) => {
+ const items = list.map((c) => {
if (!c || (!c.url && !c.sourceItemId)) fail(422, 'citation.no_source', 'A citation needs a URL or a Sources item id');
if (!c || (!c.url && !c.sourceItemId)) fail(422, 'citation.no_source', 'A citation needs a URL or a Sources item id');
if (c.url && !c.sourceItemId && !c.retrievedAt) fail(422, 'citation.retrieved_at_required', `When was ${c.url} read? A URL citation needs retrievedAt`);
if (c.url && !c.sourceItemId && !c.retrievedAt) fail(422, 'citation.retrieved_at_required', `When was ${c.url} read? A URL citation needs retrievedAt`);
return { ...c, attachedBy: actorId(actor) };
return { ...c, attachedBy: actorId(actor) };
- })));
+ });
+ return wrapStore(() => citations.attachMany(page.id, revisionNumber, items));
}
}
- function carryCitations(page, from, to, keep, actor) {
+ async function carryCitations(page, from, to, keep, actor) {
if (!from || keep === 'none' || (Array.isArray(keep) && !keep.length)) return [];
if (!from || keep === 'none' || (Array.isArray(keep) && !keep.length)) return [];
const ids = keep === 'all' || keep == null ? null : keep.map(Number).filter(Number.isInteger);
const ids = keep === 'all' || keep == null ? null : keep.map(Number).filter(Number.isInteger);
return citations.carryForward({ entityId: page.id, fromRevision: from, toRevision: to, ids, attachedBy: actorId(actor) });
return citations.carryForward({ entityId: page.id, fromRevision: from, toRevision: to, ids, attachedBy: actorId(actor) });
@@ -417,86 +435,101 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -417,86 +435,101 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
}
/** A new page with its first revision (permission checks and authorship are the caller's). */
/** A new page with its first revision (permission checks and authorship are the caller's). */
- function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
+ async function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
const cleanTitle = checkTitle(title);
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const cleanSummary = checkSummary(summary);
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const vis = checkVisibility(visibility, 'public', space);
const vis = checkVisibility(visibility, 'public', space);
- return tx(() => {
- const existing = q.pageBySlug.get(space.id, slug);
+ return tx(async () => {
+ const existing = await q.pageBySlug.get(space.id, slug);
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
- if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
+ if (parentId) { const par = await q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
const t = now();
const t = now();
const id = pageIdNew(t);
const id = pageIdNew(t);
- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
- redirects.release(`/w/${space.slug}/${slug}`);
- const { revision } = wrapContentError(() => revisions.create({
+ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
+ await redirects.release(`/w/${space.slug}/${slug}`);
+ const { revision } = await wrapStore(() => revisions.create({
entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },
entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },
meta: { authorship: rec }, author: actorId(actor), message,
meta: { authorship: rec }, author: actorId(actor), message,
}));
}));
- const page = q.pageById.get(id);
- writeRevisionExtras(space, page, revision, box);
- attachCitationList(page, revision.number, cites, actor);
- revisionEvent(space, page, revision, actor);
- if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);
+ const page = await q.pageById.get(id);
+ await writeRevisionExtras(space, page, revision, box);
+ await attachCitationList(page, revision.number, cites, actor);
+ await revisionEvent(space, page, revision, actor);
+ if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);
return { page, revision };
return { page, revision };
});
});
}
}
// ── Publication (internal: no permission checks) ─────────
// ── Publication (internal: no permission checks) ─────────
- function publishRevision(page, revisionNumber, actor) {
- const rev = revisions.get(page.id, revisionNumber);
+ async function publishRevision(page, revisionNumber, actor) {
+ const rev = await revisions.get(page.id, revisionNumber);
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
if (!rev) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
const rec = rev.meta.authorship;
const rec = rev.meta.authorship;
if (rec) {
if (rec) {
- const ok = authorship.canPublish(rec, reviews.latest(page.id, revisionNumber));
+ const ok = authorship.canPublish(rec, await reviews.latest(page.id, revisionNumber));
if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');
if (!ok.ok) fail(409, ok.reason, 'AI-generated revisions are published only after a person approves them');
}
}
- const b = before(page);
- const t = now();
- db.prepare(`UPDATE wiki_pages SET state = 'published', published_revision = ?, title = ?, published_at = COALESCE(published_at, ?),
- revision_published_at = CASE WHEN published_revision IS ? AND state = 'published' THEN revision_published_at ELSE ? END, updated_at = ? WHERE id = ?`)
- .run(revisionNumber, rev.fields.title || page.title, t, revisionNumber, t, t, page.id);
+ const b = await before(page);
+ await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
return sync(b, page.id, actor);
return sync(b, page.id, actor);
}
}
/**
/**
* Readers who are not editors see the published revision and the history before it, never
* Readers who are not editors see the published revision and the history before it, never
- * newer drafts and never an AI proposal a person has not approved.
+ * newer drafts and never an AI proposal a person has not approved. `proposal` is the one filed
+ * for revision n, when the caller already read it (undefined: read here).
*/
*/
- function revisionVisible(space, page, n, actor) {
+ async function revisionVisible(space, page, n, actor, proposal) {
if (access.canEdit(space, actor)) return true;
if (access.canEdit(space, actor)) return true;
if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
- const p = q.proposalByRev.get(page.id, n);
+ const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;
return !p || p.status === 'approved';
return !p || p.status === 'approved';
}
}
+ /** { space, page, rev, decision } for published pages, in four queries whatever their number (no N+1). */
+ async function expandPublished(pages) {
+ if (!pages.length) return [];
+ const spaces = new Map((await q.spacesByIds.all([...new Set(pages.map((p) => p.space_id))])).map((s) => [s.id, s]));
+ const refs = pages.map((p) => ({ entityId: p.id, revision: p.published_revision }));
+ const [revs, cites, revws] = await Promise.all([revisions.getMany(refs), citations.forRevisions(refs), reviews.latestMany(refs)]);
+ const out = [];
+ pages.forEach((page, i) => {
+ const space = spaces.get(page.space_id);
+ if (!space || !revs[i]) return;
+ out.push({ space, page, rev: revs[i], decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
+ });
+ return out;
+ }
+
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const svc = {
const svc = {
- access, spacePath, pagePath, pageUrl, WikiError,
+ access, spacePath, pagePath, pageUrl, WikiError, loadRoles: roles,
// Spaces ---------------------------------------------------------------------------
// Spaces ---------------------------------------------------------------------------
- listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },
+ async listSpaces(actor) { await roles(actor); return (await q.spaces.all()).filter((s) => access.canReadSpace(s, actor)); },
/** VIP spaces (for preparing a viewer's VIP answers before a list renders; WS-K task 8). */
/** VIP spaces (for preparing a viewer's VIP answers before a list renders; WS-K task 8). */
- vipSpaces() { return q.spaces.all().filter((s) => s.visibility === 'vip').slice(0, 50); },
+ async vipSpaces() { return (await q.spaces.all()).filter((s) => s.visibility === 'vip').slice(0, 50); },
/** A space's VIP-only pages (its tree), at most 50. */
/** A space's VIP-only pages (its tree), at most 50. */
- vipPagesOf(spaceId) { return db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' LIMIT 50").all(spaceId); },
+ vipPagesOf(spaceId) { return q.vipPages.all(spaceId); },
- getSpace(idOrSlug, actor) {
- const s = spaceOrFail(idOrSlug);
+ async getSpace(idOrSlug, actor) {
+ await roles(actor);
+ const s = await spaceOrFail(idOrSlug);
if (!access.canReadSpace(s, actor)) fail(404, 'space.not_found', 'No such space');
if (!access.canReadSpace(s, actor)) fail(404, 'space.not_found', 'No such space');
return s;
return s;
},
},
/** Raw lookup for redirects and routing (no permission check; callers decide). */
/** Raw lookup for redirects and routing (no permission check; callers decide). */
- findSpace(slug) { return q.spaceBySlug.get(String(slug)) || null; },
- findPage(spaceId, slug) { return q.pageBySlug.get(spaceId, String(slug)) || null; },
- pageById(id) { return PAGE_ID_RE.test(String(id)) ? q.pageById.get(id) || null : null; },
- spaceById(id) { return q.spaceById.get(id) || null; },
+ async findSpace(slug) { return (await q.spaceBySlug.get(String(slug))) || null; },
+ async findPage(spaceId, slug) { return (await q.pageBySlug.get(spaceId, String(slug))) || null; },
+ async pageById(id) { return PAGE_ID_RE.test(String(id)) ? (await q.pageById.get(id)) || null : null; },
+ async spaceById(id) { return (await q.spaceById.get(id)) || null; },
- createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {
+ async createSpace({ slug, name, description = null, kind = 'user', visibility = 'public' } = {}, actor) {
+ await roles(actor);
const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 120);
const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 120);
if (!cleanName) fail(422, 'space.invalid_name', 'A space needs a name');
if (!cleanName) fail(422, 'space.invalid_name', 'A space needs a name');
let cleanSlug;
let cleanSlug;
@@ -512,26 +545,30 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -512,26 +545,30 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
}
if (kind === 'user') requirePerson(actor);
if (kind === 'user') requirePerson(actor);
const owner = kind === 'user' ? actor.subject : (actor && actor.kind === 'service' ? actor.service : 'svc:wiki');
const owner = kind === 'user' ? actor.subject : (actor && actor.kind === 'service' ? actor.service : 'svc:wiki');
- return tx(() => {
- if (q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);
- checkSlugNotRetired(cleanSlug);
+ const space = await tx(async () => {
+ if (await q.spaceBySlug.get(cleanSlug)) fail(409, 'space.slug_taken', `The space slug "${cleanSlug}" is taken`);
+ await checkSlugNotRetired(cleanSlug);
const t = now();
const t = now();
const id = spaceIdNew(t);
const id = spaceIdNew(t);
- q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });
- if (/^usr_/.test(owner)) q.putRole.run(id, owner, 'owner', actorId(actor), t);
- redirects.release(`/s/${cleanSlug}`);
- const space = q.spaceById.get(id);
- spaceEvent(space, actor, 'created');
- return space;
+ await q.insertSpace.run({ id, slug: cleanSlug, name: cleanName, description: description == null ? null : String(description).slice(0, 1000), kind, visibility: vis, owner, created_by: actorId(actor), now: t });
+ if (/^usr_/.test(owner)) await q.putRole.run(id, owner, 'owner', actorId(actor), t);
+ await redirects.release(`/s/${cleanSlug}`);
+ const made = await q.spaceById.get(id);
+ await spaceEvent(made, actor, 'created');
+ return made;
});
});
+ await roles(actor, { fresh: true });
+ return space;
},
},
- updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {
- const space = spaceOrFail(idOrSlug);
+ async updateSpace(idOrSlug, { name, description, visibility, slug } = {}, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can change it');
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can change it');
- return tx(() => {
- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
- const snaps = new Map(pages.map((p) => [p.id, before(p)]));
+ return tx(async () => {
+ const pages = await q.publishedOfSpace.all(space.id);
+ const snaps = new Map();
+ for (const p of pages) snaps.set(p.id, await before(p));
const t = now();
const t = now();
const next = {
const next = {
name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
@@ -541,59 +578,72 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -541,59 +578,72 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
};
};
if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (next.slug !== space.slug) {
if (next.slug !== space.slug) {
- if (q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);
- checkSlugNotRetired(next.slug, space.id);
- redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });
- for (const p of q.pagesOfSpace.all(space.id)) redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });
+ if (await q.spaceBySlug.get(next.slug)) fail(409, 'space.slug_taken', `The space slug "${next.slug}" is taken`);
+ await checkSlugNotRetired(next.slug, space.id);
+ await redirects.recordMove(space.id, `/s/${space.slug}`, `/s/${next.slug}`, { reason: 'space_renamed' });
+ for (const p of await q.pagesOfSpace.all(space.id)) await redirects.recordMove(p.id, `/w/${space.slug}/${p.slug}`, `/w/${next.slug}/${p.slug}`, { reason: 'space_renamed' });
}
}
- db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')
+ await db.prepare('UPDATE wiki_spaces SET name = ?, description = ?, visibility = ?, slug = ?, updated_at = ? WHERE id = ?')
.run(next.name, next.description, next.visibility, next.slug, t, space.id);
.run(next.name, next.description, next.visibility, next.slug, t, space.id);
- const updated = q.spaceById.get(space.id);
- spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));
- for (const p of pages) sync(snaps.get(p.id), p.id, actor);
+ const updated = await q.spaceById.get(space.id);
+ await spaceEvent(updated, actor, next.slug !== space.slug ? 'renamed' : (next.visibility !== space.visibility ? 'visibility' : 'settings'));
+ for (const p of pages) await sync(snaps.get(p.id), p.id, actor);
return updated;
return updated;
});
});
},
},
- deleteSpace(idOrSlug, actor) {
- const space = spaceOrFail(idOrSlug);
+ async deleteSpace(idOrSlug, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can delete it');
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner of this space can delete it');
- return tx(() => {
- const pages = q.publishedOfSpace.all(space.id).map((r) => q.pageById.get(r.id));
- const snaps = new Map(pages.map((p) => [p.id, before(p)]));
- db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);
- for (const p of pages) { scheduler.cancelPending(p.id); sync(snaps.get(p.id), p.id, actor); }
- spaceEvent(q.spaceById.get(space.id), actor, 'deleted');
+ return tx(async () => {
+ const pages = await q.publishedOfSpace.all(space.id);
+ const snaps = new Map();
+ for (const p of pages) snaps.set(p.id, await before(p));
+ await db.prepare('UPDATE wiki_spaces SET deleted_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), space.id);
+ for (const p of pages) { await scheduler.cancelPending(p.id); await sync(snaps.get(p.id), p.id, actor); }
+ await spaceEvent(await q.spaceById.get(space.id), actor, 'deleted');
return { deleted: true };
return { deleted: true };
});
});
},
},
- roles(idOrSlug, actor) {
- const space = spaceOrFail(idOrSlug);
+ async roles(idOrSlug, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner sees the role list');
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner sees the role list');
- return q.roles.all(space.id).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));
+ return (await q.roles.all(space.id)).map((r) => ({ subject: r.subject, role: r.role, granted_by: r.granted_by, granted_at: toIso(r.granted_at) }));
},
},
- setRole(idOrSlug, subject, role, actor) {
- const space = spaceOrFail(idOrSlug);
+ async setRole(idOrSlug, subject, role, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(idOrSlug);
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner can change roles');
if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner can change roles');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) fail(422, 'wiki.invalid_subject', 'Roles go to people: a usr_… subject id');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) fail(422, 'wiki.invalid_subject', 'Roles go to people: a usr_… subject id');
if (role != null && !['owner', 'editor', 'viewer'].includes(role)) fail(422, 'wiki.invalid_role', 'role is owner, editor or viewer (or null to remove)');
if (role != null && !['owner', 'editor', 'viewer'].includes(role)) fail(422, 'wiki.invalid_role', 'role is owner, editor or viewer (or null to remove)');
- return tx(() => {
- const current = q.roles.all(space.id).find((r) => r.subject === subject);
- const losingOwner = current && current.role === 'owner' && role !== 'owner';
- if (losingOwner && space.kind === 'user' && q.owners.get(space.id).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
- if (role == null) q.delRole.run(space.id, subject);
- else q.putRole.run(space.id, subject, role, actorId(actor), now());
- q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
- spaceEvent(space, actor, 'roles');
- return svc.roles(space.id, actor);
- });
+ try {
+ return await tx(async () => {
+ const current = (await q.roles.all(space.id)).find((r) => r.subject === subject);
+ const losingOwner = current && current.role === 'owner' && role !== 'owner';
+ if (losingOwner && space.kind === 'user' && (await q.owners.get(space.id)).n <= 1) fail(409, 'space.last_owner', 'A user space keeps at least one owner');
+ if (role == null) await q.delRole.run(space.id, subject);
+ else await q.putRole.run(space.id, subject, role, actorId(actor), now());
+ await q.dirtySubject.run(subject, now()); // a removed role is no longer in the space's list
+ await spaceEvent(space, actor, 'roles');
+ // As before: the list is read with the roles just written, so an owner who hands their own
+ // role away is refused (403) and the change rolls back.
+ await roles(actor, { fresh: true });
+ return svc.roles(space.id, actor);
+ });
+ } catch (err) {
+ await roles(actor, { fresh: true }).catch(() => {}); // the cached roles must not keep a rolled-back change
+ throw err;
+ }
},
},
// Pages ----------------------------------------------------------------------------
// Pages ----------------------------------------------------------------------------
- tree(space, actor) {
- const pages = q.pagesOfSpace.all(space.id).filter((p) => access.canReadPage(space, p, actor));
+ async tree(space, actor) {
+ await roles(actor);
+ const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));
const byParent = new Map();
const byParent = new Map();
for (const p of pages) {
for (const p of pages) {
const k = p.parent_id && pages.some((x) => x.id === p.parent_id) ? p.parent_id : null;
const k = p.parent_id && pages.some((x) => x.id === p.parent_id) ? p.parent_id : null;
@@ -604,8 +654,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -604,8 +654,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return build(null, 0);
return build(null, 0);
},
},
- createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
- const space = spaceOrFail(spaceIdOrSlug);
+ async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
// Only this service's own import path (the seed) may state another authorship; people are human authors.
// Only this service's own import path (the seed) may state another authorship; people are human authors.
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
@@ -619,8 +670,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -619,8 +670,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the
* bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the
* caller cannot read is "not found", exactly like everywhere else.
* caller cannot read is "not found", exactly like everywhere else.
*/
*/
- prepareImport(spaceIdOrSlug, input, actor) {
- const space = spaceOrFail(spaceIdOrSlug);
+ async prepareImport(spaceIdOrSlug, input, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');
if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
requirePerson(actor);
requirePerson(actor);
@@ -637,14 +689,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -637,14 +689,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing
* stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing
* imported) or "skip" (left alone, reported).
* imported) or "skip" (left alone, reported).
*/
*/
- importPages(spaceIdOrSlug, bundle, actor) {
- const space = spaceOrFail(spaceIdOrSlug);
+ async importPages(spaceIdOrSlug, bundle, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
const who = requirePerson(actor);
const who = requirePerson(actor);
const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));
const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));
if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;
if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;
- return tx(() => {
- const existing = bundle.pages.map((p) => ({ p, page: q.pageBySlug.get(space.id, p.slug) })).filter((x) => x.page);
+ return tx(async () => {
+ const existing = [];
+ for (const p of bundle.pages) { const page = await q.pageBySlug.get(space.id, p.slug); if (page) existing.push({ p, page }); }
if (existing.length && bundle.onExisting !== 'skip') {
if (existing.length && bundle.onExisting !== 'skip') {
fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });
fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });
}
}
@@ -661,13 +715,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -661,13 +715,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
let parentId = null;
let parentId = null;
if (p.parentSlug) {
if (p.parentSlug) {
if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }
if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }
- const parent = made.get(p.parentSlug) || q.pageBySlug.get(space.id, p.parentSlug);
+ const parent = made.get(p.parentSlug) || await q.pageBySlug.get(space.id, p.parentSlug);
if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);
if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);
parentId = parent.id;
parentId = parent.id;
}
}
let out;
let out;
try {
try {
- out = insertPage(space, { ...p, parentId }, actor, rec);
+ out = await insertPage(space, { ...p, parentId }, actor, rec);
} catch (err) {
} catch (err) {
if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);
if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);
throw err;
throw err;
@@ -678,8 +732,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -678,8 +732,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);
if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);
pending = next;
pending = next;
}
}
- if (bundle.publish) for (const c of created) publishRevision(q.pageById.get(c.page.id), c.revision.number, actor);
- return { space, created: created.map((c) => ({ page: q.pageById.get(c.page.id), revision: c.revision })), skipped, published: !!bundle.publish };
+ if (bundle.publish) for (const c of created) await publishRevision(await q.pageById.get(c.page.id), c.revision.number, actor);
+ const pagesNow = [];
+ for (const c of created) pagesNow.push({ page: await q.pageById.get(c.page.id), revision: c.revision });
+ return { space, created: pagesNow, skipped, published: !!bundle.publish };
});
});
},
},
@@ -688,109 +744,115 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -688,109 +744,115 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* forward, an array keeps only those citation ids, 'none' drops them all (they stay on
* forward, an array keeps only those citation ids, 'none' drops them all (they stay on
* the older revision). citations: new ones for this revision.
* the older revision). citations: new ones for this revision.
*/
*/
- editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async editPage(pageId, { expectedRevision, baseRevision = null, title, body, infobox, summary, citations: cites = [], keepCitations = 'all', message = null } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can edit it');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can edit it');
const rec = humanRecord(actor);
const rec = humanRecord(actor);
if (!Number.isInteger(expectedRevision)) fail(422, 'revision.expected_required', 'expectedRevision (the revision you edited) is required');
if (!Number.isInteger(expectedRevision)) fail(422, 'revision.expected_required', 'expectedRevision (the revision you edited) is required');
- return tx(() => {
- const head = revisions.head(page.id);
+ return tx(async () => {
+ const head = await revisions.head(page.id);
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
const text = body == null ? head.content : checkBody(body);
const text = body == null ? head.content : checkBody(body);
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };
const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };
- const { revision, created } = wrapContentError(() => revisions.create({
+ const { revision, created } = await wrapStore(() => revisions.create({
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
}));
}));
if (!created) return { page, revision, created: false };
if (!created) return { page, revision, created: false };
- writeRevisionExtras(space, page, revision, box);
+ await writeRevisionExtras(space, page, revision, box);
// Citations come from the revision the editor started from (the head unless the
// Citations come from the revision the editor started from (the head unless the
// head is an AI proposal the editor chose not to build on).
// head is an AI proposal the editor chose not to build on).
- const from = baseRevision != null && revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;
- carryCitations(page, from, revision.number, keepCitations, actor);
- attachCitationList(page, revision.number, cites, actor);
- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
- revisionEvent(space, page, revision, actor);
- notifyWatchers(space, page, 'revised', revision.number, actor);
- return { page: q.pageById.get(page.id), revision, created: true };
+ const from = baseRevision != null && await revisions.get(page.id, Number(baseRevision)) ? Number(baseRevision) : head.number;
+ await carryCitations(page, from, revision.number, keepCitations, actor);
+ await attachCitationList(page, revision.number, cites, actor);
+ await q.touch.run(now(), page.id);
+ await revisionEvent(space, page, revision, actor);
+ await notifyWatchers(space, page, 'revised', revision.number, actor);
+ return { page: await q.pageById.get(page.id), revision, created: true };
});
});
},
},
/** Revert = a new revision copying an old one (content, fields, citations, infobox). */
/** Revert = a new revision copying an old one (content, fields, citations, infobox). */
- revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async revert(pageId, { toRevision, expectedRevision, message = null, publish = null } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can revert');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can revert');
const who = requirePerson(actor);
const who = requirePerson(actor);
- return tx(() => {
- const target = revisions.get(page.id, Number(toRevision));
+ return tx(async () => {
+ const target = await revisions.get(page.id, Number(toRevision));
if (!target) fail(404, 'revision.not_found', `No revision ${toRevision}`);
if (!target) fail(404, 'revision.not_found', `No revision ${toRevision}`);
const trec = target.meta.authorship;
const trec = target.meta.authorship;
// Restoring AI text keeps the AI workflow on record: the reverting person is accountable (hybrid).
// Restoring AI text keeps the AI workflow on record: the reverting person is accountable (hybrid).
const rec = trec && (trec.mode === 'ai' || trec.mode === 'hybrid') && trec.workflow
const rec = trec && (trec.mode === 'ai' || trec.mode === 'hybrid') && trec.workflow
? authorship.record({ mode: 'hybrid', authors: [who], workflow: trec.workflow })
? authorship.record({ mode: 'hybrid', authors: [who], workflow: trec.workflow })
: authorship.record({ mode: 'human', authors: [who] });
: authorship.record({ mode: 'human', authors: [who] });
- const { revision } = wrapContentError(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));
- writeRevisionExtras(space, page, revision, target.fields.infobox || []);
- carryCitations(page, target.number, revision.number, 'all', actor);
- db.prepare('UPDATE wiki_pages SET updated_at = ? WHERE id = ?').run(now(), page.id);
- revisionEvent(space, page, revision, actor, { reverted_to: target.number });
- const livePage = q.pageById.get(page.id);
+ const { revision } = await wrapStore(() => revisions.revert({ entityId: page.id, toRevision: target.number, expectedRevision, author: who, message, meta: { authorship: rec } }));
+ await writeRevisionExtras(space, page, revision, target.fields.infobox || []);
+ await carryCitations(page, target.number, revision.number, 'all', actor);
+ await q.touch.run(now(), page.id);
+ await revisionEvent(space, page, revision, actor, { reverted_to: target.number });
+ const livePage = await q.pageById.get(page.id);
const shouldPublish = publish == null ? livePage.state === 'published' : !!publish;
const shouldPublish = publish == null ? livePage.state === 'published' : !!publish;
- if (shouldPublish) publishRevision(livePage, revision.number, actor);
- else notifyWatchers(space, livePage, 'revised', revision.number, actor);
- return { page: q.pageById.get(page.id), revision, published: shouldPublish };
+ if (shouldPublish) await publishRevision(livePage, revision.number, actor);
+ else await notifyWatchers(space, livePage, 'revised', revision.number, actor);
+ return { page: await q.pageById.get(page.id), revision, published: shouldPublish };
});
});
},
},
- publish(pageId, { revision } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async publish(pageId, { revision } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can publish');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can publish');
if (!actor || actor.kind !== 'system') requirePerson(actor);
if (!actor || actor.kind !== 'system') requirePerson(actor);
- return tx(() => {
- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
- const out = publishRevision(q.pageById.get(page.id), n, actor);
- return { page: q.pageById.get(page.id), ...out };
+ return tx(async () => {
+ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);
+ const out = await publishRevision(await q.pageById.get(page.id), n, actor);
+ return { page: await q.pageById.get(page.id), ...out };
});
});
},
},
- unpublish(pageId, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async unpublish(pageId, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can unpublish');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can unpublish');
if (page.state !== 'published') fail(409, 'page.not_published', 'The page is not published');
if (page.state !== 'published') fail(409, 'page.not_published', 'The page is not published');
- return tx(() => {
- const b = before(page);
- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
- scheduler.cancelPending(page.id, 'unpublish');
- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
+ return tx(async () => {
+ const b = await before(page);
+ await q.setState.run('unpublished', now(), page.id);
+ await scheduler.cancelPending(page.id, 'unpublish');
+ const out = await sync(b, page.id, actor);
+ return { page: await q.pageById.get(page.id), ...out };
});
});
},
},
/** Idempotent: the same (page, revision, time) returns the existing job. */
/** Idempotent: the same (page, revision, time) returns the existing job. */
- schedulePublish(pageId, { revision, runAt } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async schedulePublish(pageId, { revision, runAt } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can schedule');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can schedule');
requirePerson(actor);
requirePerson(actor);
const at = typeof runAt === 'number' ? runAt : Date.parse(runAt);
const at = typeof runAt === 'number' ? runAt : Date.parse(runAt);
if (!Number.isFinite(at)) fail(422, 'schedule.invalid_time', 'runAt must be an ISO 8601 time');
if (!Number.isFinite(at)) fail(422, 'schedule.invalid_time', 'runAt must be an ISO 8601 time');
- const n = revision == null ? revisions.headNumber(page.id) : Number(revision);
- if (!revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);
- return tx(() => {
- const { job, created } = scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });
+ const n = revision == null ? await revisions.headNumber(page.id) : Number(revision);
+ if (!await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${n}`);
+ return tx(async () => {
+ const { job, created } = await scheduler.schedule({ entityId: page.id, action: 'publish', runAt: at, revision: n });
if (page.state === 'draft' || page.state === 'unpublished') {
if (page.state === 'draft' || page.state === 'unpublished') {
- const b = before(page);
- db.prepare("UPDATE wiki_pages SET state = 'scheduled', updated_at = ? WHERE id = ?").run(now(), page.id);
- sync(b, page.id, actor);
+ const b = await before(page);
+ await q.setState.run('scheduled', now(), page.id);
+ await sync(b, page.id, actor);
}
}
- return { job, created, page: q.pageById.get(page.id) };
+ return { job, created, page: await q.pageById.get(page.id) };
});
});
},
},
@@ -800,130 +862,159 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -800,130 +862,159 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async runSchedule({ worker = config.workerId } = {}) {
async runSchedule({ worker = config.workerId } = {}) {
return scheduler.runDue({
return scheduler.runDue({
worker,
worker,
- handler: (job) => tx(() => {
- const page = q.pageById.get(job.entityId);
+ handler: (job) => tx(async () => {
+ const page = await q.pageById.get(job.entityId);
if (!page || page.state === 'deleted') return { skipped: 'deleted' };
if (!page || page.state === 'deleted') return { skipped: 'deleted' };
- const space = q.spaceById.get(page.space_id);
+ const space = await q.spaceById.get(page.space_id);
if (!space || space.deleted_at) return { skipped: 'space_deleted' };
if (!space || space.deleted_at) return { skipped: 'space_deleted' };
if (job.action === 'publish') {
if (job.action === 'publish') {
if (page.state === 'published' && page.published_revision === job.revision) return { noop: true };
if (page.state === 'published' && page.published_revision === job.revision) return { noop: true };
- publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });
+ await publishRevision(page, job.revision, { kind: 'system', service: 'svc:wiki', subject: null });
return { published: job.revision };
return { published: job.revision };
}
}
if (page.state !== 'published') return { noop: true };
if (page.state !== 'published') return { noop: true };
- const b = before(page);
- db.prepare("UPDATE wiki_pages SET state = 'unpublished', updated_at = ? WHERE id = ?").run(now(), page.id);
- sync(b, page.id, { kind: 'system', service: 'svc:wiki' });
+ const b = await before(page);
+ await q.setState.run('unpublished', now(), page.id);
+ await sync(b, page.id, { kind: 'system', service: 'svc:wiki' });
return { unpublished: true };
return { unpublished: true };
}),
}),
});
});
},
},
- movePage(pageId, { slug, title, parentId } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async movePage(pageId, { slug, title, parentId } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can move pages');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can move pages');
- return tx(() => {
- const b = before(page);
+ return tx(async () => {
+ const b = await before(page);
let nextSlug = page.slug;
let nextSlug = page.slug;
if (slug != null || title != null) nextSlug = slug != null ? String(slug).trim().toLowerCase() : wrapContentError(() => content.pageSlug(checkTitle(title)));
if (slug != null || title != null) nextSlug = slug != null ? String(slug).trim().toLowerCase() : wrapContentError(() => content.pageSlug(checkTitle(title)));
if (!content.PAGE_SLUG_RE.test(nextSlug)) fail(422, 'page.invalid_slug', 'A page slug is lowercase letters, digits and dashes');
if (!content.PAGE_SLUG_RE.test(nextSlug)) fail(422, 'page.invalid_slug', 'A page slug is lowercase letters, digits and dashes');
if (nextSlug !== page.slug) {
if (nextSlug !== page.slug) {
- const clash = q.pageBySlug.get(space.id, nextSlug);
+ const clash = await q.pageBySlug.get(space.id, nextSlug);
if (clash) fail(409, 'page.slug_taken', `A page "${nextSlug}" already exists in this space`);
if (clash) fail(409, 'page.slug_taken', `A page "${nextSlug}" already exists in this space`);
- redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);
+ await redirects.recordMove(page.id, `/w/${space.slug}/${page.slug}`, `/w/${space.slug}/${nextSlug}`);
}
}
let parent = page.parent_id;
let parent = page.parent_id;
if (parentId !== undefined) {
if (parentId !== undefined) {
parent = parentId || null;
parent = parentId || null;
- for (let p = parent ? q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? q.pageById.get(p.parent_id) : null, i++) {
+ for (let p = parent ? await q.pageById.get(parent) : null, i = 0; p; p = p.parent_id ? await q.pageById.get(p.parent_id) : null, i++) {
if (p.id === page.id || i > 50) fail(422, 'page.invalid_parent', 'A page cannot be moved under itself');
if (p.id === page.id || i > 50) fail(422, 'page.invalid_parent', 'A page cannot be moved under itself');
if (p.space_id !== space.id) fail(422, 'page.invalid_parent', 'The parent must be a page of the same space');
if (p.space_id !== space.id) fail(422, 'page.invalid_parent', 'The parent must be a page of the same space');
}
}
}
}
- db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);
- sync(b, page.id, actor);
+ await db.prepare('UPDATE wiki_pages SET slug = ?, parent_id = ?, updated_at = ? WHERE id = ?').run(nextSlug, parent, now(), page.id);
+ await sync(b, page.id, actor);
return q.pageById.get(page.id);
return q.pageById.get(page.id);
});
});
},
},
- setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
- return tx(() => {
- const b = before(page);
- db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
+ return tx(async () => {
+ const b = await before(page);
+ await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
.run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
.run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
+ const out = await sync(b, page.id, actor);
+ return { page: await q.pageById.get(page.id), ...out };
});
});
},
},
- deletePage(pageId, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async deletePage(pageId, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space can delete pages');
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space can delete pages');
if (page.state === 'deleted') return { page, action: null };
if (page.state === 'deleted') return { page, action: null };
- return tx(() => {
- const b = before(page);
- db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), page.id);
- db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);
- scheduler.cancelPending(page.id);
- return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
+ return tx(async () => {
+ const b = await before(page);
+ await q.setState.run('deleted', now(), page.id);
+ await db.prepare('UPDATE wiki_pages SET parent_id = ? WHERE parent_id = ?').run(page.parent_id, page.id);
+ await scheduler.cancelPending(page.id);
+ const out = await sync(b, page.id, actor);
+ return { page: await q.pageById.get(page.id), ...out };
});
});
},
},
// Reading --------------------------------------------------------------------------
// Reading --------------------------------------------------------------------------
/** The page as `actor` may see it; revision defaults to the published one (editors: head of a draft). */
/** The page as `actor` may see it; revision defaults to the published one (editors: head of a draft). */
- view(space, page, actor, { revision } = {}) {
+ async view(space, page, actor, { revision } = {}) {
+ await roles(actor);
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
const canEdit = access.canEdit(space, actor);
const canEdit = access.canEdit(space, actor);
- let n = revision == null ? (page.state === 'published' ? page.published_revision : revisions.headNumber(page.id)) : Number(revision);
- if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
- const rev = revisions.get(page.id, n);
+ let n = revision == null ? (page.state === 'published' ? page.published_revision : await revisions.headNumber(page.id)) : Number(revision);
+ const proposal = Number.isInteger(n) ? await q.proposalByRev.get(page.id, n) : undefined;
+ if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor, proposal || null)) fail(404, 'revision.not_found', 'No such revision');
+ const rev = await revisions.get(page.id, n);
if (!rev) fail(404, 'revision.not_found', 'No such revision');
if (!rev) fail(404, 'revision.not_found', 'No such revision');
n = rev.number;
n = rev.number;
- const review = reviews.latest(page.id, n);
+ const [review, cites, infobox, links, atts, children, parentRow, backlinks, discussion, watching, proposals, jobs, headNumber] = await Promise.all([
+ reviews.latest(page.id, n),
+ citations.forRevision(page.id, n),
+ infoboxOf(page.id, n),
+ q.linksOf.all(page.id, n),
+ svc.attachmentsOf(page.id),
+ q.children.all(page.id),
+ page.parent_id ? q.pageById.get(page.parent_id) : null,
+ svc.backlinks(space, page, actor),
+ discussions.get(page.id),
+ actor && actor.subject ? q.isWatching.get(page.id, actor.subject) : null,
+ canEdit ? q.proposalsOfPage.all(page.id) : [],
+ canEdit ? scheduler.jobs(page.id) : [],
+ canEdit ? revisions.headNumber(page.id) : null,
+ ]);
const rec = rev.meta.authorship || null;
const rec = rev.meta.authorship || null;
- const decision = page.state === 'published' && n === page.published_revision ? decide(space, page, rev) : seo.evaluate({ ...gateFacts(space, page, rev), state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });
+ const facts = gateFactsWith(space, page, rev, cites.length, review);
+ const decision = page.state === 'published' && n === page.published_revision
+ ? seo.evaluate(facts, { policy, now: now() })
+ : seo.evaluate({ ...facts, state: page.state === 'published' ? 'draft' : page.state }, { policy, now: now() });
return {
return {
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
space, page, revision: rev, decision, canEdit, canManage: access.canManage(space, actor),
isPublishedRevision: n === page.published_revision && page.state === 'published',
isPublishedRevision: n === page.published_revision && page.state === 'published',
authorship: rec, review, disclosure: rec ? disclosureFor(rec, review) : null,
authorship: rec, review, disclosure: rec ? disclosureFor(rec, review) : null,
needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),
needsReview: !!rec && aiAssistedImport(rec) && !authorship.isReviewed(rec, review),
- proposal: q.proposalByRev.get(page.id, n) || null,
- infobox: infoboxOf(page.id, n),
- citations: citations.forRevision(page.id, n),
- links: q.linksOf.all(page.id, n),
- attachments: svc.attachmentsOf(page.id),
- children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
- parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
- backlinks: svc.backlinks(space, page, actor),
- discussion: discussions.get(page.id),
- watching: actor && actor.subject ? !!q.isWatching.get(page.id, actor.subject) : false,
- pendingProposals: canEdit ? q.proposalsOfPage.all(page.id).filter((p) => p.status === 'pending') : [],
- jobs: canEdit ? scheduler.jobs(page.id).filter((j) => j.status === 'pending' || j.status === 'running') : [],
- headNumber: canEdit ? revisions.headNumber(page.id) : null,
+ proposal: proposal || null,
+ infobox,
+ citations: cites,
+ links,
+ attachments: atts,
+ children: children.filter((c) => access.canReadPage(space, c, actor)),
+ parent: parentRow && access.canReadPage(space, parentRow, actor) ? parentRow : null,
+ backlinks,
+ discussion,
+ watching: !!watching,
+ pendingProposals: proposals.filter((p) => p.status === 'pending'),
+ jobs: jobs.filter((j) => j.status === 'pending' || j.status === 'running'),
+ headNumber,
};
};
},
},
- backlinks(space, page, actor) {
- return q.backlinks.all(space.slug, page.slug).filter((p) => p.id !== page.id).map((p) => ({ page: p, space: q.spaceById.get(p.space_id) }))
- .filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));
+ async backlinks(space, page, actor) {
+ await roles(actor);
+ const rows = (await q.backlinks.all(space.slug, page.slug)).filter((p) => p.id !== page.id);
+ if (!rows.length) return [];
+ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
+ return rows.map((p) => ({ page: p, space: spaces.get(p.space_id) })).filter(({ page: p, space: s }) => access.canReadPage(s, p, actor));
},
},
/** [[link]] target → { href, exists }. Pages the actor cannot read look missing. */
/** [[link]] target → { href, exists }. Pages the actor cannot read look missing. */
- resolveLink({ space: spaceSlug, slug, title }, actor) {
- const space = q.spaceBySlug.get(spaceSlug);
+ async resolveLink({ space: spaceSlug, slug, title }, actor) {
+ await roles(actor);
+ const space = await q.spaceBySlug.get(spaceSlug);
if (!space || space.deleted_at || !access.canReadSpace(space, actor)) return { href: null, exists: false };
if (!space || space.deleted_at || !access.canReadSpace(space, actor)) return { href: null, exists: false };
- let page = q.pageBySlug.get(space.id, slug);
+ let page = await q.pageBySlug.get(space.id, slug);
if (!page) {
if (!page) {
- const r = redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: (id) => { const p = q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(q.spaceById.get(p.space_id), p) : null; } });
- if (r && r.status === 301) { const moved = q.pageById.get(r.entityId); if (moved) page = moved; }
+ const r = await redirects.resolve(`/w/${space.slug}/${slug}`, { currentPath: async (id) => { const p = await q.pageById.get(id); return p && p.state !== 'deleted' ? pagePath(await q.spaceById.get(p.space_id), p) : null; } });
+ if (r && r.status === 301) { const moved = await q.pageById.get(r.entityId); if (moved) page = moved; }
}
}
- if (page && page.state === 'published' && access.canReadPage(q.spaceById.get(page.space_id), page, actor)) {
- return { href: pagePath(q.spaceById.get(page.space_id), page), exists: true };
+ const pageSpace = page ? (page.space_id === space.id ? space : await q.spaceById.get(page.space_id)) : null;
+ if (page && page.state === 'published' && access.canReadPage(pageSpace, page, actor)) {
+ return { href: pagePath(pageSpace, page), exists: true };
}
}
if (!page && access.canEdit(space, actor)) return { href: `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(slug)}/edit?title=${encodeURIComponent(title || slug)}`, exists: false };
if (!page && access.canEdit(space, actor)) return { href: `/w/${encodeURIComponent(space.slug)}/${encodeURIComponent(slug)}/edit?title=${encodeURIComponent(title || slug)}`, exists: false };
return { href: null, exists: false };
return { href: null, exists: false };
@@ -932,84 +1023,82 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -932,84 +1023,82 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
/**
/**
* Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":
* Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":
* nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces
* nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces
- * are edited by staff, who vouch for their links.
+ * are edited by staff, who vouch for their links. Every [[link]] is resolved first (the
+ * renderer itself is synchronous).
*/
*/
- renderRevision(space, rev, actor) {
- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });
+ async renderRevision(space, rev, actor) {
+ const targets = content.extractLinks(rev.content, space.slug);
+ const resolved = new Map();
+ for (const t of targets) resolved.set(`${t.space}\u0000${t.slug}`, await svc.resolveLink(t, actor));
+ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => resolved.get(`${t.space}\u0000${t.slug}`) || { href: null, exists: false }, rel: svc.linkRel(space) });
},
},
linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
- history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
- const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
- const space = q.spaceById.get(page.space_id);
- return revisions.list(page.id, { limit, before: cursor }).filter((r) => revisionVisible(space, page, r.number, actor)).map((r) => ({
- ...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
- review: reviews.latest(page.id, r.number),
+ async history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
+ await roles(actor);
+ const [props, space, list] = await Promise.all([q.proposalsOfPage.all(page.id), q.spaceById.get(page.space_id), revisions.list(page.id, { limit, before: cursor })]);
+ const byRev = new Map(props.map((p) => [p.revision, p]));
+ const visible = [];
+ for (const r of list) if (await revisionVisible(space, page, r.number, actor, byRev.get(r.number) || null)) visible.push(r);
+ const refs = visible.map((r) => ({ entityId: page.id, revision: r.number }));
+ const [revws, cites] = await Promise.all([reviews.latestMany(refs), citations.forRevisions(refs)]);
+ return visible.map((r, i) => ({
+ ...r, proposal: byRev.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
+ review: revws[i],
aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
aiAssisted: aiAssistedImport(r.meta && r.meta.authorship),
- needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, reviews.latest(page.id, r.number)),
- citationCount: citations.forRevision(page.id, r.number).length,
+ needsReview: aiAssistedImport(r.meta && r.meta.authorship) && !authorship.isReviewed(r.meta.authorship, revws[i]),
+ citationCount: (cites[i] || []).length,
}));
}));
},
},
- diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {
- const space = q.spaceById.get(page.space_id);
- for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
- return wrapContentError(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));
+ async diff(page, a, b, { mode = 'word', actor = { kind: 'system' } } = {}) {
+ await roles(actor);
+ const space = await q.spaceById.get(page.space_id);
+ for (const n of [Number(a), Number(b)]) if (!Number.isInteger(n) || !await revisionVisible(space, page, n, actor)) fail(404, 'revision.not_found', 'No such revision');
+ return wrapStore(() => revisions.diff(page.id, Number(a), Number(b), { mode: mode === 'line' ? 'line' : 'word' }));
},
},
resolveRedirect(path) {
resolveRedirect(path) {
return redirects.resolve(path, {
return redirects.resolve(path, {
- currentPath: (id) => {
- if (/^spc_/.test(id)) { const s = q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }
- const p = q.pageById.get(id);
+ currentPath: async (id) => {
+ if (/^spc_/.test(id)) { const s = await q.spaceById.get(id); return s && !s.deleted_at ? spacePath(s) : null; }
+ const p = await q.pageById.get(id);
if (!p || p.state === 'deleted') return null;
if (!p || p.state === 'deleted') return null;
- const s = q.spaceById.get(p.space_id);
+ const s = await q.spaceById.get(p.space_id);
return s && !s.deleted_at ? pagePath(s, p) : null;
return s && !s.deleted_at ? pagePath(s, p) : null;
},
},
});
});
},
},
- search(text, actor, { space = null, limit = 20 } = {}) {
+ async search(text, actor, { space = null, limit = 20 } = {}) {
+ await roles(actor);
const words = String(text || '').trim().slice(0, 200);
const words = String(text || '').trim().slice(0, 200);
if (!words) return [];
if (!words) return [];
const like = `%${words.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
const like = `%${words.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
- const out = [];
- for (const p of q.search.all({ like, space })) {
- const s = q.spaceById.get(p.space_id);
- if (!access.canReadPage(s, p, actor)) continue;
- const rev = revisions.get(p.id, p.published_revision);
- out.push({ page: p, space: s, summary: rev.fields.summary || ssr.markdownToText(rev.content, 200) });
- if (out.length >= Math.min(50, Math.max(1, Number(limit) || 20))) break;
- }
- return out;
+ const rows = await q.search.all({ like, space: space || null });
+ if (!rows.length) return [];
+ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
+ const max = Math.min(50, Math.max(1, Number(limit) || 20));
+ const hits = rows.filter((p) => access.canReadPage(spaces.get(p.space_id), p, actor)).slice(0, max);
+ const revs = await revisions.getMany(hits.map((p) => ({ entityId: p.id, revision: p.published_revision })));
+ return hits.map((p, i) => ({ page: p, space: spaces.get(p.space_id), summary: revs[i] ? (revs[i].fields.summary || ssr.markdownToText(revs[i].content, 200)) : '' }));
},
},
// Discovery (public, indexable only) -----------------------------------------------
// Discovery (public, indexable only) -----------------------------------------------
- publishedPublic() {
- return q.allPublishedPublic.all().map((page) => {
- const space = q.spaceById.get(page.space_id);
- const rev = revisions.get(page.id, page.published_revision);
- return { space, page, rev, decision: decide(space, page, rev) };
- });
- },
+ async publishedPublic() { return expandPublished(await q.allPublishedPublic.all()); },
- recentChanges(limit = 50) {
- return q.recent.all(Math.min(200, Math.max(1, limit))).map((page) => {
- const space = q.spaceById.get(page.space_id);
- const rev = revisions.get(page.id, page.published_revision);
- return { space, page, rev, decision: decide(space, page, rev) };
- });
- },
+ async recentChanges(limit = 50) { return expandPublished(await q.recent.all(Math.min(200, Math.max(1, limit)))); },
// Citations and media ----------------------------------------------------------------
// Citations and media ----------------------------------------------------------------
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
/** Citations go on an unpublished head revision (a published revision's sources are fixed). */
- attachCitations(pageId, revisionNumber, list, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async attachCitations(pageId, revisionNumber, list, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can cite');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can cite');
const n = Number(revisionNumber);
const n = Number(revisionNumber);
- if (n !== revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');
+ if (n !== await revisions.headNumber(page.id)) fail(409, 'citation.not_head', 'Citations are attached to the newest revision; edit the page to cite in a new one');
if (page.state === 'published' && page.published_revision === n) fail(409, 'citation.revision_published', 'That revision is published; its sources are fixed. Edit the page to add sources to a new revision');
if (page.state === 'published' && page.published_revision === n) fail(409, 'citation.revision_published', 'That revision is published; its sources are fixed. Edit the page to add sources to a new revision');
return tx(() => attachCitationList(page, n, list, actor));
return tx(() => attachCitationList(page, n, list, actor));
},
},
@@ -1023,21 +1112,22 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1023,21 +1112,22 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* read rules as the article (view() refuses what the actor may not see); an origin revision
* read rules as the article (view() refuses what the actor may not see); an origin revision
* the actor may not read is not named.
* the actor may not read is not named.
*/
*/
- citationInspector(space, page, actor, { revision } = {}) {
- const v = svc.view(space, page, actor, { revision });
+ async citationInspector(space, page, actor, { revision } = {}) {
+ const v = await svc.view(space, page, actor, { revision });
const n = v.revision.number;
const n = v.revision.number;
- const readable = svc.history(page, { limit: 500, actor });
+ const readable = await svc.history(page, { limit: 500, actor });
const visible = new Set(readable.map((r) => r.number));
const visible = new Set(readable.map((r) => r.number));
const lineage = (c) => c.carriedFrom || c.id;
const lineage = (c) => c.carriedFrom || c.id;
const prev = readable.find((r) => r.number < n) || null;
const prev = readable.find((r) => r.number < n) || null;
- const prevCites = prev ? citations.forRevision(page.id, prev.number) : [];
+ const prevCites = prev ? await citations.forRevision(page.id, prev.number) : [];
const prevLines = new Set(prevCites.map(lineage));
const prevLines = new Set(prevCites.map(lineage));
- const items = v.citations.map((c) => {
- const first = c.carriedFrom ? citations.get(c.carriedFrom) : c;
+ const items = [];
+ for (const c of v.citations) {
+ const first = c.carriedFrom ? await citations.get(c.carriedFrom) : c;
const shown = first && visible.has(first.revision) ? first : null;
const shown = first && visible.has(first.revision) ? first : null;
const change = prevLines.has(lineage(c)) ? 'kept' : (c.carriedFrom ? 'restored' : 'new');
const change = prevLines.has(lineage(c)) ? 'kept' : (c.carriedFrom ? 'restored' : 'new');
- return { ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null };
- });
+ items.push({ ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null });
+ }
const here = new Set(v.citations.map(lineage));
const here = new Set(v.citations.map(lineage));
return {
return {
view: v, citations: items,
view: v, citations: items,
@@ -1075,8 +1165,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1075,8 +1165,9 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* nothing is attached unchecked.
* nothing is attached unchecked.
*/
*/
async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {
async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
const who = actor && actor.kind === 'system' ? null : requirePerson(actor);
const who = actor && actor.kind === 'system' ? null : requirePerson(actor);
@@ -1092,26 +1183,28 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1092,26 +1183,28 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');
if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');
if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');
if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');
if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);
if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);
- return tx(() => {
- const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
- q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
- attachments.markAvailable(id);
- return svc.attachmentsOf(page.id).find((a) => a.id === att.id);
+ return tx(async () => {
+ const att = await wrapStore(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
+ await q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
+ await attachments.markAvailable(id);
+ return (await svc.attachmentsOf(page.id)).find((a) => a.id === att.id);
});
});
},
},
/** A page's attachments, each with who attached it (null for rows older than the record). */
/** A page's attachments, each with who attached it (null for rows older than the record). */
- attachmentsOf(pageId) {
- const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));
- return attachments.list(pageId).map((a) => {
- const o = origins.get(a.id);
+ async attachmentsOf(pageId) {
+ const [origins, list] = await Promise.all([q.originsOf.all(pageId), attachments.list(pageId)]);
+ const byId = new Map(origins.map((o) => [o.attachment_id, o]));
+ return list.map((a) => {
+ const o = byId.get(a.id);
return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };
return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };
});
});
},
},
- detachMedia(pageId, attachmentId, actor) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async detachMedia(pageId, attachmentId, actor) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can detach media');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can detach media');
return attachments.detach(page.id, attachmentId);
return attachments.detach(page.id, attachmentId);
},
},
@@ -1122,7 +1215,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1122,7 +1215,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
async verifyMedia(pageId, resolve) { return attachments.verify(pageId, { resolve }); },
async verifyMedia(pageId, resolve) { return attachments.verify(pageId, { resolve }); },
async verifyAllMedia(resolve) {
async verifyAllMedia(resolve) {
- const ids = db.prepare('SELECT DISTINCT entity_id FROM wiki_page_attachments').all().map((r) => r.entity_id);
+ const ids = (await q.attachedEntities.all()).map((r) => r.entity_id);
const out = [];
const out = [];
for (const id of ids) out.push(...await attachments.verify(id, { resolve }));
for (const id of ids) out.push(...await attachments.verify(id, { resolve }));
return out;
return out;
@@ -1131,7 +1224,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1131,7 +1224,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discussion -------------------------------------------------------------------------
// Discussion -------------------------------------------------------------------------
/** The Community thread of a public, published page (resolved once, then stored as a reference). */
/** The Community thread of a public, published page (resolved once, then stored as a reference). */
async discussionThread(space, page, client) {
async discussionThread(space, page, client) {
- const known = discussions.get(page.id);
+ const known = await discussions.get(page.id);
if (known) return known.threadId;
if (known) return known.threadId;
if (page.state !== 'published' || access.effectiveVisibility(space, page) !== 'public') return null;
if (page.state !== 'published' || access.effectiveVisibility(space, page) !== 'public') return null;
const out = await discussions.threadFor(page.id, { service: 'wiki', type: 'page', id: page.id, label: page.title.slice(0, 200) }, { client });
const out = await discussions.threadFor(page.id, { service: 'wiki', type: 'page', id: page.id, label: page.title.slice(0, 200) }, { client });
@@ -1139,12 +1232,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1139,12 +1232,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
},
// Watchers ---------------------------------------------------------------------------
// Watchers ---------------------------------------------------------------------------
- watch(pageId, actor, on = true) {
- const page = pageOrFail(pageId);
- const space = spaceOrFail(page.space_id);
+ async watch(pageId, actor, on = true) {
+ await roles(actor);
+ const page = await pageOrFail(pageId);
+ const space = await spaceOrFail(page.space_id);
const who = requirePerson(actor);
const who = requirePerson(actor);
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
if (!access.canReadPage(space, page, actor)) fail(404, 'page.not_found', 'No such page');
- if (on) q.watch.run(page.id, who, now()); else q.unwatch.run(page.id, who);
+ if (on) await q.watch.run(page.id, who, now()); else await q.unwatch.run(page.id, who);
return { watching: on };
return { watching: on };
},
},
@@ -1154,12 +1248,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1154,12 +1248,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.
* AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.
*/
*/
reconcileIndex() {
reconcileIndex() {
- return tx(() => {
+ return tx(async () => {
let sent = 0;
let sent = 0;
- for (const { id } of db.prepare('SELECT id FROM wiki_pages ORDER BY id').all()) {
- const b = before(q.pageById.get(id));
+ for (const { id } of await q.pageIds.all()) {
+ const b = await before(await q.pageById.get(id));
if (b.indexRevision == null) continue; // never sent: nothing in Search to correct
if (b.indexRevision == null) continue; // never sent: nothing in Search to correct
- const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });
+ const out = await sync(b, id, { kind: 'system', service: 'svc:wiki' });
if (out.indexRevision !== b.indexRevision) sent++;
if (out.indexRevision !== b.indexRevision) sent++;
}
}
return { sent };
return { sent };
@@ -1172,26 +1266,27 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1172,26 +1266,27 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* gate is re-evaluated, and a changed Search document goes out with wiki.page.updated.
* gate is re-evaluated, and a changed Search document goes out with wiki.page.updated.
* Pending AI proposals are reviewed through reviewProposal instead.
* Pending AI proposals are reviewed through reviewProposal instead.
*/
*/
- reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
- const space = spaceOrFail(spaceIdOrSlug);
- const page = q.pageBySlug.get(space.id, String(slug));
+ async reviewRevision(spaceIdOrSlug, slug, revisionNumber, { decision, note = null } = {}, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(spaceIdOrSlug);
+ const page = await q.pageBySlug.get(space.id, String(slug));
if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');
if (!page || page.state === 'deleted') fail(404, 'page.not_found', 'No such page');
if (!access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');
if (!access.canEdit(space, actor)) fail(403, 'review.forbidden', 'Only owners and editors of this space review revisions');
const who = requirePerson(actor);
const who = requirePerson(actor);
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'review.invalid_decision', 'decision is approved or rejected');
const n = Number(revisionNumber);
const n = Number(revisionNumber);
- if (!Number.isInteger(n) || !revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
- const prop = q.proposalByRev.get(page.id, n);
+ if (!Number.isInteger(n) || !await revisions.get(page.id, n)) fail(404, 'revision.not_found', `No revision ${revisionNumber}`);
+ const prop = await q.proposalByRev.get(page.id, n);
if (prop && prop.status === 'pending') fail(409, 'review.proposal_pending', 'This revision is a pending AI proposal: approve or reject the proposal', { proposal_id: prop.id });
if (prop && prop.status === 'pending') fail(409, 'review.proposal_pending', 'This revision is a pending AI proposal: approve or reject the proposal', { proposal_id: prop.id });
- return tx(() => {
- const b = before(page);
- const review = wrapContentError(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
+ return tx(async () => {
+ const b = await before(page);
+ const review = await wrapStore(() => reviews.record({ entityId: page.id, revision: n, reviewer: who, decision, note }));
const out = page.state === 'published' && page.published_revision === n
const out = page.state === 'published' && page.published_revision === n
- ? sync(b, page.id, actor, { updatedIfIndexChanged: true })
+ ? await sync(b, page.id, actor, { updatedIfIndexChanged: true })
: { action: null, indexRevision: b ? b.indexRevision : null };
: { action: null, indexRevision: b ? b.indexRevision : null };
- const fresh = q.pageById.get(page.id);
- const rev = revisions.get(page.id, n);
- const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? decide(space, fresh, rev) : null;
+ const fresh = await q.pageById.get(page.id);
+ const rev = await revisions.get(page.id, n);
+ const decisionNow = fresh.state === 'published' && fresh.published_revision === n ? await decide(space, fresh, rev) : null;
return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };
return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };
});
});
},
},
@@ -1202,76 +1297,80 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1202,76 +1297,80 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
* (workflow + run id) and a pending proposal; nothing is published until a person approves.
* (workflow + run id) and a pending proposal; nothing is published until a person approves.
* pageId null + title = propose a new page (created as a draft).
* pageId null + title = propose a new page (created as a draft).
*/
*/
- propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
+ async propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
// A proposal may target any space, private ones included, and never needs a person: only
// A proposal may target any space, private ones included, and never needs a person: only
// first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.
// first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.
const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');
const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');
if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');
if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
+ await roles(actor);
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
const cleanSummary = checkSummary(summary);
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const box = wrapContentError(() => content.normalizeInfobox(infobox));
- return tx(() => {
- let page = pageId ? pageOrFail(pageId) : null;
- const space = page ? spaceOrFail(page.space_id) : spaceOrFail(spaceIdOrSlug);
+ return tx(async () => {
+ let page = pageId ? await pageOrFail(pageId) : null;
+ const space = page ? await spaceOrFail(page.space_id) : await spaceOrFail(spaceIdOrSlug);
const t = now();
const t = now();
let base = 0;
let base = 0;
if (!page) {
if (!page) {
const cleanTitle = checkTitle(title);
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
- if (q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);
+ if (await q.pageBySlug.get(space.id, slug)) fail(409, 'page.slug_taken', `A page "${slug}" already exists in this space`);
const id = pageIdNew(t);
const id = pageIdNew(t);
- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });
- page = q.pageById.get(id);
+ await q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: null, position: 0, visibility: 'public', created_by: actorId(actor), now: t });
+ page = await q.pageById.get(id);
} else {
} else {
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
- base = revisions.headNumber(page.id);
+ base = await revisions.headNumber(page.id);
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
}
}
- const head = base ? revisions.head(page.id) : null;
+ const head = base ? await revisions.head(page.id) : null;
const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };
const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };
- const { revision } = wrapContentError(() => revisions.create({
+ const { revision } = await wrapStore(() => revisions.create({
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
}));
}));
- writeRevisionExtras(space, page, revision, box);
- attachCitationList(page, revision.number, cites, actor);
+ await writeRevisionExtras(space, page, revision, box);
+ await attachCitationList(page, revision.number, cites, actor);
const id = proposalIdNew(t);
const id = proposalIdNew(t);
- q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });
- revisionEvent(space, page, revision, actor, { proposal_id: id });
- notifyWatchers(space, page, 'proposed', revision.number, actor);
- return { proposal: q.proposal.get(id), page: q.pageById.get(page.id), revision };
+ await q.insertProposal.run({ id, page_id: page.id, space_id: space.id, revision: revision.number, base_revision: base, workflow_id: rec.workflow.id, run_id: rec.workflow.runId, stub_provider: rec.stubProvider ? 1 : 0, proposed_by: actorId(actor), note: note == null ? null : String(note).slice(0, 2000), now: t });
+ await revisionEvent(space, page, revision, actor, { proposal_id: id });
+ await notifyWatchers(space, page, 'proposed', revision.number, actor);
+ return { proposal: await q.proposal.get(id), page: await q.pageById.get(page.id), revision };
});
});
},
},
- proposals(pageId) { return q.proposalsOfPage.all(pageFor(pageId).id); },
- pendingProposals(actor) {
- return q.pendingProposals.all().filter((p) => access.canEdit(q.spaceById.get(p.space_id), actor));
+ async proposals(pageId) { return q.proposalsOfPage.all((await pageOrFail(pageId)).id); },
+ async pendingProposals(actor) {
+ await roles(actor);
+ const rows = await q.pendingProposals.all();
+ if (!rows.length) return [];
+ const spaces = new Map((await q.spacesByIds.all([...new Set(rows.map((p) => p.space_id))])).map((s) => [s.id, s]));
+ return rows.filter((p) => access.canEdit(spaces.get(p.space_id), actor));
},
},
- getProposal(id) { return q.proposal.get(String(id)) || null; },
+ async getProposal(id) { return (await q.proposal.get(String(id))) || null; },
/** A person approves (and by default publishes) or rejects an AI proposal. */
/** A person approves (and by default publishes) or rejects an AI proposal. */
- reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {
- const p = q.proposal.get(String(id));
+ async reviewProposal(id, { decision, note = null, publish = true } = {}, actor) {
+ await roles(actor);
+ const p = await q.proposal.get(String(id));
if (!p) fail(404, 'proposal.not_found', 'No such proposal');
if (!p) fail(404, 'proposal.not_found', 'No such proposal');
- const space = spaceOrFail(p.space_id);
+ const space = await spaceOrFail(p.space_id);
if (!access.canEdit(space, actor)) fail(403, 'proposal.forbidden', 'Only editors of this space review proposals');
if (!access.canEdit(space, actor)) fail(403, 'proposal.forbidden', 'Only editors of this space review proposals');
const who = requirePerson(actor);
const who = requirePerson(actor);
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'proposal.invalid_decision', 'decision is approved or rejected');
if (decision !== 'approved' && decision !== 'rejected') fail(422, 'proposal.invalid_decision', 'decision is approved or rejected');
if (p.status !== 'pending') fail(409, 'proposal.already_reviewed', `This proposal was already ${p.status}`);
if (p.status !== 'pending') fail(409, 'proposal.already_reviewed', `This proposal was already ${p.status}`);
- return tx(() => {
- reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });
- q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });
+ return tx(async () => {
+ await reviews.record({ entityId: p.page_id, revision: p.revision, reviewer: who, decision, note });
+ await q.reviewProposal.run({ id: p.id, status: decision, by: who, note: note == null ? null : String(note).slice(0, 2000), now: now() });
let published = null;
let published = null;
- if (decision === 'approved' && publish) published = publishRevision(q.pageById.get(p.page_id), p.revision, actor);
- return { proposal: q.proposal.get(p.id), published: !!published };
+ if (decision === 'approved' && publish) published = await publishRevision(await q.pageById.get(p.page_id), p.revision, actor);
+ return { proposal: await q.proposal.get(p.id), published: !!published };
});
});
},
},
};
};
- function pageFor(id) { return pageOrFail(id); }
-
return svc;
return svc;
}
}
@@ -52,19 +52,19 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
@@ -52,19 +52,19 @@ const { actor, serviceItself } = require('../server/http/actor-limits');
// an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past it
// an edit has its own budget (30 a minute), shared by the API and the edit form; nothing is stored past it
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
clock = Date.UTC(2026, 8, 27, 12, 5, 0);
const owner = { kind: 'user', subject: alice, staff: false };
const owner = { kind: 'user', subject: alice, staff: false };
- const space = h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);
- const { page } = h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);
- h.svc.setRole(space.id, bob, 'editor', owner);
- const head = () => h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
+ const space = await h.svc.createSpace({ name: 'Limits', slug: 'limits' }, owner);
+ const { page } = await h.svc.createPage(space.id, { title: 'Counted', body: H.LONG }, owner);
+ await h.svc.setRole(space.id, bob, 'editor', owner);
+ const head = async () => (await h.db.prepare('SELECT MAX(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;
for (let i = 0; i < 30; i++) {
for (let i = 0; i < 30; i++) {
- r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: head(), body: `${H.LONG} Edit ${i}.` } });
+ r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: aliceTok, body: { expected_revision: await head(), body: `${H.LONG} Edit ${i}.` } });
assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);
assert.ok(r.status === 200 || r.status === 201, `edit ${i + 1}: ${r.text}`);
}
}
- const stored = head();
+ const stored = await head();
r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });
r = await H.req(h, 'POST', '/w/limits/counted/edit', { cookie: H.cookieFor(aliceTok), form: { expected_revision: stored, title: 'Counted', body: `${H.LONG} One more.` } });
assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.deepStrictEqual([r.status, r.json && r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);
assert.ok(r.json.detail.includes('wiki.page.edit'), r.json.detail);
- assert.strictEqual(head(), stored, 'nothing stored');
+ assert.strictEqual(await head(), stored, 'nothing stored');
r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });
r = await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions`, { token: bobTok, body: { expected_revision: stored, body: `${H.LONG} From bob.` } });
assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);
assert.ok(r.status === 200 || r.status === 201, `another editor still edits: ${r.text}`);
@@ -16,24 +16,24 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
@@ -16,24 +16,24 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
const owner = { kind: 'user', subject: H.subject(), staff: false };
const owner = { kind: 'user', subject: H.subject(), staff: false };
const cookie = H.cookieFor(H.userToken({ subject: owner.subject }));
const cookie = H.cookieFor(H.userToken({ subject: owner.subject }));
try {
try {
- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);
- const { page } = h.svc.createPage(space.id, {
+ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);
+ const { page } = await h.svc.createPage(space.id, {
title: 'Rye', body: H.LONG,
title: 'Rye', body: H.LONG,
citations: [
citations: [
{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01T00:00:00Z', licenseNote: 'CC BY 4.0', quote: { text: 'Rye has less gluten.' } },
{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01T00:00:00Z', licenseNote: 'CC BY 4.0', quote: { text: 'Rye has less gluten.' } },
{ url: 'https://old.example/bread', title: 'Old bread book', retrievedAt: '2026-08-15T00:00:00Z' },
{ url: 'https://old.example/bread', title: 'Old bread book', retrievedAt: '2026-08-15T00:00:00Z' },
],
],
}, owner);
}, owner);
- h.svc.publish(page.id, {}, owner);
- const r1 = h.svc.citationsOf(page, 1);
+ await h.svc.publish(page.id, {}, owner);
+ const r1 = await h.svc.citationsOf(page, 1);
// Revision 2 keeps the first source, drops the second and adds a third.
// Revision 2 keeps the first source, drops the second and adds a third.
- h.svc.editPage(page.id, {
+ await h.svc.editPage(page.id, {
expectedRevision: 1, body: `${H.LONG} Sourdough helps.`, keepCitations: [r1[0].id],
expectedRevision: 1, body: `${H.LONG} Sourdough helps.`, keepCitations: [r1[0].id],
citations: [{ url: 'https://sour.example/dough', title: 'Sourdough notes', retrievedAt: '2026-09-10T12:00:00Z' }],
citations: [{ url: 'https://sour.example/dough', title: 'Sourdough notes', retrievedAt: '2026-09-10T12:00:00Z' }],
}, owner);
}, owner);
- h.svc.publish(page.id, {}, owner);
+ await h.svc.publish(page.id, {}, owner);
// Revision 3 is an unpublished draft citing something not public yet.
// Revision 3 is an unpublished draft citing something not public yet.
- h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);
+ await h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);
// Linked from the article and the history page.
// Linked from the article and the history page.
let r = await H.req(h, 'GET', '/w/bread/rye');
let r = await H.req(h, 'GET', '/w/bread/rye');
@@ -71,28 +71,28 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
@@ -71,28 +71,28 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
assert.ok(r.text.includes('secret.example/draft'));
assert.ok(r.text.includes('secret.example/draft'));
// A renamed page redirects to its inspector.
// A renamed page redirects to its inspector.
- h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);
+ await h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);
r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');
r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/bread/rye-bread/sources?rev=1');
assert.strictEqual(r.headers.get('location'), '/w/bread/rye-bread/sources?rev=1');
// Same read rules as the article: private pages, members spaces, drafts and deleted pages.
// Same read rules as the article: private pages, members spaces, drafts and deleted pages.
- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
+ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources');
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources');
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.ok(!r.text.includes('flour.example'));
assert.ok(!r.text.includes('flour.example'));
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie })).status, 200, 'the owner still inspects it');
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie })).status, 200, 'the owner still inspects it');
- h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);
- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
+ await h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);
+ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources')).status, 404, 'anonymous: members space');
assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources')).status, 404, 'anonymous: members space');
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie: H.cookieFor(H.userToken()) });
r = await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie: H.cookieFor(H.userToken()) });
assert.strictEqual(r.status, 200, 'any signed-in account reads a members space');
assert.strictEqual(r.status, 200, 'any signed-in account reads a members space');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
- const draft = h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner).page;
+ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
+ const draft = (await h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner)).page;
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 404, 'a draft page');
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 404, 'a draft page');
- h.svc.deletePage(draft.id, owner);
+ await h.svc.deletePage(draft.id, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 410);
assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 410);
console.log('citation inspector ok');
console.log('citation inspector ok');
} finally {
} finally {
@@ -38,13 +38,13 @@ for (const bad of ['Site | url | javascript:alert(1)', 'N | number | lots', 'D |
@@ -38,13 +38,13 @@ for (const bad of ['Site | url | javascript:alert(1)', 'N | number | lots', 'D |
const h = await H.boot();
const h = await H.boot();
const me = { kind: 'user', subject: H.subject() };
const me = { kind: 'user', subject: H.subject() };
try {
try {
- const s = h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);
- const { page } = h.svc.createPage(s.id, {
+ const s = await h.svc.createSpace({ name: '<b>Space</b>', slug: 'xss' }, me);
+ const { page } = await h.svc.createPage(s.id, {
title: '<script>alert(1)</script> title', body: `Body ${H.LONG}`, summary: '"><script>alert(2)</script>',
title: '<script>alert(1)</script> title', body: `Body ${H.LONG}`, summary: '"><script>alert(2)</script>',
infobox: [{ label: '<i>L</i>', type: 'text', value: '<img src=x onerror=alert(3)>' }],
infobox: [{ label: '<i>L</i>', type: 'text', value: '<img src=x onerror=alert(3)>' }],
citations: [{ url: 'https://example.org/"><script>', title: '<script>alert(4)</script>', retrievedAt: '2026-09-01' }],
citations: [{ url: 'https://example.org/"><script>', title: '<script>alert(4)</script>', retrievedAt: '2026-09-01' }],
}, me);
}, me);
- h.svc.publish(page.id, {}, me);
+ await h.svc.publish(page.id, {}, me);
const r = await H.req(h, 'GET', `/w/xss/${page.slug}`);
const r = await H.req(h, 'GET', `/w/xss/${page.slug}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const body = r.text.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, '').replace(/<script>\s*window\.__OV_PAGE[\s\S]*?<\/script>/, '').replace(/<script>\(function\(\)\{try\{var r=localStorage[\s\S]*?<\/script>/, '');
const body = r.text.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, '').replace(/<script>\s*window\.__OV_PAGE[\s\S]*?<\/script>/, '').replace(/<script>\(function\(\)\{try\{var r=localStorage[\s\S]*?<\/script>/, '');
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * WIKI_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers (openvibe-sdk scripts/test-services.sh
+ * up), with roles and a schema of this run's own, shaped as OpenVibe.Host's roles/data/add-service.sh makes them.
+ */
+const { createTestDb, createTestValkey, pgAvailable, valkeyAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../server/db');
+
+const testDb = ({ store = process.env.WIKI_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'wiki', max });
+const testValkey = () => createTestValkey({ prefix: 'wiki' });
+
+module.exports = { testDb, testValkey, pgAvailable, valkeyAvailable };
@@ -35,13 +35,13 @@ const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];
@@ -35,13 +35,13 @@ const CAPS = ['wiki.space.create', 'wiki.page.create', 'wiki.page.read'];
assert.strictEqual(r.status, 403, `${type} read as the victim`);
assert.strictEqual(r.status, 403, `${type} read as the victim`);
}
}
}
}
- assert.strictEqual(h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get().n, 0);
+ assert.strictEqual((await h.db.prepare("SELECT COUNT(*) AS n FROM wiki_pages WHERE title LIKE 'Planted%'").get()).n, 0);
// An app acts for its on_behalf_of person (the header is optional and must match).
// An app acts for its on_behalf_of person (the header is optional and must match).
const appTok = token(APP, 'app', { on_behalf_of: appUser });
const appTok = token(APP, 'app', { on_behalf_of: appUser });
r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });
r = await H.req(h, 'POST', '/api/v1/spaces', { token: appTok, body: { name: 'App user space', slug: 'app-user' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(h.svc.findSpace('app-user').owner, appUser);
+ assert.strictEqual((await h.svc.findSpace('app-user')).owner, appUser);
r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });
r = await H.req(h, 'POST', '/api/v1/spaces/app-user/pages', { token: appTok, headers: { 'X-OV-Subject': appUser }, body: { title: 'Mine', body: H.LONG } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });
r = await H.req(h, 'GET', `/api/v1/pages/${secretId}`, { token: appTok });
@@ -25,7 +25,7 @@ const H = require('./helpers');
@@ -25,7 +25,7 @@ const H = require('./helpers');
const vis = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
const vis = u.pathname.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
if (vis && method === 'PUT') {
if (vis && method === 'PUT') {
const headers = Object.fromEntries(new Headers(init.headers || {}).entries());
const headers = Object.fromEntries(new Headers(init.headers || {}).entries());
- calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction });
+ calls.push({ thread: vis[1], body: JSON.parse(init.body), headers, inTransaction: h.db.inTransaction() });
if (communityDown) return json(503, { code: 'unavailable' });
if (communityDown) return json(503, { code: 'unavailable' });
return json(200, { thread: { id: Number(vis[1]), visibility: JSON.parse(init.body).visibility } });
return json(200, { thread: { id: Number(vis[1]), visibility: JSON.parse(init.body).visibility } });
}
}
@@ -58,7 +58,7 @@ const H = require('./helpers');
@@ -58,7 +58,7 @@ const H = require('./helpers');
// Rendering the public page resolves its thread (id 1).
// Rendering the public page resolves its thread (id 1).
r = await H.req(h, 'GET', '/w/talk/discussed');
r = await H.req(h, 'GET', '/w/talk/discussed');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '1');
+ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '1');
// Unpublished: hidden. After the commit, with a moderate token.
// Unpublished: hidden. After the commit, with a moderate token.
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/unpublish`, { token: tok, body: {} });
@@ -115,7 +115,7 @@ const H = require('./helpers');
@@ -115,7 +115,7 @@ const H = require('./helpers');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
await settle(7);
await settle(7);
assert.strictEqual(calls.length, 7);
assert.strictEqual(calls.length, 7);
- assert.strictEqual(h.svc.pageById(pageId).state, 'unpublished');
+ assert.strictEqual((await h.svc.pageById(pageId)).state, 'unpublished');
communityDown = false;
communityDown = false;
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
await settle(8);
await settle(8);
@@ -60,27 +60,27 @@ function atom(text) {
@@ -60,27 +60,27 @@ function atom(text) {
assert.deepStrictEqual(f.entries, []);
assert.deepStrictEqual(f.entries, []);
// Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.
// Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.
- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
+ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const seeded = h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM).page;
- h.svc.publish(seeded.id, {}, SYSTEM);
- const hidden = h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner).page;
- h.svc.publish(hidden.id, {}, owner);
- h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);
- const secret = h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);
- const inSecret = h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner).page;
- h.svc.publish(inSecret.id, {}, owner);
+ const seeded = (await h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM)).page;
+ await h.svc.publish(seeded.id, {}, SYSTEM);
+ const hidden = (await h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner)).page;
+ await h.svc.publish(hidden.id, {}, owner);
+ await h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);
+ const secret = await h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);
+ const inSecret = (await h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner)).page;
+ await h.svc.publish(inSecret.id, {}, owner);
r = await H.req(h, 'GET', '/feed.atom');
r = await H.req(h, 'GET', '/feed.atom');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
f = atom(r.text);
f = atom(r.text);
assert.deepStrictEqual(f.entries, []);
assert.deepStrictEqual(f.entries, []);
- assert.strictEqual(f.updated, new Date(h.svc.findSpace('notes').updated_at).toISOString(), 'the last change to a public space');
+ assert.strictEqual(f.updated, new Date((await h.svc.findSpace('notes')).updated_at).toISOString(), 'the last change to a public space');
assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');
assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
// A listable page: one entry, and the feed's <updated> is that entry's.
// A listable page: one entry, and the feed's <updated> is that entry's.
- const open = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
- h.svc.publish(open.id, {}, owner);
+ const open = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;
+ await h.svc.publish(open.id, {}, owner);
f = atom((await H.req(h, 'GET', '/feed.atom')).text);
f = atom((await H.req(h, 'GET', '/feed.atom')).text);
assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
assert.strictEqual(f.updated, f.entries[0].updated);
assert.strictEqual(f.updated, f.entries[0].updated);
@@ -10,6 +10,7 @@ const path = require('path');
@@ -10,6 +10,7 @@ const path = require('path');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { load } = require('../server/config');
const { load } = require('../server/config');
const { start } = require('../server/index');
const { start } = require('../server/index');
+const { testDb } = require('./db-helper');
const ISSUER = 'https://network.test';
const ISSUER = 'https://network.test';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@ -32,14 +33,20 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', exp = 30
@@ -32,14 +33,20 @@ function serviceToken({ client = 'ai', cap = [], aud = 'openvibe.wiki', exp = 30
return serviceAuth.signServiceToken({ iss: ISSUER, sub: sub || `svc:${client}`, actor_type: actorType, aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...app, ...extra }, privateKey);
return serviceAuth.signServiceToken({ iss: ISSUER, sub: sub || `svc:${client}`, actor_type: actorType, aud: [aud], cap, ns: [], iat: now, exp: now + exp, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...app, ...extra }, privateKey);
}
}
-const quiet = { log() {}, warn() {}, error() {} };
+const quiet = process.env.WIKI_TEST_LOG ? console : { log() {}, warn() {}, error() {} };
/**
/**
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* (Community, Sources, Media, Events, Network token endpoint).
* (Community, Sources, Media, Events, Network token endpoint).
*/
*/
-async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {
+/**
+ * A running Wiki on a database of its own (PGlite by default; WIKI_TEST_STORE=pg: the PostgreSQL + PgBouncer
+ * containers). opts.db: boot on an existing handle (a restart keeps the data); h.stop() closes what boot opened.
+ */
+async function boot({ env = {}, fetch: fetchImpl, dbPath, db: givenDb = null, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
+ const owned = givenDb ? null : await testDb();
+ const db = givenDb || owned.db;
const config = load({
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
WIKI_DB_PATH: dbPath || path.join(dir, 'wiki.db'),
WIKI_DB_PATH: dbPath || path.join(dir, 'wiki.db'),
@@ -47,12 +54,13 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
@@ -47,12 +54,13 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
...env,
...env,
});
});
const h = await start({
const h = await start({
- config, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
+ config, db, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
- return { ...h, base, dir, dbPath: config.dbPath };
+ const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };
+ return { ...h, stop, base, dir, dbPath: config.dbPath, closeDb: owned ? owned.close : async () => {} };
}
}
async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
async function req(h, method, p, { token, body, form, headers = {}, cookie } = {}) {
@@ -77,8 +85,8 @@ async function req(h, method, p, { token, body, form, headers = {}, cookie } = {
@@ -77,8 +85,8 @@ async function req(h, method, p, { token, body, form, headers = {}, cookie } = {
const cookieFor = (token) => `ov_token=${token}`;
const cookieFor = (token) => `ov_token=${token}`;
/** Every envelope in the outbox, oldest first. */
/** Every envelope in the outbox, oldest first. */
-function outbox(h) {
- return h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
+async function outbox(h) {
+ return (await h.db.prepare('SELECT envelope FROM wiki_event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
}
}
const LONG = 'This page has enough words to pass the thin content rule of the indexability gate, which the tests set to twenty words so that a short paragraph is enough for a published page to be indexable.';
const LONG = 'This page has enough words to pass the thin content rule of the indexability gate, which the tests set to twenty words so that a short paragraph is enough for a published page to be indexable.';
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
const assert = require('assert');
const assert = require('assert');
const H = require('./helpers');
const H = require('./helpers');
-const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
+const count = async (h, table) => (await h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).n;
(async () => {
(async () => {
const h = await H.boot();
const h = await H.boot();
@@ -33,7 +33,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -33,7 +33,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
await H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });
await H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });
// Only an owner, acting as a person, imports.
// Only an owner, acting as a person, imports.
- const pagesBefore = count(h, 'wiki_pages');
+ const pagesBefore = await count(h, 'wiki_pages');
r = await imp(tokEditor, bundle);
r = await imp(tokEditor, bundle);
assert.strictEqual(r.status, 403, 'an editor is not an owner');
assert.strictEqual(r.status, 403, 'an editor is not an owner');
assert.strictEqual(r.json.code, 'import.forbidden');
assert.strictEqual(r.json.code, 'import.forbidden');
@@ -43,7 +43,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -43,7 +43,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 403, 'a service acting as itself is no owner');
assert.strictEqual(r.status, 403, 'a service acting as itself is no owner');
r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);
r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);
assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');
assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');
- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore);
+ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore);
// Invalid and oversized bundles are refused before anything is written.
// Invalid and oversized bundles are refused before anything is written.
const refusals = [
const refusals = [
@@ -73,12 +73,12 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -73,12 +73,12 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
r = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });
r = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });
assert.strictEqual(r.status, 413, 'a bundle over 2 MB');
assert.strictEqual(r.status, 413, 'a bundle over 2 MB');
assert.strictEqual(r.json.code, 'request.too_large');
assert.strictEqual(r.json.code, 'request.too_large');
- assert.strictEqual(count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');
+ assert.strictEqual(await count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');
// Atomicity: the third page is invalid (an infobox value that is not a number; a parent that
// Atomicity: the third page is invalid (an infobox value that is not a number; a parent that
// is nowhere) → none of the pages, revisions, citations or events exist afterwards.
// is nowhere) → none of the pages, revisions, citations or events exist afterwards.
- const snapshot = () => ['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t));
- const before = snapshot();
+ const snapshot = async () => Promise.all(['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t)));
+ const before = await snapshot();
for (const broken of [
for (const broken of [
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },
{ pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },
@@ -87,7 +87,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -87,7 +87,7 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
]) {
]) {
r = await imp(tokOwner, broken);
r = await imp(tokOwner, broken);
assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);
assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);
- assert.deepStrictEqual(snapshot(), before, `all or nothing: ${r.json.code}`);
+ assert.deepStrictEqual(await snapshot(), before, `all or nothing: ${r.json.code}`);
}
}
// Success: drafts, attributed to the importer, links and citations recorded, events emitted.
// Success: drafts, attributed to the importer, links and citations recorded, events emitted.
@@ -95,19 +95,19 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -95,19 +95,19 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');
assert.strictEqual(r.json.published, false);
assert.strictEqual(r.json.published, false);
- const space = h.svc.findSpace('bakery');
- const rye = h.svc.findPage(space.id, 'rye');
- const bread = h.svc.findPage(space.id, 'bread');
+ const space = await h.svc.findSpace('bakery');
+ const rye = await h.svc.findPage(space.id, 'rye');
+ const bread = await h.svc.findPage(space.id, 'bread');
assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');
assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');
- assert.strictEqual(h.svc.findPage(space.id, 'private-notes').visibility, 'private');
- const rev = h.stores.revisions.get(rye.id, 1);
+ assert.strictEqual((await h.svc.findPage(space.id, 'private-notes')).visibility, 'private');
+ const rev = await h.stores.revisions.get(rye.id, 1);
assert.strictEqual(rev.author, owner);
assert.strictEqual(rev.author, owner);
assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });
assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });
- assert.strictEqual(h.svc.citationsOf(rye, 1)[0].url, 'https://flour.example/rye');
- assert.ok(h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');
- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));
+ assert.strictEqual((await h.svc.citationsOf(rye, 1))[0].url, 'https://flour.example/rye');
+ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');
+ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));
assert.strictEqual(created.length, 2);
assert.strictEqual(created.length, 2);
- assert.ok(h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');
+ assert.ok(await h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');
// Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.
// Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);
r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });
r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });
@@ -121,18 +121,18 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -121,18 +121,18 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
r = await imp(tokOwner, again);
r = await imp(tokOwner, again);
assert.strictEqual(r.status, 409);
assert.strictEqual(r.status, 409);
assert.strictEqual(r.json.code, 'import.pages_exist');
assert.strictEqual(r.json.code, 'import.pages_exist');
- assert.strictEqual(h.svc.findPage(space.id, 'spelt'), null);
+ assert.strictEqual(await h.svc.findPage(space.id, 'spelt'), null);
r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });
r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);
assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);
assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);
- assert.strictEqual(h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');
+ assert.strictEqual(await h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');
// Published through the import: readable, indexed like any page (upsert event).
// Published through the import: readable, indexed like any page (upsert event).
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);
assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);
- const spelt = h.svc.findPage(space.id, 'spelt');
+ const spelt = await h.svc.findPage(space.id, 'spelt');
assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');
assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');
- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));
- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));
+ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));
+ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));
// AI-assisted bundles stay noindex until a person reviews each page.
// AI-assisted bundles stay noindex until a person reviews each page.
r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });
r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });
@@ -160,8 +160,8 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
@@ -160,8 +160,8 @@ const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).g
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });
r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });
assert.strictEqual(r.status, 403, 'cross-site form posts are refused');
assert.strictEqual(r.status, 403, 'cross-site form posts are refused');
- assert.strictEqual(h.svc.findPage(space.id, 'nope'), null);
- assert.strictEqual(h.svc.findPage(space.id, 'csrf'), null);
+ assert.strictEqual(await h.svc.findPage(space.id, 'nope'), null);
+ assert.strictEqual(await h.svc.findPage(space.id, 'csrf'), null);
console.log('import ok');
console.log('import ok');
} finally {
} finally {
await h.stop();
await h.stop();
@@ -76,7 +76,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -76,7 +76,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
sourcesUp = false;
sourcesUp = false;
r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Down', body: H.LONG, citations: [{ source_item_id: ITEM }] } });
r = await H.req(h, 'POST', '/api/v1/spaces/mt/pages', { token: tok, body: { title: 'Down', body: H.LONG, citations: [{ source_item_id: ITEM }] } });
assert.strictEqual(r.status, 503);
assert.strictEqual(r.status, 503);
- assert.strictEqual(h.svc.findPage(h.svc.findSpace('mt').id, 'down'), null, 'nothing is written without the source');
+ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('mt')).id, 'down'), null, 'nothing is written without the source');
sourcesUp = true;
sourcesUp = true;
await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tok, body: {} });
@@ -108,8 +108,8 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -108,8 +108,8 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
const resolves = calls.filter((c) => c.url === '/api/v1/comments/threads/resolve');
const resolves = calls.filter((c) => c.url === '/api/v1/comments/threads/resolve');
assert.strictEqual(resolves.length, 1);
assert.strictEqual(resolves.length, 1);
assert.deepStrictEqual(JSON.parse(resolves[0].body).ref, { service: 'wiki', type: 'page', id: pageId, label: 'Cited' });
assert.deepStrictEqual(JSON.parse(resolves[0].body).ref, { service: 'wiki', type: 'page', id: pageId, label: 'Cited' });
- assert.strictEqual(h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId).thread_id, '7');
- assert.deepStrictEqual(h.db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');
+ assert.strictEqual((await h.db.prepare('SELECT thread_id FROM wiki_discussion_refs WHERE entity_id = ?').get(pageId)).thread_id, '7');
+ assert.deepStrictEqual((await h.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'wiki_discussion_refs'").all()).map((c) => c.name).sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'no copy of comment content');
r = await H.req(h, 'POST', '/w/mt/cited/discuss', { cookie: H.cookieFor(tok), form: { message: 'Thanks' } });
r = await H.req(h, 'POST', '/w/mt/cited/discuss', { cookie: H.cookieFor(tok), form: { message: 'Thanks' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
const post = calls.find((c) => c.url === '/api/v1/comments/threads/7/comments');
const post = calls.find((c) => c.url === '/api/v1/comments/threads/7/comments');
@@ -119,11 +119,11 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -119,11 +119,11 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
assert.strictEqual(r.status, 401, 'anonymous visitors do not post through Wiki');
assert.strictEqual(r.status, 401, 'anonymous visitors do not post through Wiki');
// Events: the relay publishes the outbox with the service token.
// Events: the relay publishes the outbox with the service token.
- const pending = h.platform.outbox.pending();
+ const pending = await h.platform.outbox.pending();
assert.ok(pending > 0);
assert.ok(pending > 0);
const flushed = await h.platform.outbox.flush();
const flushed = await h.platform.outbox.flush();
assert.strictEqual(flushed.sent, pending);
assert.strictEqual(flushed.sent, pending);
- assert.strictEqual(h.platform.outbox.pending(), 0);
+ assert.strictEqual(await h.platform.outbox.pending(), 0);
const ev = calls.filter((c) => c.host === 'events.internal');
const ev = calls.filter((c) => c.host === 'events.internal');
assert.ok(ev.length >= 1);
assert.ok(ev.length >= 1);
assert.strictEqual(ev[0].headers.authorization, 'Bearer stub-token');
assert.strictEqual(ev[0].headers.authorization, 'Bearer stub-token');
@@ -55,12 +55,12 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
@@ -55,12 +55,12 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.json.attachment.state, 'available');
assert.strictEqual(r.json.attachment.state, 'available');
assert.strictEqual(r.json.attachment.attached_by, A);
assert.strictEqual(r.json.attachment.attached_by, A);
assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');
assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');
- const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
+ const origins = await h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
assert.deepStrictEqual(origins, [
assert.deepStrictEqual(origins, [
{ media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },
{ media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },
{ media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },
{ media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },
]);
]);
- assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
+ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
r = await attach(tokA, pageId, MED(4));
r = await attach(tokA, pageId, MED(4));
assert.strictEqual(r.status, 409);
assert.strictEqual(r.status, 409);
assert.strictEqual(r.json.code, 'media.not_ready');
assert.strictEqual(r.json.code, 'media.not_ready');
@@ -76,7 +76,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
@@ -76,7 +76,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });
r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });
assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);
assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);
- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);
+ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2);
// 3. B cannot attach an object only A can read — the same answer as an id that does not exist.
// 3. B cannot attach an object only A can read — the same answer as an id that does not exist.
const refused = await attach(tokB, pageId, MED(3));
const refused = await attach(tokB, pageId, MED(3));
@@ -106,7 +106,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
@@ -106,7 +106,7 @@ const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
assert.strictEqual(r.status, 503);
assert.strictEqual(r.status, 503);
mediaUp = true;
mediaUp = true;
await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');
await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');
- assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');
+ assert.strictEqual((await h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId)).n, 2, 'nothing refused was attached');
// 4. Media deletes one object and makes the other private: after the check, the public page
// 4. Media deletes one object and makes the other private: after the check, the public page
// shows the explicit states and no image.
// shows the explicit states and no image.
@@ -35,7 +35,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
@@ -35,7 +35,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'preview', title: 'Chess', body, infobox: 'Players | number | 2\nInvented | text | unknown' } });
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'preview', title: 'Chess', body, infobox: 'Players | number | 2\nInvented | text | unknown' } });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('Preview (not saved)'));
assert.ok(r.text.includes('Preview (not saved)'));
- assert.strictEqual(h.svc.findPage(h.svc.findSpace('board-games').id, 'chess'), null);
+ assert.strictEqual(await h.svc.findPage((await h.svc.findSpace('board-games')).id, 'chess'), null);
// Invalid infobox rows come back with the text intact.
// Invalid infobox rows come back with the text intact.
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'save', title: 'Chess', body, infobox: 'Players | number | two' } });
r = await H.req(h, 'POST', '/s/board-games/new', { cookie, form: { op: 'save', title: 'Chess', body, infobox: 'Players | number | two' } });
assert.strictEqual(r.status, 422);
assert.strictEqual(r.status, 422);
@@ -73,10 +73,10 @@ const H = require('./helpers');
@@ -73,10 +73,10 @@ const H = require('./helpers');
// Staff get no silent access to a private user space.
// Staff get no silent access to a private user space.
assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes', { cookie: H.cookieFor(tok(staff, 'admin')) })).status, 404);
assert.strictEqual((await H.req(h, 'GET', '/w/garden/tomatoes', { cookie: H.cookieFor(tok(staff, 'admin')) })).status, 404);
// A watcher who loses access is not notified.
// A watcher who loses access is not notified.
- h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);
- h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());
- h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });
- const watch = H.outbox(h).filter((e) => e.event_type === 'wiki.watch.triggered').pop();
+ await h.svc.watch(pageId, { kind: 'user', subject: viewer }, true);
+ await h.db.prepare('INSERT INTO wiki_watchers (page_id, subject, created_at) VALUES (?, ?, ?)').run(pageId, stranger, Date.now());
+ await h.svc.editPage(pageId, { expectedRevision: 1, body: `${H.LONG} More.` }, { kind: 'user', subject: editor });
+ const watch = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.watch.triggered').pop();
assert.ok(watch.payload.recipients.includes(viewer));
assert.ok(watch.payload.recipients.includes(viewer));
assert.ok(!watch.payload.recipients.includes(stranger), 'no notification about a page the person cannot read');
assert.ok(!watch.payload.recipients.includes(stranger), 'no notification about a page the person cannot read');
assert.ok(!watch.payload.recipients.includes(editor), 'the actor is not notified of their own change');
assert.ok(!watch.payload.recipients.includes(editor), 'the actor is not notified of their own change');
@@ -147,7 +147,7 @@ const H = require('./helpers');
@@ -147,7 +147,7 @@ const H = require('./helpers');
assert.ok(shown.text.includes('Proposed by a workflow'));
assert.ok(shown.text.includes('Proposed by a workflow'));
assert.ok(shown.text.includes('AI-generated'), 'the AI disclosure is shown at the item');
assert.ok(shown.text.includes('AI-generated'), 'the AI disclosure is shown at the item');
assert.ok(shown.text.includes('reviewed by a person'));
assert.ok(shown.text.includes('reviewed by a person'));
- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;
+ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === pageId).pop().payload;
assert.strictEqual(doc.authorship, 'ai_generated');
assert.strictEqual(doc.authorship, 'ai_generated');
assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === 'run_01'));
assert.ok(doc.provenance.some((p) => p.service === 'ai' && p.type === 'run' && p.id === 'run_01'));
r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'rejected' } });
r = await H.req(h, 'POST', `/api/v1/proposals/${prop.id}/review`, { token: tok(editor), body: { decision: 'rejected' } });
@@ -163,7 +163,7 @@ const H = require('./helpers');
@@ -163,7 +163,7 @@ const H = require('./helpers');
const p2 = r.json.proposal;
const p2 = r.json.proposal;
r = await H.req(h, 'POST', `/api/v1/proposals/${p2.id}/review`, { token: tok(editor), body: { decision: 'rejected', note: 'not sourced' } });
r = await H.req(h, 'POST', `/api/v1/proposals/${p2.id}/review`, { token: tok(editor), body: { decision: 'rejected', note: 'not sourced' } });
assert.strictEqual(r.json.published, false);
assert.strictEqual(r.json.published, false);
- assert.strictEqual(h.svc.pageById(p2.page_id).state, 'draft');
+ assert.strictEqual((await h.svc.pageById(p2.page_id)).state, 'draft');
assert.strictEqual((await H.req(h, 'GET', '/w/garden/peppers')).status, 404);
assert.strictEqual((await H.req(h, 'GET', '/w/garden/peppers')).status, 404);
console.log('permissions ok');
console.log('permissions ok');
} finally {
} finally {
@@ -22,29 +22,29 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
@@ -22,29 +22,29 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
return { ok: true, status: 201, json: async () => ({}) };
return { ok: true, status: 201, json: async () => ({}) };
};
};
const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });
const projects = createProjectsModule({ db: h.db, config: { networkInternalUrl: 'http://network.test' }, tokens: h.platform.tokenClient, fetchImpl, log: H.quiet });
- const dirty = () => h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all().map((r) => r.subject);
+ const dirty = async () => (await h.db.prepare('SELECT subject FROM wiki_module_dirty ORDER BY subject').all()).map((r) => r.subject);
try {
try {
let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Garden', slug: 'garden', visibility: 'public' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });
r = await H.req(h, 'POST', '/api/v1/spaces', { token: tok(ann), body: { name: 'Diary', slug: 'diary', visibility: 'private' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
- assert.deepStrictEqual(dirty(), [ann], 'creating a space marks its owner');
+ assert.deepStrictEqual(await dirty(), [ann], 'creating a space marks its owner');
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: 'editor' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${cat}`, { token: tok(ann), body: { role: 'viewer' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
- assert.deepStrictEqual(summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
- assert.ok(modules.validateData('wiki.projects', summarize(h.db, ann)).valid, 'matches the namespace schema');
+ assert.deepStrictEqual(await summarize(h.db, ann), { spaces: [{ slug: 'garden', name: 'Garden', role: 'owner' }], private_count: 1 });
+ assert.deepStrictEqual(await summarize(h.db, bob), { spaces: [{ slug: 'garden', name: 'Garden', role: 'editor' }], private_count: 0 });
+ assert.ok(modules.validateData('wiki.projects', await summarize(h.db, ann)).valid, 'matches the namespace schema');
assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');
assert.strictEqual(await projects.drain(), 2, 'ann and bob; cat is only a viewer');
assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());
assert.deepStrictEqual(puts.map((p) => p.subject).sort(), [ann, bob].sort());
- assert.deepStrictEqual(dirty(), [], 'every mark cleared');
+ assert.deepStrictEqual(await dirty(), [], 'every mark cleared');
// Bob loses his role: he is marked although he is no longer in the space's list.
// Bob loses his role: he is marked although he is no longer in the space's list.
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });
r = await H.req(h, 'PUT', `/api/v1/spaces/garden/roles/${bob}`, { token: tok(ann), body: { role: null } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.ok(dirty().includes(bob));
+ assert.ok((await dirty()).includes(bob));
await projects.drain();
await projects.drain();
assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');
assert.deepStrictEqual(puts.at(-1), { subject: bob, data: { spaces: [], private_count: 0 } }, 'his record empties');
@@ -55,7 +55,7 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
@@ -55,7 +55,7 @@ const { summarize, createProjectsModule } = require('../server/integrations/proj
await projects.drain();
await projects.drain();
assert.strictEqual(puts.length, before + 1);
assert.strictEqual(puts.length, before + 1);
assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');
assert.strictEqual(puts.at(-1).data.spaces[0].name, 'Big Garden');
- h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
+ await h.db.prepare('INSERT INTO wiki_module_dirty (subject, marked_at) VALUES (?, 1)').run(ann);
await projects.drain();
await projects.drain();
assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');
assert.strictEqual(puts.length, before + 1, 'unchanged: not written again');
assert.strictEqual(projects.stats().unchanged >= 2, true);
assert.strictEqual(projects.stats().unchanged >= 2, true);
@@ -19,25 +19,25 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -19,25 +19,25 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const staff = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject();
const staff = H.subject(), editor = H.subject(), viewer = H.subject(), stranger = H.subject();
const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
try {
try {
- seed(h.svc, data, { log: H.quiet });
- const space = h.svc.findSpace('openvibe');
+ await seed(h.svc, data, { log: H.quiet });
+ const space = await h.svc.findSpace('openvibe');
// A row seeded before the flag existed: recognised by its label alone.
// A row seeded before the flag existed: recognised by its label alone.
- const legacy = h.svc.createPage(space.id, {
+ const legacy = await h.svc.createPage(space.id, {
title: 'Legacy import', body: `${data.pages[1].body}`, citations: [{ url: data.pages[1].citations[0].url, retrievedAt: '2026-09-22T12:00:00Z' }],
title: 'Legacy import', body: `${data.pages[1].body}`, citations: [{ url: data.pages[1].citations[0].url, retrievedAt: '2026-09-22T12:00:00Z' }],
authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },
authorship: { mode: 'imported', importedFrom: { label: 'the READMEs, summarised with AI assistance', originalAuthor: 'OpenVibers' } },
}, SYSTEM);
}, SYSTEM);
- h.svc.publish(legacy.page.id, {}, SYSTEM);
- assert.strictEqual(h.stores.revisions.get(legacy.page.id, 1).meta.authorship.importedFrom.aiAssisted, undefined);
- assert.deepStrictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);
+ await h.svc.publish(legacy.page.id, {}, SYSTEM);
+ assert.strictEqual((await h.stores.revisions.get(legacy.page.id, 1)).meta.authorship.importedFrom.aiAssisted, undefined);
+ assert.deepStrictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.codes, ['ai_generated_unreviewed']);
- h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
- h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });
- const page = h.svc.findPage(space.id, 'openvibe-search');
+ await h.svc.setRole(space.id, editor, 'editor', { kind: 'user', subject: staff, staff: true });
+ await h.svc.setRole(space.id, viewer, 'viewer', { kind: 'user', subject: staff, staff: true });
+ const page = await h.svc.findPage(space.id, 'openvibe-search');
const n = page.published_revision;
const n = page.published_revision;
const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;
const url = `/api/v1/pages/${page.id}/revisions/${n}/review`;
- const indexEvents = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));
- const updates = () => H.outbox(h).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');
- const before = { index: indexEvents().length, updates: updates().length };
+ const indexEvents = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.'));
+ const updates = async () => (await H.outbox(h)).filter((e) => e.subject.id === page.id && e.event_type === 'wiki.page.updated');
+ const before = { index: (await indexEvents()).length, updates: (await updates()).length };
// Refused: anonymous, a signed-in stranger, a viewer, a service acting as itself, a service without the capability.
// Refused: anonymous, a signed-in stranger, a viewer, a service acting as itself, a service without the capability.
assert.strictEqual((await H.req(h, 'POST', url, { body: { decision: 'approved' } })).status, 403);
assert.strictEqual((await H.req(h, 'POST', url, { body: { decision: 'approved' } })).status, 403);
@@ -50,8 +50,8 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -50,8 +50,8 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual((await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'maybe' } })).status, 422);
assert.strictEqual((await H.req(h, 'POST', url, { token: tok(editor), body: { decision: 'maybe' } })).status, 422);
assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/99/review`, { token: tok(editor), body: { decision: 'approved' } })).status, 404);
assert.strictEqual((await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/99/review`, { token: tok(editor), body: { decision: 'approved' } })).status, 404);
// Nothing changed.
// Nothing changed.
- assert.strictEqual(h.stores.reviews.history(page.id).length, 0);
- assert.deepStrictEqual({ index: indexEvents().length, updates: updates().length }, before);
+ assert.strictEqual((await h.stores.reviews.history(page.id)).length, 0);
+ assert.deepStrictEqual({ index: (await indexEvents()).length, updates: (await updates()).length }, before);
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
// The history view offers the review form to editors only.
// The history view offers the review form to editors only.
@@ -67,18 +67,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -67,18 +67,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(editor)), form: { revision: String(n), decision: 'approved', note: 'Checked against the README' } });
r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(editor)), form: { revision: String(n), decision: 'approved', note: 'Checked against the README' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
assert.strictEqual(r.headers.get('location'), '/w/openvibe/openvibe-search/history');
assert.strictEqual(r.headers.get('location'), '/w/openvibe/openvibe-search/history');
- const reviews = h.stores.reviews.history(page.id);
+ const reviews = await h.stores.reviews.history(page.id);
assert.strictEqual(reviews.length, 1);
assert.strictEqual(reviews.length, 1);
assert.deepStrictEqual([reviews[0].reviewer, reviews[0].decision, reviews[0].note], [editor, 'approved', 'Checked against the README']);
assert.deepStrictEqual([reviews[0].reviewer, reviews[0].decision, reviews[0].note], [editor, 'approved', 'Checked against the README']);
// Indexable now; the Search document went out exactly once, with wiki.page.updated.
// Indexable now; the Search document went out exactly once, with wiki.page.updated.
- const idx = indexEvents().slice(before.index);
+ const idx = (await indexEvents()).slice(before.index);
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx[0].event_type, 'wiki.index_document.upserted');
assert.strictEqual(idx[0].event_type, 'wiki.index_document.upserted');
assert.strictEqual(idx[0].payload.indexability.decision, 'index');
assert.strictEqual(idx[0].payload.indexability.decision, 'index');
- assert.ok(idx[0].subject.revision > indexEvents()[before.index - 1].subject.revision, 'the sequencer bumped the index revision');
+ assert.ok(idx[0].subject.revision > (await indexEvents())[before.index - 1].subject.revision, 'the sequencer bumped the index revision');
assert.ok(contracts.validate('search.index-document@1', idx[0].payload).valid);
assert.ok(contracts.validate('search.index-document@1', idx[0].payload).valid);
- const upd = updates().slice(before.updates);
+ const upd = (await updates()).slice(before.updates);
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd.length, 1);
assert.strictEqual(upd[0].visibility, 'public');
assert.strictEqual(upd[0].visibility, 'public');
assert.strictEqual(upd[0].payload.indexability.decision, 'index');
assert.strictEqual(upd[0].payload.indexability.decision, 'index');
@@ -97,30 +97,30 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -97,30 +97,30 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-events'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-events'));
// A second identical approval records the review but sends nothing new (same document).
// A second identical approval records the review but sends nothing new (same document).
- const count = indexEvents().length;
+ const count = (await indexEvents()).length;
r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.indexable, true);
assert.strictEqual(r.json.indexable, true);
assert.strictEqual(r.json.action, null);
assert.strictEqual(r.json.action, null);
- assert.strictEqual(indexEvents().length, count);
+ assert.strictEqual((await indexEvents()).length, count);
// "Needs changes" takes it out again: tombstone + wiki.page.updated, gone from the sitemap.
// "Needs changes" takes it out again: tombstone + wiki.page.updated, gone from the sitemap.
r = await H.req(h, 'POST', url, { token: tok(staff, 'admin'), body: { decision: 'rejected', note: 'Status is out of date' } });
r = await H.req(h, 'POST', url, { token: tok(staff, 'admin'), body: { decision: 'rejected', note: 'Status is out of date' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json.indexable, false);
assert.strictEqual(r.json.indexable, false);
assert.deepStrictEqual(r.json.reasons, ['ai_generated_unreviewed']);
assert.deepStrictEqual(r.json.reasons, ['ai_generated_unreviewed']);
- assert.strictEqual(indexEvents().pop().event_type, 'wiki.index_document.deleted');
- assert.strictEqual(updates().length, before.updates + 2);
+ assert.strictEqual((await indexEvents()).pop().event_type, 'wiki.index_document.deleted');
+ assert.strictEqual((await updates()).length, before.updates + 2);
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('openvibe-search'));
assert.ok((await H.req(h, 'GET', '/w/openvibe/openvibe-search')).text.includes('Not yet reviewed by a person.'));
assert.ok((await H.req(h, 'GET', '/w/openvibe/openvibe-search')).text.includes('Not yet reviewed by a person.'));
// The legacy-labelled row is reviewable the same way.
// The legacy-labelled row is reviewable the same way.
- h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
- assert.strictEqual(h.svc.publishedPublic().find((x) => x.page.id === legacy.page.id).decision.indexable, true);
+ await h.svc.reviewRevision(space.id, 'legacy-import', 1, { decision: 'approved' }, { kind: 'user', subject: editor });
+ assert.strictEqual((await h.svc.publishedPublic()).find((x) => x.page.id === legacy.page.id).decision.indexable, true);
// Human revisions and AI proposals are unaffected: a pending proposal goes through its own review.
// Human revisions and AI proposals are unaffected: a pending proposal goes through its own review.
- const prop = h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });
- assert.throws(() => h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');
+ const prop = await h.svc.propose({ space: 'openvibe', pageId: page.id, body: `${data.pages[6].body} x`, workflow: { id: 'wiki.generate_page', runId: 'run_9' } }, { kind: 'service', service: 'svc:ai', subject: null });
+ await assert.rejects(async () => await h.svc.reviewRevision(space.id, page.slug, prop.revision.number, { decision: 'approved' }, { kind: 'user', subject: editor }), (e) => e.code === 'review.proposal_pending');
console.log('review ok');
console.log('review ok');
} finally {
} finally {
await h.stop();
await h.stop();
@@ -131,21 +131,22 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -131,21 +131,22 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
(async () => {
(async () => {
const { seed: seedFn } = require('../server/wiki/seed');
const { seed: seedFn } = require('../server/wiki/seed');
const h1 = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
const h1 = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
- const dbPath = h1.dbPath;
- seedFn(h1.svc, data, { log: H.quiet });
- const page = h1.svc.findPage(h1.svc.findSpace('openvibe').id, 'openvibe-network');
+ const shared = h1.db;
+ await seedFn(h1.svc, data, { log: H.quiet });
+ const page = await h1.svc.findPage((await h1.svc.findSpace('openvibe')).id, 'openvibe-network');
// Simulate the host: Search was last sent an indexable document for this page.
// Simulate the host: Search was last sent an indexable document for this page.
- const old = h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id).revision;
- h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);
- await h1.stop();
- const h2 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
- const ev = H.outbox(h2).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();
+ const old = (await h1.db.prepare("SELECT revision FROM wiki_index_revisions WHERE id = ?").get(page.id)).revision;
+ await h1.db.prepare("UPDATE wiki_index_revisions SET hash = 'sent-under-the-old-rule' WHERE id = ?").run(page.id);
+ await h1.stop({ keepDb: true });
+ const h2 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });
+ const ev = (await H.outbox(h2)).filter((e) => e.subject.id === page.id && e.event_type.startsWith('wiki.index_document.')).pop();
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.ok(ev.subject.revision > old);
assert.ok(ev.subject.revision > old);
- const n = H.outbox(h2).length;
- await h2.stop();
- const h3 = await H.boot({ dbPath, env: { WIKI_GATE_MIN_WORDS: '80' } });
- assert.strictEqual(H.outbox(h3).length, n, 'a second boot sends nothing');
+ const n = (await H.outbox(h2)).length;
+ await h2.stop({ keepDb: true });
+ const h3 = await H.boot({ db: shared, env: { WIKI_GATE_MIN_WORDS: '80' } });
+ assert.strictEqual((await H.outbox(h3)).length, n, 'a second boot sends nothing');
await h3.stop();
await h3.stop();
+ await h1.closeDb();
console.log('reconcile ok');
console.log('reconcile ok');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -10,12 +10,12 @@ const H = require('./helpers');
@@ -10,12 +10,12 @@ const H = require('./helpers');
const h = await H.boot();
const h = await H.boot();
const alice = { kind: 'user', subject: H.subject(), staff: false };
const alice = { kind: 'user', subject: H.subject(), staff: false };
try {
try {
- const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);
+ const space = await h.svc.createSpace({ name: 'Bread', slug: 'bread' }, alice);
const src1 = { url: 'https://example.org/rye', title: 'Rye notes', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass.' } };
const src1 = { url: 'https://example.org/rye', title: 'Rye notes', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass.' } };
const src2 = { url: 'https://example.org/wheat', title: 'Wheat notes', retrievedAt: '2026-09-21T10:00:00Z' };
const src2 = { url: 'https://example.org/wheat', title: 'Wheat notes', retrievedAt: '2026-09-21T10:00:00Z' };
// r1: created with a citation and a typed infobox
// r1: created with a citation and a typed infobox
- const { page, revision: r1 } = h.svc.createPage(space.id, {
+ const { page, revision: r1 } = await h.svc.createPage(space.id, {
title: 'Rye bread', body: `# Rye bread\n\n${H.LONG}`, citations: [src1],
title: 'Rye bread', body: `# Rye bread\n\n${H.LONG}`, citations: [src1],
infobox: [{ label: 'Flour', type: 'text', value: 'rye' }, { label: 'Hydration', type: 'number', value: '78' }, { label: 'Sourdough', type: 'boolean', value: 'yes' }],
infobox: [{ label: 'Flour', type: 'text', value: 'rye' }, { label: 'Hydration', type: 'number', value: '78' }, { label: 'Sourdough', type: 'boolean', value: 'yes' }],
}, alice);
}, alice);
@@ -23,11 +23,11 @@ const H = require('./helpers');
@@ -23,11 +23,11 @@ const H = require('./helpers');
assert.strictEqual(page.state, 'draft');
assert.strictEqual(page.state, 'draft');
// publish r1
// publish r1
- h.svc.publish(page.id, { revision: 1 }, alice);
- assert.strictEqual(h.svc.pageById(page.id).published_revision, 1);
+ await h.svc.publish(page.id, { revision: 1 }, alice);
+ assert.strictEqual((await h.svc.pageById(page.id)).published_revision, 1);
// r2: edit drops src1, adds src2, changes the infobox
// r2: edit drops src1, adds src2, changes the infobox
- const e2 = h.svc.editPage(page.id, {
+ const e2 = await h.svc.editPage(page.id, {
expectedRevision: 1, body: `# Rye bread\n\nA different text. ${H.LONG}`, citations: [src2], keepCitations: 'none',
expectedRevision: 1, body: `# Rye bread\n\nA different text. ${H.LONG}`, citations: [src2], keepCitations: 'none',
infobox: [{ label: 'Flour', type: 'text', value: 'rye and wheat' }],
infobox: [{ label: 'Flour', type: 'text', value: 'rye and wheat' }],
}, alice);
}, alice);
@@ -35,20 +35,20 @@ const H = require('./helpers');
@@ -35,20 +35,20 @@ const H = require('./helpers');
assert.strictEqual(e2.created, true);
assert.strictEqual(e2.created, true);
// Optimistic concurrency: an edit based on r1 is a 412 conflict.
// Optimistic concurrency: an edit based on r1 is a 412 conflict.
- assert.throws(() => h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');
+ await assert.rejects(async () => await h.svc.editPage(page.id, { expectedRevision: 1, body: 'stale edit' }, alice), (err) => err.status === 412 && err.code === 'revision.conflict');
// An identical edit creates nothing.
// An identical edit creates nothing.
- const same = h.svc.editPage(page.id, { expectedRevision: 2 }, alice);
+ const same = await h.svc.editPage(page.id, { expectedRevision: 2 }, alice);
assert.strictEqual(same.created, false);
assert.strictEqual(same.created, false);
// Citations stay with their revision.
// Citations stay with their revision.
- const c1 = h.svc.citationsOf(page, 1);
- const c2 = h.svc.citationsOf(page, 2);
+ const c1 = await h.svc.citationsOf(page, 1);
+ const c2 = await h.svc.citationsOf(page, 2);
assert.deepStrictEqual(c1.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(c1.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(c2.map((c) => c.url), ['https://example.org/wheat']);
assert.deepStrictEqual(c2.map((c) => c.url), ['https://example.org/wheat']);
assert.strictEqual(c1[0].retrievedAt, '2026-09-20T10:00:00.000Z');
assert.strictEqual(c1[0].retrievedAt, '2026-09-20T10:00:00.000Z');
assert.deepStrictEqual(c1[0].quote, { text: 'Rye is a grass.', start: null, end: null });
assert.deepStrictEqual(c1[0].quote, { text: 'Rye is a grass.', start: null, end: null });
// The published page (still r1) shows r1's sources, not r2's.
// The published page (still r1) shows r1's sources, not r2's.
- const pub = h.svc.view(space, h.svc.pageById(page.id), alice, {});
+ const pub = await h.svc.view(space, await h.svc.pageById(page.id), alice, {});
assert.strictEqual(pub.revision.number, 1);
assert.strictEqual(pub.revision.number, 1);
assert.deepStrictEqual(pub.citations.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(pub.citations.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(pub.infobox, [
assert.deepStrictEqual(pub.infobox, [
@@ -58,40 +58,40 @@ const H = require('./helpers');
@@ -58,40 +58,40 @@ const H = require('./helpers');
]);
]);
// Citations cannot be attached to the published revision after the fact.
// Citations cannot be attached to the published revision after the fact.
- assert.throws(() => h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);
+ await assert.rejects(async () => await h.svc.attachCitations(page.id, 1, [src2], alice), (err) => err.status === 409);
// ...but can be to the unpublished head.
// ...but can be to the unpublished head.
- h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);
- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2);
+ await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/extra', retrievedAt: '2026-09-22T00:00:00Z' }], alice);
+ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2);
// A URL citation without a retrieval time is refused (never invented).
// A URL citation without a retrieval time is refused (never invented).
- assert.throws(() => h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');
+ await assert.rejects(async () => await h.svc.attachCitations(page.id, 2, [{ url: 'https://example.org/no-date' }], alice), (err) => err.code === 'citation.retrieved_at_required');
// Publish r2, then diff r1 → r2
// Publish r2, then diff r1 → r2
- h.svc.publish(page.id, { revision: 2 }, alice);
- const d = h.svc.diff(h.svc.pageById(page.id), 1, 2);
+ await h.svc.publish(page.id, { revision: 2 }, alice);
+ const d = await h.svc.diff(await h.svc.pageById(page.id), 1, 2);
assert.ok(d.content.ops.some((o) => o.op === 'insert' && /different/.test(o.text)));
assert.ok(d.content.ops.some((o) => o.op === 'insert' && /different/.test(o.text)));
assert.ok(d.fields.some((f) => f.field === 'infobox'));
assert.ok(d.fields.some((f) => f.field === 'infobox'));
// Revert to r1 = r3, a copy of r1 with r1's citations carried forward; history is untouched.
// Revert to r1 = r3, a copy of r1 with r1's citations carried forward; history is untouched.
- const rv = h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);
+ const rv = await h.svc.revert(page.id, { toRevision: 1, expectedRevision: 2, message: 'back to rye' }, alice);
assert.strictEqual(rv.revision.number, 3);
assert.strictEqual(rv.revision.number, 3);
assert.strictEqual(rv.revision.kind, 'revert');
assert.strictEqual(rv.revision.kind, 'revert');
assert.strictEqual(rv.revision.revertedTo, 1);
assert.strictEqual(rv.revision.revertedTo, 1);
- assert.strictEqual(rv.revision.content, h.stores.revisions.get(page.id, 1).content);
+ assert.strictEqual(rv.revision.content, (await h.stores.revisions.get(page.id, 1)).content);
assert.strictEqual(rv.published, true, 'a revert of a published page is published');
assert.strictEqual(rv.published, true, 'a revert of a published page is published');
- const c3 = h.svc.citationsOf(page, 3);
+ const c3 = await h.svc.citationsOf(page, 3);
assert.deepStrictEqual(c3.map((c) => c.url), ['https://example.org/rye']);
assert.deepStrictEqual(c3.map((c) => c.url), ['https://example.org/rye']);
assert.strictEqual(c3[0].carriedFrom, c1[0].id);
assert.strictEqual(c3[0].carriedFrom, c1[0].id);
- assert.strictEqual(h.svc.citationsOf(page, 2).length, 2, 'r2 keeps its own citations');
- assert.deepStrictEqual(h.svc.view(space, h.svc.pageById(page.id), alice, {}).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);
+ assert.strictEqual((await h.svc.citationsOf(page, 2)).length, 2, 'r2 keeps its own citations');
+ assert.deepStrictEqual((await h.svc.view(space, await h.svc.pageById(page.id), alice, {})).infobox.map((x) => x.key), ['flour', 'hydration', 'sourdough']);
// Lineage: r3 → r2 → r1 by parent pointers.
// Lineage: r3 → r2 → r1 by parent pointers.
- assert.deepStrictEqual(h.stores.revisions.lineage(page.id).map((r) => r.number), [3, 2, 1]);
+ assert.deepStrictEqual((await h.stores.revisions.lineage(page.id)).map((r) => r.number), [3, 2, 1]);
// Immutability is enforced by the database, not by convention.
// Immutability is enforced by the database, not by convention.
- assert.throws(() => h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);
- assert.throws(() => h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);
- assert.throws(() => h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);
- assert.throws(() => h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);
- assert.throws(() => h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);
+ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_page_revisions SET content = ? WHERE entity_id = ?').run('rewritten', page.id), /immutable/);
+ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_page_revisions WHERE entity_id = ?').run(page.id), /never deleted/);
+ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_citations SET url = ? WHERE entity_id = ?').run('https://evil.example', page.id), /immutable/);
+ await assert.rejects(async () => await h.db.prepare('UPDATE wiki_infobox_values SET value_text = ? WHERE page_id = ?').run('x', page.id), /immutable/);
+ await assert.rejects(async () => await h.db.prepare('DELETE FROM wiki_infobox_values WHERE page_id = ?').run(page.id), /never deleted/);
// The same flow over HTTP: history lists every revision, the diff page renders.
// The same flow over HTTP: history lists every revision, the diff page renders.
const hist = await H.req(h, 'GET', `/w/bread/rye-bread/history`);
const hist = await H.req(h, 'GET', `/w/bread/rye-bread/history`);
@@ -108,7 +108,7 @@ const H = require('./helpers');
@@ -108,7 +108,7 @@ const H = require('./helpers');
assert.ok(old.text.includes('content="noindex, nofollow"'));
assert.ok(old.text.includes('content="noindex, nofollow"'));
// Every revision produced a wiki.revision.created event.
// Every revision produced a wiki.revision.created event.
- const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);
+ const created = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.revision.created' && e.subject.id === page.id).map((e) => e.subject.revision);
assert.deepStrictEqual(created, [1, 2, 3]);
assert.deepStrictEqual(created, [1, 2, 3]);
console.log('revisions ok');
console.log('revisions ok');
} finally {
} finally {
@@ -12,18 +12,18 @@ const H = require('./helpers');
@@ -12,18 +12,18 @@ const H = require('./helpers');
let clock = Date.parse('2026-10-01T08:00:00Z');
let clock = Date.parse('2026-10-01T08:00:00Z');
const now = () => clock;
const now = () => clock;
const h1 = await H.boot({ now });
const h1 = await H.boot({ now });
- const dbPath = h1.dbPath;
+ const shared = h1.db;
const editor = { kind: 'user', subject: H.subject(), staff: false };
const editor = { kind: 'user', subject: H.subject(), staff: false };
- const space = h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);
- const { page } = h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);
+ const space = await h1.svc.createSpace({ name: 'Diary', slug: 'diary' }, editor);
+ const { page } = await h1.svc.createPage(space.id, { title: 'Launch notes', body: H.LONG, citations: [{ url: 'https://example.org/x', retrievedAt: '2026-09-30T00:00:00Z' }] }, editor);
const runAt = '2026-10-01T09:00:00Z';
const runAt = '2026-10-01T09:00:00Z';
- const a = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
- const b = h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
+ const a = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
+ const b = await h1.svc.schedulePublish(page.id, { revision: 1, runAt }, editor);
assert.strictEqual(a.created, true);
assert.strictEqual(a.created, true);
assert.strictEqual(b.created, false, 'the same schedule twice is one job');
assert.strictEqual(b.created, false, 'the same schedule twice is one job');
assert.strictEqual(a.job.id, b.job.id);
assert.strictEqual(a.job.id, b.job.id);
- assert.strictEqual(h1.svc.pageById(page.id).state, 'scheduled');
+ assert.strictEqual((await h1.svc.pageById(page.id)).state, 'scheduled');
assert.strictEqual((await H.req(h1, 'GET', '/w/diary/launch-notes')).status, 404, 'scheduled is not public yet');
assert.strictEqual((await H.req(h1, 'GET', '/w/diary/launch-notes')).status, 404, 'scheduled is not public yet');
// Not due yet: nothing happens.
// Not due yet: nothing happens.
@@ -32,12 +32,12 @@ const H = require('./helpers');
@@ -32,12 +32,12 @@ const H = require('./helpers');
// Due: worker w1 claims it and "crashes" before completing (lease taken, never completed).
// Due: worker w1 claims it and "crashes" before completing (lease taken, never completed).
clock = Date.parse('2026-10-01T09:00:01Z');
clock = Date.parse('2026-10-01T09:00:01Z');
- const claimed = h1.stores.scheduler.claim({ worker: 'w1' });
+ const claimed = await h1.stores.scheduler.claim({ worker: 'w1' });
assert.strictEqual(claimed.length, 1);
assert.strictEqual(claimed.length, 1);
- await h1.stop();
+ await h1.stop({ keepDb: true });
// A new process on the same database. Inside the lease nothing runs twice.
// A new process on the same database. Inside the lease nothing runs twice.
- const h2 = await H.boot({ now, dbPath });
+ const h2 = await H.boot({ now, db: shared });
out = await h2.svc.runSchedule({ worker: 'w2' });
out = await h2.svc.runSchedule({ worker: 'w2' });
assert.strictEqual(out.done.length, 0, 'the lease still belongs to w1');
assert.strictEqual(out.done.length, 0, 'the lease still belongs to w1');
// After the lease expires the new worker publishes.
// After the lease expires the new worker publishes.
@@ -45,41 +45,42 @@ const H = require('./helpers');
@@ -45,41 +45,42 @@ const H = require('./helpers');
out = await h2.svc.runSchedule({ worker: 'w2' });
out = await h2.svc.runSchedule({ worker: 'w2' });
assert.strictEqual(out.done.length, 1);
assert.strictEqual(out.done.length, 1);
assert.deepStrictEqual(out.done[0].result, { published: 1 });
assert.deepStrictEqual(out.done[0].result, { published: 1 });
- let p = h2.svc.pageById(page.id);
+ let p = await h2.svc.pageById(page.id);
assert.strictEqual(p.state, 'published');
assert.strictEqual(p.state, 'published');
assert.strictEqual(p.published_revision, 1);
assert.strictEqual(p.published_revision, 1);
assert.strictEqual(p.published_at, clock);
assert.strictEqual(p.published_at, clock);
const publishedAt = p.published_at;
const publishedAt = p.published_at;
assert.strictEqual((await H.req(h2, 'GET', '/w/diary/launch-notes')).status, 200);
assert.strictEqual((await H.req(h2, 'GET', '/w/diary/launch-notes')).status, 200);
- const count = (h) => {
- const all = H.outbox(h).filter((e) => e.subject.id === page.id);
+ const count = async (h) => {
+ const all = (await H.outbox(h)).filter((e) => e.subject.id === page.id);
return { published: all.filter((e) => e.event_type === 'wiki.page.published').length, index: all.filter((e) => e.event_type === 'wiki.index_document.upserted').length };
return { published: all.filter((e) => e.event_type === 'wiki.page.published').length, index: all.filter((e) => e.event_type === 'wiki.index_document.upserted').length };
};
};
- const once = count(h2);
+ const once = (await count(h2));
assert.deepStrictEqual(once, { published: 1, index: 1 });
assert.deepStrictEqual(once, { published: 1, index: 1 });
// Re-running the same job (a replay after a crash between publish and complete) is a no-op.
// Re-running the same job (a replay after a crash between publish and complete) is a no-op.
const replay = await h2.stores.scheduler.runDue({ worker: 'w3', handler: async () => ({}) }); // nothing due any more
const replay = await h2.stores.scheduler.runDue({ worker: 'w3', handler: async () => ({}) }); // nothing due any more
assert.strictEqual(replay.done.length, 0);
assert.strictEqual(replay.done.length, 0);
- h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });
+ await h2.stores.scheduler.schedule({ entityId: page.id, action: 'publish', runAt, revision: 1, key: 'replay-of-the-same-job' });
clock += 1000;
clock += 1000;
out = await h2.svc.runSchedule({ worker: 'w3' });
out = await h2.svc.runSchedule({ worker: 'w3' });
assert.deepStrictEqual(out.done[0].result, { noop: true });
assert.deepStrictEqual(out.done[0].result, { noop: true });
- assert.deepStrictEqual(count(h2), once, 'no second publication event and no new index revision');
- p = h2.svc.pageById(page.id);
+ assert.deepStrictEqual((await count(h2)), once, 'no second publication event and no new index revision');
+ p = await h2.svc.pageById(page.id);
assert.strictEqual(p.published_at, publishedAt, 'the first publication time is kept');
assert.strictEqual(p.published_at, publishedAt, 'the first publication time is kept');
- await h2.stop();
+ await h2.stop({ keepDb: true });
// A third restart: still exactly one publication.
// A third restart: still exactly one publication.
- const h3 = await H.boot({ now, dbPath });
+ const h3 = await H.boot({ now, db: shared });
out = await h3.svc.runSchedule({ worker: 'w4' });
out = await h3.svc.runSchedule({ worker: 'w4' });
assert.strictEqual(out.done.length, 0);
assert.strictEqual(out.done.length, 0);
- assert.deepStrictEqual(count(h3), once);
+ assert.deepStrictEqual((await count(h3)), once);
// Deleting a page cancels its pending jobs.
// Deleting a page cancels its pending jobs.
- h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);
- h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });
- assert.ok(h3.svc.jobs(page.id).filter((j) => j.status === 'pending').length === 0);
+ await h3.svc.schedulePublish(page.id, { revision: 1, runAt: '2026-12-01T00:00:00Z' }, editor);
+ await h3.svc.deletePage(page.id, { kind: 'user', subject: editor.subject });
+ assert.ok((await h3.svc.jobs(page.id)).filter((j) => j.status === 'pending').length === 0);
await h3.stop();
await h3.stop();
+ await h1.closeDb();
console.log('schedule ok');
console.log('schedule ok');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -23,22 +23,22 @@ const H = require('./helpers');
@@ -23,22 +23,22 @@ const H = require('./helpers');
const ann = { kind: 'user', subject: H.subject(), staff: false };
const ann = { kind: 'user', subject: H.subject(), staff: false };
const bob = { kind: 'user', subject: H.subject(), staff: false };
const bob = { kind: 'user', subject: H.subject(), staff: false };
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const annSpace = h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);
- const annPage = h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann).page;
- h.svc.publish(annPage.id, {}, ann);
- const annPrivate = h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann).page;
- const bobSpace = h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);
- const bobPage = h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob).page;
- h.svc.publish(bobPage.id, {}, bob);
- const proposal = h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
+ const annSpace = await h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);
+ const annPage = (await h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann)).page;
+ await h.svc.publish(annPage.id, {}, ann);
+ const annPrivate = (await h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann)).page;
+ const bobSpace = await h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);
+ const bobPage = (await h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob)).page;
+ await h.svc.publish(bobPage.id, {}, bob);
+ const proposal = await h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
const proposalId = proposal.id || (proposal.proposal && proposal.proposal.id);
const proposalId = proposal.id || (proposal.proposal && proposal.proposal.id);
const bobTok = H.userToken({ subject: bob.subject, username: 'bob' });
const bobTok = H.userToken({ subject: bob.subject, username: 'bob' });
const annTok = H.userToken({ subject: ann.subject, username: 'ann' });
const annTok = H.userToken({ subject: ann.subject, username: 'ann' });
- const tables = h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'wiki_%' AND name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all().map((r) => r.name);
- const snapshot = () => Object.fromEntries(tables.map((t) => [t, h.db.prepare(`SELECT * FROM ${t}`).all()]));
- const same = (before, label) => {
- const after = snapshot();
+ const tables = (await h.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'wiki_%' AND table_name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all()).map((r) => r.name);
+ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (t) => [t, await h.db.prepare(`SELECT * FROM ${t}`).all()])));
+ const same = async (before, label) => {
+ const after = await snapshot();
for (const t of tables) assert.deepStrictEqual(after[t], before[t], `${label}: ${t} changed`);
for (const t of tables) assert.deepStrictEqual(after[t], before[t], `${label}: ${t} changed`);
};
};
const api = (method, p, body, token = bobTok) => H.req(h, method, `/api/v1${p}`, { token, body });
const api = (method, p, body, token = bobTok) => H.req(h, method, `/api/v1${p}`, { token, body });
@@ -46,7 +46,7 @@ const H = require('./helpers');
@@ -46,7 +46,7 @@ const H = require('./helpers');
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status) || (r.status === 303 && !/\/w\/ann-space/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status) || (r.status === 303 && !/\/w\/ann-space/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
await check('API: Bob, owner of his own space, cannot touch Ann\'s page or space by id', async () => {
await check('API: Bob, owner of his own space, cannot touch Ann\'s page or space by id', async () => {
- const before = snapshot();
+ const before = await snapshot();
for (const id of [annPage.id, annPrivate.id]) {
for (const id of [annPage.id, annPrivate.id]) {
refused(await api('PATCH', `/pages/${id}`, { title: 'pwned', slug: 'pwned', visibility: 'public', noindex: true }), `PATCH ${id}`);
refused(await api('PATCH', `/pages/${id}`, { title: 'pwned', slug: 'pwned', visibility: 'public', noindex: true }), `PATCH ${id}`);
refused(await api('DELETE', `/pages/${id}`), `DELETE ${id}`);
refused(await api('DELETE', `/pages/${id}`), `DELETE ${id}`);
@@ -68,11 +68,11 @@ const H = require('./helpers');
@@ -68,11 +68,11 @@ const H = require('./helpers');
refused(await api('POST', `/proposals/${proposalId}/review`, { decision: 'approve' }), 'review Ann\'s proposal');
refused(await api('POST', `/proposals/${proposalId}/review`, { decision: 'approve' }), 'review Ann\'s proposal');
refused(await api('GET', `/pages/${annPrivate.id}`), 'read the private page');
refused(await api('GET', `/pages/${annPrivate.id}`), 'read the private page');
refused(await api('GET', `/proposals/${proposalId}`), 'read Ann\'s proposal');
refused(await api('GET', `/proposals/${proposalId}`), 'read Ann\'s proposal');
- same(before, 'API');
+ await same(before, 'API');
});
});
await check('forms: Bob cannot post to Ann\'s pages, nor reach them through his own space\'s slug', async () => {
await check('forms: Bob cannot post to Ann\'s pages, nor reach them through his own space\'s slug', async () => {
- const before = snapshot();
+ const before = await snapshot();
for (const sp of ['ann-space', 'bob-space']) {
for (const sp of ['ann-space', 'bob-space']) {
for (const slug of ['ann-page', 'ann-private']) {
for (const slug of ['ann-page', 'ann-private']) {
refused(await form(`/w/${sp}/${slug}/edit`, { body: `${H.LONG} pwned`, summary: 'x', expected_revision: '1' }), `edit ${sp}/${slug}`);
refused(await form(`/w/${sp}/${slug}/edit`, { body: `${H.LONG} pwned`, summary: 'x', expected_revision: '1' }), `edit ${sp}/${slug}`);
@@ -85,7 +85,7 @@ const H = require('./helpers');
@@ -85,7 +85,7 @@ const H = require('./helpers');
refused(await form('/s/ann-space/new', { title: 'Bob in Ann', body: H.LONG }), 'new page in Ann\'s space');
refused(await form('/s/ann-space/new', { title: 'Bob in Ann', body: H.LONG }), 'new page in Ann\'s space');
refused(await form('/s/ann-space/import', { bundle: '{}' }), 'import form');
refused(await form('/s/ann-space/import', { bundle: '{}' }), 'import form');
refused(await form(`/proposals/${proposalId}`, { decision: 'approve' }), 'proposal form');
refused(await form(`/proposals/${proposalId}`, { decision: 'approve' }), 'proposal form');
- same(before, 'forms');
+ await same(before, 'forms');
const r = await H.req(h, 'GET', '/w/bob-space/ann-private', { cookie: H.cookieFor(bobTok) });
const r = await H.req(h, 'GET', '/w/bob-space/ann-private', { cookie: H.cookieFor(bobTok) });
assert.strictEqual(r.status, 404, 'Ann\'s slug under Bob\'s space is not her page');
assert.strictEqual(r.status, 404, 'Ann\'s slug under Bob\'s space is not her page');
});
});
@@ -94,9 +94,9 @@ const H = require('./helpers');
@@ -94,9 +94,9 @@ const H = require('./helpers');
assert.strictEqual((await api('PATCH', '/spaces/ann-space', { name: 'Ann Space 2' }, annTok)).status, 200);
assert.strictEqual((await api('PATCH', '/spaces/ann-space', { name: 'Ann Space 2' }, annTok)).status, 200);
const f = await form('/s/ann-space/settings', { name: 'Ann Space 3', visibility: 'public' }, annTok);
const f = await form('/s/ann-space/settings', { name: 'Ann Space 3', visibility: 'public' }, annTok);
assert.ok([200, 303].includes(f.status), `the settings form works for its owner: ${f.status} ${f.text.slice(0, 160)}`);
assert.ok([200, 303].includes(f.status), `the settings form works for its owner: ${f.status} ${f.text.slice(0, 160)}`);
- assert.strictEqual(h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get().name, 'Ann Space 3');
+ assert.strictEqual((await h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get()).name, 'Ann Space 3');
assert.strictEqual((await api('GET', `/pages/${annPrivate.id}`, undefined, annTok)).status, 200);
assert.strictEqual((await api('GET', `/pages/${annPrivate.id}`, undefined, annTok)).status, 200);
- const cur = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id).n;
+ const cur = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id)).n;
const r = await api('POST', `/pages/${annPage.id}/revisions`, { expected_revision: cur, body: `${H.LONG} by ann`, citations: cite }, annTok);
const r = await api('POST', `/pages/${annPage.id}/revisions`, { expected_revision: cur, body: `${H.LONG} by ann`, citations: cite }, annTok);
assert.ok([200, 201].includes(r.status), `${r.status} ${r.text.slice(0, 200)}`);
assert.ok([200, 201].includes(r.status), `${r.status} ${r.text.slice(0, 200)}`);
});
});
@@ -33,23 +33,23 @@ const { getPaths, crawl } = require('./security-crawl');
@@ -33,23 +33,23 @@ const { getPaths, crawl } = require('./security-crawl');
let failures = 0;
let failures = 0;
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
try {
try {
- const vault = h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);
- h.svc.setRole(vault.id, friend, 'viewer', owner);
- const hidden = h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner).page;
- h.svc.publish(hidden.id, {}, owner);
+ const vault = await h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);
+ await h.svc.setRole(vault.id, friend, 'viewer', owner);
+ const hidden = (await h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner)).page;
+ await h.svc.publish(hidden.id, {}, owner);
- const open = h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);
- const control = h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner).page;
- h.svc.publish(control.id, {}, owner);
+ const open = await h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);
+ const control = (await h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner)).page;
+ await h.svc.publish(control.id, {}, owner);
// Restricted from the start (a page that was public once was legitimately public then).
// Restricted from the start (a page that was public once was legitimately public then).
- const priv = h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner).page;
- h.svc.publish(priv.id, {}, owner);
- const members = h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner).page;
- h.svc.publish(members.id, {}, owner);
- const draft = h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner).page;
+ const priv = (await h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner)).page;
+ await h.svc.publish(priv.id, {}, owner);
+ const members = (await h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner)).page;
+ await h.svc.publish(members.id, {}, owner);
+ const draft = (await h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner)).page;
// A published page with a second, unpublished revision.
// A published page with a second, unpublished revision.
- h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);
- const proposal = h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
+ await h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);
+ const proposal = await h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
const pageIds = [hidden.id, priv.id, draft.id, members.id, proposal.id || proposal.proposal && proposal.proposal.id];
const pageIds = [hidden.id, priv.id, draft.id, members.id, proposal.id || proposal.proposal && proposal.proposal.id];
const ownerTok = H.userToken({ subject: owner.subject, username: 'owner' });
const ownerTok = H.userToken({ subject: owner.subject, username: 'owner' });
@@ -91,7 +91,7 @@ const { getPaths, crawl } = require('./security-crawl');
@@ -91,7 +91,7 @@ const { getPaths, crawl } = require('./security-crawl');
});
});
await check('public events and Search index documents carry none of it (internal events are first-party only)', async () => {
await check('public events and Search index documents carry none of it (internal events are first-party only)', async () => {
- const text = JSON.stringify(H.outbox(h).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));
+ const text = JSON.stringify((await H.outbox(h)).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));
assert.ok(text.includes('open-control-words') || text.includes('Open Page'), 'the public page is there (control)');
assert.ok(text.includes('open-control-words') || text.includes('Open Page'), 'the public page is there (control)');
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
assert.deepStrictEqual(hits, []);
assert.deepStrictEqual(hits, []);
@@ -37,10 +37,10 @@ const SECRETS = {
@@ -37,10 +37,10 @@ const SECRETS = {
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
try {
try {
const owner = { kind: 'user', subject: H.subject(), staff: false };
const owner = { kind: 'user', subject: H.subject(), staff: false };
- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
+ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const page = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
- h.svc.publish(page.id, {}, owner);
+ const page = (await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner)).page;
+ await h.svc.publish(page.id, {}, owner);
const people = {
const people = {
anonymous: null,
anonymous: null,
user: H.userToken({ subject: H.subject(), username: 'someone' }),
user: H.userToken({ subject: H.subject(), username: 'someone' }),
@@ -89,7 +89,7 @@ const SECRETS = {
@@ -89,7 +89,7 @@ const SECRETS = {
});
});
await check('the events outbox carries neither', async () => {
await check('the events outbox carries neither', async () => {
- const text = JSON.stringify(H.outbox(h));
+ const text = JSON.stringify(await H.outbox(h));
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
});
});
} finally {
} finally {
@@ -34,19 +34,19 @@ const INTERNAL = [
@@ -34,19 +34,19 @@ const INTERNAL = [
try {
try {
const owner = { kind: 'user', subject: H.subject(), staff: false };
const owner = { kind: 'user', subject: H.subject(), staff: false };
const tok = H.userToken({ subject: owner.subject, username: 'owner' });
const tok = H.userToken({ subject: owner.subject, username: 'owner' });
- const space = h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);
+ const space = await h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);
const okCite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const okCite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const page = h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner).page;
- h.svc.publish(page.id, {}, owner);
+ const page = (await h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner)).page;
+ await h.svc.publish(page.id, {}, owner);
await check('internal citation URLs are refused or stored, never fetched, through every path that handles them', async () => {
await check('internal citation URLs are refused or stored, never fetched, through every path that handles them', async () => {
for (const url of INTERNAL) {
for (const url of INTERNAL) {
// Through the API (attach to a revision), the service (a new revision) and the edit form.
// Through the API (attach to a revision), the service (a new revision) and the edit form.
await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/1/citations`, { token: tok, body: { citations: [{ url, retrieved_at: '2026-09-20T00:00:00Z' }] } });
await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/1/citations`, { token: tok, body: { citations: [{ url, retrieved_at: '2026-09-20T00:00:00Z' }] } });
- try { h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }
+ try { await h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }
}
}
- const n = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
- try { h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }
+ const n = (await h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id)).n;
+ try { await h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }
for (const p of ['/w/refs/cited-page', '/w/refs/cited-page/sources', '/w/refs/cited-page/history', `/api/v1/pages/${page.id}`, `/api/v1/pages/${page.id}/revisions/${n}/citations`,
for (const p of ['/w/refs/cited-page', '/w/refs/cited-page/sources', '/w/refs/cited-page/history', `/api/v1/pages/${page.id}`, `/api/v1/pages/${page.id}/revisions/${n}/citations`,
`/api/v1/pages/${page.id}/revisions/1/citations`, '/feed.atom', '/feed.json', '/llms.txt', '/sitemaps/pages-1.xml']) {
`/api/v1/pages/${page.id}/revisions/1/citations`, '/feed.atom', '/feed.json', '/llms.txt', '/sitemaps/pages-1.xml']) {
await H.req(h, 'GET', p, { cookie: H.cookieFor(tok) });
await H.req(h, 'GET', p, { cookie: H.cookieFor(tok) });
@@ -11,15 +11,15 @@ const H = require('./helpers');
@@ -11,15 +11,15 @@ const H = require('./helpers');
const owner = { kind: 'user', subject: H.subject(), staff: false };
const owner = { kind: 'user', subject: H.subject(), staff: false };
const mallory = { kind: 'user', subject: H.subject(), staff: false };
const mallory = { kind: 'user', subject: H.subject(), staff: false };
try {
try {
- const space = h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);
- const { page } = h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);
- h.svc.publish(page.id, {}, owner);
- h.svc.updateSpace(space.id, { slug: 'guide' }, owner);
+ const space = await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, owner);
+ const { page } = await h.svc.createPage(space.id, { title: 'Onboarding', body: H.LONG }, owner);
+ await h.svc.publish(page.id, {}, owner);
+ await h.svc.updateSpace(space.id, { slug: 'guide' }, owner);
// Another person cannot take the retired slug, by creating a space or by renaming theirs.
// Another person cannot take the retired slug, by creating a space or by renaming theirs.
- assert.throws(() => h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
- const own = h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);
- assert.throws(() => h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
+ await assert.rejects(async () => await h.svc.createSpace({ name: 'Handbook', slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
+ const own = await h.svc.createSpace({ name: 'Mine', slug: 'mine' }, mallory);
+ await assert.rejects(async () => await h.svc.updateSpace(own.id, { slug: 'handbook' }, mallory), (e) => e.status === 409 && e.code === 'space.slug_retired');
const r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.userToken({ subject: mallory.subject }), body: { name: 'Handbook', slug: 'handbook' } });
const r = await H.req(h, 'POST', '/api/v1/spaces', { token: H.userToken({ subject: mallory.subject }), body: { name: 'Handbook', slug: 'handbook' } });
assert.strictEqual(r.status, 409);
assert.strictEqual(r.status, 409);
@@ -28,7 +28,7 @@ const H = require('./helpers');
@@ -28,7 +28,7 @@ const H = require('./helpers');
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).headers.get('location'), '/w/guide/onboarding');
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).headers.get('location'), '/w/guide/onboarding');
// The space itself may take its old slug back.
// The space itself may take its old slug back.
- h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);
+ await h.svc.updateSpace(space.id, { slug: 'handbook' }, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).status, 200);
assert.strictEqual((await H.req(h, 'GET', '/w/handbook/onboarding')).status, 200);
console.log('security: ok');
console.log('security: ok');
} finally {
} finally {
@@ -35,15 +35,15 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -35,15 +35,15 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
try {
try {
- const first = seed(h.svc, data, { log: H.quiet });
+ const first = await seed(h.svc, data, { log: H.quiet });
assert.strictEqual(first.created.length, data.pages.length);
assert.strictEqual(first.created.length, data.pages.length);
- const again = seed(h.svc, data, { log: H.quiet });
+ const again = await seed(h.svc, data, { log: H.quiet });
assert.strictEqual(again.created.length, 0, 'idempotent');
assert.strictEqual(again.created.length, 0, 'idempotent');
assert.strictEqual(again.skipped.length, data.pages.length);
assert.strictEqual(again.skipped.length, data.pages.length);
- const space = h.svc.findSpace('openvibe');
+ const space = await h.svc.findSpace('openvibe');
assert.strictEqual(space.kind, 'official');
assert.strictEqual(space.kind, 'official');
- const published = h.svc.publishedPublic();
+ const published = await h.svc.publishedPublic();
assert.strictEqual(published.length, data.pages.length);
assert.strictEqual(published.length, data.pages.length);
// Generated (AI-assisted) text: published and readable, but noindex until a person reviews it.
// Generated (AI-assisted) text: published and readable, but noindex until a person reviews it.
for (const { page, decision } of published) {
for (const { page, decision } of published) {
@@ -51,7 +51,7 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -51,7 +51,7 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual(decision.indexable, false, page.title);
assert.strictEqual(decision.indexable, false, page.title);
assert.strictEqual(decision.listable, false, page.title);
assert.strictEqual(decision.listable, false, page.title);
assert.deepStrictEqual(decision.codes, ['ai_generated_unreviewed'], `${page.title}: only the review is missing (${decision.codes.join(', ')})`);
assert.deepStrictEqual(decision.codes, ['ai_generated_unreviewed'], `${page.title}: only the review is missing (${decision.codes.join(', ')})`);
- assert.strictEqual(h.stores.revisions.get(page.id, page.published_revision).meta.authorship.importedFrom.aiAssisted, true);
+ assert.strictEqual((await h.stores.revisions.get(page.id, page.published_revision)).meta.authorship.importedFrom.aiAssisted, true);
}
}
for (const p of data.pages) {
for (const p of data.pages) {
const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);
const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);
@@ -67,17 +67,17 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -67,17 +67,17 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');
assert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');
assert.ok(!atomFeed.text.includes('<entry>'));
assert.ok(!atomFeed.text.includes('<entry>'));
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
- const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));
+ const idx = (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.'));
assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
// The official space is not editable by an ordinary signed-in person, but is by staff.
// The official space is not editable by an ordinary signed-in person, but is by staff.
const person = { kind: 'user', subject: H.subject(), staff: false };
const person = { kind: 'user', subject: H.subject(), staff: false };
const staff = { kind: 'user', subject: H.subject(), staff: true };
const staff = { kind: 'user', subject: H.subject(), staff: true };
- assert.throws(() => h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
- assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);
+ await assert.rejects(async () => await h.svc.createPage(space.id, { title: 'Spam', body: 'x' }, person), (e) => e.status === 403);
+ assert.ok((await h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff)).page);
// After a person's review the page is indexable; its Search document records the imported authorship.
// After a person's review the page is indexable; its Search document records the imported authorship.
const first1 = published[0].page;
const first1 = published[0].page;
- h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);
- const doc = H.outbox(h).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;
+ await h.svc.reviewRevision('openvibe', first1.slug, first1.published_revision, { decision: 'approved' }, staff);
+ const doc = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.index_document.upserted').pop().payload;
assert.strictEqual(doc.id, first1.id);
assert.strictEqual(doc.id, first1.id);
assert.strictEqual(doc.authorship, 'imported');
assert.strictEqual(doc.authorship, 'imported');
assert.strictEqual(doc.indexability.decision, 'index');
assert.strictEqual(doc.indexability.decision, 'index');
@@ -23,9 +23,9 @@ const { SYSTEM } = require('../server/wiki/seed');
@@ -23,9 +23,9 @@ const { SYSTEM } = require('../server/wiki/seed');
const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];
const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];
try {
try {
// Community space: every outbound link is nofollow ugc.
// Community space: every outbound link is nofollow ugc.
- const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
- const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;
- h.svc.publish(up.id, {}, { kind: 'user', subject: me });
+ const user = await h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
+ const up = (await h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me })).page;
+ await h.svc.publish(up.id, {}, { kind: 'user', subject: me });
let html = (await H.req(h, 'GET', '/w/community/links')).text;
let html = (await H.req(h, 'GET', '/w/community/links')).text;
const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];
const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];
assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));
assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));
@@ -37,9 +37,9 @@ const { SYSTEM } = require('../server/wiki/seed');
@@ -37,9 +37,9 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
// Official space: staff vouch for their links.
// Official space: staff vouch for their links.
- const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
- const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;
- h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
+ const off = await h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
+ const op = (await h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true })).page;
+ await h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
html = (await H.req(h, 'GET', '/w/official/links')).text;
html = (await H.req(h, 'GET', '/w/official/links')).text;
assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);
assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);
assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));
assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));
@@ -53,7 +53,7 @@ const { SYSTEM } = require('../server/wiki/seed');
@@ -53,7 +53,7 @@ const { SYSTEM } = require('../server/wiki/seed');
}
}
let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });
let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });
assert.strictEqual(r.status, 422);
assert.strictEqual(r.status, 422);
- assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
+ await assert.rejects(async () => await h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });
r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
assert.strictEqual(r.json.revision.summary, 'One short sentence.');
assert.strictEqual(r.json.revision.summary, 'One short sentence.');
@@ -66,7 +66,7 @@ const { SYSTEM } = require('../server/wiki/seed');
@@ -66,7 +66,7 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);
assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);
assert.strictEqual(r.json.code, 'proposal.service_only');
assert.strictEqual(r.json.code, 'proposal.service_only');
}
}
- assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);
+ assert.strictEqual(await h.svc.findPage(user.id, 'planted'), null);
r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });
r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
await h.stop();
await h.stop();
@@ -78,10 +78,10 @@ const { SYSTEM } = require('../server/wiki/seed');
@@ -78,10 +78,10 @@ const { SYSTEM } = require('../server/wiki/seed');
assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);
assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);
assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);
assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);
const owner = { kind: 'user', subject: H.subject() };
const owner = { kind: 'user', subject: H.subject() };
- const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
- const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;
- limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
- limited.svc.publish(pg.id, {}, owner);
+ const sp = await limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
+ const pg = (await limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner)).page;
+ await limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
+ await limited.svc.publish(pg.id, {}, owner);
for (let i = 0; i < 15; i++) {
for (let i = 0; i < 15; i++) {
assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);
assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);
assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);
assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);
@@ -13,27 +13,16 @@ const STRANGER = H.subject();
@@ -13,27 +13,16 @@ const STRANGER = H.subject();
const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
(async () => {
(async () => {
- // Migration: a database made before `vip` (the old CHECK) is widened in place; rows stay; idempotent.
+ // The schema: `vip` is a visibility PostgreSQL accepts, and anything else is still refused by the CHECK
+ // (on SQLite a database made before `vip` was widened in place; migrations/0001 has it from the start).
{
{
- const fs = require('fs');
- const os = require('os');
- const path = require('path');
- const { openDb, createStores, widenVisibilityChecks, SCHEMA } = require('../server/db');
- const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-vipmig-')), 'wiki.db');
- const old = openDb(file);
- old.exec(SCHEMA.split("CHECK (visibility IN ('public','members','vip','private'))").join("CHECK (visibility IN ('public','members','private'))"));
- old.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);
- assert.throws(() => old.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run(), /CHECK constraint/);
- old.close();
- const db = openDb(file);
- createStores(db);
- db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();
- assert.strictEqual(db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get().visibility, 'vip', 'widened; the row kept');
- assert.throws(() => db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /CHECK constraint/, 'still checked');
- assert.deepStrictEqual(widenVisibilityChecks(db), [], 'idempotent');
- assert.strictEqual(db.pragma('integrity_check', { simple: true }), 'ok');
- db.close();
- fs.rmSync(path.dirname(file), { recursive: true, force: true });
+ const { testDb } = require('./db-helper');
+ const { db, close } = await testDb();
+ await db.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);
+ await db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();
+ assert.strictEqual((await db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get()).visibility, 'vip');
+ await assert.rejects(db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /check constraint/i, 'still checked');
+ await close();
}
}
const asks = [];
const asks = [];
@@ -51,14 +40,14 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
@@ -51,14 +40,14 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
const cookie = (sub) => H.cookieFor(H.userToken({ subject: sub }));
const cookie = (sub) => H.cookieFor(H.userToken({ subject: sub }));
try {
try {
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const club = h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);
- const secret = h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner).page;
- h.svc.publish(secret.id, {}, owner);
- const open = h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);
- const perk = h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner).page;
- h.svc.publish(perk.id, {}, owner);
- const pub = h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner).page;
- h.svc.publish(pub.id, {}, owner);
+ const club = await h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);
+ const secret = (await h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner)).page;
+ await h.svc.publish(secret.id, {}, owner);
+ const open = await h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);
+ const perk = (await h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner)).page;
+ await h.svc.publish(perk.id, {}, owner);
+ const pub = (await h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner)).page;
+ await h.svc.publish(pub.id, {}, owner);
// Anonymous: the join prompt (403, never publicly cached), no body.
// Anonymous: the join prompt (403, never publicly cached), no body.
let r = await H.req(h, 'GET', '/w/club/club-secret');
let r = await H.req(h, 'GET', '/w/club/club-secret');
@@ -118,7 +107,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
@@ -118,7 +107,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('club-secret'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('perk-page'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('perk-page'));
- const evs = H.outbox(h);
+ const evs = await H.outbox(h);
assert.ok(!evs.some((e) => e.event_type === 'wiki.index_document.upserted' && (e.subject.id === secret.id || e.subject.id === perk.id)), 'no Search document');
assert.ok(!evs.some((e) => e.event_type === 'wiki.index_document.upserted' && (e.subject.id === secret.id || e.subject.id === perk.id)), 'no Search document');
assert.ok(evs.some((e) => e.event_type === 'wiki.page.published' && e.subject.id === secret.id && e.payload.visibility === 'vip'));
assert.ok(evs.some((e) => e.event_type === 'wiki.page.published' && e.subject.id === secret.id && e.payload.visibility === 'vip'));
@@ -130,7 +119,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
@@ -130,7 +119,7 @@ const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
vipDown = false;
vipDown = false;
// Official spaces have no VIP owner.
// Official spaces have no VIP owner.
- assert.throws(() => h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);
+ await assert.rejects(async () => await h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);
} finally {
} finally {
await h.stop();
await h.stop();
}
}
@@ -9,33 +9,33 @@ const assert = require('assert');
@@ -9,33 +9,33 @@ const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const H = require('./helpers');
const H = require('./helpers');
-function lastIndexEvent(h, pageId) {
- return H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();
+async function lastIndexEvent(h, pageId) {
+ return (await H.outbox(h)).filter((e) => e.event_type.startsWith('wiki.index_document.') && e.subject.id === pageId).pop();
}
}
(async () => {
(async () => {
const h = await H.boot();
const h = await H.boot();
const owner = { kind: 'user', subject: H.subject(), staff: false };
const owner = { kind: 'user', subject: H.subject(), staff: false };
try {
try {
- const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
+ const space = await h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
- const { page } = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);
- const other = h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner).page;
+ const { page } = await h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner);
+ const other = (await h.svc.createPage(space.id, { title: 'Second page', body: H.LONG, citations: cite }, owner)).page;
// Draft: not in sitemap, feed or index; direct URL is 404 for anonymous visitors.
// Draft: not in sitemap, feed or index; direct URL is 404 for anonymous visitors.
let r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
let r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
assert.ok(!r.text.includes('/w/notes/open-page'));
assert.ok(!r.text.includes('/w/notes/open-page'));
assert.strictEqual((await H.req(h, 'GET', '/w/notes/open-page')).status, 404);
assert.strictEqual((await H.req(h, 'GET', '/w/notes/open-page')).status, 404);
- assert.strictEqual(lastIndexEvent(h, page.id), undefined, 'a draft never reaches Search');
+ assert.strictEqual((await lastIndexEvent(h, page.id)), undefined, 'a draft never reaches Search');
// Published: sitemap, feeds, index upsert, public cache.
// Published: sitemap, feeds, index upsert, public cache.
- h.svc.publish(page.id, {}, owner);
- h.svc.publish(other.id, {}, owner);
+ await h.svc.publish(page.id, {}, owner);
+ await h.svc.publish(other.id, {}, owner);
r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
r = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
assert.ok(r.text.includes('http://wiki.test/w/notes/open-page'));
assert.ok(r.text.includes('http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/notes/open-page'));
assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/notes/open-page'));
- let ev = lastIndexEvent(h, page.id);
+ let ev = (await lastIndexEvent(h, page.id));
assert.strictEqual(ev.event_type, 'wiki.index_document.upserted');
assert.strictEqual(ev.event_type, 'wiki.index_document.upserted');
assert.strictEqual(ev.payload.visibility, 'public');
assert.strictEqual(ev.payload.visibility, 'public');
assert.strictEqual(ev.payload.indexability.decision, 'index');
assert.strictEqual(ev.payload.indexability.decision, 'index');
@@ -48,9 +48,9 @@ function lastIndexEvent(h, pageId) {
@@ -48,9 +48,9 @@ function lastIndexEvent(h, pageId) {
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
// Private page: leaves sitemap, feeds and Search (tombstone at a higher revision), 404 + private for anonymous.
// Private page: leaves sitemap, feeds and Search (tombstone at a higher revision), 404 + private for anonymous.
- const before = lastIndexEvent(h, page.id).payload.revision;
- h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
- ev = lastIndexEvent(h, page.id);
+ const before = (await lastIndexEvent(h, page.id)).payload.revision;
+ await h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
+ ev = (await lastIndexEvent(h, page.id));
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.strictEqual(ev.event_type, 'wiki.index_document.deleted');
assert.ok(ev.payload.revision > before, 'the tombstone outranks the last document');
assert.ok(ev.payload.revision > before, 'the tombstone outranks the last document');
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('open-page'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('open-page'));
@@ -70,47 +70,47 @@ function lastIndexEvent(h, pageId) {
@@ -70,47 +70,47 @@ function lastIndexEvent(h, pageId) {
// Search (the wiki's own) does not return it to anonymous callers.
// Search (the wiki's own) does not return it to anonymous callers.
assert.ok(!(await H.req(h, 'GET', '/api/v1/search?q=Open')).json.results.some((x) => x.id === page.id));
assert.ok(!(await H.req(h, 'GET', '/api/v1/search?q=Open')).json.results.some((x) => x.id === page.id));
// The product event for a non-public change is internal.
// The product event for a non-public change is internal.
- const upd = H.outbox(h).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();
+ const upd = (await H.outbox(h)).filter((e) => e.event_type === 'wiki.page.updated' && e.subject.id === page.id).pop();
assert.strictEqual(upd.visibility, 'internal');
assert.strictEqual(upd.visibility, 'internal');
// Space goes members-only: its public pages leave the index too.
// Space goes members-only: its public pages leave the index too.
- h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
+ await h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
+ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 404, 'anonymous cannot read a members page');
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 404, 'anonymous cannot read a members page');
r = await H.req(h, 'GET', '/w/notes/second-page', { cookie: H.cookieFor(H.userToken()) });
r = await H.req(h, 'GET', '/w/notes/second-page', { cookie: H.cookieFor(H.userToken()) });
assert.strictEqual(r.status, 200, 'any signed-in account reads a members page');
assert.strictEqual(r.status, 200, 'any signed-in account reads a members page');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.ok(!(await H.req(h, 'GET', '/sitemaps/spaces.xml')).text.includes('/s/notes'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/spaces.xml')).text.includes('/s/notes'));
- h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.upserted', 'back in the index when public again');
+ await h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
+ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.upserted', 'back in the index when public again');
// Rename: every old address 301s to the current one (chains collapse).
// Rename: every old address 301s to the current one (chains collapse).
- h.svc.movePage(other.id, { slug: 'second' }, owner);
- h.svc.movePage(other.id, { slug: 'third' }, owner);
+ await h.svc.movePage(other.id, { slug: 'second' }, owner);
+ await h.svc.movePage(other.id, { slug: 'third' }, owner);
r = await H.req(h, 'GET', '/w/notes/second-page/history');
r = await H.req(h, 'GET', '/w/notes/second-page/history');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/notes/third/history');
assert.strictEqual(r.headers.get('location'), '/w/notes/third/history');
r = await H.req(h, 'GET', '/w/notes/second');
r = await H.req(h, 'GET', '/w/notes/second');
assert.strictEqual(r.headers.get('location'), '/w/notes/third');
assert.strictEqual(r.headers.get('location'), '/w/notes/third');
- ev = lastIndexEvent(h, other.id);
+ ev = (await lastIndexEvent(h, other.id));
assert.strictEqual(ev.payload.canonical_url, 'http://wiki.test/w/notes/third');
assert.strictEqual(ev.payload.canonical_url, 'http://wiki.test/w/notes/third');
// A renamed space redirects its pages as well.
// A renamed space redirects its pages as well.
- h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);
+ await h.svc.updateSpace(space.id, { slug: 'jottings' }, owner);
r = await H.req(h, 'GET', '/w/notes/third');
r = await H.req(h, 'GET', '/w/notes/third');
assert.strictEqual(r.status, 301);
assert.strictEqual(r.status, 301);
assert.strictEqual(r.headers.get('location'), '/w/jottings/third');
assert.strictEqual(r.headers.get('location'), '/w/jottings/third');
assert.strictEqual((await H.req(h, 'GET', '/s/notes')).headers.get('location'), '/s/jottings');
assert.strictEqual((await H.req(h, 'GET', '/s/notes')).headers.get('location'), '/s/jottings');
// Delete: 410 at the current and the old addresses, tombstone, gone from sitemap.
// Delete: 410 at the current and the old addresses, tombstone, gone from sitemap.
- h.svc.deletePage(other.id, owner);
+ await h.svc.deletePage(other.id, owner);
assert.strictEqual((await H.req(h, 'GET', '/w/jottings/third')).status, 410);
assert.strictEqual((await H.req(h, 'GET', '/w/jottings/third')).status, 410);
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 410);
assert.strictEqual((await H.req(h, 'GET', '/w/notes/second-page')).status, 410);
- assert.strictEqual(lastIndexEvent(h, other.id).event_type, 'wiki.index_document.deleted');
- assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));
+ assert.strictEqual((await lastIndexEvent(h, other.id)).event_type, 'wiki.index_document.deleted');
+ assert.ok((await H.outbox(h)).some((e) => e.event_type === 'wiki.page.deleted' && e.subject.id === other.id));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('third'));
assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('third'));
// Contracts: every envelope and every Search document validates.
// Contracts: every envelope and every Search document validates.
- const all = H.outbox(h);
+ const all = await H.outbox(h);
for (const env of all) {
for (const env of all) {
const v = contracts.validate('events.event-envelope@1', env);
const v = contracts.validate('events.event-envelope@1', env);
assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);
assert.ok(v.valid, `${env.event_type}: ${JSON.stringify(v.errors)}`);