openvibers/openvibe.wiki Commits
Generated seed text stays noindex until a person reviews it: AI-assisted imports count as AI output for the gate; reviewRevision (history form and POST /api/v1/pages/:id/revisions/:n/review) re-evaluates the gate and re-sends the Search document with wiki.page.updated; boot-time index reconciliation for rows seeded earlier
Changes
@@@ -97,14 +97,23 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.`/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons-- (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text only-- after review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and++ (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and++ AI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps andthe Search index; members/private/deleted pages are tombstones and never appear in sitemaps,feeds or search, and are served `Cache-Control: private, no-store`.- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pagesabout the network's repositories, summarised from their README/STATUS files at pinned commits,-- each cited with a GitHub permalink and retrieval time, authorship recorded as `imported`-- ("summarised with AI assistance"). Idempotent.++ each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with++ `importedFrom.aiAssisted: true` ("summarised with AI assistance"). Idempotent. Being generated++ text, the pages are published and readable with that disclosure ("Not yet reviewed by a person")++ but the gate keeps them `noindex` (`ai_generated_unreviewed`) — out of sitemaps, feeds and Search —++ until a person reviews each one.++- **Reviewing revisions**: owners and editors (people) review an existing AI-assisted revision from++ the history view ("Reviewed — correct" / "Needs changes") or `POST /api/v1/pages/:id/revisions/:n/review`.++ The review goes into the append-only review log; for the published revision the gate is++ re-evaluated and a changed Search document goes out (with `wiki.page.updated`). Imported rows++ written before the flag existed are recognised by their "AI assistance" label, and at boot Wiki++ re-sends Search any document whose indexability changed (`reconcileIndex`, idempotent).Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objectsonly), consuming Media/Sources change events (Media does not emit deletion events yet; checks are@@@ -140,16 +149,16 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` || `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` || `wiki.revision.propose` | `POST /proposals` |--| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /proposals/:id/review` |++| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` || `wiki.revision.revert` | `POST /pages/:id/revert` || `wiki.citation.attach` | `POST /pages/:id/revisions/:n/citations` || `wiki.search.query` | `GET /search` |--The ids are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's--`wiki.search` becomes the three-segment `wiki.search.query`), with the service manifest in--[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until a contracts--release defines them, [server/auth/capabilities.js](server/auth/capabilities.js) decides grants with--the contracts library's own matching rule.++The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's++`wiki.search` becomes the three-segment `wiki.search.query`) and are released in openvibe-contracts++v0.19.0 with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).++The proposal for `wiki.revision.publish` now also lists the revision review route, which the++released manifest does not name yet.## Events@@@ -158,7 +167,7 @@ transaction as the change; the relay publishes to OpenVibe.Events when `EVENTS_U- `wiki.space.updated` — created, settings, visibility, roles, renamed, deleted- `wiki.revision.created` — every revision (internal: a revision is a draft until published)--- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages)++- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages); `updated` also when a review changes what Search holds- `wiki.watch.triggered` — recipients who may read the page- `wiki.index_document.upserted | deleted` — `search.index-document@1` documents and tombstones forOpenVibe.Search, with a monotonic index revision (`createIndexSequencer`)@@@ -7,12 +7,12 @@"runtime": "node 22, express, better-sqlite3","port": 4800,"domain": "openvibe.wiki",-- "contracts": "openvibe-contracts v0.13.0",++ "contracts": "openvibe-contracts v0.19.0","packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],"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": false,++ "capabilitiesRegistered": true,"deployed": false,"onOpenVibeSites": true,-- "seed": "official space \"openvibe\" (10 pages summarised from the repositories' README/STATUS files, cited)",++ "seed": "official space \"openvibe\" (10 pages summarised with AI assistance from the repositories' README/STATUS files, cited; noindex until a person reviews each)","updated": "2026-09-22"}@@@ -4,7 +4,7 @@"owner": "wiki","status": "active","visibility": "first-party",-- "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's approval or rejection of an AI proposal, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",++ "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's review (approved or needs changes) of an AI proposal or of an existing AI-assisted revision, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.","permissions": ["wiki:revision:publish"],@@@ -23,6 +23,7 @@"POST /api/v1/pages/:id/publish","POST /api/v1/pages/:id/schedule","POST /api/v1/pages/:id/unpublish",-- "POST /api/v1/proposals/:id/review"++ "POST /api/v1/proposals/:id/review",++ "POST /api/v1/pages/:id/revisions/:n/review"]}@@@ -21,6 +21,7 @@* GET /pages/:id/diff?from=&to=&mode= wiki.page.read* GET /pages/:id/revisions/:n/citations wiki.page.read* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)++ * POST /pages/:id/revisions/:n/review wiki.revision.publish { decision: approved|rejected, note? } a person (owner/editor)* POST /pages/:id/publish wiki.revision.publish { revision? }* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }* POST /pages/:id/unpublish wiki.revision.publish@@@ -176,6 +177,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {const cites = await resolveCitations((req.body || {}).citations, platform);return { citations: 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'), R((req) => {++ const { page, space } = 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);++ 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'), 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 };@@@ -18,7 +18,7 @@* GET|POST /w/:space/:slug/revert revert as a new revision* GET /w/:space/:slug/publish?rev= confirm publishing an older revision* GET|POST /w/:space/:slug/settings publish, schedule, unpublish, move, media, visibility, delete-- * POST /w/:space/:slug/watch, /discuss++ * POST /w/:space/:slug/watch, /discuss, /review (a person reviews an AI-assisted revision)** Caching: an anonymous view of a public, published page is `public, max-age=60`; everything* else (signed-in views, members/private pages, editing surfaces, errors) is `private, no-store`.@@@ -438,6 +438,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {}}));++ router.post('/w/:space/:slug/review', form, wrap(async (req, res) => {++ const found = 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);++ res.redirect(303, `${views.wpath(space, page)}/history`);++ }));++router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {const found = locate(req, res, '/watch');if (!found) return;@@@ -22,6 +22,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.feconst stores = createStores(db, { now });const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });const svc = createWikiService({ db, stores, outbox: platform.outbox, config, now, log });++ const reconciled = 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 });@@@ -134,11 +134,17 @@ ${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><<td>${t(r.createdAt)}</td><td>${r.author || ''}</td><td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td><td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>++${r.aiAssisted ? html` <span class="wk-tag">${r.needsReview ? 'AI-assisted, not yet reviewed' : (r.review && r.review.decision === 'approved' ? 'reviewed by a person' : 'AI-assisted')}</span>` : ''}${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}</tbody></table>${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</button></p>` : ''}--</form></section>`;++</form>++${canEdit && list.some((r) => r.needsReview) ? html`<h2>Waiting for a person's review</h2>++<p class="wk-muted">These revisions were written with AI assistance. Search engines are told not to index them, and they stay out of sitemaps, feeds and search, until a person checks them against their sources.</p>++<ul class="wk-review">${list.filter((r) => r.needsReview).map((r) => html`<li><a href="${base}?rev=${String(r.number)}">Revision ${String(r.number)}</a>${r.published ? ' (published)' : ''}${r.review ? html` · last review: ${r.review.decision === 'rejected' ? 'needs changes' : r.review.decision}` : ''}++<form method="post" action="${base}/review" class="wk-inline"><input type="hidden" name="revision" value="${String(r.number)}"><label class="wk-sr" for="wk-note-${String(r.number)}">Review note</label><input id="wk-note-${String(r.number)}" type="text" name="note" maxlength="2000" placeholder="Note (optional)"> <button type="submit" name="decision" value="approved">Reviewed — correct</button> <button type="submit" name="decision" value="rejected">Needs changes</button></form></li>`)}</ul>` : ''}++</section>`;}function diffPage({ space, page, diff }) {@@@ -5,7 +5,9 @@* Idempotent: an existing space is reused and a page whose slug exists is left alone (never* overwritten), so running it twice changes nothing. Authorship is recorded as `imported` with the* truth about where the text comes from: summaries of the cited repository READMEs/STATUS files,-- * written with AI assistance. Every page carries URL citations (permalinks at the commit read, with++ * written with AI assistance (`importedFrom.aiAssisted`). Pages are published and readable with that++ * disclosure, but the gate keeps them noindex (out of sitemaps, feeds and Search) until a person++ * reviews each revision (service.reviewRevision). Every page carries URL citations (permalinks at the commit read, with* the retrieval time) on the revision that uses them.*/const { pageSlug } = require('./content');@@@ -16,6 +18,8 @@ const IMPORTED = {importedFrom: {label: 'the OpenVibe repository README and STATUS files at the cited commits, summarised with AI assistance',originalAuthor: 'OpenVibers',++ // Generated text: held noindex (the gate's ai_generated_unreviewed) until a person reviews it.++ aiAssisted: true,},};@@@ -34,6 +34,35 @@ function proposalIdNew(t) { return `prp_${ulid(t)}`; }function toIso(ms) { return ms == null ? null : new Date(ms).toISOString(); }++/**++ * An imported revision whose text was produced with AI assistance (the seed) is generated content:++ * it needs a person's review before it may be indexed, exactly like AI output. Rows written before++ * the explicit flag existed are recognised by their import label.++ */++function aiAssistedImport(rec) {++ if (!rec || rec.mode !== 'imported' || !rec.importedFrom) return false;++ return rec.importedFrom.aiAssisted === true || /AI assistance/i.test(String(rec.importedFrom.label || ''));++}++++/** Gate facts for a revision's authorship: AI-assisted imports count as AI output until reviewed. */++function authorshipGateFacts(rec, review) {++ if (aiAssistedImport(rec)) return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) } };++ return authorship.gateFacts(rec, review);++}++++/** The disclosure shown at the item; AI-assisted imports say whether a person has reviewed them. */++function disclosureFor(rec, review) {++ const d = authorship.disclosure(rec, review);++ if (!d || !aiAssistedImport(rec)) return d;++ const reviewed = authorship.isReviewed(rec, review);++ return {++ ...d,++ short: 'Imported, written with AI assistance',++ long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}`,++ reviewed,++ };++}++function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;const access = createAccess(db);@@@ -207,7 +236,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),text,citationCount: citations.forRevision(page.id, rev.number).length,noindex: !!page.noindex,-- ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),++ ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),};}@@@ -226,7 +255,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),* from the last one sent (members/private pages are always tombstones: never in search), and the* product event when the publication state moved. Both go to the outbox in the caller's transaction.*/-- function sync(before, pageId, actor) {++ function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {const page = q.pageById.get(pageId);const space = q.spaceById.get(page.space_id);const eff = access.effectiveVisibility(space, page);@@@ -236,7 +265,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),const cites = rev ? citations.forRevision(page.id, rev.number) : [];const doc = sequencer.stamp(hooks.buildIndexDocument({owner: 'wiki', type: 'page', id: page.id, revision: 0, state, visibility: GATE_VIS[eff],-- deleted: eff !== 'public' || !decision,++ // Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.++ deleted: eff !== 'public' || !decision || !decision.listable,canonicalUrl: pageUrl(space, page), title: rev ? rev.fields.title : page.title,summary: rev ? (rev.fields.summary || ssr.markdownToText(rev.content, 300)) : null,body: rev ? ssr.markdownToText(rev.content) : '',@@@ -248,7 +278,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));const after = { state, visibility: eff, revision: page.published_revision };-- const action = hooks.actionFor(before && before.state ? before : null, after);++ let action = hooks.actionFor(before && before.state ? before : null, after);++ // A review changes only the gate decision: announce it when what Search holds changed.++ if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';if (action) {let evDoc = doc;if (doc.deleted && (action === 'published' || action === 'updated')) {@@@ -459,6 +491,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),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);++ // authorship.record() keeps label and original author only; the AI-assistance flag is ours.++ if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;const cleanTitle = checkTitle(title);const slug = wrapContentError(() => content.pageSlug(cleanTitle));const text = checkBody(body == null ? '' : body);@@@ -692,7 +726,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.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 ? authorship.disclosure(rec, review) : null,++ 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),@@@ -739,6 +774,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),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),++ 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,}));},@@@ -854,6 +892,54 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),return { watching: on };},++ /**++ * Re-send Search whatever differs from what it was last sent, for every page (idempotent: the++ * sequencer sends nothing for an unchanged document). Run at boot, so a rule change — e.g.++ * AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.++ */++ reconcileIndex() {++ return tx(() => {++ 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));++ if (b.indexRevision == null) continue; // never sent: nothing in Search to correct++ const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });++ if (out.indexRevision !== b.indexRevision) sent++;++ }++ return { sent };++ });++ },++++ /**++ * A person reviews an existing revision ("reviewed, correct" = approved / "needs changes" =++ * rejected). Recorded in the append-only review log; when it is the published revision the++ * 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));++ 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 (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 }));++ const out = page.state === 'published' && page.published_revision === n++ ? 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;++ return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };++ });++ },++// AI proposals (a seam for OpenVibe.AI: Wiki never calls a model) -------------------/*** An AI workflow proposes a revision. It becomes an immutable revision with ai authorship@@@ -929,4 +1015,4 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),return svc;}--module.exports = { createWikiService, WikiError, GATE_VIS };++module.exports = { createWikiService, WikiError, GATE_VIS, aiAssistedImport };nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++/**++ * A person reviews an existing AI-assisted revision (the seed's imported pages, including rows++ * seeded before the explicit aiAssisted flag existed): only people with the owner or editor role++ * may; approval flips the page to indexable, re-sends the Search document once (with++ * wiki.page.updated), and puts it in sitemaps and feeds; "needs changes" takes it out again.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const contracts = require('openvibe-contracts');++const H = require('./helpers');++const { seed, SYSTEM } = require('../server/wiki/seed');++++const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));++++(async () => {++ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });++ const 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');++ // A row seeded before the flag existed: recognised by its label alone.++ const legacy = 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']);++++ 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');++ 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 };++++ // 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, { token: tok(stranger), body: { decision: 'approved' } })).status, 403);++ assert.strictEqual((await H.req(h, 'POST', url, { token: tok(viewer), body: { decision: 'approved' } })).status, 403);++ assert.strictEqual((await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), body: { decision: 'approved' } })).status, 403);++ let r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.page.read'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json.code, 'capability.denied');++ 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);++ // Nothing changed.++ assert.strictEqual(h.stores.reviews.history(page.id).length, 0);++ assert.deepStrictEqual({ index: indexEvents().length, updates: 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.++ let hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });++ assert.ok(hist.text.includes('Reviewed — correct') && hist.text.includes('Needs changes'));++ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history')).text.includes('Reviewed — correct'));++ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(viewer)) })).text.includes('Reviewed — correct'));++ // A viewer posting the form is refused.++ r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(viewer)), form: { revision: String(n), decision: 'approved' } });++ assert.strictEqual(r.status, 403);++++ // An editor approves through the form (a service acting for the editor would work the same way).++ 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.headers.get('location'), '/w/openvibe/openvibe-search/history');++ const reviews = 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);++ 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(contracts.validate('search.index-document@1', idx[0].payload).valid);++ const upd = updates().slice(before.updates);++ assert.strictEqual(upd.length, 1);++ assert.strictEqual(upd[0].visibility, 'public');++ assert.strictEqual(upd[0].payload.indexability.decision, 'index');++ assert.ok(contracts.validate('events.event-envelope@1', upd[0]).valid);++ r = await H.req(h, 'GET', '/w/openvibe/openvibe-search');++ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));++ assert.ok(r.text.includes('Reviewed by a person.'));++ assert.ok(!r.text.includes('Not yet reviewed by a person.'));++ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));++ assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));++ assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/openvibe/openvibe-search'));++ // The form disappears for a reviewed revision; other pages still wait.++ hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });++ assert.ok(!hist.text.includes('Reviewed — correct'));++ assert.ok(hist.text.includes('reviewed by a person'));++ 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).++ const count = 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);++++ // "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.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);++++ // 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');++ console.log('review ok');++ } finally {++ await h.stop();++ }++})().catch((err) => { console.error(err); process.exit(1); });++++// Rows seeded under the old rule (indexed before AI-assisted imports needed review) are corrected at boot.++(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');++ // 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();++ 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');++ await h3.stop();++ console.log('reconcile ok');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -1,8 +1,10 @@'use strict';/*** The official "OpenVibe" seed: idempotent, every page published, cited (GitHub permalinks at a-- * pinned commit, with a retrieval time), indexable under the production gate policy, internally-- * linked without red links, labelled with its real authorship, and free of pricing copy.++ * pinned commit, with a retrieval time), internally linked without red links, labelled with its real++ * authorship (imported, written with AI assistance), free of pricing copy, and — being generated++ * text — noindex and out of sitemaps, feeds and Search until a person reviews it; the only gate++ * reason left is that review (the production word and source policy passes).*/const assert = require('assert');const fs = require('fs');@@@ -43,27 +45,40 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeconst published = 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) {-- assert.ok(decision.indexable, `${page.title}: indexable (${decision.codes.join(', ')})`);++ assert.strictEqual(page.state, 'published');++ assert.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);}-- // The pages as a reader sees them: no red links, imported authorship disclosed, sitemap lists them all.for (const p of data.pages) {const r = await H.req(h, 'GET', `/w/openvibe/${content.pageSlug(p.title)}`);assert.strictEqual(r.status, 200, p.title);assert.ok(!r.text.includes('ov-redlink'), `${p.title}: no red links`);assert.ok(r.text.includes('summarised with AI assistance'), `${p.title}: authorship disclosed`);-- assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));++ assert.ok(r.text.includes('Not yet reviewed by a person.'), `${p.title}: review state disclosed`);++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'), `${p.title}: noindex`);}-- const sm = await H.req(h, 'GET', '/sitemaps/pages-1.xml');-- assert.strictEqual((sm.text.match(/<loc>/g) || []).length, data.pages.length);++ // Absent from sitemaps, feeds and Search until reviewed.++ assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);++ assert.strictEqual((await H.req(h, 'GET', '/feed.atom')).status, 404);++ 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.'));++ 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);-- // Search index documents record the imported authorship.-- const doc = H.outbox(h).find((e) => e.event_type === 'wiki.index_document.upserted').payload;++ // 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;++ assert.strictEqual(doc.id, first1.id);assert.strictEqual(doc.authorship, 'imported');++ assert.strictEqual(doc.indexability.decision, 'index');console.log('seed ok');} finally {await h.stop();
@@ -97,14 +97,23 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
@@ -97,14 +97,23 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
`/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);
`/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);
`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
- (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text only
- after review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
+ (policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and
+ AI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
feeds or search, and are served `Cache-Control: private, no-store`.
feeds or search, and are served `Cache-Control: private, no-store`.
- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
about the network's repositories, summarised from their README/STATUS files at pinned commits,
about the network's repositories, summarised from their README/STATUS files at pinned commits,
- each cited with a GitHub permalink and retrieval time, authorship recorded as `imported`
- ("summarised with AI assistance"). Idempotent.
+ each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with
+ `importedFrom.aiAssisted: true` ("summarised with AI assistance"). Idempotent. Being generated
+ text, the pages are published and readable with that disclosure ("Not yet reviewed by a person")
+ but the gate keeps them `noindex` (`ai_generated_unreviewed`) — out of sitemaps, feeds and Search —
+ until a person reviews each one.
+- **Reviewing revisions**: owners and editors (people) review an existing AI-assisted revision from
+ the history view ("Reviewed — correct" / "Needs changes") or `POST /api/v1/pages/:id/revisions/:n/review`.
+ The review goes into the append-only review log; for the published revision the gate is
+ re-evaluated and a changed Search document goes out (with `wiki.page.updated`). Imported rows
+ written before the flag existed are recognised by their "AI assistance" label, and at boot Wiki
+ re-sends Search any document whose indexability changed (`reconcileIndex`, idempotent).
Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objects
Not built yet: page-level talk moderation, media upload from Wiki (attach existing Media objects
only), consuming Media/Sources change events (Media does not emit deletion events yet; checks are
only), consuming Media/Sources change events (Media does not emit deletion events yet; checks are
@@ -140,16 +149,16 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
@@ -140,16 +149,16 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
| `wiki.revision.propose` | `POST /proposals` |
| `wiki.revision.propose` | `POST /proposals` |
-| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /proposals/:id/review` |
+| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` |
| `wiki.revision.revert` | `POST /pages/:id/revert` |
| `wiki.revision.revert` | `POST /pages/:id/revert` |
| `wiki.citation.attach` | `POST /pages/:id/revisions/:n/citations` |
| `wiki.citation.attach` | `POST /pages/:id/revisions/:n/citations` |
| `wiki.search.query` | `GET /search` |
| `wiki.search.query` | `GET /search` |
-The ids are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
-`wiki.search` becomes the three-segment `wiki.search.query`), with the service manifest in
-[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until a contracts
-release defines them, [server/auth/capabilities.js](server/auth/capabilities.js) decides grants with
-the contracts library's own matching rule.
+The ids were proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (the plan's
+`wiki.search` becomes the three-segment `wiki.search.query`) and are released in openvibe-contracts
+v0.19.0 with the service manifest ([docs/service-manifest-proposal.json](docs/service-manifest-proposal.json)).
+The proposal for `wiki.revision.publish` now also lists the revision review route, which the
+released manifest does not name yet.
## Events
## Events
@@ -158,7 +167,7 @@ transaction as the change; the relay publishes to OpenVibe.Events when `EVENTS_U
@@ -158,7 +167,7 @@ transaction as the change; the relay publishes to OpenVibe.Events when `EVENTS_U
- `wiki.space.updated` — created, settings, visibility, roles, renamed, deleted
- `wiki.space.updated` — created, settings, visibility, roles, renamed, deleted
- `wiki.revision.created` — every revision (internal: a revision is a draft until published)
- `wiki.revision.created` — every revision (internal: a revision is a draft until published)
-- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages)
+- `wiki.page.published | updated | unpublished | deleted` — publication changes (public only for public, listable pages); `updated` also when a review changes what Search holds
- `wiki.watch.triggered` — recipients who may read the page
- `wiki.watch.triggered` — recipients who may read the page
- `wiki.index_document.upserted | deleted` — `search.index-document@1` documents and tombstones for
- `wiki.index_document.upserted | deleted` — `search.index-document@1` documents and tombstones for
OpenVibe.Search, with a monotonic index revision (`createIndexSequencer`)
OpenVibe.Search, with a monotonic index revision (`createIndexSequencer`)
@@ -7,12 +7,12 @@
@@ -7,12 +7,12 @@
"runtime": "node 22, express, better-sqlite3",
"runtime": "node 22, express, better-sqlite3",
"port": 4800,
"port": 4800,
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
- "contracts": "openvibe-contracts v0.13.0",
+ "contracts": "openvibe-contracts v0.19.0",
"packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
"packages": ["openvibe-publishing v0.2.0", "openvibe-shared v1.3.0", "openvibe-sdk v0.2.2"],
"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"],
"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": false,
+ "capabilitiesRegistered": true,
"deployed": false,
"deployed": false,
"onOpenVibeSites": true,
"onOpenVibeSites": true,
- "seed": "official space \"openvibe\" (10 pages summarised from the repositories' README/STATUS files, cited)",
+ "seed": "official space \"openvibe\" (10 pages summarised with AI assistance from the repositories' README/STATUS files, cited; noindex until a person reviews each)",
"updated": "2026-09-22"
"updated": "2026-09-22"
}
}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"owner": "wiki",
"owner": "wiki",
"status": "active",
"status": "active",
"visibility": "first-party",
"visibility": "first-party",
- "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's approval or rejection of an AI proposal, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
+ "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's review (approved or needs changes) of an AI proposal or of an existing AI-assisted revision, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
"permissions": [
"permissions": [
"wiki:revision:publish"
"wiki:revision:publish"
],
],
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
"POST /api/v1/pages/:id/publish",
"POST /api/v1/pages/:id/publish",
"POST /api/v1/pages/:id/schedule",
"POST /api/v1/pages/:id/schedule",
"POST /api/v1/pages/:id/unpublish",
"POST /api/v1/pages/:id/unpublish",
- "POST /api/v1/proposals/:id/review"
+ "POST /api/v1/proposals/:id/review",
+ "POST /api/v1/pages/:id/revisions/:n/review"
]
]
}
}
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
+ * POST /pages/:id/revisions/:n/review wiki.revision.publish { decision: approved|rejected, note? } a person (owner/editor)
* POST /pages/:id/publish wiki.revision.publish { revision? }
* POST /pages/:id/publish wiki.revision.publish { revision? }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/unpublish wiki.revision.publish
* POST /pages/:id/unpublish wiki.revision.publish
@@ -176,6 +177,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -176,6 +177,12 @@ function createApi({ svc, viewers, platform, config, log = console }) {
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: 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'), R((req) => {
+ const { page, space } = 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);
+ 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'), R((req) => {
router.post('/pages/:id/publish', guard('wiki.revision.publish'), R((req) => {
const out = svc.publish(req.params.id, { revision: int((req.body || {}).revision) }, req.actor);
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 };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), action: out.action };
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
* GET|POST /w/:space/:slug/revert revert as a new revision
* GET|POST /w/:space/:slug/revert revert as a new revision
* GET /w/:space/:slug/publish?rev= confirm publishing an older revision
* GET /w/:space/:slug/publish?rev= confirm publishing an older revision
* GET|POST /w/:space/:slug/settings publish, schedule, unpublish, move, media, visibility, delete
* GET|POST /w/:space/:slug/settings publish, schedule, unpublish, move, media, visibility, delete
- * POST /w/:space/:slug/watch, /discuss
+ * POST /w/:space/:slug/watch, /discuss, /review (a person reviews an AI-assisted revision)
*
*
* Caching: an anonymous view of a public, published page is `public, max-age=60`; everything
* Caching: an anonymous view of a public, published page is `public, max-age=60`; everything
* else (signed-in views, members/private pages, editing surfaces, errors) is `private, no-store`.
* else (signed-in views, members/private pages, editing surfaces, errors) is `private, no-store`.
@@ -438,6 +438,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -438,6 +438,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
}
}));
}));
+ router.post('/w/:space/:slug/review', form, wrap(async (req, res) => {
+ const found = 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);
+ res.redirect(303, `${views.wpath(space, page)}/history`);
+ }));
+
router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {
router.post('/w/:space/:slug/watch', form, wrap(async (req, res) => {
const found = locate(req, res, '/watch');
const found = locate(req, res, '/watch');
if (!found) return;
if (!found) return;
@@ -22,6 +22,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -22,6 +22,8 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const stores = createStores(db, { now });
const stores = createStores(db, { now });
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, now, log });
const svc = createWikiService({ db, stores, outbox: platform.outbox, config, now, log });
+ const reconciled = 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 });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
const viewers = createViewerResolver({ keys, config });
@@ -134,11 +134,17 @@ ${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><
@@ -134,11 +134,17 @@ ${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><
<td>${t(r.createdAt)}</td><td>${r.author || ''}</td>
<td>${t(r.createdAt)}</td><td>${r.author || ''}</td>
<td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td>
<td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td>
<td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>
<td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>
+${r.aiAssisted ? html` <span class="wk-tag">${r.needsReview ? 'AI-assisted, not yet reviewed' : (r.review && r.review.decision === 'approved' ? 'reviewed by a person' : 'AI-assisted')}</span>` : ''}
${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}
${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}
${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}
${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}
</tbody></table>
</tbody></table>
${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</button></p>` : ''}
${list.length > 1 ? html`<p><button type="submit">Compare selected revisions</button></p>` : ''}
-</form></section>`;
+</form>
+${canEdit && list.some((r) => r.needsReview) ? html`<h2>Waiting for a person's review</h2>
+<p class="wk-muted">These revisions were written with AI assistance. Search engines are told not to index them, and they stay out of sitemaps, feeds and search, until a person checks them against their sources.</p>
+<ul class="wk-review">${list.filter((r) => r.needsReview).map((r) => html`<li><a href="${base}?rev=${String(r.number)}">Revision ${String(r.number)}</a>${r.published ? ' (published)' : ''}${r.review ? html` · last review: ${r.review.decision === 'rejected' ? 'needs changes' : r.review.decision}` : ''}
+<form method="post" action="${base}/review" class="wk-inline"><input type="hidden" name="revision" value="${String(r.number)}"><label class="wk-sr" for="wk-note-${String(r.number)}">Review note</label><input id="wk-note-${String(r.number)}" type="text" name="note" maxlength="2000" placeholder="Note (optional)"> <button type="submit" name="decision" value="approved">Reviewed — correct</button> <button type="submit" name="decision" value="rejected">Needs changes</button></form></li>`)}</ul>` : ''}
+</section>`;
}
}
function diffPage({ space, page, diff }) {
function diffPage({ space, page, diff }) {
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
* Idempotent: an existing space is reused and a page whose slug exists is left alone (never
* Idempotent: an existing space is reused and a page whose slug exists is left alone (never
* overwritten), so running it twice changes nothing. Authorship is recorded as `imported` with the
* overwritten), so running it twice changes nothing. Authorship is recorded as `imported` with the
* truth about where the text comes from: summaries of the cited repository READMEs/STATUS files,
* truth about where the text comes from: summaries of the cited repository READMEs/STATUS files,
- * written with AI assistance. Every page carries URL citations (permalinks at the commit read, with
+ * written with AI assistance (`importedFrom.aiAssisted`). Pages are published and readable with that
+ * disclosure, but the gate keeps them noindex (out of sitemaps, feeds and Search) until a person
+ * reviews each revision (service.reviewRevision). Every page carries URL citations (permalinks at the commit read, with
* the retrieval time) on the revision that uses them.
* the retrieval time) on the revision that uses them.
*/
*/
const { pageSlug } = require('./content');
const { pageSlug } = require('./content');
@@ -16,6 +18,8 @@ const IMPORTED = {
@@ -16,6 +18,8 @@ const IMPORTED = {
importedFrom: {
importedFrom: {
label: 'the OpenVibe repository README and STATUS files at the cited commits, summarised with AI assistance',
label: 'the OpenVibe repository README and STATUS files at the cited commits, summarised with AI assistance',
originalAuthor: 'OpenVibers',
originalAuthor: 'OpenVibers',
+ // Generated text: held noindex (the gate's ai_generated_unreviewed) until a person reviews it.
+ aiAssisted: true,
},
},
};
};
@@ -34,6 +34,35 @@ function proposalIdNew(t) { return `prp_${ulid(t)}`; }
@@ -34,6 +34,35 @@ function proposalIdNew(t) { return `prp_${ulid(t)}`; }
function toIso(ms) { return ms == null ? null : new Date(ms).toISOString(); }
function toIso(ms) { return ms == null ? null : new Date(ms).toISOString(); }
+/**
+ * An imported revision whose text was produced with AI assistance (the seed) is generated content:
+ * it needs a person's review before it may be indexed, exactly like AI output. Rows written before
+ * the explicit flag existed are recognised by their import label.
+ */
+function aiAssistedImport(rec) {
+ if (!rec || rec.mode !== 'imported' || !rec.importedFrom) return false;
+ return rec.importedFrom.aiAssisted === true || /AI assistance/i.test(String(rec.importedFrom.label || ''));
+}
+
+/** Gate facts for a revision's authorship: AI-assisted imports count as AI output until reviewed. */
+function authorshipGateFacts(rec, review) {
+ if (aiAssistedImport(rec)) return { authorship: { mode: 'ai', reviewed: authorship.isReviewed(rec, review) } };
+ return authorship.gateFacts(rec, review);
+}
+
+/** The disclosure shown at the item; AI-assisted imports say whether a person has reviewed them. */
+function disclosureFor(rec, review) {
+ const d = authorship.disclosure(rec, review);
+ if (!d || !aiAssistedImport(rec)) return d;
+ const reviewed = authorship.isReviewed(rec, review);
+ return {
+ ...d,
+ short: 'Imported, written with AI assistance',
+ long: `${d.long.replace(/\.\s*$/, '')}. ${reviewed ? 'Reviewed by a person.' : 'Not yet reviewed by a person.'}`,
+ reviewed,
+ };
+}
+
function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
function createWikiService({ db, stores, outbox, config, now = () => Date.now(), log = console }) {
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const access = createAccess(db);
const access = createAccess(db);
@@ -207,7 +236,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -207,7 +236,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
text,
text,
citationCount: citations.forRevision(page.id, rev.number).length,
citationCount: citations.forRevision(page.id, rev.number).length,
noindex: !!page.noindex,
noindex: !!page.noindex,
- ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
+ ...(rec ? authorshipGateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
};
};
}
}
@@ -226,7 +255,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -226,7 +255,7 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
* from the last one sent (members/private pages are always tombstones: never in search), and the
* from the last one sent (members/private pages are always tombstones: never in search), and the
* 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.
*/
*/
- function sync(before, pageId, actor) {
+ function sync(before, pageId, actor, { updatedIfIndexChanged = false } = {}) {
const page = q.pageById.get(pageId);
const page = q.pageById.get(pageId);
const space = q.spaceById.get(page.space_id);
const space = q.spaceById.get(page.space_id);
const eff = access.effectiveVisibility(space, page);
const eff = access.effectiveVisibility(space, page);
@@ -236,7 +265,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -236,7 +265,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
const cites = rev ? citations.forRevision(page.id, rev.number) : [];
const cites = rev ? citations.forRevision(page.id, rev.number) : [];
const doc = sequencer.stamp(hooks.buildIndexDocument({
const doc = sequencer.stamp(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],
- deleted: eff !== 'public' || !decision,
+ // Not listable (e.g. unreviewed generated text): never in Search, like members/private pages.
+ deleted: eff !== 'public' || !decision || !decision.listable,
canonicalUrl: pageUrl(space, page), title: rev ? rev.fields.title : page.title,
canonicalUrl: pageUrl(space, page), title: rev ? rev.fields.title : page.title,
summary: rev ? (rev.fields.summary || ssr.markdownToText(rev.content, 300)) : null,
summary: rev ? (rev.fields.summary || ssr.markdownToText(rev.content, 300)) : null,
body: rev ? ssr.markdownToText(rev.content) : '',
body: rev ? ssr.markdownToText(rev.content) : '',
@@ -248,7 +278,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -248,7 +278,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
if (doc.revision !== sentBefore) emit(hooks.indexEvent({ document: doc, now: now() }));
if (doc.revision !== sentBefore) 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 action = hooks.actionFor(before && before.state ? before : null, after);
+ let action = hooks.actionFor(before && before.state ? before : null, after);
+ // A review changes only the gate decision: announce it when what Search holds changed.
+ if (!action && updatedIfIndexChanged && state === 'published' && doc.revision !== sentBefore) action = 'updated';
if (action) {
if (action) {
let evDoc = doc;
let evDoc = doc;
if (doc.deleted && (action === 'published' || action === 'updated')) {
if (doc.deleted && (action === 'published' || action === 'updated')) {
@@ -459,6 +491,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -459,6 +491,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
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);
+ // authorship.record() keeps label and original author only; the AI-assistance flag is ours.
+ if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;
const cleanTitle = checkTitle(title);
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
@@ -692,7 +726,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -692,7 +726,8 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.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 ? authorship.disclosure(rec, review) : null,
+ 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,
proposal: q.proposalByRev.get(page.id, n) || null,
infobox: infoboxOf(page.id, n),
infobox: infoboxOf(page.id, n),
citations: citations.forRevision(page.id, n),
citations: citations.forRevision(page.id, n),
@@ -739,6 +774,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -739,6 +774,9 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
const space = q.spaceById.get(page.space_id);
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) => ({
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,
...r, proposal: props.get(r.number) || null, published: page.state === 'published' && r.number === page.published_revision,
+ review: reviews.latest(page.id, r.number),
+ 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,
citationCount: citations.forRevision(page.id, r.number).length,
}));
}));
},
},
@@ -854,6 +892,54 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -854,6 +892,54 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
return { watching: on };
return { watching: on };
},
},
+ /**
+ * Re-send Search whatever differs from what it was last sent, for every page (idempotent: the
+ * sequencer sends nothing for an unchanged document). Run at boot, so a rule change — e.g.
+ * AI-assisted imports becoming noindex until reviewed — reaches Search for rows written earlier.
+ */
+ reconcileIndex() {
+ return tx(() => {
+ 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));
+ if (b.indexRevision == null) continue; // never sent: nothing in Search to correct
+ const out = sync(b, id, { kind: 'system', service: 'svc:wiki' });
+ if (out.indexRevision !== b.indexRevision) sent++;
+ }
+ return { sent };
+ });
+ },
+
+ /**
+ * A person reviews an existing revision ("reviewed, correct" = approved / "needs changes" =
+ * rejected). Recorded in the append-only review log; when it is the published revision the
+ * 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));
+ 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 (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 }));
+ const out = page.state === 'published' && page.published_revision === n
+ ? 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;
+ return { review, page: fresh, action: out.action, indexable: decisionNow ? decisionNow.indexable : null, reasons: decisionNow ? decisionNow.codes : null };
+ });
+ },
+
// AI proposals (a seam for OpenVibe.AI: Wiki never calls a model) -------------------
// AI proposals (a seam for OpenVibe.AI: Wiki never calls a model) -------------------
/**
/**
* An AI workflow proposes a revision. It becomes an immutable revision with ai authorship
* An AI workflow proposes a revision. It becomes an immutable revision with ai authorship
@@ -929,4 +1015,4 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
@@ -929,4 +1015,4 @@ function createWikiService({ db, stores, outbox, config, now = () => Date.now(),
return svc;
return svc;
}
}
-module.exports = { createWikiService, WikiError, GATE_VIS };
+module.exports = { createWikiService, WikiError, GATE_VIS, aiAssistedImport };
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+/**
+ * A person reviews an existing AI-assisted revision (the seed's imported pages, including rows
+ * seeded before the explicit aiAssisted flag existed): only people with the owner or editor role
+ * may; approval flips the page to indexable, re-sends the Search document once (with
+ * wiki.page.updated), and puts it in sitemaps and feeds; "needs changes" takes it out again.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const contracts = require('openvibe-contracts');
+const H = require('./helpers');
+const { seed, SYSTEM } = require('../server/wiki/seed');
+
+const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));
+
+(async () => {
+ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
+ const 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');
+ // A row seeded before the flag existed: recognised by its label alone.
+ const legacy = 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']);
+
+ 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');
+ 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 };
+
+ // 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, { token: tok(stranger), body: { decision: 'approved' } })).status, 403);
+ assert.strictEqual((await H.req(h, 'POST', url, { token: tok(viewer), body: { decision: 'approved' } })).status, 403);
+ assert.strictEqual((await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.revision.publish'] }), body: { decision: 'approved' } })).status, 403);
+ let r = await H.req(h, 'POST', url, { token: H.serviceToken({ cap: ['wiki.page.read'] }), headers: { 'X-OV-Subject': editor }, body: { decision: 'approved' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json.code, 'capability.denied');
+ 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);
+ // Nothing changed.
+ assert.strictEqual(h.stores.reviews.history(page.id).length, 0);
+ assert.deepStrictEqual({ index: indexEvents().length, updates: 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.
+ let hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });
+ assert.ok(hist.text.includes('Reviewed — correct') && hist.text.includes('Needs changes'));
+ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history')).text.includes('Reviewed — correct'));
+ assert.ok(!(await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(viewer)) })).text.includes('Reviewed — correct'));
+ // A viewer posting the form is refused.
+ r = await H.req(h, 'POST', '/w/openvibe/openvibe-search/review', { cookie: H.cookieFor(tok(viewer)), form: { revision: String(n), decision: 'approved' } });
+ assert.strictEqual(r.status, 403);
+
+ // An editor approves through the form (a service acting for the editor would work the same way).
+ 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.headers.get('location'), '/w/openvibe/openvibe-search/history');
+ const reviews = 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);
+ 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(contracts.validate('search.index-document@1', idx[0].payload).valid);
+ const upd = updates().slice(before.updates);
+ assert.strictEqual(upd.length, 1);
+ assert.strictEqual(upd[0].visibility, 'public');
+ assert.strictEqual(upd[0].payload.indexability.decision, 'index');
+ assert.ok(contracts.validate('events.event-envelope@1', upd[0]).valid);
+ r = await H.req(h, 'GET', '/w/openvibe/openvibe-search');
+ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
+ assert.ok(r.text.includes('Reviewed by a person.'));
+ assert.ok(!r.text.includes('Not yet reviewed by a person.'));
+ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));
+ assert.ok((await H.req(h, 'GET', '/feed.atom')).text.includes('http://wiki.test/w/openvibe/openvibe-search'));
+ assert.ok((await H.req(h, 'GET', '/feed.json')).json.items.some((i) => i.url === 'http://wiki.test/w/openvibe/openvibe-search'));
+ // The form disappears for a reviewed revision; other pages still wait.
+ hist = await H.req(h, 'GET', '/w/openvibe/openvibe-search/history', { cookie: H.cookieFor(tok(editor)) });
+ assert.ok(!hist.text.includes('Reviewed — correct'));
+ assert.ok(hist.text.includes('reviewed by a person'));
+ 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).
+ const count = 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);
+
+ // "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.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);
+
+ // 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');
+ console.log('review ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
+
+// Rows seeded under the old rule (indexed before AI-assisted imports needed review) are corrected at boot.
+(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');
+ // 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();
+ 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');
+ await h3.stop();
+ console.log('reconcile ok');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,8 +1,10 @@
@@ -1,8 +1,10 @@
'use strict';
'use strict';
/**
/**
* The official "OpenVibe" seed: idempotent, every page published, cited (GitHub permalinks at a
* The official "OpenVibe" seed: idempotent, every page published, cited (GitHub permalinks at a
- * pinned commit, with a retrieval time), indexable under the production gate policy, internally
- * linked without red links, labelled with its real authorship, and free of pricing copy.
+ * pinned commit, with a retrieval time), internally linked without red links, labelled with its real
+ * authorship (imported, written with AI assistance), free of pricing copy, and — being generated
+ * text — noindex and out of sitemaps, feeds and Search until a person reviews it; the only gate
+ * reason left is that review (the production word and source policy passes).
*/
*/
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -43,27 +45,40 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -43,27 +45,40 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
const published = h.svc.publishedPublic();
const published = 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.
for (const { page, decision } of published) {
for (const { page, decision } of published) {
- assert.ok(decision.indexable, `${page.title}: indexable (${decision.codes.join(', ')})`);
+ assert.strictEqual(page.state, 'published');
+ assert.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);
}
}
- // The pages as a reader sees them: no red links, imported authorship disclosed, sitemap lists them all.
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)}`);
assert.strictEqual(r.status, 200, p.title);
assert.strictEqual(r.status, 200, p.title);
assert.ok(!r.text.includes('ov-redlink'), `${p.title}: no red links`);
assert.ok(!r.text.includes('ov-redlink'), `${p.title}: no red links`);
assert.ok(r.text.includes('summarised with AI assistance'), `${p.title}: authorship disclosed`);
assert.ok(r.text.includes('summarised with AI assistance'), `${p.title}: authorship disclosed`);
- assert.ok(r.text.includes('<meta name="robots" content="index, follow">'));
+ assert.ok(r.text.includes('Not yet reviewed by a person.'), `${p.title}: review state disclosed`);
+ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'), `${p.title}: noindex`);
}
}
- const sm = await H.req(h, 'GET', '/sitemaps/pages-1.xml');
- assert.strictEqual((sm.text.match(/<loc>/g) || []).length, data.pages.length);
+ // Absent from sitemaps, feeds and Search until reviewed.
+ assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);
+ assert.strictEqual((await H.req(h, 'GET', '/feed.atom')).status, 404);
+ 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.'));
+ 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.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);
assert.ok(h.svc.createPage(space.id, { title: 'Staff note', body: 'x' }, staff).page);
- // Search index documents record the imported authorship.
- const doc = H.outbox(h).find((e) => e.event_type === 'wiki.index_document.upserted').payload;
+ // 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;
+ assert.strictEqual(doc.id, first1.id);
assert.strictEqual(doc.authorship, 'imported');
assert.strictEqual(doc.authorship, 'imported');
+ assert.strictEqual(doc.indexability.decision, 'index');
console.log('seed ok');
console.log('seed ok');
} finally {
} finally {
await h.stop();
await h.stop();