openvibers/openvibe.blog Commits
Blog: AI desks — digests from Sources' items (news, food recalls, deals, hosting, dev) and the Is This Real? satire desk, labelled and never indexed (#57)
Changes
@@@ -53,6 +53,8 @@ Other tables in the same database:by raw URL; `server/app.js` builds the collector, `server/index.js` flushes it once a minute and on stop (one `http.request` sample per key, plus theactive-requests, p95 latency and event-loop-lag gauges, in the openvibe-sdk 0.46 `platform.telemetry-sample@1` shape), and `server/worker.js` deletesrows older than 14 days daily. `/shared`, static assets, health, readiness and `/metrics` are not counted.++- The AI desks (migration `0004_ai_desks.sql`): `blog_ingest_cursor` (Sources change cursors), `blog_desk_items` (the++ Sources items the desks may cite, 21 days) and `blog_desk_posts` (one post per desk and period).## Does not own@@@ -153,6 +155,34 @@ Other tables in the same database:| `/write/…` | the editor, as plain HTML forms with Network SSO and a form token || `/auth/*` | sign-in; the same session layer as OpenVibe.Community || `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness and metrics; `/metrics` is loopback only |++| `/desks`, `/desks/:desk`, `/desks/:desk/:slug(.json)`, `/desks/:desk/feed.xml\|atom.xml` | the AI desks (below); staff `POST …/hide\|show` |++++### AI desks (`server/desks/`)++++The owner's April ask: "AI-generated niche blogs backed by large source datasets" and a "satirical/parody type blog".++They sit beside the blogs, not in them: a desk post is never a `blog_posts` row (whose AI text waits for a person's++review), says on every page, feed entry and JSON twin that OpenVibe.AI wrote it and no person reviewed it, and is never++indexed (`noindex`, not in the sitemaps).++++| Desk | Sources (OpenVibe.Sources keys) | When |++|---|---|---|++| News digest | `govuk-news`, `eu-press-corner`, `nasa-news-releases`, `noaa-news` | daily, 06:00 UTC, for the day before (3 items at least) |++| Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) |++| Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) |++| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |++| Developer digest | `nodejs-blog` | Mondays, for the week before |++| Is This Real? (satire) | none | daily, 14:00 UTC |++++Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks++read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed++item is dropped). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the++first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to++`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and++every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to++`blog.write_satire` (fiction with its own rules in OpenVibe.AI: no real people, no words in a real organisation's mouth,++nothing dangerous) and every page and feed title says it is satire. A failed run is retried after 30 minutes with a new++key (three attempts); the stub provider's text is kept but hidden. Staff (Network admins) may hide a post and show it++again. `BLOG_DESKS=off` stops the desks; the digests need `OV_SOURCES_INTERNAL_URL`, every desk `OV_AI_INTERNAL_URL`.### The network changelog and patch notes@@@ -310,6 +340,8 @@ Each grant is `[client, capability, audience]`:with the network's token instead of anonymously; `CHANGELOG_GITHUB_TOKEN` overrides it).- `[blog, ai.run.create, openvibe.ai]` and `[blog, ai.run.read, openvibe.ai]` for AI drafts (theeditor's "draft with AI" and the dev-blog draft of each patch notes post).++- `[blog, sources.item.read, openvibe.sources]` for the AI desks' digests (`blog.write_digest` and `blog.write_satire`++ run under the `ai.run.create` grant above).- For OpenVibe.AI to deliver drafts: `[ai, blog.post.create, openvibe.blog]`. Add`[ai, blog.post.read, openvibe.blog]` if it reads the drafts back.nnew file mode 100644@@@ -0,0 +1,58 @@++-- phase: expand++-- OpenVibe.Blog's AI desks (server/desks/): digests written by OpenVibe.AI from the items OpenVibe.Sources fetched, and++-- the "Is This Real?" satire desk. A desk post is not a blog post: it never enters blog_posts (whose AI text waits for++-- a person's review), it is labelled as AI-written on every surface and it is never indexed.++--++-- blog_ingest_cursor openvibe-publishing/ingest change cursors, one per Sources category the desks read++-- blog_desk_items the Sources items the desks may cite (title, link, summary, fields), kept 21 days++-- blog_desk_posts one post per desk and period: writing, published, hidden (by staff) or failed (retried)++++CREATE TABLE IF NOT EXISTS blog_ingest_cursor (++ name text COLLATE "C" NOT NULL,++ cursor bigint NOT NULL,++ updated_at bigint NOT NULL,++ PRIMARY KEY (name)++);++++CREATE TABLE blog_desk_items (++ item_id text COLLATE "C" PRIMARY KEY,++ source_key text COLLATE "C" NOT NULL,++ title text,++ url text,++ summary text,++ published_at text,++ fields jsonb NOT NULL DEFAULT '{}'::jsonb,++ revision integer NOT NULL DEFAULT 1,++ -- When the item appeared or last changed: Sources' first_seen_at for a new item, the time Blog saw a new revision.++ changed_at bigint NOT NULL,++ updated_at bigint NOT NULL++);++CREATE INDEX blog_desk_items_changed ON blog_desk_items (source_key, changed_at);++++CREATE TABLE blog_desk_posts (++ id text COLLATE "C" PRIMARY KEY,++ desk text COLLATE "C" NOT NULL,++ slug text COLLATE "C" NOT NULL,++ status text NOT NULL CHECK (status IN ('writing','published','hidden','failed')),++ title text,++ dek text,++ -- digest: { sections: [{ heading, body, cites: [n] }], gaps: [] }; satire: { markdown, tags, topic, angle }++ body jsonb NOT NULL DEFAULT '{}'::jsonb,++ -- the items the AI was given, in order (a citation n is sources[n]): [{ item_id, source_key, title, url, published_at }]++ sources jsonb NOT NULL DEFAULT '[]'::jsonb,++ period_from bigint NOT NULL,++ period_to bigint NOT NULL,++ run_id text,++ model text,++ synthetic boolean NOT NULL DEFAULT false,++ attempts integer NOT NULL DEFAULT 0,++ error text,++ hidden_by text,++ hidden_reason text,++ created_at bigint NOT NULL,++ published_at bigint,++ updated_at bigint NOT NULL,++ UNIQUE (desk, slug),++ UNIQUE (desk, period_from)++);++CREATE INDEX blog_desk_posts_listing ON blog_desk_posts (desk, status, published_at DESC);@@@ -31,6 +31,10 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }.sources ol { padding-left: 1.2rem; }++.desk-list { list-style: none; padding: 0; }++.desk-card h2 { margin: 16px 0 4px; font-size: 1.2rem; }++.desk-satire { border-color: var(--danger, #ef4444); }++.desk-cites, .desk-gaps { font-size: .9rem; color: var(--text-secondary, #96a7c2); }.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }@@@ -97,7 +97,9 @@ async function createApp(opts = {}) {const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, indexnow };++ // The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.++ const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, desks, indexnow };const app = express();app.disable('x-powered-by');@@@ -210,6 +212,7 @@ async function createApp(opts = {}) {// ── Discovery, editor, public pages ─────────────────────app.use(createDiscoveryRoutes(ctx));++ app.use(require('./desks/routes').createDeskRoutes(ctx));const publicRoutes = createPublicRoutes(ctx);ctx.publicRoutes = publicRoutes;app.use('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));@@@ -63,6 +63,11 @@ function load(env = process.env) {},// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),++ // OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).++ sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },++ // The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with++ // BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.++ desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment// it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * The AI desks OpenVibe.Blog runs (server/desks/desks.js). A digest desk reads named OpenVibe.Sources sources and, once++ * its period is over, asks OpenVibe.AI's blog.write_digest for one post about what they published, every section cited.++ * The satire desk asks blog.write_satire for one invented mock-explainer a day from the questions below.++ *++ * key the URL (/desks/<key>)++ * every 'day' (the UTC day before) or 'week' (Monday to Sunday, UTC)++ * hour the UTC hour the post is written after the period ends (feeds settle first)++ * categories the Sources categories its sources are in (one change cursor each)++ * sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)++ * min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)++ */++++const DESKS = [++ {++ key: 'news', title: 'News digest', every: 'day', hour: 6, min: 3, max: 40,++ about: 'What governments and space agencies announced the day before: GOV.UK, the European Commission press corner and NASA.',++ categories: ['news'], sources: ['govuk-news', 'eu-press-corner', 'nasa-news-releases', 'noaa-news'],++ },++ {++ key: 'food', title: 'Food safety digest', every: 'day', hour: 6, min: 1, max: 30,++ about: 'Food recalls and allergy alerts posted through the US Food and Drug Administration the day before. Read each notice itself before you throw anything out.',++ categories: ['news'], sources: ['fda-food-recalls'],++ },++ {++ key: 'deals', title: 'Deals digest', every: 'day', hour: 7, min: 5, max: 40,++ about: 'The deals DealNews published the day before, grouped by kind. Prices are as the listing stated them when it was fetched.',++ categories: ['deals'], sources: ['dealnews-daily'],++ },++ {++ key: 'hosting', title: 'Hosting digest', every: 'week', hour: 7, min: 1, max: 40,++ about: 'New and changed VPS and cloud plans in providers\' own price lists (Akamai/Linode, AWS Lightsail, OVHcloud, Vultr) and low-end hosting offers, week by week.',++ categories: ['hosting'], sources: ['linode-types', 'aws-lightsail-bundles', 'ovhcloud-vps', 'vultr-plans', 'lowendbox-posts', 'lowendtalk-offers'],++ },++ {++ key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,++ about: 'Releases and news from the Node.js project blog, week by week.',++ categories: ['blog'], sources: ['nodejs-blog'],++ },++];++++const SATIRE = {++ key: 'satire', title: 'Is This Real?', every: 'day', hour: 14, satire: true,++ about: 'Satire. Absurd questions answered with a completely straight face, written by AI. Nothing here is true, real advice or real news.',++};++++// One question a day, in order; after the list the angles change, so a question is never written the same way twice.++const QUESTIONS = [++ 'Is it safe to let your air fryer vote?',++ 'Can you get a sunburn from your phone\'s dark mode?',++ 'Can you claim a houseplant as a dependent?',++ 'Should you be worried that your sourdough starter has started a podcast?',++ 'Can a cat file a noise complaint against you?',++ 'Are pigeons unionising in city parks?',++ 'Can you be allergic to Mondays?',++ 'Is it possible to take too many vibes?',++ 'Do houseplants judge you for skipping leg day?',++ 'Is your robot vacuum legally your employee?',++ 'Can you microdose a nap?',++ 'Is "one more episode" a recognised unit of time?',++ 'Can you get jet lag from switching browser tabs?',++ 'Are geese running a protection racket at the local pond?',++ 'Is it rude to ghost your smart fridge?',++ 'Can your group chat be declared a sovereign nation?',++ 'Should you tip the self-checkout machine?',++ 'Can you inherit a Wi-Fi password?',++ 'Is cereal a soup, and does the soup lobby know?',++ 'Is it dangerous to make eye contact with a seagull holding a chip?',++ 'Can a streamer\'s chat legally adopt them?',++ 'Is a mechanical keyboard loud enough to count as a band?',++ 'Can you file a missing-person report for a single sock?',++ 'Is hitting snooze a form of time travel?',++ 'Can you be evicted by your own cat?',++ 'Why does the neighbour\'s lawnmower have a better social life than you?',++ 'Is it possible to lose an argument to autocorrect forever?',++ 'Do frogs need a licence to sing at 3 a.m.?',++ 'Can your dog be put on a group project?',++ 'Is it safe to ask the printer how it is feeling?',++ 'Is the moon just very long-distance cheese marketing?',++ 'Can you get a parking ticket for leaving your feelings in the car?',++ 'Is pasta water legally a hot tub for spaghetti?',++ 'Can a houseplant sue you for emotional neglect?',++ 'Is buffering a spiritual practice?',++ 'Can a raccoon be your landlord?',++ 'Is it too late to apologise to your browser history?',++ 'Should you be concerned that your toaster has opinions about bread?',++ 'Can you be banned from a library for reading too loudly in your head?',++ 'Is it legal to marry your favourite hoodie?',++];++const ANGLES = [++ 'a deadly serious consumer-safety explainer',++ 'a myth-busting FAQ by a panel of invented experts',++ 'a breaking-news bulletin from an extremely calm newsroom',++ 'a field guide for beginners',++ 'a public service announcement that keeps getting more urgent',++];++++function questionFor(n) {++ const i = Math.max(0, Math.floor(Number(n) || 0));++ return { topic: QUESTIONS[i % QUESTIONS.length], angle: ANGLES[Math.floor(i / QUESTIONS.length) % ANGLES.length] };++}++++const ALL = [...DESKS, SATIRE];++const byKey = (key) => ALL.find((d) => d.key === key) || null;++++module.exports = { DESKS, SATIRE, ALL, QUESTIONS, ANGLES, questionFor, byKey };nnew file mode 100644@@@ -0,0 +1,297 @@++'use strict';++/**++ * OpenVibe.Blog's AI desks (the owner's April ask: "AI-generated niche blogs backed by large source datasets" and "a++ * satirical/parody type blog"). They sit beside the blogs, not in them: a desk post is written by OpenVibe.AI, labelled++ * as such on every surface, never indexed, and never a blog_posts row (whose AI text waits for a person's review).++ *++ * pull() Sources' change feed for each category the desks read (openvibe-publishing/ingest, cursor per++ * category): items of a desk's sources are kept in blog_desk_items (21 days); a removed item is++ * dropped, so a post written later can never cite it.++ * compose() for each desk whose latest period is over (catalog.js: every, hour) and has no post yet: the newest++ * items that appeared or changed in the period go to blog.write_digest; a section the AI did not++ * cite is left out (and said so), a run with no cited section makes no post. The satire desk asks++ * blog.write_satire for one invented post a day. A failure is retried (three attempts, 30 minutes apart).++ * tick() pull, compose, prune: every BLOG_DESKS_INTERVAL_MS where the worker runs.++ *++ * Staff (Network admins) may hide a desk post and show it again; nothing else edits one.++ */++const crypto = require('crypto');++const ingest = require('openvibe-publishing/ingest');++const { serviceAuth } = require('openvibe-contracts');++const { createAiClient } = require('openvibe-publishing/ai');++const catalog = require('./catalog');++++const DAY = 86_400_000;++const HOUR = 3_600_000;++const KEEP_ITEMS_MS = 21 * DAY;++const MAX_ATTEMPTS = 3;++const RETRY_AFTER_MS = 30 * 60_000;++++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);++const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };++const num = (v) => (v == null ? null : Number(v));++const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70).replace(/-+$/, '');++const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);++const newId = () => `dsk_${crypto.randomBytes(10).toString('hex')}`;++++/** The latest period of a desk that is over (and past its writing hour) at `now`: { from, to }. */++function duePeriod(desk, now) {++ const d = new Date(now);++ const today = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());++ const hour = (desk.hour || 0) * HOUR;++ if (desk.every === 'week') {++ const monday = today - ((d.getUTCDay() + 6) % 7) * DAY;++ const to = now >= monday + hour ? monday : monday - 7 * DAY;++ return { from: to - 7 * DAY, to };++ }++ const to = now >= today + hour ? today : today - DAY;++ return { from: to - DAY, to };++}++++/** "Wednesday 14 October 2026" or "the week of 5 to 11 October 2026". */++const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];++const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];++function periodLabel(desk, { from, to }) {++ const dm = (ms) => { const d = new Date(ms); return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; };++ const year = (ms) => new Date(ms).getUTCFullYear();++ if (desk.every === 'week') return `the week of ${dm(from)} to ${dm(to - DAY)} ${year(to - DAY)}`;++ return `${WEEKDAYS[new Date(from).getUTCDay()]} ${dm(from)} ${year(from)}`;++}++++/** What the AI reads about one item: its summary and the fields Sources stored, and whether it is new or changed. */++function snippetOf(it) {++ const lines = [];++ if (it.summary) lines.push(clean(it.summary, 1200));++ const fields = parse(it.fields, {});++ const kv = Object.entries(fields).filter(([, v]) => v !== null && v !== '').slice(0, 20).map(([k, v]) => `${k}: ${clean(v, 120)}`);++ if (kv.length) lines.push(kv.join('; '));++ lines.push(Number(it.revision) > 1 ? `Changed at the source (revision ${it.revision}).` : 'New at the source.');++ return lines.join('\n').slice(0, 3000);++}++++function createDesks({ config, store, fetchImpl = globalThis.fetch, log = console, now = () => store.now() }) {++ const db = store.db;++ const c = config.desks || {};++ const aiTokens = config.aiUrl && config.oauth.clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,++ }) : null;++ const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });++ const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);++ const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });++ const wanted = new Set(catalog.DESKS.flatMap((d) => d.sources));++ const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];++ const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));++ const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };++++ // ── Items ───────────────────────────────────────────────++++ async function applyItem(item, t) {++ if (!wanted.has(item.source_key)) return 'applied';++ if (item.removed) {++ await t.query('DELETE FROM blog_desk_items WHERE item_id = $1', [item.id]);++ return 'removed';++ }++ const at = now();++ const prev = await t.maybe('SELECT revision FROM blog_desk_items WHERE item_id = $1', [item.id]);++ const firstSeen = item.provenance && item.provenance.first_seen_at ? Date.parse(item.provenance.first_seen_at) : NaN;++ // New to Blog: dated when Sources first saw it (a first pull never presents old items as today's news).++ // A new revision of an item Blog already holds: dated now, when the change reached Blog.++ const changedAt = prev ? (Number(item.revision) > Number(prev.revision) ? at : null) : (Number.isFinite(firstSeen) ? firstSeen : at);++ await t.query(`INSERT INTO blog_desk_items (item_id, source_key, title, url, summary, published_at, fields, revision, changed_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)++ ON CONFLICT (item_id) DO UPDATE SET title = excluded.title, url = excluded.url, summary = excluded.summary,++ published_at = excluded.published_at, fields = excluded.fields, revision = excluded.revision,++ changed_at = COALESCE($11, blog_desk_items.changed_at), updated_at = excluded.updated_at`,++ [item.id, item.source_key, clean(item.title, 500) || null, item.canonical_url || null, item.summary ? String(item.summary).slice(0, 2000) : null,++ item.published_at || null, JSON.stringify(item.fields || {}), Number(item.revision) || 1, changedAt || at, at, changedAt]);++ return 'applied';++ }++++ async function pull() {++ if (!sourcesOn) return { pulled: 0 };++ let pulled = 0;++ for (const [cat, source] of clients) {++ try {++ const r = await ingest.pullChanges({ db, cursor, source, name: `desks:${cat}`, apply: applyItem, maxPages: 25, pageSize: 200 });++ pulled += r.applied + r.removed;++ } catch (err) {++ stats.lastError = `pull ${cat}: ${err.message}`;++ log.warn(`[Blog] desks: pulling ${cat} from Sources failed:`, err.message);++ }++ }++ stats.lastPullAt = new Date(now()).toISOString();++ return { pulled };++ }++++ // ── Posts ───────────────────────────────────────────────++++ const shape = (r) => (r ? {++ ...r, body: parse(r.body, {}), sources: parse(r.sources, []),++ period_from: num(r.period_from), period_to: num(r.period_to), created_at: num(r.created_at), published_at: num(r.published_at), updated_at: num(r.updated_at),++ } : null);++++ /** The row for a desk's period, created (status writing) or claimed for another attempt; null when there is nothing to do. */++ async function claim(desk, period, slug) {++ const at = now();++ const row = shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE desk = $1 AND period_from = $2', [desk.key, period.from]));++ if (row) {++ if (row.status !== 'failed' || row.attempts >= MAX_ATTEMPTS || at - row.updated_at < RETRY_AFTER_MS) return null;++ const n = await db.exec(`UPDATE blog_desk_posts SET status = 'writing', attempts = attempts + 1, updated_at = $3++ WHERE id = $1 AND status = 'failed' AND attempts = $2`, [row.id, row.attempts, at]);++ return n ? { ...row, attempts: row.attempts + 1 } : null;++ }++ const id = newId();++ const n = await db.exec(`INSERT INTO blog_desk_posts (id, desk, slug, status, period_from, period_to, attempts, created_at, updated_at)++ VALUES ($1, $2, $3, 'writing', $4, $5, 1, $6, $6) ON CONFLICT DO NOTHING`, [id, desk.key, slug, period.from, period.to, at]);++ return n ? { id, desk: desk.key, slug, attempts: 1 } : null;++ }++++ async function fail(row, err) {++ stats.lastError = `${row.desk}: ${err.message}`;++ log.warn(`[Blog] desks: ${row.desk} ${row.slug} (attempt ${row.attempts}) failed:`, err.message);++ await db.query("UPDATE blog_desk_posts SET status = 'failed', error = $2, updated_at = $3 WHERE id = $1", [row.id, clean(err.message, 300), now()]);++ }++++ /** One digest: the period's items to blog.write_digest, cited sections only. */++ async function writeDigest(desk, period) {++ const items = await db.many(`SELECT * FROM blog_desk_items WHERE source_key = ANY($1::text[]) AND changed_at >= $2 AND changed_at < $3++ ORDER BY changed_at DESC, item_id LIMIT $4`, [desk.sources, period.from, period.to, desk.max || 40]);++ if (items.length < (desk.min || 1)) return null;++ const row = await claim(desk, period, ymd(period.from));++ if (!row) return null;++ try {++ const label = periodLabel(desk, period);++ const r = await ai.run('blog.write_digest', {++ desk: desk.title, about: desk.about, period: label,++ sources: items.map((it) => ({++ source_type: 'sources.item', source_id: it.item_id,++ ...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),++ ...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),++ snippet: snippetOf(it),++ })),++ }, { target: { service: 'blog', type: 'desk', id: `${desk.key}:${ymd(period.from)}` }, idempotencyKey: `blog.desk:${desk.key}:${ymd(period.from)}:${row.attempts}` });++ const out = r.output || {};++ const gaps = (Array.isArray(out.gaps) ? out.gaps : []).map((g) => clean(g, 300)).filter(Boolean).slice(0, 20);++ const all = Array.isArray(out.sections) ? out.sections : [];++ const sections = all.map((s) => ({++ heading: clean(s && s.heading, 160),++ body: String((s && s.body) || '').trim().slice(0, 4000),++ cites: [...new Set((s && Array.isArray(s.citations) ? s.citations : []).filter((n) => Number.isInteger(n) && n >= 0 && n < items.length))].sort((a, b) => a - b),++ })).filter((s) => s.body && s.cites.length);++ if (sections.length < all.length) gaps.push(`${all.length - sections.length} section(s) the AI wrote without citing an item were left out.`);++ if (!sections.length) throw new Error(`run ${r.runId} cited nothing, so no post was made`);++ const sources = items.map((it) => ({ item_id: it.item_id, source_key: it.source_key, title: it.title || null, url: it.url || null, published_at: it.published_at || null }));++ return await finish(row, r, { title: clean(out.title, 200) || `${desk.title}: ${label}`, dek: clean(out.dek, 400) || null, body: { sections, gaps }, sources });++ } catch (err) { await fail(row, err); return null; }++ }++++ /** One satire post: the next question from the catalog to blog.write_satire. */++ async function writeSatire(desk, period) {++ const done = Number(await db.value("SELECT count(*) FROM blog_desk_posts WHERE desk = $1 AND status IN ('published','hidden')", [desk.key])) || 0;++ const row = await claim(desk, period, ymd(period.to));++ if (!row) return null;++ try {++ const q = catalog.questionFor(done);++ const r = await ai.run('blog.write_satire', { topic: q.topic, angle: q.angle }, {++ target: { service: 'blog', type: 'desk', id: `${desk.key}:${ymd(period.to)}` }, idempotencyKey: `blog.desk:${desk.key}:${ymd(period.to)}:${row.attempts}`,++ });++ const out = r.output || {};++ const markdown = String(out.body_markdown || '').trim().slice(0, 12000);++ if (!markdown) throw new Error(`run ${r.runId} wrote no text`);++ const title = clean(out.title, 200) || q.topic;++ const tags = (Array.isArray(out.tags) ? out.tags : []).map((t) => clean(t, 40).toLowerCase()).filter(Boolean).slice(0, 5);++ return await finish(row, r, { title, dek: clean(out.dek, 300) || null, body: { markdown, tags, topic: q.topic, angle: q.angle }, sources: [], slug: `${ymd(period.to)}-${slugify(title) || 'satire'}` });++ } catch (err) { await fail(row, err); return null; }++ }++++ async function finish(row, r, { title, dek, body, sources, slug = null }) {++ const at = now();++ // The stub provider's synthetic text is kept for the record but never shown (hidden, with the reason).++ const synthetic = Boolean(r.synthetic);++ await db.query(`UPDATE blog_desk_posts SET status = $2, title = $3, dek = $4, body = $5::jsonb, sources = $6::jsonb, run_id = $7, model = $8,++ synthetic = $9, error = NULL, published_at = $10, updated_at = $10, slug = COALESCE($11, slug),++ hidden_by = $12, hidden_reason = $13 WHERE id = $1`,++ [row.id, synthetic ? 'hidden' : 'published', title, dek, JSON.stringify(body), JSON.stringify(sources), r.runId || null,++ (r.workflow && r.workflow.model) || null, synthetic, at, slug, synthetic ? 'system' : null, synthetic ? 'Written by the stub provider (synthetic text).' : null]);++ stats.written++;++ return shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE id = $1', [row.id]));++ }++++ async function compose() {++ if (!ai.enabled) return [];++ const out = [];++ const at = now();++ if (sourcesOn) {++ for (const desk of catalog.DESKS) {++ const p = await writeDigest(desk, duePeriod(desk, at));++ if (p) out.push(p);++ }++ }++ const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));++ if (p) out.push(p);++ stats.lastComposeAt = new Date(at).toISOString();++ return out;++ }++++ async function prune() {++ return await db.exec('DELETE FROM blog_desk_items WHERE changed_at < $1', [now() - KEEP_ITEMS_MS]);++ }++++ let running = false;++ async function tick() {++ if (running) return null;++ running = true;++ try {++ const p = await pull();++ const written = await compose();++ await prune();++ return { ...p, written: written.length };++ } catch (err) {++ stats.lastError = err.message;++ log.warn('[Blog] desks tick failed:', err.message);++ return null;++ } finally { running = false; }++ }++++ let timer = null;++ let first = null;++++ return {++ enabled: Boolean(c.enabled && ai.enabled),++ stats,++ pull, compose, tick, prune,++ /** Published posts of a desk, newest first (staff also see hidden ones). */++ async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {++ const states = staff ? ['published', 'hidden'] : ['published'];++ const total = Number(await db.value('SELECT count(*) FROM blog_desk_posts WHERE desk = $1 AND status = ANY($2::text[])', [deskKey, states])) || 0;++ const rows = await db.many(`SELECT * FROM blog_desk_posts WHERE desk = $1 AND status = ANY($2::text[])++ ORDER BY period_from DESC LIMIT $3 OFFSET $4`, [deskKey, states, limit, offset]);++ return { total, posts: rows.map(shape) };++ },++ async latest() {++ const rows = await db.many(`SELECT DISTINCT ON (desk) * FROM blog_desk_posts WHERE status = 'published' ORDER BY desk, period_from DESC`);++ return new Map(rows.map((r) => [r.desk, shape(r)]));++ },++ async bySlug(deskKey, slug) {++ return shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE desk = $1 AND slug = $2', [deskKey, String(slug)]));++ },++ /** Staff: hide a post (it leaves the desk and its feed) or show it again. */++ async setHidden(post, hidden, { by, reason = null }) {++ if (!post || (post.status !== 'published' && post.status !== 'hidden')) return false;++ await db.query('UPDATE blog_desk_posts SET status = $2, hidden_by = $3, hidden_reason = $4, updated_at = $5 WHERE id = $1',++ [post.id, hidden ? 'hidden' : 'published', hidden ? by : null, hidden ? clean(reason, 300) || null : null, now()]);++ return true;++ },++ start() {++ if (!this.enabled || timer) return;++ timer = setInterval(() => { tick(); }, c.intervalMs || 15 * 60_000);++ timer.unref();++ first = setTimeout(() => { tick(); }, 90_000);++ first.unref();++ },++ stop() { clearInterval(timer); clearTimeout(first); timer = null; },++ };++}++++module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * The AI desks' pages (layout.js wraps them): the desks, one desk's posts, one post. Every surface says the text is++ * AI-written and not reviewed by a person; the satire desk says, above the text, that it is made up.++ */++const ssr = require('openvibe-publishing/ssr');++const catalog = require('./catalog');++const { periodLabel } = require('./desks');++++const { html: h, raw } = ssr;++const dateLabel = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '');++const time = (ms) => raw(ssr.timeTag(ms ? new Date(ms).toISOString() : null, { label: dateLabel(ms) || undefined }));++const every = (d) => (d.every === 'week' ? 'Every Monday, for the week before' : d.satire ? 'Every day' : 'Every morning, for the day before');++++const AI_NOTE = 'Written by OpenVibe.AI and not reviewed by a person before it appears. Desk posts are not offered to search engines.';++++function satireBanner() {++ return h`<p class="disclosure desk-satire" role="note"><strong>Satire.</strong> This is made up, written by AI for laughs. Nothing in it is true, real advice or real news, and the people, studies and institutes in it do not exist. Do not try anything in it.</p>`;++}++++/** Markdown from the AI, with headings starting at h2 under the page's h1 (a stray "# " becomes "## "). */++function md(text, shift) {++ return raw(ssr.renderMarkdown(String(text || '').replace(/^# /gm, '## '), { rel: 'nofollow noopener', headingShift: shift }));++}++++function index({ latest }) {++ const card = (d) => {++ const p = latest.get(d.key);++ return h`<li class="desk-card">++<h2><a href="/desks/${d.key}">${d.title}</a>${d.satire ? h` <span class="badge">Satire</span>` : ''}</h2>++<p>${d.about}</p>++<p class="meta">${every(d)}${p ? h` · Latest: <a href="/desks/${d.key}/${p.slug}">${p.title}</a> (${time(p.published_at)})` : ' · Nothing written yet'} · <a href="/desks/${d.key}/feed.xml" type="application/rss+xml">Feed</a></p>++</li>`;++ };++ return String(h`<header class="blog-header"><h1>AI desks</h1>++<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, developer releases), and one satire column. Each digest links every item it was written from.</p></header>++<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE} The people's blogs are elsewhere on <a href="/">OpenVibe.Blog</a>.</p>++<ul class="desk-list">${catalog.ALL.map(card)}</ul>`);++}++++function deskPage({ desk, posts, pager, staff }) {++ const item = (p) => h`<li class="post-item">++<h2 class="post-item-title"><a href="/desks/${desk.key}/${p.slug}">${p.title}</a>${p.status === 'hidden' ? h` <span class="badge">Hidden</span>` : ''}</h2>++<p class="meta">${time(p.published_at)}${desk.satire ? '' : h` · ${periodLabel(desk, { from: p.period_from, to: p.period_to })}`} · ${desk.satire ? 'Satire, written by AI' : h`Written by AI from ${p.sources.length} items`}</p>++${p.dek ? h`<p class="summary">${p.dek}</p>` : ''}++</li>`;++ return String(h`${raw(ssr.breadcrumbsHtml([{ name: 'AI desks', url: '/desks' }, { name: desk.title }]))}++<header class="blog-header"><h1>${desk.title}</h1><p class="lede">${desk.about}</p>++<p class="feeds">${every(desk)} · Follow: <a href="/desks/${desk.key}/feed.xml" type="application/rss+xml">RSS</a> <a href="/desks/${desk.key}/atom.xml" type="application/atom+xml">Atom</a></p></header>++${desk.satire ? satireBanner() : h`<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE}</p>`}++<section aria-label="Posts">${posts.length ? h`<ol class="post-list">${posts.map(item)}</ol>` : h`<p class="empty">Nothing written here yet. The first post appears after the desk's first ${desk.every === 'week' ? 'week' : 'day'} of items.</p>`}</section>++${raw(ssr.paginationHtml(pager))}++${staff ? h`<p class="meta">Staff see hidden posts here too.</p>` : ''}`);++}++++/** The article body (also the feeds' content). */++function articleBody(desk, post) {++ if (desk.satire) {++ return String(h`${satireBanner()}++${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}++<div class="post-body">${md(post.body.markdown, 0)}</div>++${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.map((t) => h`<span>#${t}</span> `)}</p>` : ''}`);++ }++ const cite = (n) => h`<a href="#src-${n + 1}">${n + 1}</a>`;++ return String(h`<p class="disclosure" role="note"><strong>AI-generated</strong> from ${post.sources.length} items OpenVibe.Sources fetched, by OpenVibe.AI's blog.write_digest${post.model ? ` (${post.model})` : ''}; not reviewed by a person. Check anything that matters against the linked item.</p>++${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}++<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${md(s.body, 2)}<p class="desk-cites">From ${s.cites.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}</p></section>`)}</div>++${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}++<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);++}++++function postPage({ desk, post, staff, csrf }) {++ return String(h`${raw(ssr.breadcrumbsHtml([{ name: 'AI desks', url: '/desks' }, { name: desk.title, url: `/desks/${desk.key}` }, { name: post.title }]))}++<article class="post desk-post" data-desk="${desk.key}" data-desk-post="${post.id}">++<header><h1>${post.title}</h1>++<p class="byline"><a href="/desks/${desk.key}">${desk.title}</a> · ${time(post.published_at)}${desk.satire ? '' : h` · ${periodLabel(desk, { from: post.period_from, to: post.period_to })}`} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span>${post.status === 'hidden' ? h` <span class="badge">Hidden</span>` : ''}</p></header>++${raw(articleBody(desk, post))}++<footer class="post-footer"><p class="meta">${post.run_id ? h`OpenVibe.AI run ${post.run_id} · ` : ''}<a href="/desks/${desk.key}/${post.slug}.json">JSON</a> · <a href="/desks/${desk.key}">More from ${desk.title}</a> · <a href="/desks">All AI desks</a></p>++${staff ? h`<form method="post" action="/desks/${desk.key}/${post.slug}/${post.status === 'hidden' ? 'show' : 'hide'}" class="desk-staff">++<input type="hidden" name="_csrf" value="${csrf}">++${post.status === 'hidden' ? (post.hidden_reason ? h`<p class="meta">Hidden: ${post.hidden_reason}</p>` : '') : h`<label for="desk-hide-reason">Why hide it (staff)</label><input id="desk-hide-reason" name="reason" maxlength="300">`}++<button type="submit">${post.status === 'hidden' ? 'Show it again' : 'Hide this post'}</button></form>` : ''}++</footer>++</article>`);++}++++module.exports = { index, deskPage, postPage, articleBody, satireBanner };nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * The AI desks on openvibe.blog (server/desks/desks.js):++ *++ * GET /desks every desk, its latest post and feed++ * GET /desks/:desk a desk's posts (20 a page)++ * GET /desks/:desk/feed.xml|atom.xml its feed (RSS 2.0, Atom); satire titles start "[Satire]"++ * GET /desks/:desk/:slug one post (…/:slug.json: the same post as data)++ * POST /desks/:desk/:slug/hide|show staff (Network admins): take a post down or put it back++ *++ * Every page is noindex (AI text no person reviewed) and, for an anonymous visitor, publicly cacheable for a minute.++ */++const express = require('express');++const seo = require('openvibe-publishing/seo');++const ssr = require('openvibe-publishing/ssr');++const cache = require('openvibe-shared/cache-policy');++const { renderPage } = require('../render/layout');++const pages = require('../render/pages');++const { csrfToken, checkCsrf } = require('../auth/forms');++const catalog = require('./catalog');++const render = require('./render');++++const PER_PAGE = 20;++const NOINDEX = { indexable: false, listable: true, robots: 'noindex, follow', codes: ['ai_generated_unreviewed'] };++++function createDeskRoutes(ctx) {++ const { config, store, desks, viewers } = ctx;++ const router = express.Router();++ router.use('/desks', viewers.middleware({ services: false }));++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++ const abs = (p) => `${config.baseUrl}${p}`;++ const isStaff = (req) => Boolean(req.viewer && req.viewer.staff);++++ function decision(path) {++ return seo.evaluate({ state: 'draft', visibility: 'public', canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query: ['page'] }), wordCount: 0, noindex: true },++ { policy: { minWords: 0 }, now: store.now() });++ }++++ function send(req, res, status, page, { cacheable = true } = {}) {++ res.vary('Cookie');++ res.vary('Authorization');++ const anon = req.viewer.kind === 'anonymous';++ res.set('Cache-Control', cache.htmlHeaders(cacheable && anon && status === 200 ? { maxAge: 60 } : { private: true }));++ res.set('X-Robots-Tag', 'noindex, follow');++ res.status(status).type('html').send(renderPage({ ...page, decision: page.decision || decision(req.path), viewer: req.viewer, config, path: req.originalUrl }));++ }++++ function notFound(req, res) {++ send(req, res, 404, { title: 'Not found', body: pages.message({ heading: 'Not found', text: 'There is no desk post at this address.', action: { href: '/desks', label: 'All AI desks' } }) }, { cacheable: false });++ }++++ const deskOf = (req) => catalog.byKey(req.params.desk);++ const feedsOf = (desk) => [++ { type: 'application/rss+xml', href: `/desks/${desk.key}/feed.xml`, title: `${desk.title} (RSS)` },++ { type: 'application/atom+xml', href: `/desks/${desk.key}/atom.xml`, title: `${desk.title} (Atom)` },++ ];++++ router.get('/desks', wrap(async (req, res) => {++ send(req, res, 200, {++ title: 'AI desks',++ description: 'Niche blogs written by AI from the data OpenVibe.Sources collects, and a satire column. Labelled, cited, never indexed.',++ canonical: abs('/desks'),++ body: render.index({ latest: await desks.latest() }),++ });++ }));++++ async function feed(req, res, kind) {++ const desk = deskOf(req);++ if (!desk) return notFound(req, res);++ const { posts } = await desks.list(desk.key, { limit: 20 });++ const items = posts.map((p) => ({++ id: `tag:openvibe.blog,2026:desk/${p.id}`,++ url: abs(`/desks/${desk.key}/${p.slug}`),++ title: desk.satire ? `[Satire] ${p.title}` : p.title,++ summary: p.dek || null,++ contentHtml: render.articleBody(desk, p),++ published: new Date(p.published_at).toISOString(),++ authors: [{ name: desk.satire ? `${desk.title} (satire, written by AI)` : `${desk.title} (written by AI)` }],++ tags: desk.satire ? ['satire', ...(p.body.tags || [])] : [desk.key],++ decision: NOINDEX,++ }));++ const link = abs(`/desks/${desk.key}`);++ const feedUrl = abs(`/desks/${desk.key}/${kind === 'rss' ? 'feed.xml' : 'atom.xml'}`);++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));++ res.set('X-Robots-Tag', 'noindex, follow');++ const description = `${desk.about} Written by AI on OpenVibe.Blog.`;++ if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: `${desk.title} · OpenVibe.Blog`, link, description, feedUrl, language: 'en' }, items));++ return res.type('application/atom+xml').send(seo.atomFeed({ title: `${desk.title} · OpenVibe.Blog`, link, feedUrl, id: `tag:openvibe.blog,2026:desk/${desk.key}`, subtitle: description, ...(items.length ? {} : { updated: new Date(store.now()).toISOString() }) }, items));++ }++ router.get('/desks/:desk/feed.xml', wrap(async (req, res) => await feed(req, res, 'rss')));++ router.get('/desks/:desk/atom.xml', wrap(async (req, res) => await feed(req, res, 'atom')));++++ router.get('/desks/:desk', wrap(async (req, res) => {++ const desk = deskOf(req);++ if (!desk) return notFound(req, res);++ const n = parseInt(req.query.page, 10);++ const page = Number.isInteger(n) && n > 0 ? Math.min(n, 10000) : 1;++ const staff = isStaff(req);++ const { total, posts } = await desks.list(desk.key, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE, staff });++ const path = `/desks/${desk.key}`;++ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });++ if (pager.outOfRange && total) return notFound(req, res);++ send(req, res, 200, {++ title: desk.title, description: desk.about, canonical: abs(pager.page === 1 ? path : `${path}?page=${pager.page}`),++ feeds: feedsOf(desk), prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,++ body: render.deskPage({ desk, posts, pager, staff }),++ }, { cacheable: !staff });++ }));++++ async function postOf(req) {++ const desk = deskOf(req);++ if (!desk) return {};++ const post = await desks.bySlug(desk.key, req.params.slug);++ if (!post || !(post.status === 'published' || (post.status === 'hidden' && isStaff(req)))) return { desk };++ return { desk, post };++ }++++ const jsonView = (desk, p) => ({++ id: p.id, desk: desk.key, desk_title: desk.title, satire: Boolean(desk.satire), slug: p.slug, url: abs(`/desks/${desk.key}/${p.slug}`),++ title: p.title, dek: p.dek, published_at: new Date(p.published_at).toISOString(),++ period: desk.satire ? null : { from: new Date(p.period_from).toISOString(), to: new Date(p.period_to).toISOString() },++ authorship: { mode: 'ai', reviewed: false, workflow: desk.satire ? 'blog.write_satire' : 'blog.write_digest', run_id: p.run_id, model: p.model },++ ...(desk.satire ? { body_markdown: p.body.markdown, tags: p.body.tags || [] } : { sections: p.body.sections, gaps: p.body.gaps || [], sources: p.sources }),++ });++++ router.get('/desks/:desk/:slug.json', wrap(async (req, res) => {++ const { desk, post } = await postOf(req);++ if (!post || post.status !== 'published') return res.status(404).json({ code: 'desk.not_found', detail: 'No desk post at this address' });++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));++ res.set('X-Robots-Tag', 'noindex');++ res.set('Access-Control-Allow-Origin', '*');++ res.json({ post: jsonView(desk, post) });++ }));++++ router.get('/desks/:desk/:slug', wrap(async (req, res) => {++ const { desk, post } = await postOf(req);++ if (!post) return notFound(req, res);++ const staff = isStaff(req);++ send(req, res, 200, {++ title: desk.satire ? `${post.title} (satire)` : post.title,++ description: desk.satire ? `Satire, written by AI: ${post.dek || desk.about}` : (post.dek || desk.about),++ canonical: abs(`/desks/${desk.key}/${post.slug}`),++ type: 'article', feeds: feedsOf(desk),++ published: new Date(post.published_at).toISOString(),++ body: render.postPage({ desk, post, staff, csrf: staff ? csrfToken(config, req.viewer) : '' }),++ }, { cacheable: !staff && post.status === 'published' });++ }));++++ for (const action of ['hide', 'show']) {++ router.post(`/desks/:desk/:slug/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), wrap(async (req, res) => {++ if (!isStaff(req)) return send(req, res, 403, { title: 'Staff only', body: pages.message({ heading: 'Staff only', text: 'Only OpenVibe staff can hide or show a desk post.' }) }, { cacheable: false });++ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return send(req, res, 403, { title: 'Form expired', body: pages.message({ heading: 'Form expired', text: 'Reload the page and try again.' }) }, { cacheable: false });++ const { desk, post } = await postOf(req);++ if (!post) return notFound(req, res);++ await desks.setHidden(post, action === 'hide', { by: req.viewer.subject, reason: req.body.reason });++ res.redirect(303, `/desks/${desk.key}/${post.slug}`);++ }));++ }++++ return router;++}++++module.exports = { createDeskRoutes };@@@ -72,6 +72,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {{ title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },] },{ title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },++ { title: 'AI desks', links: [{ title: 'AI desks', url: abs('/desks'), note: 'digests OpenVibe.AI writes from OpenVibe.Sources items (news, food recalls, deals, hosting plans, developer releases) and a satire column; labelled as AI, every digest section cited, never indexed; each desk has /desks/<desk>/feed.xml and each post a .json twin' }] },],}));});@@@ -88,7 +88,7 @@ function createPublicRoutes(ctx) {eyebrow: 'OpenVibe.Blog',title: 'Write on', accent: 'OpenVibe',lede: 'A blog of your own with drafts, scheduling, feeds and comments. Sign in once with your OpenVibe account; anyone can read without one.',-- actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }],++ actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],}) + showcase.features({title: 'What your blog gets',items: [@@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');function createLifecycle({ server, ctx, exit, signals, extra = [] }) {return gracefulStop({name: 'Blog', server, deadlineExitCode: 0, exit, signals,-- stop: [() => { clearInterval(ctx.telemetryTick); return ctx.telemetry.stop(); }, () => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],++ stop: [() => { clearInterval(ctx.telemetryTick); return ctx.telemetry.stop(); }, () => ctx.worker.stop(), () => ctx.changelog.stop(), () => ctx.desks.stop(), ...extra],close: [() => ctx.outbox.stop(), () => ctx.store.close()],});}@@@ -39,6 +39,8 @@ async function start() {ctx.telemetryTick.unref();// The network changelog and patch notes run where the worker runs (one process).if (config.worker.enabled) ctx.changelog.start();++ // The AI desks (server/desks/desks.js) write where the worker runs too.++ if (config.worker.enabled) ctx.desks.start();// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,// BLOG_EVENTS_SECRET or the client secret.@@@ -60,6 +60,7 @@ function renderPage(o) {apiBase: NETWORK_URL,links: [{ label: 'Blog', href: '/' },++ { label: 'AI desks', href: '/desks' },{ label: 'Write', href: '/write', icon: 'fa-pen' },],history: { type: 'page', title: o.title || SITE_NAME },@@@ -97,7 +98,7 @@ function renderPage(o) {next: o.next,navbar: nav,footer,-- navLinks: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }],++ navLinks: [{ label: 'Blog', href: '/' }, { label: 'AI desks', href: '/desks' }, { label: 'Write', href: '/write' }],home: '/',css: asset('css/blog.css'),styles: o.styles,nnew file mode 100644@@@ -0,0 +1,209 @@++'use strict';++/**++ * The AI desks (server/desks/): digests written by OpenVibe.AI from OpenVibe.Sources' items, and the satire desk.++ * - a desk's period is the day (or Monday-to-Sunday week) before, written after its hour;++ * - the first pull dates items by when Sources first saw them, a later revision by when Blog saw it, and a removed++ * item is dropped; a period with fewer items than the desk's minimum gets no post;++ * - a section the AI did not cite is left out and said so; a failure is retried with a new key; stub output is hidden;++ * - every page and feed says the text is AI-written (the satire desk: made up), and nothing is indexed;++ * - staff can hide a post and show it again; nobody else can.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const { duePeriod, periodLabel } = require('../server/desks/desks');++const catalog = require('../server/desks/catalog');++++const at = (s) => Date.parse(s);++++function stub(handler) {++ return new Promise((resolve) => {++ const s = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const out = handler(req, raw ? JSON.parse(raw) : null) || { status: 404, body: {} };++ res.writeHead(out.status || 200, { 'content-type': 'application/json' });++ res.end(JSON.stringify(out.body));++ });++ }).listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise((r) => s.close(r)) }));++ });++}++++(async () => {++ await check('periods: the day before after the desk\'s hour, Monday-to-Sunday weeks, satire by the day', () => {++ const news = catalog.byKey('news');++ assert.deepStrictEqual(duePeriod(news, at('2026-10-15T05:00:00Z')), { from: at('2026-10-13T00:00:00Z'), to: at('2026-10-14T00:00:00Z') });++ assert.deepStrictEqual(duePeriod(news, at('2026-10-15T06:00:00Z')), { from: at('2026-10-14T00:00:00Z'), to: at('2026-10-15T00:00:00Z') });++ const hosting = catalog.byKey('hosting');++ assert.deepStrictEqual(duePeriod(hosting, at('2026-10-12T07:30:00Z')), { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }, 'Monday after 07:00');++ assert.deepStrictEqual(duePeriod(hosting, at('2026-10-18T23:00:00Z')), { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }, 'Sunday: still last week');++ assert.strictEqual(periodLabel(news, { from: at('2026-10-14T00:00:00Z'), to: at('2026-10-15T00:00:00Z') }), 'Wednesday 14 October 2026');++ assert.strictEqual(periodLabel(hosting, { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }), 'the week of 5 October to 11 October 2026');++ assert.deepStrictEqual(catalog.questionFor(0), { topic: catalog.QUESTIONS[0], angle: catalog.ANGLES[0] });++ assert.strictEqual(catalog.questionFor(catalog.QUESTIONS.length).angle, catalog.ANGLES[1], 'the second round changes the angle');++ });++++ // OpenVibe.Sources: the change feed by category.++ let seq = 0;++ const feed = [];++ const add = (category, source_key, item) => {++ const it = { id: item.id, source_key, category, kind: 'article', canonical_url: `https://example.gov/${item.id}`, title: `Item ${item.id}`, summary: `What ${item.id} says.`,++ published_at: null, fields: {}, revision: 1, removed: null, provenance: { first_seen_at: item.first_seen_at }, ...item, change_seq: ++seq };++ const i = feed.findIndex((x) => x.id === it.id);++ if (i >= 0) feed.splice(i, 1);++ feed.push(it);++ };++ const sources = await stub((req) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname !== '/api/v1/items') return null;++ const after = Number(u.searchParams.get('after'));++ const items = feed.filter((x) => x.category === u.searchParams.get('category') && x.change_seq > after).sort((a, b) => a.change_seq - b.change_seq);++ return { body: { items, next_after: items.length ? items[items.length - 1].change_seq : after, more: false, sources: {} } };++ });++ add('news', 'govuk-news', { id: 'n1', first_seen_at: '2026-10-14T09:00:00Z' });++ add('news', 'eu-press-corner', { id: 'n2', first_seen_at: '2026-10-14T11:00:00Z' });++ add('news', 'nasa-news-releases', { id: 'n3', first_seen_at: '2026-10-14T20:00:00Z' });++ add('news', 'govuk-news', { id: 'n0', first_seen_at: '2026-10-10T09:00:00Z' });++ add('news', 'some-other-feed', { id: 'x1', first_seen_at: '2026-10-14T09:00:00Z' });++ add('news', 'fda-food-recalls', { id: 'f1', first_seen_at: '2026-10-14T15:00:00Z', title: 'Company recalls hummus over undeclared sesame' });++ add('hosting', 'linode-types', { id: 'h1', first_seen_at: '2026-10-01T09:00:00Z', kind: 'plan', fields: { vcpus: 1, price_monthly: 5 } });++++ // OpenVibe.AI: blog.write_digest and blog.write_satire.++ const runs = [];++ let mode = 'ok';++ const ai = await stub((req, body) => {++ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {++ runs.push(body);++ const id = `run_${runs.length}`;++ if (mode === 'fail') return { status: 201, body: { run: { id, status: 'failed', error: { code: 'provider.unavailable', detail: 'down' } } } };++ const n = body.input.sources ? body.input.sources.length : 0;++ const output = body.workflow === 'blog.write_satire'++ ? { title: 'Is it safe to let your air fryer vote?', dek: 'Experts at the Institute of Crisp are alarmed.', body_markdown: '## The crisis\n\nYour air fryer has opinions.\n\n# A stray title\n\nIt does.', tags: ['Appliances', 'democracy'] }++ : { title: 'Recalls and releases', dek: 'What was announced.', sections: [++ { heading: 'Announcements', body: 'Two agencies announced things.', citations: [0, 1, 99] },++ { heading: 'Unsourced', body: 'Something the AI made up.', citations: [] },++ ...(n > 2 ? [{ heading: 'Space', body: 'NASA said something.', citations: [2] }] : []),++ ], citations: [0], gaps: ['Item 3 had no date.'] };++ return { status: 201, body: { run: { id, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, synthetic: mode === 'stub', provenance: { model: 'gpt-test' }, output } } };++ }++ if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };++ return null;++ });++++ const t = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, OV_SOURCES_INTERNAL_URL: sources.url } });++ t.clock.set(at('2026-10-15T08:00:00Z'));++ const desks = t.ctx.desks;++ const boss = t.network.addUser('boss', { role: 'admin' });++ const kim = t.network.addUser('kim');++ let news;++ let satire;++++ await check('a tick pulls, then writes the news, food and satire posts; a desk below its minimum writes nothing', async () => {++ assert.ok(desks.enabled);++ const r = await desks.tick();++ assert.ok(r && r.written === 3, JSON.stringify(r));++ const byWorkflow = (w) => runs.filter((x) => x.workflow === w);++ const digests = byWorkflow('blog.write_digest');++ assert.deepStrictEqual(digests.map((x) => x.input.desk).sort(), ['Food safety digest', 'News digest']);++ const n = digests.find((x) => x.input.desk === 'News digest');++ assert.deepStrictEqual(n.input.sources.map((s) => s.source_id), ['n3', 'n2', 'n1'], 'the period\'s items of its own sources, newest first (n0 is older, x1 is another source)');++ assert.strictEqual(n.input.period, 'Wednesday 14 October 2026');++ assert.ok(n.input.sources[0].snippet.includes('New at the source.'));++ assert.strictEqual(n.idempotency_key, 'blog.desk:news:2026-10-14:1');++ assert.deepStrictEqual(byWorkflow('blog.write_satire').map((x) => x.input), [catalog.questionFor(0)]);++ news = await desks.bySlug('news', '2026-10-14');++ assert.strictEqual(news.status, 'published');++ assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.cites]), [['Announcements', [0, 1]], ['Space', [2]]], 'uncited section dropped, out-of-range citation dropped');++ assert.ok(news.body.gaps.some((g) => g.includes('without citing')));++ satire = (await desks.list('satire')).posts[0];++ assert.strictEqual(satire.slug, '2026-10-14-is-it-safe-to-let-your-air-fryer-vote');++ assert.deepStrictEqual(satire.body.tags, ['appliances', 'democracy']);++ assert.strictEqual((await desks.list('deals')).total, 0);++ assert.strictEqual((await desks.list('hosting')).total, 0, 'a plan first seen weeks ago is not news this week');++ });++++ await check('nothing is written twice; a changed item is dated when Blog saw it; a removed item is dropped', async () => {++ const before = runs.length;++ await desks.tick();++ assert.strictEqual(runs.length, before, 'the period already has its posts');++ add('hosting', 'linode-types', { id: 'h1', first_seen_at: '2026-10-01T09:00:00Z', kind: 'plan', revision: 2, fields: { vcpus: 1, price_monthly: 6 } });++ add('news', 'govuk-news', { id: 'n1', removed: { at: '2026-10-15T08:00:00Z', reason: 'licence' } });++ await desks.pull();++ const h1 = await t.ctx.store.db.maybe('SELECT * FROM blog_desk_items WHERE item_id = $1', ['h1']);++ assert.strictEqual(Number(h1.changed_at), at('2026-10-15T08:00:00Z'));++ assert.strictEqual(await t.ctx.store.db.maybe('SELECT * FROM blog_desk_items WHERE item_id = $1', ['n1']), null);++ t.clock.set(at('2026-10-19T08:00:00Z'));++ await desks.tick();++ const hosting = (await desks.list('hosting')).posts[0];++ assert.ok(hosting, 'the week with the price change gets a post');++ assert.strictEqual(hosting.slug, '2026-10-12');++ const req = runs.filter((x) => x.input.desk === 'Hosting digest').pop();++ assert.ok(req.input.sources[0].snippet.includes('Changed at the source (revision 2)') && req.input.sources[0].snippet.includes('price_monthly: 6'));++ });++++ await check('a failed run is retried after 30 minutes with a new key, three attempts at most; stub text is hidden', async () => {++ add('news', 'fda-food-recalls', { id: 'f2', first_seen_at: '2026-10-19T10:00:00Z' });++ t.clock.set(at('2026-10-20T08:00:00Z'));++ await desks.pull();++ mode = 'fail';++ await desks.compose();++ let row = await t.ctx.store.db.maybe("SELECT * FROM blog_desk_posts WHERE desk = 'food' AND slug = '2026-10-19'");++ assert.deepStrictEqual([row.status, row.attempts], ['failed', 1]);++ const n = runs.length;++ await desks.compose();++ assert.strictEqual(runs.filter((x) => x.input.desk === 'Food safety digest').length, runs.slice(0, n).filter((x) => x.input.desk === 'Food safety digest').length, 'not before 30 minutes');++ t.clock.advance(31 * 60_000);++ mode = 'stub';++ await desks.compose();++ row = await t.ctx.store.db.maybe("SELECT * FROM blog_desk_posts WHERE desk = 'food' AND slug = '2026-10-19'");++ assert.strictEqual(runs.filter((x) => x.input.desk === 'Food safety digest').pop().idempotency_key, 'blog.desk:food:2026-10-19:2');++ assert.deepStrictEqual([row.status, row.synthetic, row.hidden_reason], ['hidden', true, 'Written by the stub provider (synthetic text).']);++ mode = 'ok';++ });++++ await check('pages: labelled as AI, satire says it is made up, cited items linked, never indexed', async () => {++ const index = await t.get('/desks');++ assert.strictEqual(index.status, 200);++ assert.ok(index.text.includes('Written by AI.') && index.text.includes('/desks/news/2026-10-14') && index.text.includes('Is This Real?'));++ assert.match(index.headers.get('x-robots-tag'), /noindex/);++ assert.match(index.text, /<meta name="robots" content="noindex/);++ const page = await t.get('/desks/news/2026-10-14');++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('AI-generated</strong> from 3 items'));++ assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));++ assert.ok(page.text.includes('<a href="#src-1">1</a>') && page.text.includes('id="src-3"'));++ assert.ok(!page.text.includes('Something the AI made up'));++ assert.match(page.headers.get('cache-control'), /max-age=60/);++ const s = await t.get(`/desks/satire/${satire.slug}`);++ assert.ok(s.text.includes('<strong>Satire.</strong> This is made up'));++ assert.ok(s.text.includes('<title>Is it safe to let your air fryer vote? (satire)'));++ assert.ok(s.text.includes('<h2') && !/<h1[^>]*>A stray title/.test(s.text), 'the AI\'s headings sit under the page title');++ const rss = await t.get('/desks/satire/feed.xml');++ assert.ok(rss.text.includes('[Satire] Is it safe to let your air fryer vote?'));++ const atom = await t.get('/desks/news/atom.xml');++ assert.ok(atom.text.includes('<feed') && atom.text.includes('Recalls and releases'));++ const json = (await t.get('/desks/news/2026-10-14.json')).json();++ assert.deepStrictEqual([json.post.authorship.mode, json.post.authorship.reviewed, json.post.sources.length], ['ai', false, 3]);++ assert.strictEqual((await t.get('/desks/food/2026-10-19')).status, 404, 'hidden: not for readers');++ assert.strictEqual((await t.get('/desks/nope')).status, 404);++ });++++ await check('staff hide a post and show it again; a member cannot', async () => {++ const denied = await t.get('/desks/news/2026-10-14/hide', { as: kim, form: { _csrf: t.csrf(kim), reason: 'no' } });++ assert.strictEqual(denied.status, 403);++ const hid = await t.get('/desks/news/2026-10-14/hide', { as: boss, form: { _csrf: t.csrf(boss), reason: 'Wrong numbers' } });++ assert.strictEqual(hid.status, 303);++ assert.strictEqual((await t.get('/desks/news/2026-10-14')).status, 404);++ const staffView = await t.get('/desks/news/2026-10-14', { as: boss });++ assert.ok(staffView.text.includes('Hidden: Wrong numbers') && staffView.text.includes('Show it again'));++ assert.ok(!(await t.get('/desks/news/feed.xml')).text.includes('Recalls and releases'));++ await t.get('/desks/news/2026-10-14/show', { as: boss, form: { _csrf: t.csrf(boss) } });++ assert.strictEqual((await t.get('/desks/news/2026-10-14')).status, 200);++ });++++ await t.close();++ await ai.close();++ await sources.close();++ done();++})();@@@ -20,7 +20,7 @@ const BUDGETS = {jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 1-- cssRawKB: 17.5, // 5.0++ cssRawKB: 18.5, // 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)cssBrotliKB: 4.5, // 1.4externalFiles: 1, // 0};
@@ -53,6 +53,8 @@ Other tables in the same database:
@@ -53,6 +53,8 @@ Other tables in the same database:
by raw URL; `server/app.js` builds the collector, `server/index.js` flushes it once a minute and on stop (one `http.request` sample per key, plus the
by raw URL; `server/app.js` builds the collector, `server/index.js` flushes it once a minute and on stop (one `http.request` sample per key, plus the
active-requests, p95 latency and event-loop-lag gauges, in the openvibe-sdk 0.46 `platform.telemetry-sample@1` shape), and `server/worker.js` deletes
active-requests, p95 latency and event-loop-lag gauges, in the openvibe-sdk 0.46 `platform.telemetry-sample@1` shape), and `server/worker.js` deletes
rows older than 14 days daily. `/shared`, static assets, health, readiness and `/metrics` are not counted.
rows older than 14 days daily. `/shared`, static assets, health, readiness and `/metrics` are not counted.
+- The AI desks (migration `0004_ai_desks.sql`): `blog_ingest_cursor` (Sources change cursors), `blog_desk_items` (the
+ Sources items the desks may cite, 21 days) and `blog_desk_posts` (one post per desk and period).
## Does not own
## Does not own
@@ -153,6 +155,34 @@ Other tables in the same database:
@@ -153,6 +155,34 @@ Other tables in the same database:
| `/write/…` | the editor, as plain HTML forms with Network SSO and a form token |
| `/write/…` | the editor, as plain HTML forms with Network SSO and a form token |
| `/auth/*` | sign-in; the same session layer as OpenVibe.Community |
| `/auth/*` | sign-in; the same session layer as OpenVibe.Community |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness and metrics; `/metrics` is loopback only |
| `/api/health`, `/api/ready`, `/release.json`, `/metrics` | health, readiness and metrics; `/metrics` is loopback only |
+| `/desks`, `/desks/:desk`, `/desks/:desk/:slug(.json)`, `/desks/:desk/feed.xml\|atom.xml` | the AI desks (below); staff `POST …/hide\|show` |
+
+### AI desks (`server/desks/`)
+
+The owner's April ask: "AI-generated niche blogs backed by large source datasets" and a "satirical/parody type blog".
+They sit beside the blogs, not in them: a desk post is never a `blog_posts` row (whose AI text waits for a person's
+review), says on every page, feed entry and JSON twin that OpenVibe.AI wrote it and no person reviewed it, and is never
+indexed (`noindex`, not in the sitemaps).
+
+| Desk | Sources (OpenVibe.Sources keys) | When |
+|---|---|---|
+| News digest | `govuk-news`, `eu-press-corner`, `nasa-news-releases`, `noaa-news` | daily, 06:00 UTC, for the day before (3 items at least) |
+| Food safety digest | `fda-food-recalls` | daily, 06:00 UTC (1 item at least) |
+| Deals digest | `dealnews-daily` | daily, 07:00 UTC (5 items at least) |
+| Hosting digest | `linode-types`, `aws-lightsail-bundles`, `ovhcloud-vps`, `vultr-plans`, `lowendbox-posts`, `lowendtalk-offers` | Mondays, for the week before |
+| Developer digest | `nodejs-blog` | Mondays, for the week before |
+| Is This Real? (satire) | none | daily, 14:00 UTC |
+
+Every 15 minutes (`BLOG_DESKS_INTERVAL_MS`) where the worker runs: the change feed of each Sources category the desks
+read is pulled (openvibe-publishing/ingest cursors in `blog_ingest_cursor`) into `blog_desk_items` (21 days; a removed
+item is dropped). An item new to Blog is dated by when Sources first saw it, a new revision by when Blog saw it, so the
+first pull never presents old items as new. When a desk's period is over, its newest items (40 at most) go to
+`blog.write_digest`; a section the AI did not cite is left out and said so, a run that cites nothing makes no post, and
+every cited item is linked under the post. The satire desk sends the next question of `server/desks/catalog.js` to
+`blog.write_satire` (fiction with its own rules in OpenVibe.AI: no real people, no words in a real organisation's mouth,
+nothing dangerous) and every page and feed title says it is satire. A failed run is retried after 30 minutes with a new
+key (three attempts); the stub provider's text is kept but hidden. Staff (Network admins) may hide a post and show it
+again. `BLOG_DESKS=off` stops the desks; the digests need `OV_SOURCES_INTERNAL_URL`, every desk `OV_AI_INTERNAL_URL`.
### The network changelog and patch notes
### The network changelog and patch notes
@@ -310,6 +340,8 @@ Each grant is `[client, capability, audience]`:
@@ -310,6 +340,8 @@ Each grant is `[client, capability, audience]`:
with the network's token instead of anonymously; `CHANGELOG_GITHUB_TOKEN` overrides it).
with the network's token instead of anonymously; `CHANGELOG_GITHUB_TOKEN` overrides it).
- `[blog, ai.run.create, openvibe.ai]` and `[blog, ai.run.read, openvibe.ai]` for AI drafts (the
- `[blog, ai.run.create, openvibe.ai]` and `[blog, ai.run.read, openvibe.ai]` for AI drafts (the
editor's "draft with AI" and the dev-blog draft of each patch notes post).
editor's "draft with AI" and the dev-blog draft of each patch notes post).
+- `[blog, sources.item.read, openvibe.sources]` for the AI desks' digests (`blog.write_digest` and `blog.write_satire`
+ run under the `ai.run.create` grant above).
- For OpenVibe.AI to deliver drafts: `[ai, blog.post.create, openvibe.blog]`. Add
- For OpenVibe.AI to deliver drafts: `[ai, blog.post.create, openvibe.blog]`. Add
`[ai, blog.post.read, openvibe.blog]` if it reads the drafts back.
`[ai, blog.post.read, openvibe.blog]` if it reads the drafts back.
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+-- phase: expand
+-- OpenVibe.Blog's AI desks (server/desks/): digests written by OpenVibe.AI from the items OpenVibe.Sources fetched, and
+-- the "Is This Real?" satire desk. A desk post is not a blog post: it never enters blog_posts (whose AI text waits for
+-- a person's review), it is labelled as AI-written on every surface and it is never indexed.
+--
+-- blog_ingest_cursor openvibe-publishing/ingest change cursors, one per Sources category the desks read
+-- blog_desk_items the Sources items the desks may cite (title, link, summary, fields), kept 21 days
+-- blog_desk_posts one post per desk and period: writing, published, hidden (by staff) or failed (retried)
+
+CREATE TABLE IF NOT EXISTS blog_ingest_cursor (
+ name text COLLATE "C" NOT NULL,
+ cursor bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (name)
+);
+
+CREATE TABLE blog_desk_items (
+ item_id text COLLATE "C" PRIMARY KEY,
+ source_key text COLLATE "C" NOT NULL,
+ title text,
+ url text,
+ summary text,
+ published_at text,
+ fields jsonb NOT NULL DEFAULT '{}'::jsonb,
+ revision integer NOT NULL DEFAULT 1,
+ -- When the item appeared or last changed: Sources' first_seen_at for a new item, the time Blog saw a new revision.
+ changed_at bigint NOT NULL,
+ updated_at bigint NOT NULL
+);
+CREATE INDEX blog_desk_items_changed ON blog_desk_items (source_key, changed_at);
+
+CREATE TABLE blog_desk_posts (
+ id text COLLATE "C" PRIMARY KEY,
+ desk text COLLATE "C" NOT NULL,
+ slug text COLLATE "C" NOT NULL,
+ status text NOT NULL CHECK (status IN ('writing','published','hidden','failed')),
+ title text,
+ dek text,
+ -- digest: { sections: [{ heading, body, cites: [n] }], gaps: [] }; satire: { markdown, tags, topic, angle }
+ body jsonb NOT NULL DEFAULT '{}'::jsonb,
+ -- the items the AI was given, in order (a citation n is sources[n]): [{ item_id, source_key, title, url, published_at }]
+ sources jsonb NOT NULL DEFAULT '[]'::jsonb,
+ period_from bigint NOT NULL,
+ period_to bigint NOT NULL,
+ run_id text,
+ model text,
+ synthetic boolean NOT NULL DEFAULT false,
+ attempts integer NOT NULL DEFAULT 0,
+ error text,
+ hidden_by text,
+ hidden_reason text,
+ created_at bigint NOT NULL,
+ published_at bigint,
+ updated_at bigint NOT NULL,
+ UNIQUE (desk, slug),
+ UNIQUE (desk, period_from)
+);
+CREATE INDEX blog_desk_posts_listing ON blog_desk_posts (desk, status, published_at DESC);
@@ -31,6 +31,10 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
@@ -31,6 +31,10 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
.disclosure { border: 1px solid var(--warning, #f59e0b); border-radius: 8px; padding: 8px 12px; }
.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }
.series { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 8px 14px; margin: 12px 0; font-size: .95rem; }
.sources ol { padding-left: 1.2rem; }
.sources ol { padding-left: 1.2rem; }
+.desk-list { list-style: none; padding: 0; }
+.desk-card h2 { margin: 16px 0 4px; font-size: 1.2rem; }
+.desk-satire { border-color: var(--danger, #ef4444); }
+.desk-cites, .desk-gaps { font-size: .9rem; color: var(--text-secondary, #96a7c2); }
.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
.ov-breadcrumbs ol { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: .9rem; }
.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
.ov-breadcrumbs li + li::before { content: "›"; margin-right: 6px; color: var(--text-muted); }
.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
.ov-pagination { display: flex; gap: 10px; flex-wrap: wrap; margin: 20px 0; }
@@ -97,7 +97,9 @@ async function createApp(opts = {}) {
@@ -97,7 +97,9 @@ async function createApp(opts = {}) {
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
const aiDrafts = opts.aiDrafts || require('./domain/ai-drafts').createAiDrafts({ config, store, posts, access, fetchImpl });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
const changelog = opts.changelog || require('./changelog').createChangelog({ config, store, blogs, posts, aiDrafts, fetchImpl, log });
- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, indexnow };
+ // The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
+ const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
+ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, desks, indexnow };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -210,6 +212,7 @@ async function createApp(opts = {}) {
@@ -210,6 +212,7 @@ async function createApp(opts = {}) {
// ── Discovery, editor, public pages ─────────────────────
// ── Discovery, editor, public pages ─────────────────────
app.use(createDiscoveryRoutes(ctx));
app.use(createDiscoveryRoutes(ctx));
+ app.use(require('./desks/routes').createDeskRoutes(ctx));
const publicRoutes = createPublicRoutes(ctx);
const publicRoutes = createPublicRoutes(ctx);
ctx.publicRoutes = publicRoutes;
ctx.publicRoutes = publicRoutes;
app.use('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));
app.use('/write', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), createEditorRoutes({ ...ctx, publicRoutes }));
@@ -63,6 +63,11 @@ function load(env = process.env) {
@@ -63,6 +63,11 @@ function load(env = process.env) {
},
},
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
// OpenVibe.AI for "Draft with AI" (server/domain/ai-drafts.js); empty = the button is not offered.
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
+ // OpenVibe.Sources (sources.item.read): the items the AI desks write their digests from (server/desks/).
+ sources: { internalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''), timeoutMs: int(env.BLOG_SOURCES_TIMEOUT_MS, 10000) },
+ // The AI desks (server/desks/desks.js): pull, write and prune every intervalMs where the worker runs. Off with
+ // BLOG_DESKS=off; the digests also need OV_SOURCES_INTERNAL_URL, every desk needs OV_AI_INTERNAL_URL.
+ desks: { enabled: env.BLOG_DESKS !== 'off', intervalMs: Math.max(60_000, int(env.BLOG_DESKS_INTERVAL_MS, 15 * 60_000)) },
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment
// it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →
// it appears, changes or leaves the index (the key file is served at /<key>.txt). Unset →
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * The AI desks OpenVibe.Blog runs (server/desks/desks.js). A digest desk reads named OpenVibe.Sources sources and, once
+ * its period is over, asks OpenVibe.AI's blog.write_digest for one post about what they published, every section cited.
+ * The satire desk asks blog.write_satire for one invented mock-explainer a day from the questions below.
+ *
+ * key the URL (/desks/<key>)
+ * every 'day' (the UTC day before) or 'week' (Monday to Sunday, UTC)
+ * hour the UTC hour the post is written after the period ends (feeds settle first)
+ * categories the Sources categories its sources are in (one change cursor each)
+ * sources the Sources source keys it reads (a source that is disabled or empty just contributes nothing)
+ * min, max fewer items than min: no post for that period; at most max items go to the AI (the newest)
+ */
+
+const DESKS = [
+ {
+ key: 'news', title: 'News digest', every: 'day', hour: 6, min: 3, max: 40,
+ about: 'What governments and space agencies announced the day before: GOV.UK, the European Commission press corner and NASA.',
+ categories: ['news'], sources: ['govuk-news', 'eu-press-corner', 'nasa-news-releases', 'noaa-news'],
+ },
+ {
+ key: 'food', title: 'Food safety digest', every: 'day', hour: 6, min: 1, max: 30,
+ about: 'Food recalls and allergy alerts posted through the US Food and Drug Administration the day before. Read each notice itself before you throw anything out.',
+ categories: ['news'], sources: ['fda-food-recalls'],
+ },
+ {
+ key: 'deals', title: 'Deals digest', every: 'day', hour: 7, min: 5, max: 40,
+ about: 'The deals DealNews published the day before, grouped by kind. Prices are as the listing stated them when it was fetched.',
+ categories: ['deals'], sources: ['dealnews-daily'],
+ },
+ {
+ key: 'hosting', title: 'Hosting digest', every: 'week', hour: 7, min: 1, max: 40,
+ about: 'New and changed VPS and cloud plans in providers\' own price lists (Akamai/Linode, AWS Lightsail, OVHcloud, Vultr) and low-end hosting offers, week by week.',
+ categories: ['hosting'], sources: ['linode-types', 'aws-lightsail-bundles', 'ovhcloud-vps', 'vultr-plans', 'lowendbox-posts', 'lowendtalk-offers'],
+ },
+ {
+ key: 'dev', title: 'Developer digest', every: 'week', hour: 7, min: 1, max: 30,
+ about: 'Releases and news from the Node.js project blog, week by week.',
+ categories: ['blog'], sources: ['nodejs-blog'],
+ },
+];
+
+const SATIRE = {
+ key: 'satire', title: 'Is This Real?', every: 'day', hour: 14, satire: true,
+ about: 'Satire. Absurd questions answered with a completely straight face, written by AI. Nothing here is true, real advice or real news.',
+};
+
+// One question a day, in order; after the list the angles change, so a question is never written the same way twice.
+const QUESTIONS = [
+ 'Is it safe to let your air fryer vote?',
+ 'Can you get a sunburn from your phone\'s dark mode?',
+ 'Can you claim a houseplant as a dependent?',
+ 'Should you be worried that your sourdough starter has started a podcast?',
+ 'Can a cat file a noise complaint against you?',
+ 'Are pigeons unionising in city parks?',
+ 'Can you be allergic to Mondays?',
+ 'Is it possible to take too many vibes?',
+ 'Do houseplants judge you for skipping leg day?',
+ 'Is your robot vacuum legally your employee?',
+ 'Can you microdose a nap?',
+ 'Is "one more episode" a recognised unit of time?',
+ 'Can you get jet lag from switching browser tabs?',
+ 'Are geese running a protection racket at the local pond?',
+ 'Is it rude to ghost your smart fridge?',
+ 'Can your group chat be declared a sovereign nation?',
+ 'Should you tip the self-checkout machine?',
+ 'Can you inherit a Wi-Fi password?',
+ 'Is cereal a soup, and does the soup lobby know?',
+ 'Is it dangerous to make eye contact with a seagull holding a chip?',
+ 'Can a streamer\'s chat legally adopt them?',
+ 'Is a mechanical keyboard loud enough to count as a band?',
+ 'Can you file a missing-person report for a single sock?',
+ 'Is hitting snooze a form of time travel?',
+ 'Can you be evicted by your own cat?',
+ 'Why does the neighbour\'s lawnmower have a better social life than you?',
+ 'Is it possible to lose an argument to autocorrect forever?',
+ 'Do frogs need a licence to sing at 3 a.m.?',
+ 'Can your dog be put on a group project?',
+ 'Is it safe to ask the printer how it is feeling?',
+ 'Is the moon just very long-distance cheese marketing?',
+ 'Can you get a parking ticket for leaving your feelings in the car?',
+ 'Is pasta water legally a hot tub for spaghetti?',
+ 'Can a houseplant sue you for emotional neglect?',
+ 'Is buffering a spiritual practice?',
+ 'Can a raccoon be your landlord?',
+ 'Is it too late to apologise to your browser history?',
+ 'Should you be concerned that your toaster has opinions about bread?',
+ 'Can you be banned from a library for reading too loudly in your head?',
+ 'Is it legal to marry your favourite hoodie?',
+];
+const ANGLES = [
+ 'a deadly serious consumer-safety explainer',
+ 'a myth-busting FAQ by a panel of invented experts',
+ 'a breaking-news bulletin from an extremely calm newsroom',
+ 'a field guide for beginners',
+ 'a public service announcement that keeps getting more urgent',
+];
+
+function questionFor(n) {
+ const i = Math.max(0, Math.floor(Number(n) || 0));
+ return { topic: QUESTIONS[i % QUESTIONS.length], angle: ANGLES[Math.floor(i / QUESTIONS.length) % ANGLES.length] };
+}
+
+const ALL = [...DESKS, SATIRE];
+const byKey = (key) => ALL.find((d) => d.key === key) || null;
+
+module.exports = { DESKS, SATIRE, ALL, QUESTIONS, ANGLES, questionFor, byKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,297 @@
@@ -0,0 +1,297 @@
+'use strict';
+/**
+ * OpenVibe.Blog's AI desks (the owner's April ask: "AI-generated niche blogs backed by large source datasets" and "a
+ * satirical/parody type blog"). They sit beside the blogs, not in them: a desk post is written by OpenVibe.AI, labelled
+ * as such on every surface, never indexed, and never a blog_posts row (whose AI text waits for a person's review).
+ *
+ * pull() Sources' change feed for each category the desks read (openvibe-publishing/ingest, cursor per
+ * category): items of a desk's sources are kept in blog_desk_items (21 days); a removed item is
+ * dropped, so a post written later can never cite it.
+ * compose() for each desk whose latest period is over (catalog.js: every, hour) and has no post yet: the newest
+ * items that appeared or changed in the period go to blog.write_digest; a section the AI did not
+ * cite is left out (and said so), a run with no cited section makes no post. The satire desk asks
+ * blog.write_satire for one invented post a day. A failure is retried (three attempts, 30 minutes apart).
+ * tick() pull, compose, prune: every BLOG_DESKS_INTERVAL_MS where the worker runs.
+ *
+ * Staff (Network admins) may hide a desk post and show it again; nothing else edits one.
+ */
+const crypto = require('crypto');
+const ingest = require('openvibe-publishing/ingest');
+const { serviceAuth } = require('openvibe-contracts');
+const { createAiClient } = require('openvibe-publishing/ai');
+const catalog = require('./catalog');
+
+const DAY = 86_400_000;
+const HOUR = 3_600_000;
+const KEEP_ITEMS_MS = 21 * DAY;
+const MAX_ATTEMPTS = 3;
+const RETRY_AFTER_MS = 30 * 60_000;
+
+const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
+const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
+const num = (v) => (v == null ? null : Number(v));
+const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70).replace(/-+$/, '');
+const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
+const newId = () => `dsk_${crypto.randomBytes(10).toString('hex')}`;
+
+/** The latest period of a desk that is over (and past its writing hour) at `now`: { from, to }. */
+function duePeriod(desk, now) {
+ const d = new Date(now);
+ const today = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
+ const hour = (desk.hour || 0) * HOUR;
+ if (desk.every === 'week') {
+ const monday = today - ((d.getUTCDay() + 6) % 7) * DAY;
+ const to = now >= monday + hour ? monday : monday - 7 * DAY;
+ return { from: to - 7 * DAY, to };
+ }
+ const to = now >= today + hour ? today : today - DAY;
+ return { from: to - DAY, to };
+}
+
+/** "Wednesday 14 October 2026" or "the week of 5 to 11 October 2026". */
+const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+function periodLabel(desk, { from, to }) {
+ const dm = (ms) => { const d = new Date(ms); return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; };
+ const year = (ms) => new Date(ms).getUTCFullYear();
+ if (desk.every === 'week') return `the week of ${dm(from)} to ${dm(to - DAY)} ${year(to - DAY)}`;
+ return `${WEEKDAYS[new Date(from).getUTCDay()]} ${dm(from)} ${year(from)}`;
+}
+
+/** What the AI reads about one item: its summary and the fields Sources stored, and whether it is new or changed. */
+function snippetOf(it) {
+ const lines = [];
+ if (it.summary) lines.push(clean(it.summary, 1200));
+ const fields = parse(it.fields, {});
+ const kv = Object.entries(fields).filter(([, v]) => v !== null && v !== '').slice(0, 20).map(([k, v]) => `${k}: ${clean(v, 120)}`);
+ if (kv.length) lines.push(kv.join('; '));
+ lines.push(Number(it.revision) > 1 ? `Changed at the source (revision ${it.revision}).` : 'New at the source.');
+ return lines.join('\n').slice(0, 3000);
+}
+
+function createDesks({ config, store, fetchImpl = globalThis.fetch, log = console, now = () => store.now() }) {
+ const db = store.db;
+ const c = config.desks || {};
+ const aiTokens = config.aiUrl && config.oauth.clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
+ }) : null;
+ const ai = createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokens, fetch: fetchImpl });
+ const sourcesOn = Boolean(config.sources && config.sources.internalUrl && config.oauth.clientSecret);
+ const cursor = ingest.createChangeCursor(db, { prefix: 'blog', now });
+ const wanted = new Set(catalog.DESKS.flatMap((d) => d.sources));
+ const categories = [...new Set(catalog.DESKS.flatMap((d) => d.categories))];
+ const clients = new Map(categories.map((cat) => [cat, ingest.createSourcesClient({ config: { ...config, sources: { ...config.sources, category: cat } }, fetchImpl, now })]));
+ const stats = { lastPullAt: null, lastComposeAt: null, lastError: null, written: 0 };
+
+ // ── Items ───────────────────────────────────────────────
+
+ async function applyItem(item, t) {
+ if (!wanted.has(item.source_key)) return 'applied';
+ if (item.removed) {
+ await t.query('DELETE FROM blog_desk_items WHERE item_id = $1', [item.id]);
+ return 'removed';
+ }
+ const at = now();
+ const prev = await t.maybe('SELECT revision FROM blog_desk_items WHERE item_id = $1', [item.id]);
+ const firstSeen = item.provenance && item.provenance.first_seen_at ? Date.parse(item.provenance.first_seen_at) : NaN;
+ // New to Blog: dated when Sources first saw it (a first pull never presents old items as today's news).
+ // A new revision of an item Blog already holds: dated now, when the change reached Blog.
+ const changedAt = prev ? (Number(item.revision) > Number(prev.revision) ? at : null) : (Number.isFinite(firstSeen) ? firstSeen : at);
+ await t.query(`INSERT INTO blog_desk_items (item_id, source_key, title, url, summary, published_at, fields, revision, changed_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)
+ ON CONFLICT (item_id) DO UPDATE SET title = excluded.title, url = excluded.url, summary = excluded.summary,
+ published_at = excluded.published_at, fields = excluded.fields, revision = excluded.revision,
+ changed_at = COALESCE($11, blog_desk_items.changed_at), updated_at = excluded.updated_at`,
+ [item.id, item.source_key, clean(item.title, 500) || null, item.canonical_url || null, item.summary ? String(item.summary).slice(0, 2000) : null,
+ item.published_at || null, JSON.stringify(item.fields || {}), Number(item.revision) || 1, changedAt || at, at, changedAt]);
+ return 'applied';
+ }
+
+ async function pull() {
+ if (!sourcesOn) return { pulled: 0 };
+ let pulled = 0;
+ for (const [cat, source] of clients) {
+ try {
+ const r = await ingest.pullChanges({ db, cursor, source, name: `desks:${cat}`, apply: applyItem, maxPages: 25, pageSize: 200 });
+ pulled += r.applied + r.removed;
+ } catch (err) {
+ stats.lastError = `pull ${cat}: ${err.message}`;
+ log.warn(`[Blog] desks: pulling ${cat} from Sources failed:`, err.message);
+ }
+ }
+ stats.lastPullAt = new Date(now()).toISOString();
+ return { pulled };
+ }
+
+ // ── Posts ───────────────────────────────────────────────
+
+ const shape = (r) => (r ? {
+ ...r, body: parse(r.body, {}), sources: parse(r.sources, []),
+ period_from: num(r.period_from), period_to: num(r.period_to), created_at: num(r.created_at), published_at: num(r.published_at), updated_at: num(r.updated_at),
+ } : null);
+
+ /** The row for a desk's period, created (status writing) or claimed for another attempt; null when there is nothing to do. */
+ async function claim(desk, period, slug) {
+ const at = now();
+ const row = shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE desk = $1 AND period_from = $2', [desk.key, period.from]));
+ if (row) {
+ if (row.status !== 'failed' || row.attempts >= MAX_ATTEMPTS || at - row.updated_at < RETRY_AFTER_MS) return null;
+ const n = await db.exec(`UPDATE blog_desk_posts SET status = 'writing', attempts = attempts + 1, updated_at = $3
+ WHERE id = $1 AND status = 'failed' AND attempts = $2`, [row.id, row.attempts, at]);
+ return n ? { ...row, attempts: row.attempts + 1 } : null;
+ }
+ const id = newId();
+ const n = await db.exec(`INSERT INTO blog_desk_posts (id, desk, slug, status, period_from, period_to, attempts, created_at, updated_at)
+ VALUES ($1, $2, $3, 'writing', $4, $5, 1, $6, $6) ON CONFLICT DO NOTHING`, [id, desk.key, slug, period.from, period.to, at]);
+ return n ? { id, desk: desk.key, slug, attempts: 1 } : null;
+ }
+
+ async function fail(row, err) {
+ stats.lastError = `${row.desk}: ${err.message}`;
+ log.warn(`[Blog] desks: ${row.desk} ${row.slug} (attempt ${row.attempts}) failed:`, err.message);
+ await db.query("UPDATE blog_desk_posts SET status = 'failed', error = $2, updated_at = $3 WHERE id = $1", [row.id, clean(err.message, 300), now()]);
+ }
+
+ /** One digest: the period's items to blog.write_digest, cited sections only. */
+ async function writeDigest(desk, period) {
+ const items = await db.many(`SELECT * FROM blog_desk_items WHERE source_key = ANY($1::text[]) AND changed_at >= $2 AND changed_at < $3
+ ORDER BY changed_at DESC, item_id LIMIT $4`, [desk.sources, period.from, period.to, desk.max || 40]);
+ if (items.length < (desk.min || 1)) return null;
+ const row = await claim(desk, period, ymd(period.from));
+ if (!row) return null;
+ try {
+ const label = periodLabel(desk, period);
+ const r = await ai.run('blog.write_digest', {
+ desk: desk.title, about: desk.about, period: label,
+ sources: items.map((it) => ({
+ source_type: 'sources.item', source_id: it.item_id,
+ ...(it.url ? { url: it.url } : {}), ...(it.title ? { title: clean(it.title, 500) } : {}),
+ ...(it.published_at ? { published_at: String(it.published_at).slice(0, 40) } : {}),
+ snippet: snippetOf(it),
+ })),
+ }, { target: { service: 'blog', type: 'desk', id: `${desk.key}:${ymd(period.from)}` }, idempotencyKey: `blog.desk:${desk.key}:${ymd(period.from)}:${row.attempts}` });
+ const out = r.output || {};
+ const gaps = (Array.isArray(out.gaps) ? out.gaps : []).map((g) => clean(g, 300)).filter(Boolean).slice(0, 20);
+ const all = Array.isArray(out.sections) ? out.sections : [];
+ const sections = all.map((s) => ({
+ heading: clean(s && s.heading, 160),
+ body: String((s && s.body) || '').trim().slice(0, 4000),
+ cites: [...new Set((s && Array.isArray(s.citations) ? s.citations : []).filter((n) => Number.isInteger(n) && n >= 0 && n < items.length))].sort((a, b) => a - b),
+ })).filter((s) => s.body && s.cites.length);
+ if (sections.length < all.length) gaps.push(`${all.length - sections.length} section(s) the AI wrote without citing an item were left out.`);
+ if (!sections.length) throw new Error(`run ${r.runId} cited nothing, so no post was made`);
+ const sources = items.map((it) => ({ item_id: it.item_id, source_key: it.source_key, title: it.title || null, url: it.url || null, published_at: it.published_at || null }));
+ return await finish(row, r, { title: clean(out.title, 200) || `${desk.title}: ${label}`, dek: clean(out.dek, 400) || null, body: { sections, gaps }, sources });
+ } catch (err) { await fail(row, err); return null; }
+ }
+
+ /** One satire post: the next question from the catalog to blog.write_satire. */
+ async function writeSatire(desk, period) {
+ const done = Number(await db.value("SELECT count(*) FROM blog_desk_posts WHERE desk = $1 AND status IN ('published','hidden')", [desk.key])) || 0;
+ const row = await claim(desk, period, ymd(period.to));
+ if (!row) return null;
+ try {
+ const q = catalog.questionFor(done);
+ const r = await ai.run('blog.write_satire', { topic: q.topic, angle: q.angle }, {
+ target: { service: 'blog', type: 'desk', id: `${desk.key}:${ymd(period.to)}` }, idempotencyKey: `blog.desk:${desk.key}:${ymd(period.to)}:${row.attempts}`,
+ });
+ const out = r.output || {};
+ const markdown = String(out.body_markdown || '').trim().slice(0, 12000);
+ if (!markdown) throw new Error(`run ${r.runId} wrote no text`);
+ const title = clean(out.title, 200) || q.topic;
+ const tags = (Array.isArray(out.tags) ? out.tags : []).map((t) => clean(t, 40).toLowerCase()).filter(Boolean).slice(0, 5);
+ return await finish(row, r, { title, dek: clean(out.dek, 300) || null, body: { markdown, tags, topic: q.topic, angle: q.angle }, sources: [], slug: `${ymd(period.to)}-${slugify(title) || 'satire'}` });
+ } catch (err) { await fail(row, err); return null; }
+ }
+
+ async function finish(row, r, { title, dek, body, sources, slug = null }) {
+ const at = now();
+ // The stub provider's synthetic text is kept for the record but never shown (hidden, with the reason).
+ const synthetic = Boolean(r.synthetic);
+ await db.query(`UPDATE blog_desk_posts SET status = $2, title = $3, dek = $4, body = $5::jsonb, sources = $6::jsonb, run_id = $7, model = $8,
+ synthetic = $9, error = NULL, published_at = $10, updated_at = $10, slug = COALESCE($11, slug),
+ hidden_by = $12, hidden_reason = $13 WHERE id = $1`,
+ [row.id, synthetic ? 'hidden' : 'published', title, dek, JSON.stringify(body), JSON.stringify(sources), r.runId || null,
+ (r.workflow && r.workflow.model) || null, synthetic, at, slug, synthetic ? 'system' : null, synthetic ? 'Written by the stub provider (synthetic text).' : null]);
+ stats.written++;
+ return shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE id = $1', [row.id]));
+ }
+
+ async function compose() {
+ if (!ai.enabled) return [];
+ const out = [];
+ const at = now();
+ if (sourcesOn) {
+ for (const desk of catalog.DESKS) {
+ const p = await writeDigest(desk, duePeriod(desk, at));
+ if (p) out.push(p);
+ }
+ }
+ const p = await writeSatire(catalog.SATIRE, duePeriod(catalog.SATIRE, at));
+ if (p) out.push(p);
+ stats.lastComposeAt = new Date(at).toISOString();
+ return out;
+ }
+
+ async function prune() {
+ return await db.exec('DELETE FROM blog_desk_items WHERE changed_at < $1', [now() - KEEP_ITEMS_MS]);
+ }
+
+ let running = false;
+ async function tick() {
+ if (running) return null;
+ running = true;
+ try {
+ const p = await pull();
+ const written = await compose();
+ await prune();
+ return { ...p, written: written.length };
+ } catch (err) {
+ stats.lastError = err.message;
+ log.warn('[Blog] desks tick failed:', err.message);
+ return null;
+ } finally { running = false; }
+ }
+
+ let timer = null;
+ let first = null;
+
+ return {
+ enabled: Boolean(c.enabled && ai.enabled),
+ stats,
+ pull, compose, tick, prune,
+ /** Published posts of a desk, newest first (staff also see hidden ones). */
+ async list(deskKey, { limit = 20, offset = 0, staff = false } = {}) {
+ const states = staff ? ['published', 'hidden'] : ['published'];
+ const total = Number(await db.value('SELECT count(*) FROM blog_desk_posts WHERE desk = $1 AND status = ANY($2::text[])', [deskKey, states])) || 0;
+ const rows = await db.many(`SELECT * FROM blog_desk_posts WHERE desk = $1 AND status = ANY($2::text[])
+ ORDER BY period_from DESC LIMIT $3 OFFSET $4`, [deskKey, states, limit, offset]);
+ return { total, posts: rows.map(shape) };
+ },
+ async latest() {
+ const rows = await db.many(`SELECT DISTINCT ON (desk) * FROM blog_desk_posts WHERE status = 'published' ORDER BY desk, period_from DESC`);
+ return new Map(rows.map((r) => [r.desk, shape(r)]));
+ },
+ async bySlug(deskKey, slug) {
+ return shape(await db.maybe('SELECT * FROM blog_desk_posts WHERE desk = $1 AND slug = $2', [deskKey, String(slug)]));
+ },
+ /** Staff: hide a post (it leaves the desk and its feed) or show it again. */
+ async setHidden(post, hidden, { by, reason = null }) {
+ if (!post || (post.status !== 'published' && post.status !== 'hidden')) return false;
+ await db.query('UPDATE blog_desk_posts SET status = $2, hidden_by = $3, hidden_reason = $4, updated_at = $5 WHERE id = $1',
+ [post.id, hidden ? 'hidden' : 'published', hidden ? by : null, hidden ? clean(reason, 300) || null : null, now()]);
+ return true;
+ },
+ start() {
+ if (!this.enabled || timer) return;
+ timer = setInterval(() => { tick(); }, c.intervalMs || 15 * 60_000);
+ timer.unref();
+ first = setTimeout(() => { tick(); }, 90_000);
+ first.unref();
+ },
+ stop() { clearInterval(timer); clearTimeout(first); timer = null; },
+ };
+}
+
+module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * The AI desks' pages (layout.js wraps them): the desks, one desk's posts, one post. Every surface says the text is
+ * AI-written and not reviewed by a person; the satire desk says, above the text, that it is made up.
+ */
+const ssr = require('openvibe-publishing/ssr');
+const catalog = require('./catalog');
+const { periodLabel } = require('./desks');
+
+const { html: h, raw } = ssr;
+const dateLabel = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '');
+const time = (ms) => raw(ssr.timeTag(ms ? new Date(ms).toISOString() : null, { label: dateLabel(ms) || undefined }));
+const every = (d) => (d.every === 'week' ? 'Every Monday, for the week before' : d.satire ? 'Every day' : 'Every morning, for the day before');
+
+const AI_NOTE = 'Written by OpenVibe.AI and not reviewed by a person before it appears. Desk posts are not offered to search engines.';
+
+function satireBanner() {
+ return h`<p class="disclosure desk-satire" role="note"><strong>Satire.</strong> This is made up, written by AI for laughs. Nothing in it is true, real advice or real news, and the people, studies and institutes in it do not exist. Do not try anything in it.</p>`;
+}
+
+/** Markdown from the AI, with headings starting at h2 under the page's h1 (a stray "# " becomes "## "). */
+function md(text, shift) {
+ return raw(ssr.renderMarkdown(String(text || '').replace(/^# /gm, '## '), { rel: 'nofollow noopener', headingShift: shift }));
+}
+
+function index({ latest }) {
+ const card = (d) => {
+ const p = latest.get(d.key);
+ return h`<li class="desk-card">
+<h2><a href="/desks/${d.key}">${d.title}</a>${d.satire ? h` <span class="badge">Satire</span>` : ''}</h2>
+<p>${d.about}</p>
+<p class="meta">${every(d)}${p ? h` · Latest: <a href="/desks/${d.key}/${p.slug}">${p.title}</a> (${time(p.published_at)})` : ' · Nothing written yet'} · <a href="/desks/${d.key}/feed.xml" type="application/rss+xml">Feed</a></p>
+</li>`;
+ };
+ return String(h`<header class="blog-header"><h1>AI desks</h1>
+<p class="lede">Niche blogs written by AI from the data OpenVibe.Sources collects (government news, food recalls, deals, hosting plans, developer releases), and one satire column. Each digest links every item it was written from.</p></header>
+<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE} The people's blogs are elsewhere on <a href="/">OpenVibe.Blog</a>.</p>
+<ul class="desk-list">${catalog.ALL.map(card)}</ul>`);
+}
+
+function deskPage({ desk, posts, pager, staff }) {
+ const item = (p) => h`<li class="post-item">
+<h2 class="post-item-title"><a href="/desks/${desk.key}/${p.slug}">${p.title}</a>${p.status === 'hidden' ? h` <span class="badge">Hidden</span>` : ''}</h2>
+<p class="meta">${time(p.published_at)}${desk.satire ? '' : h` · ${periodLabel(desk, { from: p.period_from, to: p.period_to })}`} · ${desk.satire ? 'Satire, written by AI' : h`Written by AI from ${p.sources.length} items`}</p>
+${p.dek ? h`<p class="summary">${p.dek}</p>` : ''}
+</li>`;
+ return String(h`${raw(ssr.breadcrumbsHtml([{ name: 'AI desks', url: '/desks' }, { name: desk.title }]))}
+<header class="blog-header"><h1>${desk.title}</h1><p class="lede">${desk.about}</p>
+<p class="feeds">${every(desk)} · Follow: <a href="/desks/${desk.key}/feed.xml" type="application/rss+xml">RSS</a> <a href="/desks/${desk.key}/atom.xml" type="application/atom+xml">Atom</a></p></header>
+${desk.satire ? satireBanner() : h`<p class="disclosure" role="note"><strong>Written by AI.</strong> ${AI_NOTE}</p>`}
+<section aria-label="Posts">${posts.length ? h`<ol class="post-list">${posts.map(item)}</ol>` : h`<p class="empty">Nothing written here yet. The first post appears after the desk's first ${desk.every === 'week' ? 'week' : 'day'} of items.</p>`}</section>
+${raw(ssr.paginationHtml(pager))}
+${staff ? h`<p class="meta">Staff see hidden posts here too.</p>` : ''}`);
+}
+
+/** The article body (also the feeds' content). */
+function articleBody(desk, post) {
+ if (desk.satire) {
+ return String(h`${satireBanner()}
+${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
+<div class="post-body">${md(post.body.markdown, 0)}</div>
+${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.map((t) => h`<span>#${t}</span> `)}</p>` : ''}`);
+ }
+ const cite = (n) => h`<a href="#src-${n + 1}">${n + 1}</a>`;
+ return String(h`<p class="disclosure" role="note"><strong>AI-generated</strong> from ${post.sources.length} items OpenVibe.Sources fetched, by OpenVibe.AI's blog.write_digest${post.model ? ` (${post.model})` : ''}; not reviewed by a person. Check anything that matters against the linked item.</p>
+${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
+<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${md(s.body, 2)}<p class="desk-cites">From ${s.cites.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}</p></section>`)}</div>
+${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}
+<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);
+}
+
+function postPage({ desk, post, staff, csrf }) {
+ return String(h`${raw(ssr.breadcrumbsHtml([{ name: 'AI desks', url: '/desks' }, { name: desk.title, url: `/desks/${desk.key}` }, { name: post.title }]))}
+<article class="post desk-post" data-desk="${desk.key}" data-desk-post="${post.id}">
+<header><h1>${post.title}</h1>
+<p class="byline"><a href="/desks/${desk.key}">${desk.title}</a> · ${time(post.published_at)}${desk.satire ? '' : h` · ${periodLabel(desk, { from: post.period_from, to: post.period_to })}`} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span>${post.status === 'hidden' ? h` <span class="badge">Hidden</span>` : ''}</p></header>
+${raw(articleBody(desk, post))}
+<footer class="post-footer"><p class="meta">${post.run_id ? h`OpenVibe.AI run ${post.run_id} · ` : ''}<a href="/desks/${desk.key}/${post.slug}.json">JSON</a> · <a href="/desks/${desk.key}">More from ${desk.title}</a> · <a href="/desks">All AI desks</a></p>
+${staff ? h`<form method="post" action="/desks/${desk.key}/${post.slug}/${post.status === 'hidden' ? 'show' : 'hide'}" class="desk-staff">
+<input type="hidden" name="_csrf" value="${csrf}">
+${post.status === 'hidden' ? (post.hidden_reason ? h`<p class="meta">Hidden: ${post.hidden_reason}</p>` : '') : h`<label for="desk-hide-reason">Why hide it (staff)</label><input id="desk-hide-reason" name="reason" maxlength="300">`}
+<button type="submit">${post.status === 'hidden' ? 'Show it again' : 'Hide this post'}</button></form>` : ''}
+</footer>
+</article>`);
+}
+
+module.exports = { index, deskPage, postPage, articleBody, satireBanner };
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * The AI desks on openvibe.blog (server/desks/desks.js):
+ *
+ * GET /desks every desk, its latest post and feed
+ * GET /desks/:desk a desk's posts (20 a page)
+ * GET /desks/:desk/feed.xml|atom.xml its feed (RSS 2.0, Atom); satire titles start "[Satire]"
+ * GET /desks/:desk/:slug one post (…/:slug.json: the same post as data)
+ * POST /desks/:desk/:slug/hide|show staff (Network admins): take a post down or put it back
+ *
+ * Every page is noindex (AI text no person reviewed) and, for an anonymous visitor, publicly cacheable for a minute.
+ */
+const express = require('express');
+const seo = require('openvibe-publishing/seo');
+const ssr = require('openvibe-publishing/ssr');
+const cache = require('openvibe-shared/cache-policy');
+const { renderPage } = require('../render/layout');
+const pages = require('../render/pages');
+const { csrfToken, checkCsrf } = require('../auth/forms');
+const catalog = require('./catalog');
+const render = require('./render');
+
+const PER_PAGE = 20;
+const NOINDEX = { indexable: false, listable: true, robots: 'noindex, follow', codes: ['ai_generated_unreviewed'] };
+
+function createDeskRoutes(ctx) {
+ const { config, store, desks, viewers } = ctx;
+ const router = express.Router();
+ router.use('/desks', viewers.middleware({ services: false }));
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+ const abs = (p) => `${config.baseUrl}${p}`;
+ const isStaff = (req) => Boolean(req.viewer && req.viewer.staff);
+
+ function decision(path) {
+ return seo.evaluate({ state: 'draft', visibility: 'public', canonicalUrl: seo.canonicalUrl(config.baseUrl, path, { query: ['page'] }), wordCount: 0, noindex: true },
+ { policy: { minWords: 0 }, now: store.now() });
+ }
+
+ function send(req, res, status, page, { cacheable = true } = {}) {
+ res.vary('Cookie');
+ res.vary('Authorization');
+ const anon = req.viewer.kind === 'anonymous';
+ res.set('Cache-Control', cache.htmlHeaders(cacheable && anon && status === 200 ? { maxAge: 60 } : { private: true }));
+ res.set('X-Robots-Tag', 'noindex, follow');
+ res.status(status).type('html').send(renderPage({ ...page, decision: page.decision || decision(req.path), viewer: req.viewer, config, path: req.originalUrl }));
+ }
+
+ function notFound(req, res) {
+ send(req, res, 404, { title: 'Not found', body: pages.message({ heading: 'Not found', text: 'There is no desk post at this address.', action: { href: '/desks', label: 'All AI desks' } }) }, { cacheable: false });
+ }
+
+ const deskOf = (req) => catalog.byKey(req.params.desk);
+ const feedsOf = (desk) => [
+ { type: 'application/rss+xml', href: `/desks/${desk.key}/feed.xml`, title: `${desk.title} (RSS)` },
+ { type: 'application/atom+xml', href: `/desks/${desk.key}/atom.xml`, title: `${desk.title} (Atom)` },
+ ];
+
+ router.get('/desks', wrap(async (req, res) => {
+ send(req, res, 200, {
+ title: 'AI desks',
+ description: 'Niche blogs written by AI from the data OpenVibe.Sources collects, and a satire column. Labelled, cited, never indexed.',
+ canonical: abs('/desks'),
+ body: render.index({ latest: await desks.latest() }),
+ });
+ }));
+
+ async function feed(req, res, kind) {
+ const desk = deskOf(req);
+ if (!desk) return notFound(req, res);
+ const { posts } = await desks.list(desk.key, { limit: 20 });
+ const items = posts.map((p) => ({
+ id: `tag:openvibe.blog,2026:desk/${p.id}`,
+ url: abs(`/desks/${desk.key}/${p.slug}`),
+ title: desk.satire ? `[Satire] ${p.title}` : p.title,
+ summary: p.dek || null,
+ contentHtml: render.articleBody(desk, p),
+ published: new Date(p.published_at).toISOString(),
+ authors: [{ name: desk.satire ? `${desk.title} (satire, written by AI)` : `${desk.title} (written by AI)` }],
+ tags: desk.satire ? ['satire', ...(p.body.tags || [])] : [desk.key],
+ decision: NOINDEX,
+ }));
+ const link = abs(`/desks/${desk.key}`);
+ const feedUrl = abs(`/desks/${desk.key}/${kind === 'rss' ? 'feed.xml' : 'atom.xml'}`);
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
+ res.set('X-Robots-Tag', 'noindex, follow');
+ const description = `${desk.about} Written by AI on OpenVibe.Blog.`;
+ if (kind === 'rss') return res.type('application/rss+xml').send(seo.rssFeed({ title: `${desk.title} · OpenVibe.Blog`, link, description, feedUrl, language: 'en' }, items));
+ return res.type('application/atom+xml').send(seo.atomFeed({ title: `${desk.title} · OpenVibe.Blog`, link, feedUrl, id: `tag:openvibe.blog,2026:desk/${desk.key}`, subtitle: description, ...(items.length ? {} : { updated: new Date(store.now()).toISOString() }) }, items));
+ }
+ router.get('/desks/:desk/feed.xml', wrap(async (req, res) => await feed(req, res, 'rss')));
+ router.get('/desks/:desk/atom.xml', wrap(async (req, res) => await feed(req, res, 'atom')));
+
+ router.get('/desks/:desk', wrap(async (req, res) => {
+ const desk = deskOf(req);
+ if (!desk) return notFound(req, res);
+ const n = parseInt(req.query.page, 10);
+ const page = Number.isInteger(n) && n > 0 ? Math.min(n, 10000) : 1;
+ const staff = isStaff(req);
+ const { total, posts } = await desks.list(desk.key, { limit: PER_PAGE, offset: (page - 1) * PER_PAGE, staff });
+ const path = `/desks/${desk.key}`;
+ const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
+ if (pager.outOfRange && total) return notFound(req, res);
+ send(req, res, 200, {
+ title: desk.title, description: desk.about, canonical: abs(pager.page === 1 ? path : `${path}?page=${pager.page}`),
+ feeds: feedsOf(desk), prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
+ body: render.deskPage({ desk, posts, pager, staff }),
+ }, { cacheable: !staff });
+ }));
+
+ async function postOf(req) {
+ const desk = deskOf(req);
+ if (!desk) return {};
+ const post = await desks.bySlug(desk.key, req.params.slug);
+ if (!post || !(post.status === 'published' || (post.status === 'hidden' && isStaff(req)))) return { desk };
+ return { desk, post };
+ }
+
+ const jsonView = (desk, p) => ({
+ id: p.id, desk: desk.key, desk_title: desk.title, satire: Boolean(desk.satire), slug: p.slug, url: abs(`/desks/${desk.key}/${p.slug}`),
+ title: p.title, dek: p.dek, published_at: new Date(p.published_at).toISOString(),
+ period: desk.satire ? null : { from: new Date(p.period_from).toISOString(), to: new Date(p.period_to).toISOString() },
+ authorship: { mode: 'ai', reviewed: false, workflow: desk.satire ? 'blog.write_satire' : 'blog.write_digest', run_id: p.run_id, model: p.model },
+ ...(desk.satire ? { body_markdown: p.body.markdown, tags: p.body.tags || [] } : { sections: p.body.sections, gaps: p.body.gaps || [], sources: p.sources }),
+ });
+
+ router.get('/desks/:desk/:slug.json', wrap(async (req, res) => {
+ const { desk, post } = await postOf(req);
+ if (!post || post.status !== 'published') return res.status(404).json({ code: 'desk.not_found', detail: 'No desk post at this address' });
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
+ res.set('X-Robots-Tag', 'noindex');
+ res.set('Access-Control-Allow-Origin', '*');
+ res.json({ post: jsonView(desk, post) });
+ }));
+
+ router.get('/desks/:desk/:slug', wrap(async (req, res) => {
+ const { desk, post } = await postOf(req);
+ if (!post) return notFound(req, res);
+ const staff = isStaff(req);
+ send(req, res, 200, {
+ title: desk.satire ? `${post.title} (satire)` : post.title,
+ description: desk.satire ? `Satire, written by AI: ${post.dek || desk.about}` : (post.dek || desk.about),
+ canonical: abs(`/desks/${desk.key}/${post.slug}`),
+ type: 'article', feeds: feedsOf(desk),
+ published: new Date(post.published_at).toISOString(),
+ body: render.postPage({ desk, post, staff, csrf: staff ? csrfToken(config, req.viewer) : '' }),
+ }, { cacheable: !staff && post.status === 'published' });
+ }));
+
+ for (const action of ['hide', 'show']) {
+ router.post(`/desks/:desk/:slug/${action}`, express.urlencoded({ extended: false, limit: '8kb' }), wrap(async (req, res) => {
+ if (!isStaff(req)) return send(req, res, 403, { title: 'Staff only', body: pages.message({ heading: 'Staff only', text: 'Only OpenVibe staff can hide or show a desk post.' }) }, { cacheable: false });
+ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return send(req, res, 403, { title: 'Form expired', body: pages.message({ heading: 'Form expired', text: 'Reload the page and try again.' }) }, { cacheable: false });
+ const { desk, post } = await postOf(req);
+ if (!post) return notFound(req, res);
+ await desks.setHidden(post, action === 'hide', { by: req.viewer.subject, reason: req.body.reason });
+ res.redirect(303, `/desks/${desk.key}/${post.slug}`);
+ }));
+ }
+
+ return router;
+}
+
+module.exports = { createDeskRoutes };
@@ -72,6 +72,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -72,6 +72,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
{ title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },
{ title: 'Member blogs', url: abs('/sitemaps/blogs.xml'), note: 'each member blog has /@handle/feed.xml, /@handle/atom.xml and /@handle/feed.json' },
] },
] },
{ title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },
{ title: 'Data', links: [{ title: 'Post JSON', url: abs('/'), note: 'append .json to any post URL (/@handle/slug.json)' }] },
+ { title: 'AI desks', links: [{ title: 'AI desks', url: abs('/desks'), note: 'digests OpenVibe.AI writes from OpenVibe.Sources items (news, food recalls, deals, hosting plans, developer releases) and a satire column; labelled as AI, every digest section cited, never indexed; each desk has /desks/<desk>/feed.xml and each post a .json twin' }] },
],
],
}));
}));
});
});
@@ -88,7 +88,7 @@ function createPublicRoutes(ctx) {
@@ -88,7 +88,7 @@ function createPublicRoutes(ctx) {
eyebrow: 'OpenVibe.Blog',
eyebrow: 'OpenVibe.Blog',
title: 'Write on', accent: 'OpenVibe',
title: 'Write on', accent: 'OpenVibe',
lede: 'A blog of your own with drafts, scheduling, feeds and comments. Sign in once with your OpenVibe account; anyone can read without one.',
lede: 'A blog of your own with drafts, scheduling, feeds and comments. Sign in once with your OpenVibe account; anyone can read without one.',
- actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }],
+ actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],
}) + showcase.features({
}) + showcase.features({
title: 'What your blog gets',
title: 'What your blog gets',
items: [
items: [
@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
@@ -17,7 +17,7 @@ const { startSubscriptions } = require('openvibe-sdk/account-data');
function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Blog', server, deadlineExitCode: 0, exit, signals,
name: 'Blog', server, deadlineExitCode: 0, exit, signals,
- stop: [() => { clearInterval(ctx.telemetryTick); return ctx.telemetry.stop(); }, () => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],
+ stop: [() => { clearInterval(ctx.telemetryTick); return ctx.telemetry.stop(); }, () => ctx.worker.stop(), () => ctx.changelog.stop(), () => ctx.desks.stop(), ...extra],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
});
});
}
}
@@ -39,6 +39,8 @@ async function start() {
@@ -39,6 +39,8 @@ async function start() {
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
// The network changelog and patch notes run where the worker runs (one process).
// The network changelog and patch notes run where the worker runs (one process).
if (config.worker.enabled) ctx.changelog.start();
if (config.worker.enabled) ctx.changelog.start();
+ // The AI desks (server/desks/desks.js) write where the worker runs too.
+ if (config.worker.enabled) ctx.desks.start();
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// BLOG_EVENTS_SECRET or the client secret.
// BLOG_EVENTS_SECRET or the client secret.
@@ -60,6 +60,7 @@ function renderPage(o) {
@@ -60,6 +60,7 @@ function renderPage(o) {
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: [
links: [
{ label: 'Blog', href: '/' },
{ label: 'Blog', href: '/' },
+ { label: 'AI desks', href: '/desks' },
{ label: 'Write', href: '/write', icon: 'fa-pen' },
{ label: 'Write', href: '/write', icon: 'fa-pen' },
],
],
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
@@ -97,7 +98,7 @@ function renderPage(o) {
@@ -97,7 +98,7 @@ function renderPage(o) {
next: o.next,
next: o.next,
navbar: nav,
navbar: nav,
footer,
footer,
- navLinks: [{ label: 'Blog', href: '/' }, { label: 'Write', href: '/write' }],
+ navLinks: [{ label: 'Blog', href: '/' }, { label: 'AI desks', href: '/desks' }, { label: 'Write', href: '/write' }],
home: '/',
home: '/',
css: asset('css/blog.css'),
css: asset('css/blog.css'),
styles: o.styles,
styles: o.styles,
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+'use strict';
+/**
+ * The AI desks (server/desks/): digests written by OpenVibe.AI from OpenVibe.Sources' items, and the satire desk.
+ * - a desk's period is the day (or Monday-to-Sunday week) before, written after its hour;
+ * - the first pull dates items by when Sources first saw them, a later revision by when Blog saw it, and a removed
+ * item is dropped; a period with fewer items than the desk's minimum gets no post;
+ * - a section the AI did not cite is left out and said so; a failure is retried with a new key; stub output is hidden;
+ * - every page and feed says the text is AI-written (the satire desk: made up), and nothing is indexed;
+ * - staff can hide a post and show it again; nobody else can.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const { duePeriod, periodLabel } = require('../server/desks/desks');
+const catalog = require('../server/desks/catalog');
+
+const at = (s) => Date.parse(s);
+
+function stub(handler) {
+ return new Promise((resolve) => {
+ const s = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const out = handler(req, raw ? JSON.parse(raw) : null) || { status: 404, body: {} };
+ res.writeHead(out.status || 200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(out.body));
+ });
+ }).listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${s.address().port}`, close: () => new Promise((r) => s.close(r)) }));
+ });
+}
+
+(async () => {
+ await check('periods: the day before after the desk\'s hour, Monday-to-Sunday weeks, satire by the day', () => {
+ const news = catalog.byKey('news');
+ assert.deepStrictEqual(duePeriod(news, at('2026-10-15T05:00:00Z')), { from: at('2026-10-13T00:00:00Z'), to: at('2026-10-14T00:00:00Z') });
+ assert.deepStrictEqual(duePeriod(news, at('2026-10-15T06:00:00Z')), { from: at('2026-10-14T00:00:00Z'), to: at('2026-10-15T00:00:00Z') });
+ const hosting = catalog.byKey('hosting');
+ assert.deepStrictEqual(duePeriod(hosting, at('2026-10-12T07:30:00Z')), { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }, 'Monday after 07:00');
+ assert.deepStrictEqual(duePeriod(hosting, at('2026-10-18T23:00:00Z')), { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }, 'Sunday: still last week');
+ assert.strictEqual(periodLabel(news, { from: at('2026-10-14T00:00:00Z'), to: at('2026-10-15T00:00:00Z') }), 'Wednesday 14 October 2026');
+ assert.strictEqual(periodLabel(hosting, { from: at('2026-10-05T00:00:00Z'), to: at('2026-10-12T00:00:00Z') }), 'the week of 5 October to 11 October 2026');
+ assert.deepStrictEqual(catalog.questionFor(0), { topic: catalog.QUESTIONS[0], angle: catalog.ANGLES[0] });
+ assert.strictEqual(catalog.questionFor(catalog.QUESTIONS.length).angle, catalog.ANGLES[1], 'the second round changes the angle');
+ });
+
+ // OpenVibe.Sources: the change feed by category.
+ let seq = 0;
+ const feed = [];
+ const add = (category, source_key, item) => {
+ const it = { id: item.id, source_key, category, kind: 'article', canonical_url: `https://example.gov/${item.id}`, title: `Item ${item.id}`, summary: `What ${item.id} says.`,
+ published_at: null, fields: {}, revision: 1, removed: null, provenance: { first_seen_at: item.first_seen_at }, ...item, change_seq: ++seq };
+ const i = feed.findIndex((x) => x.id === it.id);
+ if (i >= 0) feed.splice(i, 1);
+ feed.push(it);
+ };
+ const sources = await stub((req) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname !== '/api/v1/items') return null;
+ const after = Number(u.searchParams.get('after'));
+ const items = feed.filter((x) => x.category === u.searchParams.get('category') && x.change_seq > after).sort((a, b) => a.change_seq - b.change_seq);
+ return { body: { items, next_after: items.length ? items[items.length - 1].change_seq : after, more: false, sources: {} } };
+ });
+ add('news', 'govuk-news', { id: 'n1', first_seen_at: '2026-10-14T09:00:00Z' });
+ add('news', 'eu-press-corner', { id: 'n2', first_seen_at: '2026-10-14T11:00:00Z' });
+ add('news', 'nasa-news-releases', { id: 'n3', first_seen_at: '2026-10-14T20:00:00Z' });
+ add('news', 'govuk-news', { id: 'n0', first_seen_at: '2026-10-10T09:00:00Z' });
+ add('news', 'some-other-feed', { id: 'x1', first_seen_at: '2026-10-14T09:00:00Z' });
+ add('news', 'fda-food-recalls', { id: 'f1', first_seen_at: '2026-10-14T15:00:00Z', title: 'Company recalls hummus over undeclared sesame' });
+ add('hosting', 'linode-types', { id: 'h1', first_seen_at: '2026-10-01T09:00:00Z', kind: 'plan', fields: { vcpus: 1, price_monthly: 5 } });
+
+ // OpenVibe.AI: blog.write_digest and blog.write_satire.
+ const runs = [];
+ let mode = 'ok';
+ const ai = await stub((req, body) => {
+ if (req.method === 'POST' && req.url.startsWith('/api/v1/runs')) {
+ runs.push(body);
+ const id = `run_${runs.length}`;
+ if (mode === 'fail') return { status: 201, body: { run: { id, status: 'failed', error: { code: 'provider.unavailable', detail: 'down' } } } };
+ const n = body.input.sources ? body.input.sources.length : 0;
+ const output = body.workflow === 'blog.write_satire'
+ ? { title: 'Is it safe to let your air fryer vote?', dek: 'Experts at the Institute of Crisp are alarmed.', body_markdown: '## The crisis\n\nYour air fryer has opinions.\n\n# A stray title\n\nIt does.', tags: ['Appliances', 'democracy'] }
+ : { title: 'Recalls and releases', dek: 'What was announced.', sections: [
+ { heading: 'Announcements', body: 'Two agencies announced things.', citations: [0, 1, 99] },
+ { heading: 'Unsourced', body: 'Something the AI made up.', citations: [] },
+ ...(n > 2 ? [{ heading: 'Space', body: 'NASA said something.', citations: [2] }] : []),
+ ], citations: [0], gaps: ['Item 3 had no date.'] };
+ return { status: 201, body: { run: { id, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, synthetic: mode === 'stub', provenance: { model: 'gpt-test' }, output } } };
+ }
+ if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };
+ return null;
+ });
+
+ const t = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, OV_SOURCES_INTERNAL_URL: sources.url } });
+ t.clock.set(at('2026-10-15T08:00:00Z'));
+ const desks = t.ctx.desks;
+ const boss = t.network.addUser('boss', { role: 'admin' });
+ const kim = t.network.addUser('kim');
+ let news;
+ let satire;
+
+ await check('a tick pulls, then writes the news, food and satire posts; a desk below its minimum writes nothing', async () => {
+ assert.ok(desks.enabled);
+ const r = await desks.tick();
+ assert.ok(r && r.written === 3, JSON.stringify(r));
+ const byWorkflow = (w) => runs.filter((x) => x.workflow === w);
+ const digests = byWorkflow('blog.write_digest');
+ assert.deepStrictEqual(digests.map((x) => x.input.desk).sort(), ['Food safety digest', 'News digest']);
+ const n = digests.find((x) => x.input.desk === 'News digest');
+ assert.deepStrictEqual(n.input.sources.map((s) => s.source_id), ['n3', 'n2', 'n1'], 'the period\'s items of its own sources, newest first (n0 is older, x1 is another source)');
+ assert.strictEqual(n.input.period, 'Wednesday 14 October 2026');
+ assert.ok(n.input.sources[0].snippet.includes('New at the source.'));
+ assert.strictEqual(n.idempotency_key, 'blog.desk:news:2026-10-14:1');
+ assert.deepStrictEqual(byWorkflow('blog.write_satire').map((x) => x.input), [catalog.questionFor(0)]);
+ news = await desks.bySlug('news', '2026-10-14');
+ assert.strictEqual(news.status, 'published');
+ assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.cites]), [['Announcements', [0, 1]], ['Space', [2]]], 'uncited section dropped, out-of-range citation dropped');
+ assert.ok(news.body.gaps.some((g) => g.includes('without citing')));
+ satire = (await desks.list('satire')).posts[0];
+ assert.strictEqual(satire.slug, '2026-10-14-is-it-safe-to-let-your-air-fryer-vote');
+ assert.deepStrictEqual(satire.body.tags, ['appliances', 'democracy']);
+ assert.strictEqual((await desks.list('deals')).total, 0);
+ assert.strictEqual((await desks.list('hosting')).total, 0, 'a plan first seen weeks ago is not news this week');
+ });
+
+ await check('nothing is written twice; a changed item is dated when Blog saw it; a removed item is dropped', async () => {
+ const before = runs.length;
+ await desks.tick();
+ assert.strictEqual(runs.length, before, 'the period already has its posts');
+ add('hosting', 'linode-types', { id: 'h1', first_seen_at: '2026-10-01T09:00:00Z', kind: 'plan', revision: 2, fields: { vcpus: 1, price_monthly: 6 } });
+ add('news', 'govuk-news', { id: 'n1', removed: { at: '2026-10-15T08:00:00Z', reason: 'licence' } });
+ await desks.pull();
+ const h1 = await t.ctx.store.db.maybe('SELECT * FROM blog_desk_items WHERE item_id = $1', ['h1']);
+ assert.strictEqual(Number(h1.changed_at), at('2026-10-15T08:00:00Z'));
+ assert.strictEqual(await t.ctx.store.db.maybe('SELECT * FROM blog_desk_items WHERE item_id = $1', ['n1']), null);
+ t.clock.set(at('2026-10-19T08:00:00Z'));
+ await desks.tick();
+ const hosting = (await desks.list('hosting')).posts[0];
+ assert.ok(hosting, 'the week with the price change gets a post');
+ assert.strictEqual(hosting.slug, '2026-10-12');
+ const req = runs.filter((x) => x.input.desk === 'Hosting digest').pop();
+ assert.ok(req.input.sources[0].snippet.includes('Changed at the source (revision 2)') && req.input.sources[0].snippet.includes('price_monthly: 6'));
+ });
+
+ await check('a failed run is retried after 30 minutes with a new key, three attempts at most; stub text is hidden', async () => {
+ add('news', 'fda-food-recalls', { id: 'f2', first_seen_at: '2026-10-19T10:00:00Z' });
+ t.clock.set(at('2026-10-20T08:00:00Z'));
+ await desks.pull();
+ mode = 'fail';
+ await desks.compose();
+ let row = await t.ctx.store.db.maybe("SELECT * FROM blog_desk_posts WHERE desk = 'food' AND slug = '2026-10-19'");
+ assert.deepStrictEqual([row.status, row.attempts], ['failed', 1]);
+ const n = runs.length;
+ await desks.compose();
+ assert.strictEqual(runs.filter((x) => x.input.desk === 'Food safety digest').length, runs.slice(0, n).filter((x) => x.input.desk === 'Food safety digest').length, 'not before 30 minutes');
+ t.clock.advance(31 * 60_000);
+ mode = 'stub';
+ await desks.compose();
+ row = await t.ctx.store.db.maybe("SELECT * FROM blog_desk_posts WHERE desk = 'food' AND slug = '2026-10-19'");
+ assert.strictEqual(runs.filter((x) => x.input.desk === 'Food safety digest').pop().idempotency_key, 'blog.desk:food:2026-10-19:2');
+ assert.deepStrictEqual([row.status, row.synthetic, row.hidden_reason], ['hidden', true, 'Written by the stub provider (synthetic text).']);
+ mode = 'ok';
+ });
+
+ await check('pages: labelled as AI, satire says it is made up, cited items linked, never indexed', async () => {
+ const index = await t.get('/desks');
+ assert.strictEqual(index.status, 200);
+ assert.ok(index.text.includes('Written by AI.') && index.text.includes('/desks/news/2026-10-14') && index.text.includes('Is This Real?'));
+ assert.match(index.headers.get('x-robots-tag'), /noindex/);
+ assert.match(index.text, /<meta name="robots" content="noindex/);
+ const page = await t.get('/desks/news/2026-10-14');
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('AI-generated</strong> from 3 items'));
+ assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));
+ assert.ok(page.text.includes('<a href="#src-1">1</a>') && page.text.includes('id="src-3"'));
+ assert.ok(!page.text.includes('Something the AI made up'));
+ assert.match(page.headers.get('cache-control'), /max-age=60/);
+ const s = await t.get(`/desks/satire/${satire.slug}`);
+ assert.ok(s.text.includes('<strong>Satire.</strong> This is made up'));
+ assert.ok(s.text.includes('<title>Is it safe to let your air fryer vote? (satire)'));
+ assert.ok(s.text.includes('<h2') && !/<h1[^>]*>A stray title/.test(s.text), 'the AI\'s headings sit under the page title');
+ const rss = await t.get('/desks/satire/feed.xml');
+ assert.ok(rss.text.includes('[Satire] Is it safe to let your air fryer vote?'));
+ const atom = await t.get('/desks/news/atom.xml');
+ assert.ok(atom.text.includes('<feed') && atom.text.includes('Recalls and releases'));
+ const json = (await t.get('/desks/news/2026-10-14.json')).json();
+ assert.deepStrictEqual([json.post.authorship.mode, json.post.authorship.reviewed, json.post.sources.length], ['ai', false, 3]);
+ assert.strictEqual((await t.get('/desks/food/2026-10-19')).status, 404, 'hidden: not for readers');
+ assert.strictEqual((await t.get('/desks/nope')).status, 404);
+ });
+
+ await check('staff hide a post and show it again; a member cannot', async () => {
+ const denied = await t.get('/desks/news/2026-10-14/hide', { as: kim, form: { _csrf: t.csrf(kim), reason: 'no' } });
+ assert.strictEqual(denied.status, 403);
+ const hid = await t.get('/desks/news/2026-10-14/hide', { as: boss, form: { _csrf: t.csrf(boss), reason: 'Wrong numbers' } });
+ assert.strictEqual(hid.status, 303);
+ assert.strictEqual((await t.get('/desks/news/2026-10-14')).status, 404);
+ const staffView = await t.get('/desks/news/2026-10-14', { as: boss });
+ assert.ok(staffView.text.includes('Hidden: Wrong numbers') && staffView.text.includes('Show it again'));
+ assert.ok(!(await t.get('/desks/news/feed.xml')).text.includes('Recalls and releases'));
+ await t.get('/desks/news/2026-10-14/show', { as: boss, form: { _csrf: t.csrf(boss) } });
+ assert.strictEqual((await t.get('/desks/news/2026-10-14')).status, 200);
+ });
+
+ await t.close();
+ await ai.close();
+ await sources.close();
+ done();
+})();
@@ -20,7 +20,7 @@ const BUDGETS = {
@@ -20,7 +20,7 @@ const BUDGETS = {
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssFiles: 2, // 1
- cssRawKB: 17.5, // 5.0
+ cssRawKB: 18.5, // 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
cssBrotliKB: 4.5, // 1.4
cssBrotliKB: 4.5, // 1.4
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};