openvibers/openvibe.blog Commits
Blog: the AI editor's review is a second kind, labelled "Checked by OpenVibe's AI editor" (#213)
Changes
nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "id": "blog.review.ai_editor",++ "version": "1.0",++ "owner": "blog",++ "status": "active",++ "visibility": "first-party",++ "description": "Record the editorial queue's AI editor decision (approved or rejected) on a post revision, as the first-party service principal acting as itself (a svc: principal with no X-OV-Subject). This is never a person's review: an approval lets an AI draft publish through the existing publish action and the post says \"Checked by OpenVibe's AI editor\" with its date, never \"Reviewed by a person\". Granted only to the orchestrator's service principal.",++ "permissions": [++ "blog:write"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "blog-write",++ "events": [++ "blog.post.updated",++ "blog.index_document.upserted",++ "blog.index_document.deleted"++ ],++ "implementedBy": [++ "POST /api/v1/posts/:id/reviews"++ ]++}@@@ -25,7 +25,8 @@"blog.post.schedule","blog.post.unpublish","blog.post.delete",-- "blog.feed.read"++ "blog.feed.read",++ "blog.review.ai_editor"],"eventsProduced": ["blog.post.created",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- Two reviewer kinds in the append-only review log (server/domain/review-kind.js; the AI editor++-- review of the editorial queue, plan 2026-10-22 §"The editorial queue"):++--++-- person a signed-in person's approval or rejection (the default; every row written before++-- this migration, and every row openvibe-publishing/authorship reviews.record() writes);++-- ai_editor the orchestrator's service principal, which holds blog.review.ai_editor.++--++-- Expand only: existing rows keep their meaning through the default, and code that does not know++-- the column (the shared log's INSERT, older processes during a rolling deploy) keeps working.++ALTER TABLE blog_post_reviews++ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'++ CHECK (reviewer_kind IN ('person', 'ai_editor'));@@@ -24,12 +24,23 @@ const CAPABILITIES = Object.freeze({POST_SCHEDULE: 'blog.post.schedule',POST_UNPUBLISH: 'blog.post.unpublish',POST_DELETE: 'blog.post.delete',++ REVIEW_AI_EDITOR: 'blog.review.ai_editor',FEED_READ: 'blog.feed.read',});++// The ids above as a local fallback while openvibe-contracts does not define one yet (the same++// rule the library uses: the exact id, or a `prefix.*` grant covering it). An id the library knows++// always goes through the library, so the day a release lands nothing changes here.++const PROPOSED = new Set(Object.values(CAPABILITIES));++/** → { allowed, code, reason } like capabilities.check(). */function checkCapability(claims, capabilityId) {++ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {++ return capabilities.grants(claims && claims.cap, capabilityId)++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };++ }return capabilities.check(claims, capabilityId);}--module.exports = { CAPABILITIES, checkCapability };++module.exports = { CAPABILITIES, PROPOSED, checkCapability };@@@ -35,6 +35,7 @@ const { createScheduler } = require('openvibe-publishing/schedule');const { createAttachmentStore } = require('openvibe-publishing/media');const { createDiscussionRefs } = require('openvibe-publishing/discussion');const { createReviewLog } = require('openvibe-publishing/authorship');++const { createReviewKindLog } = require('./domain/review-kind');const { createRedirectStore } = require('openvibe-publishing/seo');const { createIndexSequencer } = require('openvibe-publishing/index-hooks');@@@ -78,7 +79,8 @@ function createStore(db, { now = () => Date.now(), leaseMs = 60000 } = {}) {scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),-- reviews: createReviewLog(db, { prefix: 'blog_post', now }),++ // The shared log records a person's review; the product adds the ai_editor kind and keeps the kinds of both.++ reviews: createReviewKindLog(createReviewLog(db, { prefix: 'blog_post', now }), { db, prefix: 'blog_post', now }),redirects: createRedirectStore(db, { prefix: 'blog', now }),sequencer: createIndexSequencer(db, { prefix: 'blog', now }),tx: async (fn) => await db.tx(() => fn()),@@@ -22,7 +22,9 @@ const seo = require('openvibe-publishing/seo');const authorship = require('openvibe-publishing/authorship');const { isMediaId } = require('openvibe-publishing/media');const { ApiError } = require('../http/errors');--const { actorRef } = require('./publication');++const { actorRef, canPublish } = require('./publication');++const reviewKind = require('./review-kind');++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;@@@ -35,6 +37,15 @@ const newPostId = (now) => `pst_${ids.ulid(now)}`;const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';++/**++ * The AI editor records a review as itself: a first-party service principal (svc:…) with no++ * person named, holding blog.review.ai_editor. A service acting for a person is that person.++ */++function isAiEditor(viewer) {++ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;++ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;++}++function listOf(v) {if (v == null || v === '') return [];const arr = Array.isArray(v) ? v : String(v).split(',');@@@ -352,8 +363,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));-- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);++ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);if (publishGate) {const gate = await publishGate(post, rev);if (gate && gate.blocking && gate.blocking.length) {@@@ -429,8 +440,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;if (action === 'publish') {if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));-- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);++ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));++ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);}return await store.tx(async () => {const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });@@@ -516,7 +527,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,/*** Posts whose head revision is AI-generated and nobody has approved it yet: what a person* still has to review before the post can be published or indexed. Only posts this viewer-- * may review, oldest first. The gate's own reasons and words come with each item.++ * may review, oldest first. The gate's own reasons and words come with each item, and an++ * ai_editor decision is named in `reviewed` (an approval publishes and leaves the queue; a++ * rejection stays until a person decides).*/async pendingReviews(viewer) {const rows = await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id@@@ -529,8 +542,10 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,const head = await store.revisions.head(post.id);const rec = publication.authorshipOf(head);if (!head || !rec || !authorship.needsReview(rec)) continue;-- const review = await store.reviews.latest(post.id, head.number);-- if (review) continue; // a decision, approved or rejected, has been made for this revision++ const kinds = await store.reviews.latestByKind(post.id, head.number);++ if (kinds.person) continue; // a person's decision, approved or rejected, has been made for this revision++ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') continue; // the AI editor approved it: published and indexable++ const ai = kinds.ai_editor;const decision = await publication.decide(blog, post, head);items.push({site: 'blog',@@@ -543,6 +558,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,editUrl: `/write/posts/${post.id}`,createdAt: new Date(head.createdAt).toISOString(),reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),++ reviewed: ai ? { kind: reviewKind.AI_EDITOR, decision: ai.decision, by: ai.reviewer, at: ai.reviewedAt, label: reviewKind.aiEditorVerdict(ai) } : null,review: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/reviews` },...(post.state === 'draft' ? { publish: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/publish` } } : {}),});@@@ -550,16 +566,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));},-- /** A person's review of one revision (what lets an AI draft be published and indexed). */++ /**++ * A review of one revision: a signed-in person's (what lets an AI draft be published and++ * indexed), or the AI editor's — a svc:… service principal acting as itself with++ * blog.review.ai_editor — recorded with kind `ai_editor`, never as a person's review.++ */async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {-- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');++ const aiEditor = isAiEditor(viewer);++ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw new ApiError(403, 'review.person_required', 'Only a signed-in person (or the AI editor) can review a revision');const blog = await blogOf(post);-- if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');++ if (!aiEditor && !await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');const n = parseInt(revision, 10);if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);return await store.tx(async () => {const before = publication.snapshot(blog, post);-- const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });++ const row = aiEditor++ ? await store.reviews.recordAiEditor({ entityId: post.id, revision: n, principal: viewer.service, decision, note })++ : await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });await publication.afterChange(before, post.id, { actor: viewer, ...ctx });return row;});@@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');const hooks = require('openvibe-publishing/index-hooks');const authorship = require('openvibe-publishing/authorship');++const reviewKind = require('./review-kind');// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is// served and listed in feeds but not offered to search engines (reason "thin").@@@ -25,6 +26,44 @@ const OWNER = 'blog';/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };++/** The gate's authorship facts: an approving review of either kind (a person's or the AI editor's). */++function gateFacts(rec, review) {++ const out = { authorship: { mode: rec.mode, reviewed: reviewKind.approved(review) } };++ if (rec.stubProvider) out.stubProvider = true;++ return out;++}++++/** May this revision be published? AI-generated text needs an approval from a person or the AI editor. */++function canPublish(rec, review) {++ if (authorship.needsReview(rec) && !reviewKind.approved(review)) {++ return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };++ }++ return { ok: true, reason: null };++}++++/** Does either kind of review approve this revision? */++function approvedReview(kinds = { person: null, ai_editor: null }) {++ return reviewKind.approved(kinds.person) || reviewKind.approved(kinds.ai_editor);++}++++/**++ * The disclosure at the item, with the review of each kind (a person's, the AI editor's, both):++ * "Checked by OpenVibe's AI editor on 2026-11-06" is never "Reviewed by a person". With no review++ * it is the library's own text ("not yet reviewed by a person"), unchanged.++ */++function disclosure(rec, kinds = { person: null, ai_editor: null }) {++ const d = authorship.disclosure(rec, null);++ if (!d) return null;++ const person = reviewKind.isPersonReview(kinds.person);++ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);++ if (!person && !ai) return { ...d, reviewed: false };++ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');++ const tail = person && ai ? `, reviewed by a person. ${ai}.`++ : person ? ', reviewed by a person.'++ : `. ${ai}.`;++ return { ...d, long: `${base}${tail}`, reviewed: true };++}++function createPublication({ store, config, outbox, indexnow }) {const { db } = store;const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');@@@ -44,6 +83,11 @@ function createPublication({ store, config, outbox, indexnow }) {return rev ? await store.reviews.latest(post.id, rev.number) : null;}++ /** The review of each kind for one revision: { person, ai_editor }. */++ async function reviewsOf(post, rev) {++ return rev ? await store.reviews.latestByKind(post.id, rev.number) : { person: null, ai_editor: null };++ }++/*** The gate's decision for a post at one revision (the published one by default). `review` lets a* caller that already fetched reviews in one batch (sitemaps, llms-full) skip the per-post lookup;@@@ -58,7 +102,8 @@ function createPublication({ store, config, outbox, indexnow }) {text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',noindex: Boolean(post.noindex),};-- if (rec) Object.assign(facts, authorship.gateFacts(rec, review !== undefined ? review : await reviewOf(post, rev)));++ const rv = review !== undefined ? review : await reviewOf(post, rev);++ if (rec) Object.assign(facts, gateFacts(rec, rv));return seo.evaluate(facts, { policy: POLICY, now: store.now() });}@@@ -168,7 +213,8 @@ function createPublication({ store, config, outbox, indexnow }) {return {POLICY, OWNER, GATE_VISIBILITY,blogPath, postPath, postUrl, abs, feedId,-- decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, termsOf,++ gateFacts, canPublish, approvedReview, disclosure,++ decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, reviewsOf, termsOf,};}@@@ -185,4 +231,4 @@ function actorRef(actor) {return { type: 'service', id: 'blog' };}--module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY };++module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY, gateFacts, canPublish, approvedReview, disclosure };@@@ -7,7 +7,7 @@*/const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');--const authorship = require('openvibe-publishing/authorship');++const reviewKind = require('./review-kind');const { renderBody } = require('../render/pages');function createReading({ store, blogs, posts: postsApi, publication, people, media, vip = null }) {@@@ -105,7 +105,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med/** The machine-readable representation of a post (same content as the page). */async function postJson({ blog, post, rev, person, decision }) {const rec = publication.authorshipOf(rev);-- const review = await publication.reviewOf(post, rev);++ const kinds = await publication.reviewsOf(post, rev);const { tags, categories } = await termsView(post, blog);const series = await blogs.seriesById(post.series_id);return {@@@ -121,7 +121,16 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medpublished_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,revised_at: rev.createdAt,author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,-- authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, reviewed: authorship.isReviewed(rec, review), disclosure: authorship.disclosure(rec, review) } : null,++ authorship: rec ? {++ mode: rec.mode, workflow: rec.workflow || null,++ reviewed: publication.approvedReview(kinds),++ labels: reviewKind.labelsFor(kinds),++ disclosure: publication.disclosure(rec, kinds),++ reviews: {++ person: kinds.person ? { decision: kinds.person.decision, at: kinds.person.reviewedAt } : null,++ ai_editor: kinds.ai_editor ? { decision: kinds.ai_editor.decision, at: kinds.ai_editor.reviewedAt } : null,++ },++ } : null,tags: tags.map((t) => t.name),categories: categories.map((c) => c.name),series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * Two reviewer kinds in one append-only log (the `blog_post_reviews` table++ * openvibe-publishing/authorship creates):++ *++ * person a signed-in person's approval or rejection (a usr_… subject). The library's++ * reviews.record() writes these, unchanged.++ * ai_editor the orchestrator's service principal — a svc:… principal whose token holds++ * blog.review.ai_editor — recorded with its principal, the time and a note.++ * migrations/0008_review_kinds.sql adds the reviewer_kind column (default 'person').++ *++ * An ai_editor approval publishes and indexes AI-written text exactly as a person's approval++ * does, but it never reads as a person's review: the label is "Checked by OpenVibe's AI editor".++ * Authorship stays the library's: this module only adds the kind to the log's rows and the words++ * that follow from it.++ */++const { ids } = require('openvibe-contracts');++++const AI_EDITOR = 'ai_editor';++const PERSON = 'person';++const AI_EDITOR_LABEL = "Checked by OpenVibe's AI editor";++const PRINCIPAL_RE = /^svc:[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;++++const isPersonSubject = (v) => ids.isSubjectId('user', v);++++/** 'person' for anything the shared log wrote before the kind column existed. */++function reviewerKind(review) {++ return review && review.reviewerKind === AI_EDITOR ? AI_EDITOR : PERSON;++}++++function isPersonReview(review) {++ return Boolean(review && reviewerKind(review) === PERSON && review.decision === 'approved' && isPersonSubject(review.reviewer));++}++++function isAiEditorReview(review) {++ return Boolean(review && reviewerKind(review) === AI_EDITOR && review.decision === 'approved' && PRINCIPAL_RE.test(String(review.reviewer)));++}++++/** An approving review of either kind: what lets AI-written text be published and indexed. */++function approved(review) {++ return isPersonReview(review) || isAiEditorReview(review);++}++++/** The ISO date a review was recorded (YYYY-MM-DD), or null. */++function reviewDate(review) {++ if (!review || !review.reviewedAt) return null;++ const d = new Date(review.reviewedAt);++ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);++}++++/** "Checked by OpenVibe's AI editor on 2026-11-06", or null when the AI editor did not approve. */++function aiEditorLabel(review) {++ if (!isAiEditorReview(review)) return null;++ const d = reviewDate(review);++ return d ? `${AI_EDITOR_LABEL} on ${d}` : AI_EDITOR_LABEL;++}++++/** What the AI editor decided, for the staff queue: the approval label or "Kept hidden by …". */++function aiEditorVerdict(review) {++ if (!review || reviewerKind(review) !== AI_EDITOR) return null;++ const d = reviewDate(review);++ const what = review.decision === 'approved' ? AI_EDITOR_LABEL : "Kept hidden by OpenVibe's AI editor";++ return d ? `${what} on ${d}` : what;++}++++/** The public labels for a revision, from the review of each kind: [] when nobody approved it. */++function labelsFor({ person = null, ai_editor = null } = {}) {++ const labels = [];++ if (isPersonReview(person)) labels.push('Reviewed by a person');++ const ai = aiEditorLabel(ai_editor);++ if (ai) labels.push(ai);++ return labels;++}++++function shape(row) {++ if (!row) return null;++ return {++ id: Number(row.id),++ entityId: row.entityId !== undefined ? row.entityId : row.entity_id,++ revision: Number(row.revision),++ reviewer: row.reviewer,++ reviewerKind: (row.reviewerKind || row.reviewer_kind) === AI_EDITOR ? AI_EDITOR : PERSON,++ decision: row.decision,++ note: row.note,++ reviewedAt: typeof row.reviewedAt === 'string' ? row.reviewedAt : new Date(Number(row.reviewed_at)).toISOString(),++ };++}++++function byKind(rows) {++ const out = { person: null, ai_editor: null };++ for (const row of rows) out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);++ return out;++}++++/** The newest decision of either kind (the log's latest row decides whether the text is reviewed). */++function latestOf({ person = null, ai_editor = null } = {}) {++ if (!person) return ai_editor || null;++ if (!ai_editor) return person;++ return Number(ai_editor.id) > Number(person.id) ? ai_editor : person;++}++++/**++ * The product's review store: the shared log plus kind-aware reads and the ai_editor write.++ * `prefix` names the product's own table (e.g. 'blog_post' → blog_post_reviews).++ */++function createReviewKindLog(log, { db, prefix, now = () => Date.now() } = {}) {++ const R = `${prefix}_reviews`;++ const latestStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`);++ const latestByKindStmt = db.prepare(`SELECT DISTINCT ON (reviewer_kind) * FROM ${R}++ WHERE entity_id = ? AND revision = ? ORDER BY reviewer_kind, id DESC`);++ const latestManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision) r.*++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision++ ORDER BY r.entity_id, r.revision, r.id DESC`);++ const latestByKindManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision, r.reviewer_kind) r.*++ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)++ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision++ ORDER BY r.entity_id, r.revision, r.reviewer_kind, r.id DESC`);++ const insertEditor = db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, reviewer_kind, decision, note, reviewed_at)++ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`);++ const historyStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id LIMIT ?`);++++ const number = (v) => {++ const n = Number(v);++ return Number.isInteger(n) && n >= 0 && n <= 2147483647 ? n : null;++ };++++ async function latest(entityId, revision) {++ const n = number(revision);++ if (n == null) return null;++ return shape(await latestStmt.get(entityId, n));++ }++++ async function latestByKind(entityId, revision) {++ const n = number(revision);++ if (n == null) return { person: null, ai_editor: null };++ return byKind(await latestByKindStmt.all(entityId, n));++ }++++ async function latestMany(refs = []) {++ const keys = refs.filter((r) => r && number(r.revision) != null);++ if (!keys.length) return refs.map(() => null);++ const rows = await latestManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));++ const by = new Map(rows.map((r) => [`${r.entity_id}\u0000${r.revision}`, shape(r)]));++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || null);++ }++++ async function latestByKindMany(refs = []) {++ const keys = refs.filter((r) => r && number(r.revision) != null);++ if (!keys.length) return refs.map(() => ({ person: null, ai_editor: null }));++ const rows = await latestByKindManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));++ const by = new Map();++ for (const row of rows) {++ const key = `${row.entity_id}\u0000${row.revision}`;++ const out = by.get(key) || { person: null, ai_editor: null };++ out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);++ by.set(key, out);++ }++ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || { person: null, ai_editor: null });++ }++++ /** An ai_editor review: the service principal that made it, never a person's subject. */++ async function recordAiEditor({ entityId, revision, principal, decision, note = null } = {}) {++ const n = number(revision);++ if (n == null) throw new TypeError('revision must be a non-negative integer');++ if (!PRINCIPAL_RE.test(String(principal))) throw new TypeError('principal must be a svc:… principal');++ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');++ return shape(await insertEditor.get(String(entityId), n, String(principal), AI_EDITOR, decision, note == null ? null : String(note).slice(0, 2000), now()));++ }++++ /** The shared log's person write, with the kind its row has by default. */++ async function record(args) {++ return shape(await log.record(args));++ }++++ async function history(entityId, { limit = 1000 } = {}) {++ const n = Math.max(1, Math.min(10000, Math.floor(Number(limit)) || 1000));++ return (await historyStmt.all(entityId, n)).map(shape);++ }++++ return {++ ...log,++ record,++ history,++ latest,++ latestMany,++ latestByKind,++ latestByKindMany,++ recordAiEditor,++ };++}++++module.exports = {++ AI_EDITOR, PERSON, AI_EDITOR_LABEL, PRINCIPAL_RE,++ reviewerKind, isPersonReview, isAiEditorReview, approved, reviewDate, aiEditorLabel, aiEditorVerdict, labelsFor, latestOf,++ createReviewKindLog,++};@@@ -35,7 +35,7 @@ const seo = require('openvibe-publishing/seo');const cache = require('openvibe-shared/cache-policy');const { run, jsonBody, ApiError, privateNoStore } = require('./errors');const { guard } = require('../auth/viewer');--const { checkCapability } = require('../auth/capabilities');++const { checkCapability, CAPABILITIES } = require('../auth/capabilities');function cors(origins) {const allowed = new Set(origins);@@@ -290,7 +290,16 @@ function createApi(ctx) {const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };}, 201);-- router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {++ // A review by the AI editor (a svc:… principal acting as itself) needs blog.review.ai_editor.++ // People, and services acting for a person, are judged by posts.review as before.++ const reviewGuard = (req, res, next) => {++ const v = req.viewer;++ if (!v || v.kind !== 'service' || v.subject) return next();++ const c = checkCapability(v.claims, CAPABILITIES.REVIEW_AI_EDITOR);++ if (c.allowed) return next();++ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });++ };++ router.post('/posts/:id/reviews', reviewGuard, B('blog.post.publish'), jsonBody, run(async (req) => {const { post } = await mustPost(req);const b = req.body || {};const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));@@@ -209,14 +209,14 @@ function createEditorRoutes(ctx) {const head = await posts.head(post);const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;const rec = publication.authorshipOf(head);-- const review = await publication.reviewOf(post, head);++ const kinds = await publication.reviewsOf(post, head);const terms = await publication.termsOf(post);const decision = await publication.decide(blog, { ...post, state: 'published' }, head);const patchNotes = ctx.patchNotes ? await ctx.patchNotes.load(post.id) : null;const body = editor.editPage({blog, post, head, published, decision, patchNotes, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),-- disclosure: rec ? authorship.disclosure(rec, review) : null,-- needsReview: rec ? !authorship.canPublish(rec, review).ok : false,++ disclosure: rec ? publication.disclosure(rec, kinds) : null,++ needsReview: rec ? authorship.needsReview(rec) && !publication.approvedReview(kinds) : false,publicUrl: publication.postPath(blog, post),canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),@@@ -205,12 +205,12 @@ route('post', '/posts/{id}/revert', {});route('get', '/review-queue', {operationId: 'getReviewQueue', summary: 'The reviewer queue: posts whose AI-written head waits for a person', tags: ['revisions'], security: PERSON,-- description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post.',++ description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post. An item the AI editor rejected carries `reviewed` (kind, decision, principal, time, label); the AI editor\'s approvals publish the post and leave the queue.',responses: { 200: json('The waiting posts', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },});route('post', '/posts/{id}/reviews', {operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],-- description: 'People only. An approved revision may then publish.',++ description: 'A person who may publish the post (blog.post.publish), or the AI editor — a svc:… service principal acting as itself with blog.review.ai_editor — whose review is recorded with kind `ai_editor` and labelled "Checked by OpenVibe\'s AI editor", never "Reviewed by a person". An approved revision may then publish.',requestBody: body({ type: 'object', required: ['revision', 'decision'], properties: {revision: { type: 'integer', minimum: 1 }, decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string'),} }),@@@ -22,7 +22,6 @@ const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const seo = require('openvibe-publishing/seo');const ssr = require('openvibe-publishing/ssr');--const authorship = require('openvibe-publishing/authorship');const cache = require('openvibe-shared/cache-policy');const { renderPage, SITE_SUMMARY } = require('../render/layout');const pages = require('../render/pages');@@@ -347,7 +346,7 @@ function createPublicRoutes(ctx) {const attachments = await store.attachments.list(post.id);const citations = await store.citations.forRevision(post.id, rev.number);const rec = publication.authorshipOf(rev);-- const review = await publication.reviewOf(post, rev);++ const kinds = await publication.reviewsOf(post, rev);const path = publication.postPath(blog, post);const firstCategory = categories[0];const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];@@@ -373,7 +372,7 @@ function createPublicRoutes(ctx) {blog, blogUrl: publication.blogPath(blog), post, rev,author: { name: person.name, url: reading.urls.author(person, blog) },tags, categories, series: await seriesView(blog, post), attachments, citations,-- disclosure: rec ? authorship.disclosure(rec, review) : null,++ disclosure: rec ? publication.disclosure(rec, kinds) : null,decisionNote: preview ? `preview · ${decision.robots}` : null,comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,@@@ -1,14 +1,16 @@'use strict';/*** The blog.* capabilities are released by openvibe-contracts, so the service guards delegate to the-- * library's grant rule rather than deciding a proposed id locally (the fallback that existed while-- * the ids were only proposals). These pin that: every guarded id is defined by the installed-- * contracts and by the blog service manifest, and checkCapability agrees with capabilities.check for-- * an exact grant, a prefix grant, no grant, and an id the contracts do not define.++ * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and++ * waits for a Contracts release — blog.review.ai_editor (the AI editor's review kind) — so it is++ * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These++ * pin that: every guarded id is defined by the installed contracts and the blog service manifest,++ * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check++ * for an exact grant, a prefix grant, no grant, and an id nobody defines.*/const assert = require('assert');const { capabilities, services } = require('openvibe-contracts');--const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');++const { checkCapability, CAPABILITIES, PROPOSED } = require('../server/auth/capabilities');const { check, done } = require('./helpers/boot');const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };@@@ -17,23 +19,32 @@ const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };await check('every guarded capability is defined by the installed contracts and the blog manifest', async () => {const manifest = services.get('blog');assert.ok(manifest, 'openvibe-contracts defines the blog service manifest');-- assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());++ const released = Object.values(CAPABILITIES).filter((id) => capabilities.get(id));++ assert.deepStrictEqual([...manifest.capabilities].sort(), released.sort());for (const id of Object.values(CAPABILITIES)) {const cap = capabilities.get(id);-- assert.ok(cap, `${id} is defined by openvibe-contracts`);++ if (!cap) {++ assert.ok(PROPOSED.has(id), `${id} is not released yet, so it must be a local proposal`);++ continue;++ }assert.strictEqual(cap.owner, 'blog', `${id} is owned by blog`);}++ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.++ assert.strictEqual(capabilities.get('blog.review.ai_editor'), undefined);});await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', async () => {// An exact grant and a `prefix.*` grant the library's matching rule accepts.assert.deepStrictEqual(checkCapability(GRANTED, 'blog.post.read'), { allowed: true, code: null, reason: null });assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.post.read'), { allowed: true, code: null, reason: null });++ // The proposal is decided with the same rule until Contracts defines it.++ assert.deepStrictEqual(checkCapability({ cap: ['blog.review.ai_editor'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });++ assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });++ assert.strictEqual(checkCapability({ cap: ['blog.post.publish'] }, 'blog.review.ai_editor').code, 'capability.denied');// A grant of a sibling capability, or no cap at all, is denied with the library's code.assert.strictEqual(checkCapability({ cap: ['blog.post.create'] }, 'blog.post.read').code, 'capability.denied');assert.strictEqual(checkCapability(null, 'blog.post.read').code, 'capability.denied');-- // An id the contracts do not define still answers capability.unknown — the guard delegates,-- // it never decides an unknown id on its own.++ // An id nobody defines still answers capability.unknown — the guard only falls back for a proposal.assert.strictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.not.a.capability').code, 'capability.unknown');});nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,++ * recorded on the review by the orchestrator's service principal. Its approval lets an AI draft be++ * published and indexed exactly as a person's does, but the post says "Checked by OpenVibe's AI++ * editor" (with the date) — never "Reviewed by a person". A person's review path and label do not++ * change, and a page can show both.++ *++ * Only a service token whose principal holds blog.review.ai_editor may record one; a token without++ * it is refused and writes nothing.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };++const AI_LABEL = "Checked by OpenVibe's AI editor";++// The page escapes the apostrophe; the rendered text still reads "Checked by OpenVibe's AI editor".++const AI_ON_PAGE = /Checked by OpenVibe(?:'|')s AI editor/;++++(async () => {++ const t = await boot();++ const lead = t.official;++ const admin = t.network.addUser('admin', { role: 'admin' });++ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read', 'blog.post.publish']);++ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.review.ai_editor'] });++ const noCap = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.post.publish'] });++ const makeDraft = async (title) => {++ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': lead.subject }, json: { title, body: LONG, authorship: { workflow: WORKFLOW } } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().post;++ };++ try {++ const post = await makeDraft('An AI draft');++++ await check('a token without blog.review.ai_editor is refused and writes nothing', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: noCap, json: { revision: 1, decision: 'approved' } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'capability.denied');++ assert.strictEqual((await t.ctx.store.reviews.history(post.id)).length, 0, 'nothing was recorded');++ });++++ await check('the AI editor approves: the draft publishes and reads "Checked by OpenVibe\'s AI editor"', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: editor, json: { revision: 1, decision: 'approved', note: 'Checked against the sources' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.json().review.reviewerKind, r.json().review.reviewer, r.json().review.decision], ['ai_editor', 'svc:orchestrator', 'approved']);++++ const pub = await t.get(`/api/v1/posts/${post.id}/publish`, { as: admin, json: { revision: 1 } });++ assert.strictEqual(pub.status, 200, pub.text);++ assert.strictEqual(pub.json().post.state, 'published');++++ const path = new URL(pub.json().post.url).pathname;++ const page = await t.get(path);++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'indexable');++ assert.ok(AI_ON_PAGE.test(page.text), 'the page says who checked it');++ assert.ok(!/reviewed by a person/i.test(page.text), 'never as a person\'s review');++ assert.ok(!page.text.includes('not yet reviewed by a person'));++++ const json = await t.get(`${path}.json`);++ assert.strictEqual(json.status, 200, json.text);++ assert.strictEqual(json.json().authorship.reviewed, true);++ assert.ok(json.json().authorship.labels.some((l) => l.startsWith(AI_LABEL)), JSON.stringify(json.json().authorship));++ assert.deepStrictEqual([json.json().authorship.reviews.ai_editor.decision, json.json().authorship.reviews.person], ['approved', null]);++ });++++ await check('a person\'s review still says "Reviewed by a person"', async () => {++ const other = await makeDraft('A person-checked draft');++ const r = await t.get(`/api/v1/posts/${other.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().review.reviewerKind, 'person');++ const pub = await t.get(`/api/v1/posts/${other.id}/publish`, { as: admin, json: { revision: 1 } });++ assert.strictEqual(pub.status, 200, pub.text);++ const page = await t.get(new URL(pub.json().post.url).pathname);++ assert.ok(/reviewed by a person/i.test(page.text), 'the person\'s label is unchanged');++ assert.ok(!AI_ON_PAGE.test(page.text));++ });++++ await check('both reviews on one post show both labels', async () => {++ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });++ assert.strictEqual(r.status, 201, r.text);++ const page = await t.get(new URL((await t.get(`/api/v1/posts/${post.id}`, { as: admin })).json().post.url).pathname);++ assert.ok(/reviewed by a person/i.test(page.text));++ assert.ok(AI_ON_PAGE.test(page.text));++ assert.ok(page.text.toLowerCase().indexOf('reviewed by a person') < page.text.indexOf('Checked by OpenVibe'), 'the person\'s sentence, then the AI editor\'s');++ });++++ await check('an AI editor rejection keeps the draft out of publish and names its kind in the queue', async () => {++ const third = await makeDraft('Kept hidden');++ const r = await t.get(`/api/v1/posts/${third.id}/reviews`, { as: editor, json: { revision: 1, decision: 'rejected', note: 'Not accurate' } });++ assert.strictEqual(r.status, 201, r.text);++ const pub = await t.get(`/api/v1/posts/${third.id}/publish`, { as: admin, json: { revision: 1 } });++ assert.notStrictEqual(pub.status, 200, 'a rejected revision cannot be published');++ const q = await t.get('/api/v1/review-queue', { as: admin });++ const item = q.json().items.find((i) => i.id === third.id);++ assert.ok(item, 'the AI editor\'s rejection leaves the draft in the person\'s queue');++ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);++ assert.ok(item.reviewed.label.startsWith('Kept hidden by OpenVibe\'s AI editor'), item.reviewed.label);++ });++ } finally {++ await t.close();++ done();++ }++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "id": "blog.review.ai_editor",
+ "version": "1.0",
+ "owner": "blog",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Record the editorial queue's AI editor decision (approved or rejected) on a post revision, as the first-party service principal acting as itself (a svc: principal with no X-OV-Subject). This is never a person's review: an approval lets an AI draft publish through the existing publish action and the post says \"Checked by OpenVibe's AI editor\" with its date, never \"Reviewed by a person\". Granted only to the orchestrator's service principal.",
+ "permissions": [
+ "blog:write"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "blog-write",
+ "events": [
+ "blog.post.updated",
+ "blog.index_document.upserted",
+ "blog.index_document.deleted"
+ ],
+ "implementedBy": [
+ "POST /api/v1/posts/:id/reviews"
+ ]
+}
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
"blog.post.schedule",
"blog.post.schedule",
"blog.post.unpublish",
"blog.post.unpublish",
"blog.post.delete",
"blog.post.delete",
- "blog.feed.read"
+ "blog.feed.read",
+ "blog.review.ai_editor"
],
],
"eventsProduced": [
"eventsProduced": [
"blog.post.created",
"blog.post.created",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- Two reviewer kinds in the append-only review log (server/domain/review-kind.js; the AI editor
+-- review of the editorial queue, plan 2026-10-22 §"The editorial queue"):
+--
+-- person a signed-in person's approval or rejection (the default; every row written before
+-- this migration, and every row openvibe-publishing/authorship reviews.record() writes);
+-- ai_editor the orchestrator's service principal, which holds blog.review.ai_editor.
+--
+-- Expand only: existing rows keep their meaning through the default, and code that does not know
+-- the column (the shared log's INSERT, older processes during a rolling deploy) keeps working.
+ALTER TABLE blog_post_reviews
+ ADD COLUMN IF NOT EXISTS reviewer_kind text NOT NULL DEFAULT 'person'
+ CHECK (reviewer_kind IN ('person', 'ai_editor'));
@@ -24,12 +24,23 @@ const CAPABILITIES = Object.freeze({
@@ -24,12 +24,23 @@ const CAPABILITIES = Object.freeze({
POST_SCHEDULE: 'blog.post.schedule',
POST_SCHEDULE: 'blog.post.schedule',
POST_UNPUBLISH: 'blog.post.unpublish',
POST_UNPUBLISH: 'blog.post.unpublish',
POST_DELETE: 'blog.post.delete',
POST_DELETE: 'blog.post.delete',
+ REVIEW_AI_EDITOR: 'blog.review.ai_editor',
FEED_READ: 'blog.feed.read',
FEED_READ: 'blog.feed.read',
});
});
+// The ids above as a local fallback while openvibe-contracts does not define one yet (the same
+// rule the library uses: the exact id, or a `prefix.*` grant covering it). An id the library knows
+// always goes through the library, so the day a release lands nothing changes here.
+const PROPOSED = new Set(Object.values(CAPABILITIES));
+
/** → { allowed, code, reason } like capabilities.check(). */
/** → { allowed, code, reason } like capabilities.check(). */
function checkCapability(claims, capabilityId) {
function checkCapability(claims, capabilityId) {
+ if (!capabilities.get(capabilityId) && PROPOSED.has(capabilityId)) {
+ return capabilities.grants(claims && claims.cap, capabilityId)
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${capabilityId} not granted` };
+ }
return capabilities.check(claims, capabilityId);
return capabilities.check(claims, capabilityId);
}
}
-module.exports = { CAPABILITIES, checkCapability };
+module.exports = { CAPABILITIES, PROPOSED, checkCapability };
@@ -35,6 +35,7 @@ const { createScheduler } = require('openvibe-publishing/schedule');
@@ -35,6 +35,7 @@ const { createScheduler } = require('openvibe-publishing/schedule');
const { createAttachmentStore } = require('openvibe-publishing/media');
const { createAttachmentStore } = require('openvibe-publishing/media');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createDiscussionRefs } = require('openvibe-publishing/discussion');
const { createReviewLog } = require('openvibe-publishing/authorship');
const { createReviewLog } = require('openvibe-publishing/authorship');
+const { createReviewKindLog } = require('./domain/review-kind');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createRedirectStore } = require('openvibe-publishing/seo');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
const { createIndexSequencer } = require('openvibe-publishing/index-hooks');
@@ -78,7 +79,8 @@ function createStore(db, { now = () => Date.now(), leaseMs = 60000 } = {}) {
@@ -78,7 +79,8 @@ function createStore(db, { now = () => Date.now(), leaseMs = 60000 } = {}) {
scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),
scheduler: createScheduler(db, { prefix: 'blog', now, leaseMs, maxAttempts: 3, backoffMs: [30000, 120000] }),
attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),
attachments: createAttachmentStore(db, { prefix: 'blog_post', now }),
discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),
discussion: createDiscussionRefs(db, { prefix: 'blog_post', now }),
- reviews: createReviewLog(db, { prefix: 'blog_post', now }),
+ // The shared log records a person's review; the product adds the ai_editor kind and keeps the kinds of both.
+ reviews: createReviewKindLog(createReviewLog(db, { prefix: 'blog_post', now }), { db, prefix: 'blog_post', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
redirects: createRedirectStore(db, { prefix: 'blog', now }),
sequencer: createIndexSequencer(db, { prefix: 'blog', now }),
sequencer: createIndexSequencer(db, { prefix: 'blog', now }),
tx: async (fn) => await db.tx(() => fn()),
tx: async (fn) => await db.tx(() => fn()),
@@ -22,7 +22,9 @@ const seo = require('openvibe-publishing/seo');
@@ -22,7 +22,9 @@ const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
const { isMediaId } = require('openvibe-publishing/media');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
-const { actorRef } = require('./publication');
+const { actorRef, canPublish } = require('./publication');
+const reviewKind = require('./review-kind');
+const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];
const VISIBILITIES = ['public', 'unlisted', 'members', 'private'];
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
@@ -35,6 +37,15 @@ const newPostId = (now) => `pst_${ids.ulid(now)}`;
@@ -35,6 +37,15 @@ const newPostId = (now) => `pst_${ids.ulid(now)}`;
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'on' || v === 'true';
+/**
+ * The AI editor records a review as itself: a first-party service principal (svc:…) with no
+ * person named, holding blog.review.ai_editor. A service acting for a person is that person.
+ */
+function isAiEditor(viewer) {
+ if (!viewer || viewer.kind !== 'service' || viewer.subject || !/^svc:/.test(String(viewer.service))) return false;
+ return checkCapability(viewer.claims, CAPABILITIES.REVIEW_AI_EDITOR).allowed;
+}
+
function listOf(v) {
function listOf(v) {
if (v == null || v === '') return [];
if (v == null || v === '') return [];
const arr = Array.isArray(v) ? v : String(v).split(',');
const arr = Array.isArray(v) ? v : String(v).split(',');
@@ -352,8 +363,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -352,8 +363,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
+ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
+ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's or the AI editor's approval before it can be published (${ok.reason})`);
if (publishGate) {
if (publishGate) {
const gate = await publishGate(post, rev);
const gate = await publishGate(post, rev);
if (gate && gate.blocking && gate.blocking.length) {
if (gate && gate.blocking && gate.blocking.length) {
@@ -429,8 +440,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -429,8 +440,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
if (action === 'publish') {
if (action === 'publish') {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
- if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
+ const ok = canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
+ if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's or the AI editor's approval before it can be scheduled (${ok.reason})`);
}
}
return await store.tx(async () => {
return await store.tx(async () => {
const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
@@ -516,7 +527,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -516,7 +527,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
/**
/**
* Posts whose head revision is AI-generated and nobody has approved it yet: what a person
* Posts whose head revision is AI-generated and nobody has approved it yet: what a person
* still has to review before the post can be published or indexed. Only posts this viewer
* still has to review before the post can be published or indexed. Only posts this viewer
- * may review, oldest first. The gate's own reasons and words come with each item.
+ * may review, oldest first. The gate's own reasons and words come with each item, and an
+ * ai_editor decision is named in `reviewed` (an approval publishes and leaves the queue; a
+ * rejection stays until a person decides).
*/
*/
async pendingReviews(viewer) {
async pendingReviews(viewer) {
const rows = await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
const rows = await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
@@ -529,8 +542,10 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -529,8 +542,10 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const head = await store.revisions.head(post.id);
const head = await store.revisions.head(post.id);
const rec = publication.authorshipOf(head);
const rec = publication.authorshipOf(head);
if (!head || !rec || !authorship.needsReview(rec)) continue;
if (!head || !rec || !authorship.needsReview(rec)) continue;
- const review = await store.reviews.latest(post.id, head.number);
- if (review) continue; // a decision, approved or rejected, has been made for this revision
+ const kinds = await store.reviews.latestByKind(post.id, head.number);
+ if (kinds.person) continue; // a person's decision, approved or rejected, has been made for this revision
+ if (kinds.ai_editor && kinds.ai_editor.decision === 'approved') continue; // the AI editor approved it: published and indexable
+ const ai = kinds.ai_editor;
const decision = await publication.decide(blog, post, head);
const decision = await publication.decide(blog, post, head);
items.push({
items.push({
site: 'blog',
site: 'blog',
@@ -543,6 +558,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -543,6 +558,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
editUrl: `/write/posts/${post.id}`,
editUrl: `/write/posts/${post.id}`,
createdAt: new Date(head.createdAt).toISOString(),
createdAt: new Date(head.createdAt).toISOString(),
reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
+ reviewed: ai ? { kind: reviewKind.AI_EDITOR, decision: ai.decision, by: ai.reviewer, at: ai.reviewedAt, label: reviewKind.aiEditorVerdict(ai) } : null,
review: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/reviews` },
review: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/reviews` },
...(post.state === 'draft' ? { publish: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/publish` } } : {}),
...(post.state === 'draft' ? { publish: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/publish` } } : {}),
});
});
@@ -550,16 +566,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -550,16 +566,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
},
},
- /** A person's review of one revision (what lets an AI draft be published and indexed). */
+ /**
+ * A review of one revision: a signed-in person's (what lets an AI draft be published and
+ * indexed), or the AI editor's — a svc:… service principal acting as itself with
+ * blog.review.ai_editor — recorded with kind `ai_editor`, never as a person's review.
+ */
async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
- if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
+ const aiEditor = isAiEditor(viewer);
+ if (!aiEditor && (!viewer || viewer.kind !== 'user' || !viewer.subject)) throw new ApiError(403, 'review.person_required', 'Only a signed-in person (or the AI editor) can review a revision');
const blog = await blogOf(post);
const blog = await blogOf(post);
- if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
+ if (!aiEditor && !await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
const n = parseInt(revision, 10);
const n = parseInt(revision, 10);
if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
return await store.tx(async () => {
return await store.tx(async () => {
const before = publication.snapshot(blog, post);
const before = publication.snapshot(blog, post);
- const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
+ const row = aiEditor
+ ? await store.reviews.recordAiEditor({ entityId: post.id, revision: n, principal: viewer.service, decision, note })
+ : await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
return row;
return row;
});
});
@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
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');
+const reviewKind = require('./review-kind');
// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is
// Blog's editorial policy for the gate: sources are optional on a blog; a very short post is
// served and listed in feeds but not offered to search engines (reason "thin").
// served and listed in feeds but not offered to search engines (reason "thin").
@@ -25,6 +26,44 @@ const OWNER = 'blog';
@@ -25,6 +26,44 @@ const OWNER = 'blog';
/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
/** Blog visibility → the gate's vocabulary (members = entitlement-gated). */
const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
const GATE_VISIBILITY = { public: 'public', unlisted: 'unlisted', members: 'gated', private: 'private' };
+/** The gate's authorship facts: an approving review of either kind (a person's or the AI editor's). */
+function gateFacts(rec, review) {
+ const out = { authorship: { mode: rec.mode, reviewed: reviewKind.approved(review) } };
+ if (rec.stubProvider) out.stubProvider = true;
+ return out;
+}
+
+/** May this revision be published? AI-generated text needs an approval from a person or the AI editor. */
+function canPublish(rec, review) {
+ if (authorship.needsReview(rec) && !reviewKind.approved(review)) {
+ return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
+ }
+ return { ok: true, reason: null };
+}
+
+/** Does either kind of review approve this revision? */
+function approvedReview(kinds = { person: null, ai_editor: null }) {
+ return reviewKind.approved(kinds.person) || reviewKind.approved(kinds.ai_editor);
+}
+
+/**
+ * The disclosure at the item, with the review of each kind (a person's, the AI editor's, both):
+ * "Checked by OpenVibe's AI editor on 2026-11-06" is never "Reviewed by a person". With no review
+ * it is the library's own text ("not yet reviewed by a person"), unchanged.
+ */
+function disclosure(rec, kinds = { person: null, ai_editor: null }) {
+ const d = authorship.disclosure(rec, null);
+ if (!d) return null;
+ const person = reviewKind.isPersonReview(kinds.person);
+ const ai = reviewKind.aiEditorLabel(kinds.ai_editor);
+ if (!person && !ai) return { ...d, reviewed: false };
+ const base = d.long.replace(/,\s*(?:not yet )?reviewed by a person\.?\s*$/, '').replace(/\.\s*$/, '');
+ const tail = person && ai ? `, reviewed by a person. ${ai}.`
+ : person ? ', reviewed by a person.'
+ : `. ${ai}.`;
+ return { ...d, long: `${base}${tail}`, reviewed: true };
+}
+
function createPublication({ store, config, outbox, indexnow }) {
function createPublication({ store, config, outbox, indexnow }) {
const { db } = store;
const { db } = store;
const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
const blogById = db.prepare('SELECT * FROM blogs WHERE id = ?');
@@ -44,6 +83,11 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -44,6 +83,11 @@ function createPublication({ store, config, outbox, indexnow }) {
return rev ? await store.reviews.latest(post.id, rev.number) : null;
return rev ? await store.reviews.latest(post.id, rev.number) : null;
}
}
+ /** The review of each kind for one revision: { person, ai_editor }. */
+ async function reviewsOf(post, rev) {
+ return rev ? await store.reviews.latestByKind(post.id, rev.number) : { person: null, ai_editor: null };
+ }
+
/**
/**
* The gate's decision for a post at one revision (the published one by default). `review` lets a
* The gate's decision for a post at one revision (the published one by default). `review` lets a
* caller that already fetched reviews in one batch (sitemaps, llms-full) skip the per-post lookup;
* caller that already fetched reviews in one batch (sitemaps, llms-full) skip the per-post lookup;
@@ -58,7 +102,8 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -58,7 +102,8 @@ function createPublication({ store, config, outbox, indexnow }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
noindex: Boolean(post.noindex),
};
};
- if (rec) Object.assign(facts, authorship.gateFacts(rec, review !== undefined ? review : await reviewOf(post, rev)));
+ const rv = review !== undefined ? review : await reviewOf(post, rev);
+ if (rec) Object.assign(facts, gateFacts(rec, rv));
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
}
@@ -168,7 +213,8 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -168,7 +213,8 @@ function createPublication({ store, config, outbox, indexnow }) {
return {
return {
POLICY, OWNER, GATE_VISIBILITY,
POLICY, OWNER, GATE_VISIBILITY,
blogPath, postPath, postUrl, abs, feedId,
blogPath, postPath, postUrl, abs, feedId,
- decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, termsOf,
+ gateFacts, canPublish, approvedReview, disclosure,
+ decide, visibleRevision, documentFor, syncIndex, snapshot, afterChange, authorshipOf, reviewOf, reviewsOf, termsOf,
};
};
}
}
@@ -185,4 +231,4 @@ function actorRef(actor) {
@@ -185,4 +231,4 @@ function actorRef(actor) {
return { type: 'service', id: 'blog' };
return { type: 'service', id: 'blog' };
}
}
-module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY };
+module.exports = { createPublication, actorRef, POLICY, GATE_VISIBILITY, gateFacts, canPublish, approvedReview, disclosure };
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
*/
*/
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
-const authorship = require('openvibe-publishing/authorship');
+const reviewKind = require('./review-kind');
const { renderBody } = require('../render/pages');
const { renderBody } = require('../render/pages');
function createReading({ store, blogs, posts: postsApi, publication, people, media, vip = null }) {
function createReading({ store, blogs, posts: postsApi, publication, people, media, vip = null }) {
@@ -105,7 +105,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -105,7 +105,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
/** The machine-readable representation of a post (same content as the page). */
/** The machine-readable representation of a post (same content as the page). */
async function postJson({ blog, post, rev, person, decision }) {
async function postJson({ blog, post, rev, person, decision }) {
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
- const review = await publication.reviewOf(post, rev);
+ const kinds = await publication.reviewsOf(post, rev);
const { tags, categories } = await termsView(post, blog);
const { tags, categories } = await termsView(post, blog);
const series = await blogs.seriesById(post.series_id);
const series = await blogs.seriesById(post.series_id);
return {
return {
@@ -121,7 +121,16 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -121,7 +121,16 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
published_at: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
revised_at: rev.createdAt,
revised_at: rev.createdAt,
author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,
author: person ? { subject: post.author_subject, name: person.known ? person.name : null, username: person.username } : null,
- authorship: rec ? { mode: rec.mode, workflow: rec.workflow || null, reviewed: authorship.isReviewed(rec, review), disclosure: authorship.disclosure(rec, review) } : null,
+ authorship: rec ? {
+ mode: rec.mode, workflow: rec.workflow || null,
+ reviewed: publication.approvedReview(kinds),
+ labels: reviewKind.labelsFor(kinds),
+ disclosure: publication.disclosure(rec, kinds),
+ reviews: {
+ person: kinds.person ? { decision: kinds.person.decision, at: kinds.person.reviewedAt } : null,
+ ai_editor: kinds.ai_editor ? { decision: kinds.ai_editor.decision, at: kinds.ai_editor.reviewedAt } : null,
+ },
+ } : null,
tags: tags.map((t) => t.name),
tags: tags.map((t) => t.name),
categories: categories.map((c) => c.name),
categories: categories.map((c) => c.name),
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * Two reviewer kinds in one append-only log (the `blog_post_reviews` table
+ * openvibe-publishing/authorship creates):
+ *
+ * person a signed-in person's approval or rejection (a usr_… subject). The library's
+ * reviews.record() writes these, unchanged.
+ * ai_editor the orchestrator's service principal — a svc:… principal whose token holds
+ * blog.review.ai_editor — recorded with its principal, the time and a note.
+ * migrations/0008_review_kinds.sql adds the reviewer_kind column (default 'person').
+ *
+ * An ai_editor approval publishes and indexes AI-written text exactly as a person's approval
+ * does, but it never reads as a person's review: the label is "Checked by OpenVibe's AI editor".
+ * Authorship stays the library's: this module only adds the kind to the log's rows and the words
+ * that follow from it.
+ */
+const { ids } = require('openvibe-contracts');
+
+const AI_EDITOR = 'ai_editor';
+const PERSON = 'person';
+const AI_EDITOR_LABEL = "Checked by OpenVibe's AI editor";
+const PRINCIPAL_RE = /^svc:[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
+
+const isPersonSubject = (v) => ids.isSubjectId('user', v);
+
+/** 'person' for anything the shared log wrote before the kind column existed. */
+function reviewerKind(review) {
+ return review && review.reviewerKind === AI_EDITOR ? AI_EDITOR : PERSON;
+}
+
+function isPersonReview(review) {
+ return Boolean(review && reviewerKind(review) === PERSON && review.decision === 'approved' && isPersonSubject(review.reviewer));
+}
+
+function isAiEditorReview(review) {
+ return Boolean(review && reviewerKind(review) === AI_EDITOR && review.decision === 'approved' && PRINCIPAL_RE.test(String(review.reviewer)));
+}
+
+/** An approving review of either kind: what lets AI-written text be published and indexed. */
+function approved(review) {
+ return isPersonReview(review) || isAiEditorReview(review);
+}
+
+/** The ISO date a review was recorded (YYYY-MM-DD), or null. */
+function reviewDate(review) {
+ if (!review || !review.reviewedAt) return null;
+ const d = new Date(review.reviewedAt);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
+}
+
+/** "Checked by OpenVibe's AI editor on 2026-11-06", or null when the AI editor did not approve. */
+function aiEditorLabel(review) {
+ if (!isAiEditorReview(review)) return null;
+ const d = reviewDate(review);
+ return d ? `${AI_EDITOR_LABEL} on ${d}` : AI_EDITOR_LABEL;
+}
+
+/** What the AI editor decided, for the staff queue: the approval label or "Kept hidden by …". */
+function aiEditorVerdict(review) {
+ if (!review || reviewerKind(review) !== AI_EDITOR) return null;
+ const d = reviewDate(review);
+ const what = review.decision === 'approved' ? AI_EDITOR_LABEL : "Kept hidden by OpenVibe's AI editor";
+ return d ? `${what} on ${d}` : what;
+}
+
+/** The public labels for a revision, from the review of each kind: [] when nobody approved it. */
+function labelsFor({ person = null, ai_editor = null } = {}) {
+ const labels = [];
+ if (isPersonReview(person)) labels.push('Reviewed by a person');
+ const ai = aiEditorLabel(ai_editor);
+ if (ai) labels.push(ai);
+ return labels;
+}
+
+function shape(row) {
+ if (!row) return null;
+ return {
+ id: Number(row.id),
+ entityId: row.entityId !== undefined ? row.entityId : row.entity_id,
+ revision: Number(row.revision),
+ reviewer: row.reviewer,
+ reviewerKind: (row.reviewerKind || row.reviewer_kind) === AI_EDITOR ? AI_EDITOR : PERSON,
+ decision: row.decision,
+ note: row.note,
+ reviewedAt: typeof row.reviewedAt === 'string' ? row.reviewedAt : new Date(Number(row.reviewed_at)).toISOString(),
+ };
+}
+
+function byKind(rows) {
+ const out = { person: null, ai_editor: null };
+ for (const row of rows) out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);
+ return out;
+}
+
+/** The newest decision of either kind (the log's latest row decides whether the text is reviewed). */
+function latestOf({ person = null, ai_editor = null } = {}) {
+ if (!person) return ai_editor || null;
+ if (!ai_editor) return person;
+ return Number(ai_editor.id) > Number(person.id) ? ai_editor : person;
+}
+
+/**
+ * The product's review store: the shared log plus kind-aware reads and the ai_editor write.
+ * `prefix` names the product's own table (e.g. 'blog_post' → blog_post_reviews).
+ */
+function createReviewKindLog(log, { db, prefix, now = () => Date.now() } = {}) {
+ const R = `${prefix}_reviews`;
+ const latestStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`);
+ const latestByKindStmt = db.prepare(`SELECT DISTINCT ON (reviewer_kind) * FROM ${R}
+ WHERE entity_id = ? AND revision = ? ORDER BY reviewer_kind, id DESC`);
+ const latestManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision) r.*
+ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
+ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
+ ORDER BY r.entity_id, r.revision, r.id DESC`);
+ const latestByKindManyStmt = db.prepare(`SELECT DISTINCT ON (r.entity_id, r.revision, r.reviewer_kind) r.*
+ FROM unnest(?::text[], ?::integer[]) AS k(entity_id, revision)
+ JOIN ${R} r ON r.entity_id = k.entity_id AND r.revision = k.revision
+ ORDER BY r.entity_id, r.revision, r.reviewer_kind, r.id DESC`);
+ const insertEditor = db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, reviewer_kind, decision, note, reviewed_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`);
+ const historyStmt = db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id LIMIT ?`);
+
+ const number = (v) => {
+ const n = Number(v);
+ return Number.isInteger(n) && n >= 0 && n <= 2147483647 ? n : null;
+ };
+
+ async function latest(entityId, revision) {
+ const n = number(revision);
+ if (n == null) return null;
+ return shape(await latestStmt.get(entityId, n));
+ }
+
+ async function latestByKind(entityId, revision) {
+ const n = number(revision);
+ if (n == null) return { person: null, ai_editor: null };
+ return byKind(await latestByKindStmt.all(entityId, n));
+ }
+
+ async function latestMany(refs = []) {
+ const keys = refs.filter((r) => r && number(r.revision) != null);
+ if (!keys.length) return refs.map(() => null);
+ const rows = await latestManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
+ const by = new Map(rows.map((r) => [`${r.entity_id}\u0000${r.revision}`, shape(r)]));
+ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || null);
+ }
+
+ async function latestByKindMany(refs = []) {
+ const keys = refs.filter((r) => r && number(r.revision) != null);
+ if (!keys.length) return refs.map(() => ({ person: null, ai_editor: null }));
+ const rows = await latestByKindManyStmt.all(keys.map((r) => r.entityId), keys.map((r) => number(r.revision)));
+ const by = new Map();
+ for (const row of rows) {
+ const key = `${row.entity_id}\u0000${row.revision}`;
+ const out = by.get(key) || { person: null, ai_editor: null };
+ out[row.reviewer_kind === AI_EDITOR ? AI_EDITOR : PERSON] = shape(row);
+ by.set(key, out);
+ }
+ return refs.map((r) => (r && by.get(`${r.entityId}\u0000${Number(r.revision)}`)) || { person: null, ai_editor: null });
+ }
+
+ /** An ai_editor review: the service principal that made it, never a person's subject. */
+ async function recordAiEditor({ entityId, revision, principal, decision, note = null } = {}) {
+ const n = number(revision);
+ if (n == null) throw new TypeError('revision must be a non-negative integer');
+ if (!PRINCIPAL_RE.test(String(principal))) throw new TypeError('principal must be a svc:… principal');
+ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
+ return shape(await insertEditor.get(String(entityId), n, String(principal), AI_EDITOR, decision, note == null ? null : String(note).slice(0, 2000), now()));
+ }
+
+ /** The shared log's person write, with the kind its row has by default. */
+ async function record(args) {
+ return shape(await log.record(args));
+ }
+
+ async function history(entityId, { limit = 1000 } = {}) {
+ const n = Math.max(1, Math.min(10000, Math.floor(Number(limit)) || 1000));
+ return (await historyStmt.all(entityId, n)).map(shape);
+ }
+
+ return {
+ ...log,
+ record,
+ history,
+ latest,
+ latestMany,
+ latestByKind,
+ latestByKindMany,
+ recordAiEditor,
+ };
+}
+
+module.exports = {
+ AI_EDITOR, PERSON, AI_EDITOR_LABEL, PRINCIPAL_RE,
+ reviewerKind, isPersonReview, isAiEditorReview, approved, reviewDate, aiEditorLabel, aiEditorVerdict, labelsFor, latestOf,
+ createReviewKindLog,
+};
@@ -35,7 +35,7 @@ const seo = require('openvibe-publishing/seo');
@@ -35,7 +35,7 @@ const seo = require('openvibe-publishing/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
-const { checkCapability } = require('../auth/capabilities');
+const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
function cors(origins) {
function cors(origins) {
const allowed = new Set(origins);
const allowed = new Set(origins);
@@ -290,7 +290,16 @@ function createApi(ctx) {
@@ -290,7 +290,16 @@ function createApi(ctx) {
const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };
return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };
}, 201);
}, 201);
- router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
+ // A review by the AI editor (a svc:… principal acting as itself) needs blog.review.ai_editor.
+ // People, and services acting for a person, are judged by posts.review as before.
+ const reviewGuard = (req, res, next) => {
+ const v = req.viewer;
+ if (!v || v.kind !== 'service' || v.subject) return next();
+ const c = checkCapability(v.claims, CAPABILITIES.REVIEW_AI_EDITOR);
+ if (c.allowed) return next();
+ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
+ };
+ router.post('/posts/:id/reviews', reviewGuard, B('blog.post.publish'), jsonBody, run(async (req) => {
const { post } = await mustPost(req);
const { post } = await mustPost(req);
const b = req.body || {};
const b = req.body || {};
const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
@@ -209,14 +209,14 @@ function createEditorRoutes(ctx) {
@@ -209,14 +209,14 @@ function createEditorRoutes(ctx) {
const head = await posts.head(post);
const head = await posts.head(post);
const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;
const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;
const rec = publication.authorshipOf(head);
const rec = publication.authorshipOf(head);
- const review = await publication.reviewOf(post, head);
+ const kinds = await publication.reviewsOf(post, head);
const terms = await publication.termsOf(post);
const terms = await publication.termsOf(post);
const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
const patchNotes = ctx.patchNotes ? await ctx.patchNotes.load(post.id) : null;
const patchNotes = ctx.patchNotes ? await ctx.patchNotes.load(post.id) : null;
const body = editor.editPage({
const body = editor.editPage({
blog, post, head, published, decision, patchNotes, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
blog, post, head, published, decision, patchNotes, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
- disclosure: rec ? authorship.disclosure(rec, review) : null,
- needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
+ disclosure: rec ? publication.disclosure(rec, kinds) : null,
+ needsReview: rec ? authorship.needsReview(rec) && !publication.approvedReview(kinds) : false,
publicUrl: publication.postPath(blog, post),
publicUrl: publication.postPath(blog, post),
canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),
canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),
form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
@@ -205,12 +205,12 @@ route('post', '/posts/{id}/revert', {
@@ -205,12 +205,12 @@ route('post', '/posts/{id}/revert', {
});
});
route('get', '/review-queue', {
route('get', '/review-queue', {
operationId: 'getReviewQueue', summary: 'The reviewer queue: posts whose AI-written head waits for a person', tags: ['revisions'], security: PERSON,
operationId: 'getReviewQueue', summary: 'The reviewer queue: posts whose AI-written head waits for a person', tags: ['revisions'], security: PERSON,
- description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post.',
+ description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post. An item the AI editor rejected carries `reviewed` (kind, decision, principal, time, label); the AI editor\'s approvals publish the post and leave the queue.',
responses: { 200: json('The waiting posts', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
responses: { 200: json('The waiting posts', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
});
});
route('post', '/posts/{id}/reviews', {
route('post', '/posts/{id}/reviews', {
operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
- description: 'People only. An approved revision may then publish.',
+ description: 'A person who may publish the post (blog.post.publish), or the AI editor — a svc:… service principal acting as itself with blog.review.ai_editor — whose review is recorded with kind `ai_editor` and labelled "Checked by OpenVibe\'s AI editor", never "Reviewed by a person". An approved revision may then publish.',
requestBody: body({ type: 'object', required: ['revision', 'decision'], properties: {
requestBody: body({ type: 'object', required: ['revision', 'decision'], properties: {
revision: { type: 'integer', minimum: 1 }, decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string'),
revision: { type: 'integer', minimum: 1 }, decision: { type: 'string', enum: ['approved', 'rejected'] }, note: nullable('string'),
} }),
} }),
@@ -22,7 +22,6 @@ const ovServe = require('openvibe-shared/serve');
@@ -22,7 +22,6 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
-const authorship = require('openvibe-publishing/authorship');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
@@ -347,7 +346,7 @@ function createPublicRoutes(ctx) {
@@ -347,7 +346,7 @@ function createPublicRoutes(ctx) {
const attachments = await store.attachments.list(post.id);
const attachments = await store.attachments.list(post.id);
const citations = await store.citations.forRevision(post.id, rev.number);
const citations = await store.citations.forRevision(post.id, rev.number);
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
- const review = await publication.reviewOf(post, rev);
+ const kinds = await publication.reviewsOf(post, rev);
const path = publication.postPath(blog, post);
const path = publication.postPath(blog, post);
const firstCategory = categories[0];
const firstCategory = categories[0];
const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
@@ -373,7 +372,7 @@ function createPublicRoutes(ctx) {
@@ -373,7 +372,7 @@ function createPublicRoutes(ctx) {
blog, blogUrl: publication.blogPath(blog), post, rev,
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
author: { name: person.name, url: reading.urls.author(person, blog) },
tags, categories, series: await seriesView(blog, post), attachments, citations,
tags, categories, series: await seriesView(blog, post), attachments, citations,
- disclosure: rec ? authorship.disclosure(rec, review) : null,
+ disclosure: rec ? publication.disclosure(rec, kinds) : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),
comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),
urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,
urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,
@@ -1,14 +1,16 @@
@@ -1,14 +1,16 @@
'use strict';
'use strict';
/**
/**
* The blog.* capabilities are released by openvibe-contracts, so the service guards delegate to the
* The blog.* capabilities are released by openvibe-contracts, so the service guards delegate to the
- * library's grant rule rather than deciding a proposed id locally (the fallback that existed while
- * the ids were only proposals). These pin that: every guarded id is defined by the installed
- * contracts and by the blog service manifest, and checkCapability agrees with capabilities.check for
- * an exact grant, a prefix grant, no grant, and an id the contracts do not define.
+ * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and
+ * waits for a Contracts release — blog.review.ai_editor (the AI editor's review kind) — so it is
+ * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These
+ * pin that: every guarded id is defined by the installed contracts and the blog service manifest,
+ * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check
+ * for an exact grant, a prefix grant, no grant, and an id nobody defines.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { capabilities, services } = require('openvibe-contracts');
const { capabilities, services } = require('openvibe-contracts');
-const { checkCapability, CAPABILITIES } = require('../server/auth/capabilities');
+const { checkCapability, CAPABILITIES, PROPOSED } = require('../server/auth/capabilities');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
@@ -17,23 +19,32 @@ const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
@@ -17,23 +19,32 @@ const GRANTED = { cap: ['blog.post.read'], sub: 'svc:openvibe.network' };
await check('every guarded capability is defined by the installed contracts and the blog manifest', async () => {
await check('every guarded capability is defined by the installed contracts and the blog manifest', async () => {
const manifest = services.get('blog');
const manifest = services.get('blog');
assert.ok(manifest, 'openvibe-contracts defines the blog service manifest');
assert.ok(manifest, 'openvibe-contracts defines the blog service manifest');
- assert.deepStrictEqual([...manifest.capabilities].sort(), Object.values(CAPABILITIES).sort());
+ const released = Object.values(CAPABILITIES).filter((id) => capabilities.get(id));
+ assert.deepStrictEqual([...manifest.capabilities].sort(), released.sort());
for (const id of Object.values(CAPABILITIES)) {
for (const id of Object.values(CAPABILITIES)) {
const cap = capabilities.get(id);
const cap = capabilities.get(id);
- assert.ok(cap, `${id} is defined by openvibe-contracts`);
+ if (!cap) {
+ assert.ok(PROPOSED.has(id), `${id} is not released yet, so it must be a local proposal`);
+ continue;
+ }
assert.strictEqual(cap.owner, 'blog', `${id} is owned by blog`);
assert.strictEqual(cap.owner, 'blog', `${id} is owned by blog`);
}
}
+ // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
+ assert.strictEqual(capabilities.get('blog.review.ai_editor'), undefined);
});
});
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', async () => {
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', async () => {
// An exact grant and a `prefix.*` grant the library's matching rule accepts.
// An exact grant and a `prefix.*` grant the library's matching rule accepts.
assert.deepStrictEqual(checkCapability(GRANTED, 'blog.post.read'), { allowed: true, code: null, reason: null });
assert.deepStrictEqual(checkCapability(GRANTED, 'blog.post.read'), { allowed: true, code: null, reason: null });
assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.post.read'), { allowed: true, code: null, reason: null });
assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.post.read'), { allowed: true, code: null, reason: null });
+ // The proposal is decided with the same rule until Contracts defines it.
+ assert.deepStrictEqual(checkCapability({ cap: ['blog.review.ai_editor'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });
+ assert.deepStrictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.review.ai_editor'), { allowed: true, code: null, reason: null });
+ assert.strictEqual(checkCapability({ cap: ['blog.post.publish'] }, 'blog.review.ai_editor').code, 'capability.denied');
// A grant of a sibling capability, or no cap at all, is denied with the library's code.
// A grant of a sibling capability, or no cap at all, is denied with the library's code.
assert.strictEqual(checkCapability({ cap: ['blog.post.create'] }, 'blog.post.read').code, 'capability.denied');
assert.strictEqual(checkCapability({ cap: ['blog.post.create'] }, 'blog.post.read').code, 'capability.denied');
assert.strictEqual(checkCapability(null, 'blog.post.read').code, 'capability.denied');
assert.strictEqual(checkCapability(null, 'blog.post.read').code, 'capability.denied');
- // An id the contracts do not define still answers capability.unknown — the guard delegates,
- // it never decides an unknown id on its own.
+ // An id nobody defines still answers capability.unknown — the guard only falls back for a proposal.
assert.strictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.not.a.capability').code, 'capability.unknown');
assert.strictEqual(checkCapability({ cap: ['blog.*'] }, 'blog.not.a.capability').code, 'capability.unknown');
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * The AI editor (plan 2026-10-22 §"The editorial queue"): a second reviewer kind, `ai_editor`,
+ * recorded on the review by the orchestrator's service principal. Its approval lets an AI draft be
+ * published and indexed exactly as a person's does, but the post says "Checked by OpenVibe's AI
+ * editor" (with the date) — never "Reviewed by a person". A person's review path and label do not
+ * change, and a page can show both.
+ *
+ * Only a service token whose principal holds blog.review.ai_editor may record one; a token without
+ * it is refused and writes nothing.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };
+const AI_LABEL = "Checked by OpenVibe's AI editor";
+// The page escapes the apostrophe; the rendered text still reads "Checked by OpenVibe's AI editor".
+const AI_ON_PAGE = /Checked by OpenVibe(?:'|')s AI editor/;
+
+(async () => {
+ const t = await boot();
+ const lead = t.official;
+ const admin = t.network.addUser('admin', { role: 'admin' });
+ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read', 'blog.post.publish']);
+ const editor = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.review.ai_editor'] });
+ const noCap = t.network.signService({ sub: 'svc:orchestrator', aud: ['openvibe.blog'], cap: ['blog.post.publish'] });
+ const makeDraft = async (title) => {
+ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': lead.subject }, json: { title, body: LONG, authorship: { workflow: WORKFLOW } } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().post;
+ };
+ try {
+ const post = await makeDraft('An AI draft');
+
+ await check('a token without blog.review.ai_editor is refused and writes nothing', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: noCap, json: { revision: 1, decision: 'approved' } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ assert.strictEqual((await t.ctx.store.reviews.history(post.id)).length, 0, 'nothing was recorded');
+ });
+
+ await check('the AI editor approves: the draft publishes and reads "Checked by OpenVibe\'s AI editor"', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: editor, json: { revision: 1, decision: 'approved', note: 'Checked against the sources' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.json().review.reviewerKind, r.json().review.reviewer, r.json().review.decision], ['ai_editor', 'svc:orchestrator', 'approved']);
+
+ const pub = await t.get(`/api/v1/posts/${post.id}/publish`, { as: admin, json: { revision: 1 } });
+ assert.strictEqual(pub.status, 200, pub.text);
+ assert.strictEqual(pub.json().post.state, 'published');
+
+ const path = new URL(pub.json().post.url).pathname;
+ const page = await t.get(path);
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'indexable');
+ assert.ok(AI_ON_PAGE.test(page.text), 'the page says who checked it');
+ assert.ok(!/reviewed by a person/i.test(page.text), 'never as a person\'s review');
+ assert.ok(!page.text.includes('not yet reviewed by a person'));
+
+ const json = await t.get(`${path}.json`);
+ assert.strictEqual(json.status, 200, json.text);
+ assert.strictEqual(json.json().authorship.reviewed, true);
+ assert.ok(json.json().authorship.labels.some((l) => l.startsWith(AI_LABEL)), JSON.stringify(json.json().authorship));
+ assert.deepStrictEqual([json.json().authorship.reviews.ai_editor.decision, json.json().authorship.reviews.person], ['approved', null]);
+ });
+
+ await check('a person\'s review still says "Reviewed by a person"', async () => {
+ const other = await makeDraft('A person-checked draft');
+ const r = await t.get(`/api/v1/posts/${other.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().review.reviewerKind, 'person');
+ const pub = await t.get(`/api/v1/posts/${other.id}/publish`, { as: admin, json: { revision: 1 } });
+ assert.strictEqual(pub.status, 200, pub.text);
+ const page = await t.get(new URL(pub.json().post.url).pathname);
+ assert.ok(/reviewed by a person/i.test(page.text), 'the person\'s label is unchanged');
+ assert.ok(!AI_ON_PAGE.test(page.text));
+ });
+
+ await check('both reviews on one post show both labels', async () => {
+ const r = await t.get(`/api/v1/posts/${post.id}/reviews`, { as: admin, json: { revision: 1, decision: 'approved' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const page = await t.get(new URL((await t.get(`/api/v1/posts/${post.id}`, { as: admin })).json().post.url).pathname);
+ assert.ok(/reviewed by a person/i.test(page.text));
+ assert.ok(AI_ON_PAGE.test(page.text));
+ assert.ok(page.text.toLowerCase().indexOf('reviewed by a person') < page.text.indexOf('Checked by OpenVibe'), 'the person\'s sentence, then the AI editor\'s');
+ });
+
+ await check('an AI editor rejection keeps the draft out of publish and names its kind in the queue', async () => {
+ const third = await makeDraft('Kept hidden');
+ const r = await t.get(`/api/v1/posts/${third.id}/reviews`, { as: editor, json: { revision: 1, decision: 'rejected', note: 'Not accurate' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const pub = await t.get(`/api/v1/posts/${third.id}/publish`, { as: admin, json: { revision: 1 } });
+ assert.notStrictEqual(pub.status, 200, 'a rejected revision cannot be published');
+ const q = await t.get('/api/v1/review-queue', { as: admin });
+ const item = q.json().items.find((i) => i.id === third.id);
+ assert.ok(item, 'the AI editor\'s rejection leaves the draft in the person\'s queue');
+ assert.deepStrictEqual([item.reviewed.kind, item.reviewed.decision, item.reviewed.by], ['ai_editor', 'rejected', 'svc:orchestrator']);
+ assert.ok(item.reviewed.label.startsWith('Kept hidden by OpenVibe\'s AI editor'), item.reviewed.label);
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });