openvibers/openvibe.blog Commits
Blog moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G1), async from end to end.
Changes
@@@ -18,6 +18,14 @@ TRUST_PROXY=2#BLOG_LIMITS_HOUR=3000# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh blog: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.BLOG_DB_PATH=./data/blog.db# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: blog++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176@@@ -25,7 +25,7 @@ It also uses:## Owns--The ten charter tables live in Blog's own SQLite (`BLOG_DB_PATH`). Where a publishing package++The ten charter tables live in Blog's own PostgreSQL database (`ov_blog` on the host's data role, ADR-035; schema in [migrations/](migrations/)). Where a publishing packageapplies, it creates the table with the `blog` prefix:| Charter table | What it is |@@@ -240,10 +240,12 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an## Depends on--- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.4.0 (revisions, schedule,-- taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`-- v0.53.0, `openvibe-shared` v1.25.0 (chrome, app icon, footer, legal, release, metrics, ready,-- theme presets), `openvibe-sdk` v0.12.0 (events outbox, service tokens, per-actor limits).++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through++ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,++ schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`++ v0.76.0, `openvibe-shared` v1.25.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),++ `openvibe-sdk` v0.19.0 (db, PostgreSQL events outbox, service tokens, per-actor limits, testing).- **OpenVibe.Network:**- SSO: the OAuth client `blog` is already seeded with redirect`https://openvibe.blog/auth/callback`.@@@ -412,7 +414,11 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTHProduction deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. nginx serves++The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. The database is++`ov_blog` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh blog` writes its settings); the++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old++`/var/lib/openvibe-blog/blog.db` stays read-only for 7 days as the rollback. nginx serves`openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf).Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after therestart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema@@@ -4,11 +4,16 @@"code": true,"wave": "W16","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express 4, better-sqlite3 11",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4810,"domain": "openvibe.blog",-- "contracts": "openvibe-contracts v0.53.0",-- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-publishing v1.0.0",++ "openvibe-contracts v0.76.0",++ "openvibe-shared v1.25.0",++ "openvibe-sdk v0.19.0"++ ],"capabilities": ["blog.blog.create","blog.blog.configure",@@@ -47,5 +52,6 @@"/api/v1 by capability","SDK outbox events and Search index documents","per-actor limits (openvibe-sdk/limits)"-- ]++ ],++ "database": "PostgreSQL ov_blog (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"}@@@ -2,8 +2,8 @@Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Blog's SQLite database (BLOG_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row++ * counts and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table++ * keeps its name and columns; the three charter views are views on both sides and are not copied.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const configLib = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = configLib.load();++ runSqliteMigration({ service: 'blog', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -16,15 +16,17 @@ const { seedOfficialPost } = require('../server/seed');const args = process.argv.slice(2);const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };--const { ctx } = createApp();--try {-- const out = seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });-- console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(ctx.blogs.get(out.post.blog_id), out.post)}`);-- if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);-- if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');--} catch (err) {-- console.error(`[seed] ${err.message}`);-- process.exitCode = 1;--} finally {-- ctx.store.close();--}++(async () => {++ const { ctx } = await createApp();++ try {++ const out = await seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });++ console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(await ctx.blogs.get(out.post.blog_id), out.post)}`);++ if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);++ if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');++ } catch (err) {++ console.error(`[seed] ${err.message}`);++ process.exitCode = 1;++ } finally {++ await ctx.store.close();++ }++})();@@@ -47,11 +47,12 @@ const VERSION = require('../package.json').version;* entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,* limitsNow (the per-actor limiter's clock, tests)*/--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl || globalThis.fetch;-- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.++ const store = opts.store || await openStore(config, { now: opts.now, log });const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });const blogs = createBlogs({ store, config });@@@ -67,7 +68,7 @@ function createApp(opts = {}) {const auth = opts.auth || createAuthClient(config);const viewers = createViewerResolver({ auth, config, people });const worker = createWorker({ config, store, posts, media, outbox, log });-- blogs.ensureOfficial();++ await blogs.ensureOfficial();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 });@@@ -82,7 +83,10 @@ function createApp(opts = {}) {app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each// router resolved req.viewer; the per-address limits below stay.-- ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });app.use(contracts.http.middleware());app.use(helmet({@@@ -117,7 +121,7 @@ function createApp(opts = {}) {app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.release.mount(app, { registry: metrics.registry });-- const readiness = createBlogReadiness({ store, auth, outbox, release: release.release });++ const readiness = createBlogReadiness({ store, auth, outbox, release: release.release, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────@@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {const claims = await auth.verify(token);if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;-- if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }++ if (subject && people) { try { await people.rememberClaims(subject, claims); } catch { /* display cache only */ } }// Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };}@@@ -79,7 +79,7 @@ function createViewerResolver({ auth, config, people }) {const payload = decodeJwtPayload(token);if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {if (opts.services === false) return ANONYMOUS;-- return fromServiceToken(req, token);++ return await fromServiceToken(req, token);}}const token = extractToken(req);@@@ -31,50 +31,8 @@ const { serviceAuth } = require('openvibe-contracts');const GITHUB = 'https://api.github.com';const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS changelog_state (-- service TEXT PRIMARY KEY,-- release TEXT NOT NULL,-- head TEXT,-- checked_at INTEGER NOT NULL-- );-- CREATE TABLE IF NOT EXISTS changelog_entries (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- service TEXT NOT NULL,-- repo TEXT NOT NULL,-- sha TEXT NOT NULL,-- subject TEXT NOT NULL,-- committed_at TEXT,-- deployed_at TEXT NOT NULL,-- release TEXT NOT NULL,-- release_lines INTEGER NOT NULL DEFAULT 0,-- major INTEGER NOT NULL DEFAULT 0,-- post_id TEXT,-- UNIQUE (service, sha)-- );-- CREATE INDEX IF NOT EXISTS idx_changelog_pending ON changelog_entries(post_id, deployed_at);-- CREATE TABLE IF NOT EXISTS changelog_posts (-- post_id TEXT PRIMARY KEY,-- blog_id TEXT NOT NULL,-- entries INTEGER NOT NULL,-- from_at TEXT,-- to_at TEXT,-- reason TEXT NOT NULL,-- ai_draft_id TEXT,-- created_at INTEGER NOT NULL-- );-- CREATE TABLE IF NOT EXISTS changelog_history (-- service TEXT PRIMARY KEY,-- imported INTEGER NOT NULL,-- imported_at INTEGER NOT NULL-- );-- CREATE INDEX IF NOT EXISTS idx_changelog_service ON changelog_entries(service, deployed_at);-- `);-- // Added after the first release: who wrote each change (the commit's author name).-- const cols = db.prepare('PRAGMA table_info(changelog_entries)').all().map((c) => c.name);-- if (!cols.includes('author')) db.exec('ALTER TABLE changelog_entries ADD COLUMN author TEXT');--}++/** The changelog tables are in migrations/0001_initial.sql (PostgreSQL, ADR-035); kept for callers of the old helper. */++async function ensureSchema() {}/** The feed's page cursor: "<deployed_at>|<id>" of the last entry shown. */function encodeCursor(e) { return Buffer.from(`${e.deployed_at}|${e.id}`).toString('base64url'); }@@@ -98,7 +56,6 @@ const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subjectfunction createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {const c = config.changelog;const db = store.db;-- ensureSchema(db);const nowMs = () => store.now();const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };@@@ -106,11 +63,11 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImstate: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),putState: db.prepare(`INSERT INTO changelog_state (service, release, head, checked_at) VALUES (@service, @release, @head, @at)ON CONFLICT(service) DO UPDATE SET release = excluded.release, head = excluded.head, checked_at = excluded.checked_at`),-- insert: db.prepare(`INSERT OR IGNORE INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)-- VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id)`),++ insert: db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)++ VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id) ON CONFLICT (service, sha) DO NOTHING`),fillAuthor: db.prepare('UPDATE changelog_entries SET author = ? WHERE service = ? AND sha = ? AND author IS NULL'),history: db.prepare('SELECT * FROM changelog_history WHERE service = ?'),-- putHistory: db.prepare('INSERT OR REPLACE INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?)'),++ putHistory: db.prepare('INSERT INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?) ON CONFLICT (service) DO UPDATE SET imported = excluded.imported, imported_at = excluded.imported_at'),recentPosts: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT ?'),sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),@@@ -184,7 +141,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm// history: the commits a site shipped before the changelog watched it, so its updates page is not// empty on day one. They carry post_id 'history' (never pending, never in a patch notes post) and// their commit time stands in for the deploy time nobody recorded.-- function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {++ async function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {const deployedAt = svc.releasedAt || new Date(nowMs()).toISOString();let n = 0;for (const cm of commits) {@@@ -194,12 +151,12 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImconst committedAt = (cm.commit && cm.commit.author && cm.commit.author.date) || null;if (history && !committedAt) continue;const author = authorOf(cm);-- const added = q.insert.run({++ const added = (await q.insert.run({service: svc.id, repo: svc.repo, sha: cm.sha, subject, author, committed_at: committedAt,deployed_at: history ? new Date(committedAt).toISOString() : deployedAt, release: svc.release, release_lines: releaseLines, major: flagged ? 1 : 0,post_id: history ? 'history' : null,-- }).changes;-- if (!added && author) q.fillAuthor.run(author, svc.id, cm.sha);++ })).changes;++ if (!added && author) await q.fillAuthor.run(author, svc.id, cm.sha);n += added;}return n;@@@ -207,36 +164,36 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm/** Once per service: its last c.history commits on the deployed release. */async function importHistory(svc) {-- if (!(c.history > 0) || q.history.get(svc.id)) return 0;++ if (!(c.history > 0) || await q.history.get(svc.id)) return 0;let list;try { list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&per_page=${Math.min(c.history, 100)}`); } catch (err) {// A repository GitHub will not list (private, empty, gone) is not asked again; anything else is.-- if ([404, 409, 422].includes(err.status)) { q.putHistory.run(svc.id, 0, nowMs()); return 0; }++ if ([404, 409, 422].includes(err.status)) { await q.putHistory.run(svc.id, 0, nowMs()); return 0; }throw err;}-- const n = addEntries(svc, Array.isArray(list) ? list : [], { history: true });-- q.putHistory.run(svc.id, n, nowMs());++ const n = await addEntries(svc, Array.isArray(list) ? list : [], { history: true });++ await q.putHistory.run(svc.id, n, nowMs());return n;}async function collectOne(svc) {-- const st = q.state.get(svc.id);++ const st = await q.state.get(svc.id);if (st && st.release === svc.release) return 0;if (!st) {let n = 0;if (c.since) {const list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&since=${encodeURIComponent(c.since)}&per_page=100`);-- n = addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());++ n = await addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());}-- q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });++ await q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });return n;}const cmp = await gh(`/repos/${svc.repo}/compare/${st.head || st.release}...${svc.release}`);const commits = Array.isArray(cmp.commits) ? cmp.commits : [];const lines = (Array.isArray(cmp.files) ? cmp.files : []).reduce((a, f) => a + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0);-- const n = cmp.status === 'behind' ? 0 : addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });++ const n = cmp.status === 'behind' ? 0 : await addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });const head = commits.length ? commits[commits.length - 1].sha : st.head;-- q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });++ await q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });return n;}@@@ -247,7 +204,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImtry {added += await collectOne(svc);// A few history imports per run keep an unauthenticated GitHub budget intact.-- if (histories < 6 && !q.history.get(svc.id)) { histories++; await importHistory(svc); }++ if (histories < 6 && !await q.history.get(svc.id)) { histories++; await importHistory(svc); }} catch (err) {stats.lastError = `${svc.id}: ${err.message}`;if (err.status === 403 || err.status === 429) break; // GitHub's rate limit: try again next run@@@ -258,9 +215,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm}/** { due, reason } for the pending entries. */-- function due(list, at = nowMs()) {++ async function due(list, at = nowMs()) {if (!list.length) return { due: false, reason: 'nothing pending' };-- const last = q.lastPost.get();++ const last = await q.lastPost.get();const sinceLast = last ? at - last.created_at : Infinity;const newest = Math.max(...list.map((e) => Date.parse(e.deployed_at) || 0));const oldest = Math.min(...list.map((e) => Date.parse(e.deployed_at) || at));@@@ -307,19 +264,19 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm}async function publish(list, reason) {-- const blog = blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? blogs.ensureOfficial() : null);++ const blog = await blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? await blogs.ensureOfficial() : null);const owner = config.official.owners.find((s) => /^usr_/.test(s));if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });const doc = render(list, services);const viewer = { kind: 'user', subject: owner, staff: false };-- const { post } = posts.create(viewer, blog, {++ const { post } = await posts.create(viewer, blog, {title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },message: `Patch notes from ${list.length} commits (${reason})`,});-- posts.publish(viewer, posts.get ? posts.get(post.id) || post : post);++ await posts.publish(viewer, posts.get ? await posts.get(post.id) || post : post);let aiDraftId = null;if (c.aiDraft && aiDrafts && aiDrafts.enabled) {try {@@@ -328,9 +285,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImaiDraftId = d.post.id;} catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }}-- store.tx(() => {-- for (const e of list) q.mark.run(post.id, e.id);-- q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });++ await store.tx(async () => {++ for (const e of list) await q.mark.run(post.id, e.id);++ await q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });});stats.posts++;log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);@@@ -341,8 +298,8 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImstats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();try {await collect();-- const list = q.pending.all();-- const d = due(list);++ const list = await q.pending.all();++ const d = await due(list);if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);return null;} catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }@@@ -359,14 +316,14 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImfunction stop() { if (timer) clearInterval(timer); timer = null; }/** The public feed, newest first: { entries, next } (next = the cursor for the page after, or null). */-- function page({ service = null, limit = 20, before = null } = {}) {++ async function page({ service = null, limit = 20, before = null } = {}) {const n = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);const cur = decodeCursor(before);const where = [];const args = [];if (service) { where.push('service = ?'); args.push(String(service)); }if (cur) { where.push('(deployed_at < ? OR (deployed_at = ? AND id < ?))'); args.push(cur.at, cur.at, cur.id); }-- const rows = db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);++ const rows = await db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);const more = rows.length > n;const shown = rows.slice(0, n);return {@@@ -377,13 +334,13 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImnext: more && shown.length ? encodeCursor(shown[shown.length - 1]) : null,};}-- function entries(opts = {}) { return page(opts).entries; }-- function latestPost() { return q.lastPost.get() || null; }-- function recentPosts(limit = 5) { return q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }++ async function entries(opts = {}) { return (await page(opts)).entries; }++ async function latestPost() { return await q.lastPost.get() || null; }++ async function recentPosts(limit = 5) { return await q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */-- function sites() { return q.sites.all(); }++ async function sites() { return await q.sites.all(); }-- return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: q.pending.all().length }) };++ return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };}/** A stable id for a batch (tests). */@@@ -54,7 +54,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {/** The stored thread id, or resolve it once through Community. Public posts only. */async threadFor(post, label, ctx) {if (post.visibility !== 'public') return null;-- const known = store.discussion.get(post.id);++ const known = await store.discussion.get(post.id);if (known) return known.threadId;if (!discussion) return null;const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });@@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */async readThread(threadId, { after, ctx } = {}) {const qs = after ? `?after=${encodeURIComponent(after)}` : '';-- return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });++ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });},/** Comment as the signed-in member. */async comment(threadId, subject, { message, parentId } = {}, ctx) {if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }-- return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {++ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,});},@@@ -80,7 +80,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {* again when it is public again. Needs community.comment.moderate.*/async setThreadVisibility(post, visibility, ctx) {-- const known = store.discussion.get(post.id);++ const known = await store.discussion.get(post.id);if (!known || !modTokens) return false;try {await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });@@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {}) : null;const q = {get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),++ getMany: db.prepare('SELECT * FROM subject_projections WHERE subject = ANY(?)'),byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,@@@ -30,9 +31,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {};const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);-- function remember(subject, p) {++ async function remember(subject, p) {if (!ids.isSubjectId('user', subject) || !p) return;-- q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());++ await q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());}async function resolve(subjects) {@@@ -46,7 +47,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {if (res.status === 401) tokens.invalidate && tokens.invalidate();const data = await res.json().catch(() => null);if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);-- for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);++ for (const s of subjects) if (data.results[s]) await remember(s, data.results[s]);}const shape = (row, subject) => (row@@@ -56,12 +57,14 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {return {enabled,/** From a verified sign-in: the member's own claims are a fresh projection. */-- rememberClaims(subject, claims) { remember(subject, claims); },++ async rememberClaims(subject, claims) { await remember(subject, claims); },/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */async many(subjects) {const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];-- const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });++ const read = async () => new Map((list.length ? await q.getMany.all(list) : []).map((r) => [r.subject, r]));++ let rows = await read();++ const missing = list.filter((s) => { const r = rows.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });if (missing.length && tokens) {let timer;await Promise.race([@@@ -69,19 +72,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),]);clearTimeout(timer);++ rows = await read();}-- return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));++ return new Map(list.map((s) => [s, shape(rows.get(s) || null, s)]));},async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },/** The subject a username belongs to, from people Blog has seen. */-- byUsername(username) {-- const row = q.byUsername.get(String(username || '').toLowerCase());++ async byUsername(username) {++ const row = await q.byUsername.get(String(username || '').toLowerCase());return row ? shape(row, row.subject) : null;},-- cached: (subject) => shape(q.get.get(subject), subject),++ cached: async (subject) => shape(await q.get.get(subject), subject),};}@@@ -30,15 +30,15 @@const ROLE_RANK = { author: 1, editor: 2, owner: 3 };--function roleOf(store, blogId, subject) {++async function roleOf(store, blogId, subject) {if (!subject) return null;-- const row = store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);++ const row = await store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);return row ? row.role : null;}/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */--function effectiveRole(store, viewer, blog) {-- const role = roleOf(store, blog.id, viewer && viewer.subject);++async function effectiveRole(store, viewer, blog) {++ const role = await roleOf(store, blog.id, viewer && viewer.subject);if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';return role;}@@@ -48,8 +48,8 @@ const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[minfunction isStaff(viewer) { return Boolean(viewer && viewer.staff); }/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */--function memberCanSee(store, viewer, blog, post) {-- const role = effectiveRole(store, viewer, blog);++async function memberCanSee(store, viewer, blog, post) {++ const role = await effectiveRole(store, viewer, blog);if (!role) return false;if (atLeast(role, 'editor')) return true;return post.author_subject === viewer.subject;@@@ -103,7 +103,7 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = nullasync function canReadPost(store, viewer, blog, post, entitlements) {if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };-- const member = memberCanSee(store, viewer, blog, post) || isStaff(viewer);++ const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };if (member) return { allowed: true, status: 200, reason: 'member' };@@@ -115,10 +115,10 @@ async function canReadPost(store, viewer, blog, post, entitlements) {}/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */--function canWrite(store, viewer, blog, action, post = null) {++async function canWrite(store, viewer, blog, action, post = null) {if (!viewer || !viewer.subject) return false;if (blog.status !== 'active' && !isStaff(viewer)) return false;-- const role = effectiveRole(store, viewer, blog);++ const role = await effectiveRole(store, viewer, blog);switch (action) {case 'create': return atLeast(role, 'author');case 'edit':@@@ -25,7 +25,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.fasync function draft(viewer, blog, input = {}, { traceparent } = {}) {if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');-- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');++ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');const topic = clean(input.topic, 300);if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);@@@ -49,7 +49,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f}));// The member acts (their blog, their draft); the text is the AI's (authorship mode 'ai').const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };-- return posts.create(aiViewer, blog, {++ return await posts.create(aiViewer, blog, {title: clean(out.title, 200) || topic,summary: clean(out.dek, 400) || null,body,@@@ -39,7 +39,7 @@ function createBlogs({ store, config }) {official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),-- feedDefaults: db.prepare('INSERT OR IGNORE INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?)'),++ feedDefaults: db.prepare('INSERT INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?) ON CONFLICT DO NOTHING'),member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)@@@ -53,59 +53,59 @@ function createBlogs({ store, config }) {seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),};-- function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {++ async function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {const now = store.now();const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };-- q.insert.run(row);-- q.feedDefaults.run(row.id, now);-- if (owner) q.putMember.run(row.id, owner, 'owner', owner, now, now);-- return q.byId.get(row.id);++ await q.insert.run(row);++ await q.feedDefaults.run(row.id, now);++ if (owner) await q.putMember.run(row.id, owner, 'owner', owner, now, now);++ return await q.byId.get(row.id);}const api = {THEMES, THEME_PRESETS, ROLES, validHandle,-- get: (id) => q.byId.get(id) || null,-- byHandle: (handle) => q.byHandle.get(String(handle || '').toLowerCase()) || null,-- ownedBy: (subject) => (subject ? q.byOwner.get(subject) || null : null),-- blogsOf: (subject) => (subject ? q.blogsOf.all(subject) : []),++ get: async (id) => await q.byId.get(id) || null,++ byHandle: async (handle) => await q.byHandle.get(String(handle || '').toLowerCase()) || null,++ ownedBy: async (subject) => (subject ? await q.byOwner.get(subject) || null : null),++ blogsOf: async (subject) => (subject ? await q.blogsOf.all(subject) : []),/** The official blog, created on first boot; config owners become owner memberships. */-- ensureOfficial() {-- return store.tx(() => {-- let blog = q.official.get();++ async ensureOfficial() {++ return await store.tx(async () => {++ let blog = await q.official.get();if (!blog) {-- blog = create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });++ blog = await create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });}const now = store.now();for (const s of config.official.owners) {if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }-- if (!q.member.get(blog.id, s)) q.putMember.run(blog.id, s, 'owner', 'config', now, now);++ if (!await q.member.get(blog.id, s)) await q.putMember.run(blog.id, s, 'owner', 'config', now, now);}return blog;});},-- official: () => q.official.get() || null,++ official: async () => await q.official.get() || null,/*** The member's own blog, created on first use. Handle: the requested one, else the Network* username. A taken or reserved handle is a 409 the member resolves by choosing another.*/-- ensureMemberBlog({ subject, username, handle, title, description } = {}) {++ async ensureMemberBlog({ subject, username, handle, title, description } = {}) {if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');-- return store.tx(() => {-- const existing = q.byOwner.get(subject);++ return await store.tx(async () => {++ const existing = await q.byOwner.get(subject);if (existing) return { blog: existing, created: false };const h = String(handle || username || '').toLowerCase();if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);-- if (q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);-- const blog = create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });++ if (await q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);++ const blog = await create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });return { blog, created: true };});},-- update(blog, { title, description, language } = {}) {++ async update(blog, { title, description, language } = {}) {const sets = [];const vals = [];if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }@@@ -115,77 +115,77 @@ function createBlogs({ store, config }) {sets.push('language = ?'); vals.push(String(language));}if (!sets.length) return blog;-- db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);-- return q.byId.get(blog.id);++ await db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);++ return await q.byId.get(blog.id);},-- setTheme(blog, theme) {++ async setTheme(blog, theme) {if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);-- db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);-- return q.byId.get(blog.id);++ await db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);++ return await q.byId.get(blog.id);},/** CSS custom properties of the blog's preset, for its content surface. */themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },-- feedSettings(blog) { return q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },++ async feedSettings(blog) { return await q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },-- setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {-- const cur = api.feedSettings(blog);++ async setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {++ const cur = await api.feedSettings(blog);const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');-- db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)++ await db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`).run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());-- return api.feedSettings(blog);++ return await api.feedSettings(blog);},-- members: (blog) => q.members.all(blog.id),-- membership: (blog, subject) => q.member.get(blog.id, subject) || null,++ members: async (blog) => await q.members.all(blog.id),++ membership: async (blog, subject) => await q.member.get(blog.id, subject) || null,-- setMember(blog, subject, role, addedBy) {++ async setMember(blog, subject, role, addedBy) {if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);-- return store.tx(() => {-- const cur = q.member.get(blog.id, subject);-- if (cur && cur.role === 'owner' && role !== 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') {++ return await store.tx(async () => {++ const cur = await q.member.get(blog.id, subject);++ if (cur && cur.role === 'owner' && role !== 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') {throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');}const now = store.now();-- q.putMember.run(blog.id, subject, role, addedBy || null, now, now);-- return q.member.get(blog.id, subject);++ await q.putMember.run(blog.id, subject, role, addedBy || null, now, now);++ return await q.member.get(blog.id, subject);});},-- removeMember(blog, subject) {-- return store.tx(() => {-- const cur = q.member.get(blog.id, subject);++ async removeMember(blog, subject) {++ return await store.tx(async () => {++ const cur = await q.member.get(blog.id, subject);if (!cur) return false;-- if (cur.role === 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');-- q.dropMember.run(blog.id, subject);++ if (cur.role === 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');++ await q.dropMember.run(blog.id, subject);return true;});},-- series: (blog) => q.series.all(blog.id),-- seriesBySlug: (blog, slug) => q.seriesBySlug.get(blog.id, slug) || null,-- seriesById: (id) => (id ? q.seriesById.get(id) || null : null),++ series: async (blog) => await q.series.all(blog.id),++ seriesBySlug: async (blog, slug) => await q.seriesBySlug.get(blog.id, slug) || null,++ seriesById: async (id) => (id ? await q.seriesById.get(id) || null : null),/** Get-or-create a series by title. */-- ensureSeries(blog, title, description = null) {++ async ensureSeries(blog, title, description = null) {const t = String(title || '').trim().slice(0, 120);if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');const slug = slugify(t);-- return store.tx(() => {-- const cur = q.seriesBySlug.get(blog.id, slug);++ return await store.tx(async () => {++ const cur = await q.seriesBySlug.get(blog.id, slug);if (cur) return cur;const now = store.now();const id = newSeriesId(now);-- db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, blog.id, slug, t, description, now, now);-- return q.seriesById.get(id);++ return await q.seriesById.get(id);});},@@@ -9,8 +9,8 @@*/function createEffects({ outbox, community }) {return {-- after(before, post, ctx) {-- outbox.kick();++ async after(before, post, ctx) {++ await outbox.kick();if (!post || !before) return;// Public = readable by everyone: published and public. Unpublishing hides the thread too.const wasPublic = before.visibility === 'public' && before.state === 'published';@@@ -52,11 +52,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleVALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,@allow_comments, @noindex, @now, @now)`),};-- const blogOf = (post) => blogs.get(post.blog_id);++ const blogOf = async (post) => await blogs.get(post.blog_id);// Staff acting where only their staff powers allow it (not a role on the blog, not their own post)// is moderation: it also goes to Network's moderation audit log (ADR-022).-- const byStaff = (viewer, blog, action, post) => access.isStaff(viewer) && !access.canWrite(store, { ...viewer, staff: false }, blog, action, post);-- const moderated = (action, post, viewer, ctx, details) => outbox.moderationAction({++ const byStaff = async (viewer, blog, action, post) => access.isStaff(viewer) && !await access.canWrite(store, { ...viewer, staff: false }, blog, action, post);++ const moderated = async (action, post, viewer, ctx, details) => await outbox.moderationAction({action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },}, { traceparent: ctx.traceparent });@@@ -86,13 +86,13 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleif (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');return { visibility: vis, entitlement_key: k };}-- function slugFor(blog, wanted, postId = null) {++ async function slugFor(blog, wanted, postId = null) {let base;try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');for (let i = 1; i < 1000; i++) {const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;-- const hit = q.slugTaken.get(blog.id, s);++ const hit = await q.slugTaken.get(blog.id, s);if (!hit || hit.id === postId) return s;}throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');@@@ -127,12 +127,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = consolereturn viewer.subject;}-- function setTerms(blog, post, input) {++ async function setTerms(blog, post, input) {if (input.tags !== undefined) {const tags = listOf(input.tags);if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');-- store.taxonomy.setTerms(post.id, 'tag', tags);++ await store.taxonomy.setTerms(post.id, 'tag', tags);}if (input.categories !== undefined) {const vocab = blogs.categoryVocabulary(blog);@@@ -141,20 +141,20 @@ function createPosts({ store, blogs, publication, access, outbox, log = consolefor (const pathText of listOf(input.categories).slice(0, 10)) {let parentId = null;for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {-- const term = store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });++ const term = await store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });parentId = term.id;}if (parentId != null) termIds.push(parentId);}-- store.taxonomy.setTerms(post.id, vocab, termIds);++ await store.taxonomy.setTerms(post.id, vocab, termIds);}}-- function seriesOf(blog, input, current) {++ async function seriesOf(blog, input, current) {if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;const v = input.seriesId ?? input.series_id ?? input.series;if (v == null || v === '') return { series_id: null, series_position: null };-- let series = typeof v === 'string' && v.startsWith('ser_') ? blogs.seriesById(v) : blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);++ let series = typeof v === 'string' && v.startsWith('ser_') ? await blogs.seriesById(v) : await blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');const posRaw = input.seriesPosition ?? input.series_position;const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);@@@ -162,9 +162,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = consolereturn { series_id: series.id, series_position: pos };}-- function attachCitations(post, revision, list) {++ async function attachCitations(post, revision, list) {if (!Array.isArray(list) || !list.length) return;-- store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({++ await store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,retrievedAt: c.retrievedAt || c.retrieved_at || null,quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,@@@ -177,14 +177,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleconst api = {VISIBILITIES, MEDIA_ROLES,-- get: (id) => q.byId.get(String(id || '')) || null,-- bySlug: (blog, slug) => q.bySlug.get(blog.id, String(slug || '')) || null,-- deletedBySlug: (blog, slug) => q.deletedBySlug.get(blog.id, String(slug || '')) || null,++ get: async (id) => await q.byId.get(String(id || '')) || null,++ bySlug: async (blog, slug) => await q.bySlug.get(blog.id, String(slug || '')) || null,++ deletedBySlug: async (blog, slug) => await q.deletedBySlug.get(blog.id, String(slug || '')) || null,blogOf,/** Must exist and not be deleted, else 404. */-- mustGet(id) {-- const p = api.get(id);++ async mustGet(id) {++ const p = await api.get(id);if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');return p;},@@@ -194,7 +194,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console* member of the blog or staff). Unlisted posts are never listed.* filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset*/-- listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {++ async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {const where = ["p.state = 'published'"];const args = [];if (restricted) where.push("p.visibility IN ('public','members','private')");@@@ -209,63 +209,63 @@ function createPosts({ store, blogs, publication, access, outbox, log = console}where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");const w = where.join(' AND ');-- const total = db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args).n;++ const total = (await db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args)).n;const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';-- const posts = db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);++ const posts = await db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);return { total, posts };},/** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */-- listForDashboard(blog, viewer) {-- const role = access.effectiveRole(store, viewer, blog);++ async listForDashboard(blog, viewer) {++ const role = await access.effectiveRole(store, viewer, blog);if (!role && !access.isStaff(viewer)) return [];const own = role === 'author' ? ' AND author_subject = ?' : '';const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];-- return db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);++ return await db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);},-- head: (post) => store.revisions.head(post.id),-- revision: (post, n) => store.revisions.get(post.id, n),-- revisions: (post, opts) => store.revisions.list(post.id, opts),-- diff: (post, from, to, mode) => store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),-- pendingJobs: (post) => store.scheduler.jobs(post.id).filter((j) => j.status === 'pending' || j.status === 'running'),-- attachments: (post) => store.attachments.list(post.id),-- citations: (post, rev) => (rev ? store.citations.forRevision(post.id, rev) : []),++ head: async (post) => await store.revisions.head(post.id),++ revision: async (post, n) => await store.revisions.get(post.id, n),++ revisions: async (post, opts) => await store.revisions.list(post.id, opts),++ diff: async (post, from, to, mode) => await store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),++ pendingJobs: async (post) => (await store.scheduler.jobs(post.id)).filter((j) => j.status === 'pending' || j.status === 'running'),++ attachments: async (post) => await store.attachments.list(post.id),++ citations: async (post, rev) => (rev ? await store.citations.forRevision(post.id, rev) : []),// ── Writes ──────────────────────────────────────────/** A new draft (revision 1). Returns { post, revision }. */-- create(viewer, blog, input = {}, { traceparent } = {}) {++ async create(viewer, blog, input = {}, { traceparent } = {}) {const subject = actingSubject(viewer);-- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');++ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');const t = title(input.title);const content = body(input.body);const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);const rec = authorshipFor(viewer, input, null);-- return store.tx(() => {++ return await store.tx(async () => {const now = store.now();const id = newPostId(now);-- const slug = slugFor(blog, input.slug || t);-- store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path-- const ser = seriesOf(blog, input, { series_id: null, series_position: null });-- q.insert.run({++ const slug = await slugFor(blog, input.slug || t);++ await store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path++ const ser = await seriesOf(blog, input, { series_id: null, series_position: null });++ await q.insert.run({id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),noindex: truthy(input.noindex) ? 1 : 0, now,});-- const { revision } = store.revisions.create({++ const { revision } = await store.revisions.create({entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',});-- attachCitations({ id }, revision.number, input.citations);-- const post = q.byId.get(id);-- setTerms(blog, post, input);-- outbox.emit({++ await attachCitations({ id }, revision.number, input.citations);++ const post = await q.byId.get(id);++ await setTerms(blog, post, input);++ await outbox.emit({event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',subject: { type: 'post', id, revision: revision.number },payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },}, { traceparent });-- return { post: q.byId.get(id), revision };++ return { post: await q.byId.get(id), revision };});},@@@ -274,14 +274,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console* on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug* change leaves a 301 from the old path.*/-- update(viewer, post, input = {}, { traceparent } = {}) {++ async update(viewer, post, input = {}, { traceparent } = {}) {actingSubject(viewer);-- const blog = blogOf(post);-- if (!access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ const blog = await blogOf(post);++ if (!await access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');-- return store.tx(() => {++ return await store.tx(async () => {const before = publication.snapshot(blog, post);-- const head = store.revisions.head(post.id);++ const head = await store.revisions.head(post.id);let revision = head;let created = false;const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;@@@ -293,7 +293,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = consolesummary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),};const rec = authorshipFor(viewer, input, publication.authorshipOf(head));-- const out = store.revisions.create({++ const out = await store.revisions.create({entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,});@@@ -302,88 +302,88 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleif (created) {// An edit that does not send citations keeps the ones it had (the /write editor does not// resend them); sending a list, even an empty one, replaces them.-- if (input.citations !== undefined) attachCitations(post, revision.number, input.citations);-- else store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });++ if (input.citations !== undefined) await attachCitations(post, revision.number, input.citations);++ else await store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });}}const sets = {};if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {-- const slug = slugFor(blog, input.slug, post.id);++ const slug = await slugFor(blog, input.slug, post.id);if (slug !== post.slug) {-- store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });++ await store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });sets.slug = slug;}}if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));}-- Object.assign(sets, seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));++ Object.assign(sets, await seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;const keys = Object.keys(sets);-- db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)++ await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`).run({ ...sets, now: store.now(), id: post.id });-- setTerms(blog, post, input);-- const { post: after } = publication.afterChange(before, post.id, { actor: viewer, traceparent });++ await setTerms(blog, post, input);++ const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });return { post: after, revision, created };});},/** The idempotent effect: revision N is the published one. Returns { changed, post }. */-- applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {-- return store.tx(() => {-- const post = q.byId.get(postId);++ async applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {++ return await store.tx(async () => {++ const post = await q.byId.get(postId);if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');-- const rev = revisionNumber ? store.revisions.get(post.id, revisionNumber) : store.revisions.head(post.id);++ const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);-- const before = publication.snapshot(blogOf(post), post);++ const before = publication.snapshot(await blogOf(post), post);const now = store.now();-- db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),++ await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);-- const out = publication.afterChange(before, post.id, { actor, traceparent });++ const out = await publication.afterChange(before, post.id, { actor, traceparent });return { changed: true, post: out.post };});},-- applyUnpublish(postId, actor, { traceparent } = {}) {-- return store.tx(() => {-- const post = q.byId.get(postId);++ async applyUnpublish(postId, actor, { traceparent } = {}) {++ return await store.tx(async () => {++ const post = await q.byId.get(postId);if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');if (post.state !== 'published') return { changed: false, post };-- const before = publication.snapshot(blogOf(post), post);-- db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);-- return { changed: true, post: publication.afterChange(before, post.id, { actor, traceparent }).post };++ const before = publication.snapshot(await blogOf(post), post);++ await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };});},-- publish(viewer, post, { revision } = {}, ctx = {}) {++ async publish(viewer, post, { revision } = {}, ctx = {}) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');++ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');const n = revision == null || revision === '' ? null : parseInt(revision, 10);-- return store.tx(() => {-- store.scheduler.cancelPending(post.id, 'publish');-- return api.applyPublish(post.id, n, viewer, ctx);++ return await store.tx(async () => {++ await store.scheduler.cancelPending(post.id, 'publish');++ return await api.applyPublish(post.id, n, viewer, ctx);});},-- unpublish(viewer, post, ctx = {}) {++ async unpublish(viewer, post, ctx = {}) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');-- const moderation = byStaff(viewer, blogOf(post), 'unpublish', post);-- return store.tx(() => {-- store.scheduler.cancelPending(post.id);-- const cur = q.byId.get(post.id);++ if (!await access.canWrite(store, viewer, await blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');++ const moderation = await byStaff(viewer, await blogOf(post), 'unpublish', post);++ return await store.tx(async () => {++ await store.scheduler.cancelPending(post.id);++ const cur = await q.byId.get(post.id);let out;if (cur.state === 'scheduled') {-- db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);-- out = { changed: true, post: q.byId.get(post.id) };++ await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ out = { changed: true, post: await q.byId.get(post.id) };} else {-- out = api.applyUnpublish(post.id, viewer, ctx);++ out = await api.applyUnpublish(post.id, viewer, ctx);}-- if (moderation && out.changed) moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });++ if (moderation && out.changed) await moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });return out;});},@@@ -392,38 +392,38 @@ function createPosts({ store, blogs, publication, access, outbox, log = console* Schedule a publish (of revision N, default the head) or an unpublish at a future time.* Idempotent: the same (post, action, time, revision) is one job.*/-- schedule(viewer, post, { at, revision, action = 'publish' } = {}) {++ async schedule(viewer, post, { at, revision, action = 'publish' } = {}) {actingSubject(viewer);-- const blog = blogOf(post);-- if (!access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');++ const blog = await blogOf(post);++ if (!await access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');-- const rev = action === 'publish' ? (revision ? store.revisions.get(post.id, parseInt(revision, 10)) : store.revisions.head(post.id)) : null;++ const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;if (action === 'publish') {if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');-- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));++ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);}-- return store.tx(() => {-- const { job, created } = store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });-- const cur = q.byId.get(post.id);++ return await store.tx(async () => {++ const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });++ const cur = await q.byId.get(post.id);if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {-- db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ await db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);}-- return { job, created, post: q.byId.get(post.id) };++ return { job, created, post: await q.byId.get(post.id) };});},-- cancelSchedule(viewer, post) {++ async cancelSchedule(viewer, post) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');-- return store.tx(() => {-- const n = store.scheduler.cancelPending(post.id);-- const cur = q.byId.get(post.id);-- if (cur.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);-- return { cancelled: n, post: q.byId.get(post.id) };++ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');++ return await store.tx(async () => {++ const n = await store.scheduler.cancelPending(post.id);++ const cur = await q.byId.get(post.id);++ if (cur.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ return { cancelled: n, post: await q.byId.get(post.id) };});},@@@ -434,15 +434,15 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleasync runScheduled(worker) {const summary = await store.scheduler.runDue({worker,-- handler: (job) => (job.action === 'publish'-- ? api.applyPublish(job.entityId, job.revision, null).changed-- : api.applyUnpublish(job.entityId, null).changed),++ handler: async (job) => (job.action === 'publish'++ ? (await api.applyPublish(job.entityId, job.revision, null)).changed++ : (await api.applyUnpublish(job.entityId, null)).changed),});for (const job of summary.failed) {-- store.tx(() => {-- const post = q.byId.get(job.entityId);-- if (post && post.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);-- outbox.emit({++ await store.tx(async () => {++ const post = await q.byId.get(job.entityId);++ if (post && post.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ await outbox.emit({event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },@@@ -454,68 +454,68 @@ function createPosts({ store, blogs, publication, access, outbox, log = console},/** Soft delete: readers get 410, Search a tombstone, revisions are kept. */-- remove(viewer, post, ctx = {}) {++ async remove(viewer, post, ctx = {}) {actingSubject(viewer);-- const blog = blogOf(post);-- if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');-- const moderation = byStaff(viewer, blog, 'delete', post);-- return store.tx(() => {-- store.scheduler.cancelPending(post.id);-- const cur = q.byId.get(post.id);++ const blog = await blogOf(post);++ if (!await access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');++ const moderation = await byStaff(viewer, blog, 'delete', post);++ return await store.tx(async () => {++ await store.scheduler.cancelPending(post.id);++ const cur = await q.byId.get(post.id);const before = publication.snapshot(blog, cur);const now = store.now();-- db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);-- if (moderation && cur.state !== 'deleted') moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });-- return publication.afterChange(before, post.id, { actor: viewer, ...ctx });++ await db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);++ if (moderation && cur.state !== 'deleted') await moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });++ return await publication.afterChange(before, post.id, { actor: viewer, ...ctx });});},/** Revert = a new revision copying revision N (content, fields and its authorship). */-- revert(viewer, post, { toRevision, expectedRevision } = {}) {++ async revert(viewer, post, { toRevision, expectedRevision } = {}) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');-- const target = store.revisions.get(post.id, parseInt(toRevision, 10));++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ const target = await store.revisions.get(post.id, parseInt(toRevision, 10));if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);-- return store.tx(() => {-- const { revision } = store.revisions.revert({++ return await store.tx(async () => {++ const { revision } = await store.revisions.revert({entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },});-- store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });-- db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);-- return { revision, post: q.byId.get(post.id) };++ await store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });++ await db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);++ return { revision, post: await q.byId.get(post.id) };});},/** A person's review of one revision (what lets an AI draft be published and indexed). */-- review(viewer, post, { revision, decision, note } = {}, ctx = {}) {++ async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');-- const blog = blogOf(post);-- if (!access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');++ const blog = await blogOf(post);++ if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');const n = parseInt(revision, 10);-- if (!store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);-- return store.tx(() => {++ if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);++ return await store.tx(async () => {const before = publication.snapshot(blog, post);-- const row = store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });-- publication.afterChange(before, post.id, { actor: viewer, ...ctx });++ const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });++ await publication.afterChange(before, post.id, { actor: viewer, ...ctx });return row;});},-- attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {++ async attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');const id = String(mediaId || '').trim();if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);const pos = parseInt(position, 10);-- return store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });++ return await store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });},-- detach(viewer, post, attachmentId) {++ async detach(viewer, post, attachmentId) {actingSubject(viewer);-- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');-- return store.attachments.detach(post.id, attachmentId);++ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');++ return await store.attachments.detach(post.id, attachmentId);},};return api;@@@ -40,12 +40,12 @@ function createPublication({ store, config, outbox }) {return (rev && rev.meta && rev.meta.authorship) || null;}-- function reviewOf(post, rev) {-- return rev ? store.reviews.latest(post.id, rev.number) : null;++ async function reviewOf(post, rev) {++ return rev ? await store.reviews.latest(post.id, rev.number) : null;}/** The gate's decision for a post at one revision (the published one by default). */-- function decide(blog, post, rev, { state } = {}) {++ async function decide(blog, post, rev, { state } = {}) {const rec = authorshipOf(rev);const facts = {state: state || post.state,@@@ -54,24 +54,24 @@ function createPublication({ store, config, outbox }) {text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',noindex: Boolean(post.noindex),};-- if (rec) Object.assign(facts, authorship.gateFacts(rec, reviewOf(post, rev)));++ if (rec) Object.assign(facts, authorship.gateFacts(rec, await reviewOf(post, rev)));return seo.evaluate(facts, { policy: POLICY, now: store.now() });}/** The revision readers see (published) or, for a never-published post, the head. */-- function visibleRevision(post) {-- return post.published_revision ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);++ async function visibleRevision(post) {++ return post.published_revision ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);}-- function aclFor(blog, post) {-- const subjects = [...new Set([...membersOf.all(blog.id).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));++ async function aclFor(blog, post) {++ const subjects = [...new Set([...(await membersOf.all(blog.id)).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };if (post.visibility === 'private') return { subjects };return {};}-- function termsOf(post) {-- const terms = store.taxonomy.termsFor(post.id);++ async function termsOf(post) {++ const terms = await store.taxonomy.termsFor(post.id);return {tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),@@@ -82,16 +82,16 @@ function createPublication({ store, config, outbox }) {* The index document as it would describe this post (real visibility, for the product events),* or a tombstone when it is not published.*/-- function documentFor(blog, post, { forSearch }) {-- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;++ async function documentFor(blog, post, { forSearch }) {++ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };-- if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(blog, post, rev) : null };-- const decision = decide(blog, post, rev);++ if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? await decide(blog, post, rev) : null };++ const decision = await decide(blog, post, rev);if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };-- const { tags, categories } = termsOf(post);-- const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;++ const { tags, categories } = await termsOf(post);++ const series = post.series_id ? await db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;const rec = authorshipOf(rev);-- const acl = aclFor(blog, post);++ const acl = await aclFor(blog, post);// Members-only: whatever carries this document (the blog.post.* events) gets the teaser — the// title and the author's summary — never the body. (Search itself only ever gets a tombstone.)const gated = post.visibility === 'members';@@@ -107,7 +107,7 @@ function createPublication({ store, config, outbox }) {body: gated ? '' : ssr.markdownToText(rev.content),facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },authorship: rec,-- citations: store.citations.forRevision(post.id, rev.number),++ citations: await store.citations.forRevision(post.id, rev.number),decision,publishedAt: post.first_published_at,updatedAt: rev.createdAt,@@@ -117,13 +117,13 @@ function createPublication({ store, config, outbox }) {}/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */-- function syncIndex(blog, post, { traceparent } = {}) {-- const { doc } = documentFor(blog, post, { forSearch: true });-- const prev = store.sequencer.current(OWNER, 'post', post.id);++ async function syncIndex(blog, post, { traceparent } = {}) {++ const { doc } = await documentFor(blog, post, { forSearch: true });++ const prev = await store.sequencer.current(OWNER, 'post', post.id);if (doc.deleted && prev == null) return null; // never indexed: nothing to remove-- const stamped = store.sequencer.stamp(doc);++ const stamped = await store.sequencer.stamp(db, doc); // db: joins the ambient transactionif (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send-- return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });++ return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });}/** Snapshot for actionFor(before, after). */@@@ -135,22 +135,22 @@ function createPublication({ store, config, outbox }) {* Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),* and re-sync Search. Inside the caller's transaction, after the row changed.*/-- function afterChange(before, postId, { actor, traceparent } = {}) {-- const post = db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);-- const blog = blogById.get(post.blog_id);++ async function afterChange(before, postId, { actor, traceparent } = {}) {++ const post = await db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);++ const blog = await blogById.get(post.blog_id);const after = snapshot(blog, post);let action = hooks.actionFor(before, after);if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';let event = null;if (action) {-- const { doc, decision } = documentFor(blog, post, { forSearch: false });-- event = outbox.emit(hooks.publicationEvent({++ const { doc, decision } = await documentFor(blog, post, { forSearch: false });++ event = await outbox.emit(hooks.publicationEvent({product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,actor: actorRef(actor), document: doc, decision, now: store.now(),extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },}), { traceparent });}-- syncIndex(blog, post, { traceparent });++ await syncIndex(blog, post, { traceparent });return { action, event, post, blog };}@@@ -24,8 +24,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medfeed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,};-- function feedsOf(blog) {-- const s = blogs.feedSettings(blog);++ async function feedsOf(blog) {++ const s = await blogs.feedSettings(blog);const out = [];if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });@@@ -33,49 +33,61 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medreturn out;}-- function termsView(post, blog, { networkTags = false } = {}) {-- const terms = store.taxonomy.termsFor(post.id);++ async function termsView(post, blog, { networkTags = false } = {}) {++ const terms = await store.taxonomy.termsFor(post.id);return {tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),};}-- /** List items for rows of published posts (possibly from several blogs). */++ /** Blogs of these rows, one lookup per distinct blog. */++ async function blogsFor(rows) {++ const out = new Map();++ for (const id of new Set(rows.map((p) => p.blog_id))) out.set(id, await blogs.get(id));++ return out;++ }++ const revRefs = (rows) => rows.map((p) => ({ entityId: p.id, revision: p.published_revision }));++++ /** List items for rows of published posts (possibly from several blogs): revisions in one query (no N+1). */async function listItems(rows, { perBlogLinks = true } = {}) {-- const who = await people.many(rows.map((p) => p.author_subject));-- return rows.map((post) => {-- const blog = blogs.get(post.blog_id);-- const rev = store.revisions.get(post.id, post.published_revision);++ const [who, revs, blogById] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows)), blogsFor(rows)]);++ const out = [];++ for (const [i, post] of rows.entries()) {++ const rev = revs[i];++ if (!rev) continue;++ const blog = blogById.get(post.blog_id);const person = who.get(post.author_subject);-- const { tags } = termsView(post, blog, { networkTags: !perBlogLinks });-- return {++ const { tags } = await termsView(post, blog, { networkTags: !perBlogLinks });++ out.push({post, rev, blog,url: publication.postPath(blog, post),author: person ? { name: person.name, url: urls.author(person, blog) } : null,tags,badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,-- };-- }).filter((it) => it.rev);++ });++ }++ return out;}-- function categoriesTree(blog) {-- return store.taxonomy.tree(blogs.categoryVocabulary(blog));++ async function categoriesTree(blog) {++ return await store.taxonomy.tree(blogs.categoryVocabulary(blog));}/** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */async function feedItems(blog, { limit, fullContent }) {-- const { posts: rows } = postsApi.listPublished({ blogId: blog.id, limit, restricted: false });-- const who = await people.many(rows.map((p) => p.author_subject));-- return rows.map((post) => {-- const rev = store.revisions.get(post.id, post.published_revision);-- if (!rev) return null;-- const decision = publication.decide(blog, post, rev);++ const { posts: rows } = await postsApi.listPublished({ blogId: blog.id, limit, restricted: false });++ const [who, revs] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows))]);++ const out = [];++ for (const [i, post] of rows.entries()) {++ const rev = revs[i];++ if (!rev) continue;++ const decision = await publication.decide(blog, post, rev);const person = who.get(post.author_subject);-- const atts = store.attachments.list(post.id);++ const atts = await store.attachments.list(post.id);const cover = atts.find((a) => a.role === 'cover' && !a.broken);const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';-- return {++ out.push({id: publication.feedId(post),url: publication.postUrl(blog, post),title: rev.fields.title,@@@ -85,18 +97,19 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medupdated: rev.createdAt,decision,authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],-- tags: termsView(post, blog).tags.map((t) => t.name),++ tags: (await termsView(post, blog)).tags.map((t) => t.name),image: cover ? media.urlFor(cover.mediaId) : null,-- };-- }).filter(Boolean);++ });++ }++ return out;}/** The machine-readable representation of a post (same content as the page). */-- function postJson({ blog, post, rev, person, decision }) {++ async function postJson({ blog, post, rev, person, decision }) {const rec = publication.authorshipOf(rev);-- const review = publication.reviewOf(post, rev);-- const { tags, categories } = termsView(post, blog);-- const series = blogs.seriesById(post.series_id);++ const review = await publication.reviewOf(post, rev);++ const { tags, categories } = await termsView(post, blog);++ const series = await blogs.seriesById(post.series_id);return {id: post.id,url: publication.postUrl(blog, post),@@@ -114,8 +127,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, medtags: tags.map((t) => t.name),categories: categories.map((c) => c.name),series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,-- media: store.attachments.list(post.id).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),-- citations: store.citations.forRevision(post.id, rev.number).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),++ media: (await store.attachments.list(post.id)).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),++ citations: (await store.citations.forRevision(post.id, rev.number)).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },};}@@@ -147,7 +160,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med* OpenVibe.VIP. The official blog has no VIP owner: no join link.*/async function teaser(blog, post, { reason = null } = {}) {-- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;++ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;const owner = blog.owner_subject ? await people.one(blog.owner_subject) : null;return {id: post.id,@@@ -61,9 +61,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {* blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;* target: { type, id, owner_subject? }. Never the content.*/-- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };-- return emit({++ return await emit({event_type: 'blog.moderation.action',actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },@@@ -29,7 +29,7 @@* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds* people read.*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');const FIRST_PARTY = /^svc:/;const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;@@@ -86,7 +86,7 @@ const BUDGETS = {* limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every* GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {const refused = registry? registry.counter({ name: 'blog_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -94,6 +94,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.limits.minute, hour: config.limits.hour },actor,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is a subject id, a principal or an address, never a token.log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -66,30 +66,30 @@ function createApi(ctx) {const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });-- function mustBlog(req) {-- const blog = blogs.byHandle(req.params.handle);++ async function mustBlog(req) {++ const blog = await blogs.byHandle(req.params.handle);if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');return blog;}-- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);++ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);-- function blogDto(blog, { full = false } = {}) {++ async function blogDto(blog, { full = false } = {}) {const out = {id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,-- feeds: reading.feedsOf(blog).map((f) => ({ type: f.type, url: publication.abs(f.href) })),++ feeds: (await reading.feedsOf(blog)).map((f) => ({ type: f.type, url: publication.abs(f.href) })),};-- if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: blogs.feedSettings(blog) });++ if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: await blogs.feedSettings(blog) });return out;}-- function postDto(post, { full = false } = {}) {-- const blog = posts.blogOf(post);-- const rev = full ? posts.head(post) : posts.revision(post, post.published_revision);-- const shown = post.published_revision ? posts.revision(post, post.published_revision) : rev;-- const decision = shown ? publication.decide(blog, post, shown) : null;-- const { tags, categories } = publication.termsOf(post);-- const series = blogs.seriesById(post.series_id);++ async function postDto(post, { full = false } = {}) {++ const blog = await posts.blogOf(post);++ const rev = full ? await posts.head(post) : await posts.revision(post, post.published_revision);++ const shown = post.published_revision ? await posts.revision(post, post.published_revision) : rev;++ const decision = shown ? await publication.decide(blog, post, shown) : null;++ const { tags, categories } = await publication.termsOf(post);++ const series = await blogs.seriesById(post.series_id);const out = {id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),state: post.state, visibility: post.visibility, author_subject: post.author_subject,@@@ -105,127 +105,127 @@ function createApi(ctx) {const rec = publication.authorshipOf(rev);Object.assign(out, {entitlement_key: post.entitlement_key, body: rev ? rev.content : null,-- authorship: rec, review: rev ? publication.reviewOf(post, rev) : null,-- scheduled: posts.pendingJobs(post), attachments: posts.attachments(post),-- citations: rev ? posts.citations(post, rev.number) : [],++ authorship: rec, review: rev ? await publication.reviewOf(post, rev) : null,++ scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),++ citations: rev ? await posts.citations(post, rev.number) : [],});}return out;}-- function mustPost(req, cap) {-- const post = posts.mustGet(req.params.id);-- return { post, blog: posts.blogOf(post), cap };++ async function mustPost(req, cap) {++ const post = await posts.mustGet(req.params.id);++ return { post, blog: await posts.blogOf(post), cap };}-- function after(before, postId, req) {-- effects.after(before, posts.get(postId), req.ov);++ async function after(before, postId, req) {++ await effects.after(before, await posts.get(postId), req.ov);}// ── Blogs ───────────────────────────────────────────────-- router.get('/blogs/:handle', run((req) => {-- const blog = mustBlog(req);-- return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };++ router.get('/blogs/:handle', run(async (req) => {++ const blog = await mustBlog(req);++ return { blog: await blogDto(blog, { full: await isMember(blog, req.viewer) }) };}));-- router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run((req) => {++ router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run(async (req) => {const b = req.body || {};const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;-- const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });-- return { blog: blogDto(blog, { full: true }), created };++ const { blog, created } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });++ return { blog: await blogDto(blog, { full: true }), created };}, (out) => (out.created ? 201 : 200)));-- router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run((req) => {-- const blog = mustBlog(req);-- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');++ router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run(async (req) => {++ const blog = await mustBlog(req);++ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');const b = req.body || {};-- const updated = store.tx(() => {-- const u = blogs.update(blog, { title: b.title, description: b.description, language: b.language });-- if (b.feeds) blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });-- return blogs.get(u.id);++ const updated = await store.tx(async () => {++ const u = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });++ if (b.feeds) await blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });++ return await blogs.get(u.id);});-- return { blog: blogDto(updated, { full: true }) };++ return { blog: await blogDto(updated, { full: true }) };}));-- router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run((req) => {-- const blog = mustBlog(req);-- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');-- return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };++ router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run(async (req) => {++ const blog = await mustBlog(req);++ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');++ return { blog: await blogDto(await blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };}));router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {-- const blog = mustBlog(req);-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');-- const list = blogs.members(blog);++ const blog = await mustBlog(req);++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');++ const list = await blogs.members(blog);const who = await people.many(list.map((m) => m.subject));return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };}));-- router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run((req) => {-- const blog = mustBlog(req);-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');-- return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };++ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run(async (req) => {++ const blog = await mustBlog(req);++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');++ return { member: await blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };}));-- router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run((req) => {-- const blog = mustBlog(req);-- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');-- return { removed: blogs.removeMember(blog, req.params.subject) };++ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run(async (req) => {++ const blog = await mustBlog(req);++ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');++ return { removed: await blogs.removeMember(blog, req.params.subject) };}));-- router.get('/blogs/:handle/posts', run((req) => {-- const blog = mustBlog(req);++ router.get('/blogs/:handle/posts', run(async (req) => {++ const blog = await mustBlog(req);const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);if (req.query.all === '1') {if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');-- if (!isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');-- return { posts: posts.listForDashboard(blog, req.viewer).slice(offset, offset + limit).map((p) => postDto(p, { full: false })) };++ if (!await isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');++ return { posts: await Promise.all((await posts.listForDashboard(blog, req.viewer)).slice(offset, offset + limit).map((p) => postDto(p, { full: false }))) };}-- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted: isMember(blog, req.viewer), limit, offset });-- return { total, posts: rows.map((p) => postDto(p)) };++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted: await isMember(blog, req.viewer), limit, offset });++ return { total, posts: await Promise.all(rows.map((p) => postDto(p))) };}));// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and// the latest patch notes post. Public: every site's "recently shipped" reads it.-- router.get('/changelog', run((req, res) => {++ router.get('/changelog', run(async (req, res) => {res.set('Access-Control-Allow-Origin', '*');res.set('Cache-Control', 'public, max-age=60');const service = /^[a-z][a-z0-9-]{1,39}$/.test(String(req.query.service || '')) ? String(req.query.service) : null;-- const postOf = (row) => {-- const p = row && posts.get(row.post_id);-- const blog = p ? blogs.get(p.blog_id) : null;++ const postOf = async (row) => {++ const p = row && await posts.get(row.post_id);++ const blog = p ? await blogs.get(p.blog_id) : null;if (!p || !blog || p.state !== 'published') return null;-- return { id: p.id, title: (posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };++ return { id: p.id, title: (await posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };};-- const page = ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });-- const recent = ctx.changelog.recentPosts(5).map(postOf).filter(Boolean);++ const page = await ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });++ const recent = (await Promise.all((await ctx.changelog.recentPosts(5)).map(postOf))).filter(Boolean);return {service, entries: page.entries, next: page.next, latest_post: recent[0] || null, posts: recent,-- ...(service ? {} : { sites: ctx.changelog.sites() }),++ ...(service ? {} : { sites: await ctx.changelog.sites() }),};}));// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {-- const blog = mustBlog(req);++ const blog = await mustBlog(req);if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));-- after(null, post.id, req);-- return { post: postDto(post, { full: true }), revision: revision.number };++ await after(null, post.id, req);++ return { post: await postDto(post, { full: true }), revision: revision.number };}, 201));-- router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run((req) => {-- const blog = mustBlog(req);-- const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));-- after(null, post.id, req);-- return { post: postDto(post, { full: true }), revision: revision.number };++ router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run(async (req) => {++ const blog = await mustBlog(req);++ const { post, revision } = await posts.create(req.viewer, blog, req.body || {}, tp(req));++ await after(null, post.id, req);++ return { post: await postDto(post, { full: true }), revision: revision.number };}, 201));router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {-- const blog = mustBlog(req);-- const s = blogs.feedSettings(blog);++ const blog = await mustBlog(req);++ const s = await blogs.feedSettings(blog);const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });res.set('Cache-Control', 'public, max-age=300');res.removeHeader('X-Robots-Tag');@@@ -235,82 +235,82 @@ function createApi(ctx) {// ── Posts ───────────────────────────────────────────────router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {-- const { post, blog } = mustPost(req);-- if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };++ const { post, blog } = await mustPost(req);++ if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);// Members only: the teaser (title, the author's summary) and the join link, never the body.if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');-- return { post: postDto(post) };++ return { post: await postDto(post) };}));const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {-- const { post } = mustPost(req);++ const { post } = await mustPost(req);const before = { ...post };const out = await fn(req, post, req.body || {});-- after(before, post.id, req);++ await after(before, post.id, req);return out;}, status));-- write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {++ write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };-- const out = posts.update(req.viewer, post, input, tp(req));-- return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };++ const out = await posts.update(req.viewer, post, input, tp(req));++ return { post: await postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };});-- write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), (req, post, b) => {-- const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));-- return { post: postDto(out.post, { full: true }), changed: out.changed };++ write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), async (req, post, b) => {++ const out = await posts.publish(req.viewer, post, { revision: b.revision }, tp(req));++ return { post: await postDto(out.post, { full: true }), changed: out.changed };});-- write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post, b) => {-- const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });-- return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };++ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post, b) => {++ const out = await posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });++ return { job: out.job, created: out.created, post: await postDto(out.post, { full: true }) };}, 201);-- write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post) => {-- const out = posts.cancelSchedule(req.viewer, post);-- return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };++ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post) => {++ const out = await posts.cancelSchedule(req.viewer, post);++ return { cancelled: out.cancelled, post: await postDto(out.post, { full: true }) };});-- write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), (req, post) => {-- const out = posts.unpublish(req.viewer, post, tp(req));-- return { post: postDto(out.post, { full: true }), changed: out.changed };++ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), async (req, post) => {++ const out = await posts.unpublish(req.viewer, post, tp(req));++ return { post: await postDto(out.post, { full: true }), changed: out.changed };});-- write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), (req, post) => {-- posts.remove(req.viewer, post, tp(req));++ write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), async (req, post) => {++ await posts.remove(req.viewer, post, tp(req));return { deleted: true, id: post.id };});-- write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {-- const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });-- return { revision: out.revision.number, post: postDto(out.post, { full: true }) };++ write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {++ const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });++ return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };}, 201);router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {-- const { post } = mustPost(req);++ const { post } = await mustPost(req);const b = req.body || {};-- const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));-- after({ ...post }, post.id, req);++ const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));++ await after({ ...post }, post.id, req);return { review };}, 201));-- write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), (req, post, b) => ({-- attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),++ write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), async (req, post, b) => ({++ attachment: await posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),}), 201);-- write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));++ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), async (req, post) => ({ removed: await posts.detach(req.viewer, post, req.params.aid) }));-- function readable(req) {-- const { post, blog } = mustPost(req);-- if (!(access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');++ async function readable(req) {++ const { post, blog } = await mustPost(req);++ if (!(await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');return post;}-- router.get('/posts/:id/revisions', guard('blog.post.read'), run((req) => {-- const post = readable(req);-- return { revisions: posts.revisions(post, { limit: req.query.limit, before: req.query.before }).map(({ content, ...r }) => ({ ...r, length: content.length })) };++ router.get('/posts/:id/revisions', guard('blog.post.read'), run(async (req) => {++ const post = await readable(req);++ return { revisions: (await posts.revisions(post, { limit: req.query.limit, before: req.query.before })).map(({ content, ...r }) => ({ ...r, length: content.length })) };}));-- router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run((req) => {-- const post = readable(req);-- const rev = posts.revision(post, parseInt(req.params.n, 10));++ router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run(async (req) => {++ const post = await readable(req);++ const rev = await posts.revision(post, parseInt(req.params.n, 10));if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');-- return { revision: rev, citations: posts.citations(post, rev.number) };++ return { revision: rev, citations: await posts.citations(post, rev.number) };}));-- router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run((req) => {-- const post = readable(req);-- return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };++ router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run(async (req) => {++ const post = await readable(req);++ return { diff: await posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };}));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));@@@ -22,16 +22,22 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {const { db } = store;const abs = (p) => seo.canonicalUrl(config.baseUrl, p);-- const publicPosts = () => db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id++ const publicPosts = async () => await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_idWHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'ORDER BY p.published_at DESC LIMIT 50000`).all();-- function postEntries() {-- return publicPosts().map((post) => {-- const blog = blogs.get(post.blog_id);-- const rev = store.revisions.get(post.id, post.published_revision);-- return { post, blog, rev, decision: publication.decide(blog, post, rev) };-- });++ async function postEntries() {++ // Revisions in one query and each blog once, whatever the number of posts (no N+1).++ const rows = await publicPosts();++ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));++ const blogById = new Map();++ for (const id of new Set(rows.map((p) => p.blog_id))) blogById.set(id, await blogs.get(id));++ const out = [];++ for (const [i, post] of rows.entries()) {++ const blog = blogById.get(post.blog_id);++ out.push({ post, blog, rev: revs[i], decision: await publication.decide(blog, post, revs[i]) });++ }++ return out;}const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);@@@ -46,8 +52,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);});-- router.get('/llms.txt', (_req, res) => {-- const official = blogs.official();++ router.get('/llms.txt', async (_req, res) => {++ const official = await blogs.official();res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({name: 'OpenVibe.Blog',summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',@@@ -66,8 +72,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {}));});-- router.get('/sitemap.xml', (_req, res) => {-- const entries = postEntries().filter((e) => e.decision.indexable);++ router.get('/sitemap.xml', async (_req, res) => {++ const entries = (await postEntries()).filter((e) => e.decision.indexable);const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },@@@ -75,15 +81,15 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {]));});-- router.get('/sitemaps/posts.xml', (_req, res) => {-- const out = seo.sitemap(postEntries().map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));++ router.get('/sitemaps/posts.xml', async (_req, res) => {++ const out = seo.sitemap((await postEntries()).map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));xml(res, out.files[0]);});-- router.get('/sitemaps/blogs.xml', (_req, res) => {++ router.get('/sitemaps/blogs.xml', async (_req, res) => {const byBlog = new Map();const bySeries = new Map();-- for (const e of postEntries()) {++ for (const e of await postEntries()) {if (!e.decision.indexable) continue;const t = e.rev.createdAt;if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });@@@ -96,7 +102,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {entries.push({ loc: abs(path), lastmod, decision: listing(path) });}for (const [id, { blog, lastmod }] of bySeries) {-- const s = blogs.seriesById(id);++ const s = await blogs.seriesById(id);if (!s) continue;const path = reading.urls.series(blog, s);entries.push({ loc: abs(path), lastmod, decision: listing(path) });@@@ -85,17 +85,17 @@ function createEditorRoutes(ctx) {const csrf = (req) => csrfToken(config, req.viewer);const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);-- function blogFor(req) {-- const blog = blogs.byHandle(req.params.handle);++ async function blogFor(req) {++ const blog = await blogs.byHandle(req.params.handle);if (!blog) return null;-- return access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;++ return await access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;}-- function postFor(req) {-- const post = posts.get(req.params.id);++ async function postFor(req) {++ const post = await posts.get(req.params.id);if (!post || post.state === 'deleted') return null;-- const blog = posts.blogOf(post);-- if (!access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;++ const blog = await posts.blogOf(post);++ if (!await access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;return { post, blog };}@@@ -104,176 +104,176 @@ function createEditorRoutes(ctx) {// ── Home, first use ─────────────────────────────────────router.get('/', wrap(async (req, res) => {-- const mine = blogs.blogsOf(req.viewer.subject);-- const official = blogs.official();++ const mine = await blogs.blogsOf(req.viewer.subject);++ const official = await blogs.official();if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));}));router.post('/start', wrap(async (req, res) => {const b = formInput(req.body);-- const { blog } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });++ const { blog } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);}));// ── A blog ──────────────────────────────────────────────router.get('/@:handle', wrap(async (req, res) => {-- const blog = blogFor(req);++ const blog = await blogFor(req);if (!blog) return notFound(req, res);-- const list = posts.listForDashboard(blog, req.viewer);-- const heads = new Map(list.map((p) => [p.id, (posts.head(p) || { fields: {} }).fields]));-- page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));++ const list = await posts.listForDashboard(blog, req.viewer);++ const heads = new Map(await Promise.all(list.map(async (p) => [p.id, ((await posts.head(p)) || { fields: {} }).fields])));++ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: await access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));}));router.get('/@:handle/new', wrap(async (req, res) => {-- const blog = blogFor(req);-- if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);++ const blog = await blogFor(req);++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);}));// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.router.post('/@:handle/ai-draft', wrap(async (req, res) => {-- const blog = blogFor(req);++ const blog = await blogFor(req);if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);const b = formInput(req.body);const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, { topic: b.topic, brief: b.brief, tone: b.tone, audience: b.audience }, { traceparent: req.ov && req.ov.traceparent });-- effects.after(null, post, req.ov);++ await effects.after(null, post, req.ov);res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(`AI draft saved (revision ${revision.number}). It stays a draft, marked as AI-written, until you review and publish it.`)}`);}));router.post('/@:handle/new', wrap(async (req, res) => {-- const blog = blogFor(req);++ const blog = await blogFor(req);if (!blog) return notFound(req, res);-- const { post } = posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });-- effects.after(null, post, req.ov);++ const { post } = await posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });++ await effects.after(null, post, req.ov);res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);}));router.get('/@:handle/settings', wrap(async (req, res) => {-- const blog = blogFor(req);-- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);-- const members = blogs.members(blog);++ const blog = await blogFor(req);++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);++ const members = await blogs.members(blog);const who = await people.many(members.map((m) => m.subject));-- page(req, res, 'Settings', editor.settingsPage({ blog, feed: blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));++ page(req, res, 'Settings', editor.settingsPage({ blog, feed: await blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));}));router.post('/@:handle/settings', wrap(async (req, res) => {-- const blog = blogFor(req);-- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);++ const blog = await blogFor(req);++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);const b = formInput(req.body);-- store.tx(() => {-- const updated = blogs.update(blog, { title: b.title, description: b.description, language: b.language });-- if (b.theme) blogs.setTheme(updated, b.theme);-- blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });++ await store.tx(async () => {++ const updated = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });++ if (b.theme) await blogs.setTheme(updated, b.theme);++ await blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });});res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);}));router.post('/@:handle/members', wrap(async (req, res) => {-- const blog = blogFor(req);-- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);++ const blog = await blogFor(req);++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);const b = formInput(req.body);const who = String(b.member || '').trim().replace(/^@/, '');-- const subject = ids.isSubjectId('user', who) ? who : (people.byUsername(who) || {}).subject;++ const subject = ids.isSubjectId('user', who) ? who : (await people.byUsername(who) || {}).subject;if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });-- blogs.setMember(blog, subject, b.role, req.viewer.subject);++ await blogs.setMember(blog, subject, b.role, req.viewer.subject);res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);}));router.post('/@:handle/members/remove', wrap(async (req, res) => {-- const blog = blogFor(req);-- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);-- blogs.removeMember(blog, formInput(req.body).subject);++ const blog = await blogFor(req);++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);++ await blogs.removeMember(blog, formInput(req.body).subject);res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);}));// ── A post ──────────────────────────────────────────────router.get('/posts/:id', wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found) return notFound(req, res);const { post, blog } = found;-- const head = posts.head(post);-- const published = post.published_revision ? posts.revision(post, post.published_revision) : null;++ const head = await posts.head(post);++ const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;const rec = publication.authorshipOf(head);-- const review = publication.reviewOf(post, head);-- const terms = publication.termsOf(post);-- const decision = publication.decide(blog, { ...post, state: 'published' }, head);++ const review = await publication.reviewOf(post, head);++ const terms = await publication.termsOf(post);++ const decision = await publication.decide(blog, { ...post, state: 'published' }, head);const body = editor.editPage({-- blog, post, head, published, decision, jobs: posts.pendingJobs(post), attachments: posts.attachments(post), csrf: csrf(req),++ blog, post, head, published, decision, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),disclosure: rec ? authorship.disclosure(rec, review) : null,needsReview: rec ? !authorship.canPublish(rec, review).ok : false,publicUrl: publication.postPath(blog, post),-- canDelete: access.canWrite(store, req.viewer, blog, 'delete', post),-- form: editor.postForm({ blog, post, head, terms, series: blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),++ canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),++ form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),});page(req, res, `Edit: ${head.fields.title}`, body);}));router.post('/posts/:id', wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found) return notFound(req, res);const before = { ...found.post };-- const { post, revision, created } = posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });-- effects.after(before, post, req.ov);++ const { post, revision, created } = await posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });++ await effects.after(before, post, req.ov);res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);}));const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);-- const post = found ? found.post : posts.get(req.params.id);++ const post = found ? found.post : await posts.get(req.params.id);if (!post || post.state === 'deleted') return notFound(req, res);const before = { ...post };const out = await fn(req, post, formInput(req.body));-- const after = posts.get(post.id);-- effects.after(before, after, req.ov);-- if (name === 'delete') return res.redirect(303, `/write/@${posts.blogOf(post).handle}?saved=${encodeURIComponent('Post deleted.')}`);++ const after = await posts.get(post.id);++ await effects.after(before, after, req.ov);++ if (name === 'delete') return res.redirect(303, `/write/@${(await posts.blogOf(post)).handle}?saved=${encodeURIComponent('Post deleted.')}`);res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);}));const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });-- action('publish', (req, post, b) => posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));-- action('schedule', (req, post, b) => {++ action('publish', async (req, post, b) => await posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));++ action('schedule', async (req, post, b) => {// <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.const raw = String(b.at || '');const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;-- return posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });++ return await posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });}, (out) => `Scheduled for ${out.job.runAt}.`);-- action('unschedule', (req, post) => posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');-- action('unpublish', (req, post) => posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');-- action('delete', (req, post, b) => {++ action('unschedule', async (req, post) => await posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');++ action('unpublish', async (req, post) => await posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');++ action('delete', async (req, post, b) => {if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });-- return posts.remove(req.viewer, post, tp(req));++ return await posts.remove(req.viewer, post, tp(req));}, 'Deleted.');-- action('review', (req, post, b) => posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);-- action('revert', (req, post, b) => posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);-- action('media', (req, post, b) => posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');++ action('review', async (req, post, b) => await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);++ action('revert', async (req, post, b) => await posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);++ action('media', async (req, post, b) => await posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found) return notFound(req, res);-- posts.detach(req.viewer, found.post, req.params.aid);++ await posts.detach(req.viewer, found.post, req.params.aid);res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);}));router.get('/posts/:id/revisions', wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found) return notFound(req, res);const { post } = found;-- const head = posts.head(post);++ const head = await posts.head(post);const from = parseInt(req.query.from, 10);const to = parseInt(req.query.to, 10);-- const diff = Number.isInteger(from) && Number.isInteger(to) ? posts.diff(post, from, to, 'word') : null;-- page(req, res, 'History', editor.revisionsPage({ post, head, revisions: posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));++ const diff = Number.isInteger(from) && Number.isInteger(to) ? await posts.diff(post, from, to, 'word') : null;++ page(req, res, 'History', editor.revisionsPage({ post, head, revisions: await posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));}));router.get('/posts/:id/preview', wrap(async (req, res) => {-- const found = postFor(req);++ const found = await postFor(req);if (!found) return notFound(req, res);const n = parseInt(req.query.revision, 10);-- const rev = Number.isInteger(n) ? posts.revision(found.post, n) : posts.head(found.post);++ const rev = Number.isInteger(n) ? await posts.revision(found.post, n) : await posts.head(found.post);if (!rev) return notFound(req, res);return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });}));@@@ -61,15 +61,15 @@ function createPublicRoutes(ctx) {}const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });-- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);++ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);function pageNumber(req) {const n = parseInt(req.query.page, 10);return Number.isInteger(n) && n > 0 ? n : 1;}-- function activeBlog(req) {-- const blog = blogs.byHandle(req.params.handle);++ async function activeBlog(req) {++ const blog = await blogs.byHandle(req.params.handle);if (!blog) return null;if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;return blog;@@@ -81,19 +81,27 @@ function createPublicRoutes(ctx) {async function blogFront(req, res, blog, { home = false } = {}) {const page = pageNumber(req);-- const restricted = isMember(blog, req.viewer);++ const restricted = await isMember(blog, req.viewer);const path = publication.blogPath(blog);-- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });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);const items = await reading.listItems(rows);const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });-- const feeds = reading.feedsOf(blog);++ const feeds = await reading.feedsOf(blog);// Series and categories are named when a draft or restricted post uses them: list only those// holding a post this viewer may see in the list, so their names never leak ahead of a post.-- const shows = (filter) => posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 }).total > 0;-- const prune = (nodes) => nodes.map((n) => ({ ...n, children: prune(n.children || []) }))-- .filter((n) => n.children.length || shows({ termIds: [n.id] }));++ const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 })).total > 0;++ const prune = async (nodes) => {++ const out = [];++ for (const n of nodes) {++ const children = await prune(n.children || []);++ if (children.length || await shows({ termIds: [n.id] })) out.push({ ...n, children });++ }++ return out;++ };++ const seriesShown = [];++ for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);send(req, res, 200, {title: blog.title,description: blog.description,@@@ -109,13 +117,13 @@ function createPublicRoutes(ctx) {: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],body: pages.blogIndex({blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,-- series: blogs.series(blog).filter((sr) => shows({ seriesId: sr.id })), categories: prune(reading.categoriesTree(blog)),-- canWrite: access.canWrite(store, req.viewer, blog, 'create'),++ series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),++ canWrite: await access.canWrite(store, req.viewer, blog, 'create'),}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),}, { cacheable: !restricted });}-- router.get('/', wrap(async (req, res) => blogFront(req, res, blogs.official(), { home: true })));++ router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));// What shipped on OpenVibe.Blog: the shared update log (openvibe-shared shipped.js, fed by the// network changelog this service keeps), like every OpenVibe site's /updates.@@@ -128,16 +136,16 @@ function createPublicRoutes(ctx) {}, { cacheable: true })));router.get('/@:handle', wrap(async (req, res) => {-- const blog = activeBlog(req);++ const blog = await activeBlog(req);if (!blog) return notFound(req, res);if (blog.kind === 'official') return res.redirect(301, '/');-- return blogFront(req, res, blog);++ return await blogFront(req, res, blog);}));// ── Feeds (public posts only, never viewer-dependent) ───async function feed(req, res, blog, kind) {-- const s = blogs.feedSettings(blog);++ const s = await blogs.feedSettings(blog);if (!s[kind]) return notFound(req, res);const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });const link = publication.abs(publication.blogPath(blog));@@@ -152,12 +160,12 @@ function createPublicRoutes(ctx) {}const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };for (const [file, kind] of Object.entries(FEEDS)) {-- router.get(`/${file}`, wrap((req, res) => feed(req, res, blogs.official(), kind)));-- router.get(`/@:handle/${file}`, wrap((req, res) => {-- const blog = activeBlog(req);++ router.get(`/${file}`, wrap(async (req, res) => await feed(req, res, await blogs.official(), kind)));++ router.get(`/@:handle/${file}`, wrap(async (req, res) => {++ const blog = await activeBlog(req);if (!blog) return notFound(req, res);if (blog.kind === 'official') return res.redirect(301, `/${file}`);-- return feed(req, res, blog, kind);++ return await feed(req, res, blog, kind);}));}@@@ -165,8 +173,8 @@ function createPublicRoutes(ctx) {async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {const page = pageNumber(req);-- const restricted = blog ? isMember(blog, req.viewer) : false;-- const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });++ const restricted = blog ? await isMember(blog, req.viewer) : false;++ const { total, posts: rows } = await posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });// A series or category named only by drafts or posts this viewer cannot see does not exist for them.if (hideEmpty && !total) return notFound(req, res);const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });@@@ -186,56 +194,56 @@ function createPublicRoutes(ctx) {const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });router.get('/@:handle/tags/:tag', wrap(async (req, res) => {-- const blog = activeBlog(req);-- const tag = blog && store.taxonomy.bySlug('tag', req.params.tag);++ const blog = await activeBlog(req);++ const tag = blog && await store.taxonomy.bySlug('tag', req.params.tag);if (!tag) return notFound(req, res);-- return collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),++ return await collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });}));router.get('/tags/:tag', wrap(async (req, res) => {-- const tag = store.taxonomy.bySlug('tag', req.params.tag);++ const tag = await store.taxonomy.bySlug('tag', req.params.tag);if (!tag) return notFound(req, res);-- return collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),++ return await collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });}));router.get('/@:handle/categories/:category', wrap(async (req, res) => {-- const blog = activeBlog(req);-- const term = blog && store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);++ const blog = await activeBlog(req);++ const term = blog && await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);if (!term) return notFound(req, res);-- const trail = store.taxonomy.ancestors(term.id);-- const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];-- return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),++ const trail = await store.taxonomy.ancestors(term.id);++ const ids = [term.id, ...(await store.taxonomy.descendants(term.id)).map((t) => t.id)];++ return await collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],filter: { termIds: ids }, empty: 'No public posts in this category yet.', hideEmpty: true });}));router.get('/@:handle/series/:series', wrap(async (req, res) => {-- const blog = activeBlog(req);-- const series = blog && blogs.seriesBySlug(blog, req.params.series);++ const blog = await activeBlog(req);++ const series = blog && await blogs.seriesBySlug(blog, req.params.series);if (!series) return notFound(req, res);-- return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),++ return await collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.', hideEmpty: true });}));async function personFor(who) {-- if (/^usr_/.test(who)) return people.one(who);-- return people.byUsername(who);++ if (/^usr_/.test(who)) return await people.one(who);++ return await people.byUsername(who);}router.get('/@:handle/authors/:who', wrap(async (req, res) => {-- const blog = activeBlog(req);++ const blog = await activeBlog(req);const person = blog && await personFor(req.params.who);-- if (!person || !blogs.membership(blog, person.subject) && !posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 }).total) return notFound(req, res);-- return collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),++ if (!person || !await blogs.membership(blog, person.subject) && !(await posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 })).total) return notFound(req, res);++ return await collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });}));router.get('/authors/:who', wrap(async (req, res) => {const person = await personFor(req.params.who);if (!person) return notFound(req, res);-- return collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),++ return await collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });}));@@@ -256,28 +264,29 @@ function createPublicRoutes(ctx) {}}-- function seriesView(blog, post) {-- const series = blogs.seriesById(post.series_id);++ async function seriesView(blog, post) {++ const series = await blogs.seriesById(post.series_id);if (!series) return null;-- const { posts: rows } = posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });++ const { posts: rows } = await posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });++ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));return {title: series.title, url: reading.urls.series(blog, series), position: post.series_position,-- items: rows.map((p) => ({ title: (store.revisions.get(p.id, p.published_revision) || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),++ items: rows.map((p, i) => ({ title: (revs[i] || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),};}/** Render one post (also used by the editor's preview with a chosen revision). */async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {-- const decision = preview ? publication.decide(blog, post, rev, { state: 'draft' }) : publication.decide(blog, post, rev);++ const decision = preview ? await publication.decide(blog, post, rev, { state: 'draft' }) : await publication.decide(blog, post, rev);const person = await people.one(post.author_subject);-- const { tags, categories } = reading.termsView(post, blog);-- const attachments = store.attachments.list(post.id);-- const citations = store.citations.forRevision(post.id, rev.number);++ const { tags, categories } = await reading.termsView(post, blog);++ const attachments = await store.attachments.list(post.id);++ const citations = await store.citations.forRevision(post.id, rev.number);const rec = publication.authorshipOf(rev);-- const review = publication.reviewOf(post, rev);++ const review = await publication.reviewOf(post, rev);const path = publication.postPath(blog, post);const firstCategory = categories[0];-- const crumbs = [blogCrumb(blog), ...(firstCategory ? store.taxonomy.ancestors(firstCategory.id).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];++ const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];const comments = preview ? null : await commentsFor(req, blog, post, rev);const cacheable = !preview && post.state === 'published' && post.visibility === 'public';const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';@@@ -291,17 +300,17 @@ function createPublicRoutes(ctx) {image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,modified: post.published_revision ? rev.createdAt : null,-- feeds: reading.feedsOf(blog),++ feeds: await reading.feedsOf(blog),theme: { slug: blog.theme, vars: blogs.themeVars(blog) },lang: blog.language,jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],body: pages.postPage({blog, blogUrl: publication.blogPath(blog), post, rev,author: { name: person.name, url: reading.urls.author(person, blog) },-- tags, categories, series: seriesView(blog, post), attachments, citations,++ tags, categories, series: await seriesView(blog, post), attachments, citations,disclosure: rec ? authorship.disclosure(rec, review) : null,decisionNote: preview ? `preview · ${decision.robots}` : null,-- comments, canEdit: access.canWrite(store, req.viewer, blog, 'edit', post),++ comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,@@@ -330,22 +339,22 @@ function createPublicRoutes(ctx) {/** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */async function resolvePost(req, res, blog, slug) {-- const post = posts.bySlug(blog, slug);++ const post = await posts.bySlug(blog, slug);if (!post) {const path = `/@${blog.handle}/${slug}`;-- const r = store.redirects.resolve(path, {-- currentPath: (id) => { const p = posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(blogs.get(p.blog_id), p) : null; },++ const r = await store.redirects.resolve(path, {++ currentPath: async (id) => { const p = await posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(await blogs.get(p.blog_id), p) : null; },});if (r && r.status === 301) {-- const target = posts.get(r.entityId);-- const decision = await access.canReadPost(store, req.viewer, blogs.get(target.blog_id), target, entitlements);++ const target = await posts.get(r.entityId);++ const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements);if (decision.allowed) {cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));return null;}}-- if ((r && r.status === 410) || posts.deletedBySlug(blog, slug)) {++ if ((r && r.status === 410) || await posts.deletedBySlug(blog, slug)) {messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });return null;}@@@ -362,27 +371,27 @@ function createPublicRoutes(ctx) {}router.get('/@:handle/:slug', wrap(async (req, res) => {-- const blog = activeBlog(req);++ const blog = await activeBlog(req);if (!blog) return notFound(req, res);const asJson = req.params.slug.endsWith('.json');const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;const post = await resolvePost(req, res, blog, slug);if (!post) return;// Readers see the published revision; members looking at an unpublished post see its head.-- const rev = post.state === 'published' ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);++ const rev = post.state === 'published' ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);if (asJson) {-- const decision = publication.decide(blog, post, rev);++ const decision = await publication.decide(blog, post, rev);cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });-- return res.json(reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));++ return res.json(await reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));}-- return renderPost(req, res, { blog, post, rev });++ return await renderPost(req, res, { blog, post, rev });}));// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in// the person's name.router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {-- const blog = activeBlog(req);-- const post = blog && posts.bySlug(blog, req.params.slug);++ const blog = await activeBlog(req);++ const post = blog && await posts.bySlug(blog, req.params.slug);if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');@@@ -390,7 +399,7 @@ function createPublicRoutes(ctx) {const message = String((req.body && req.body.message) || '').trim();if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);try {-- const rev = store.revisions.get(post.id, post.published_revision);++ const rev = await store.revisions.get(post.id, post.published_revision);const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);await community.comment(threadId, req.viewer.subject, { message }, req.ov);} catch (err) {@@@ -7,11 +7,12 @@*/const { createApp } = require('./app');--const { app, ctx } = createApp();++(async () => {++const { app, ctx } = await createApp();const { config } = ctx;const server = app.listen(config.port, config.host, () => {-- console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);});server.keepAliveTimeout = 65_000;@@@ -26,10 +27,11 @@ function shutdown(signal) {ctx.changelog.stop();server.close(async () => {try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { ctx.store.close(); } catch { /* already closed */ }++ try { await ctx.store.close(); } catch { /* already closed */ }process.exit(0);});setTimeout(() => process.exit(0), 5000).unref();}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));++})().catch((err) => { console.error('[Blog] failed to start:', err); process.exit(1); });@@@ -14,7 +14,7 @@const { createReadiness } = require('openvibe-shared/ready');const { CHARTER_TABLES } = require('./db');--function createBlogReadiness({ store, auth, outbox, release = null }) {++function createBlogReadiness({ store, auth, outbox, valkey = null, release = null }) {const { db } = store;return createReadiness({service: 'blog',@@@ -22,12 +22,16 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {checks: [{name: 'db', required: true,-- check: () => {-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));++ check: async () => {++ // A real round trip that names the store (postgresql / pglite), and the charter tables and views present.++ const r = await db.ready();++ if (!r.ok) return r.error;++ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));const missing = CHARTER_TABLES.filter((t) => !names.has(t));-- return missing.length ? `missing ${missing.join(', ')}` : true;++ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };},},++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => {@@@ -38,8 +42,8 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {},{name: 'events_relay', required: false,-- check: () => {-- const s = outbox.status();++ check: async () => {++ const s = await outbox.status();if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;return { ok: true, detail: { pending: s.pending } };@@@ -47,9 +51,9 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {},{name: 'scheduler', required: false,-- check: () => {-- const failed = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000).n;-- const overdue = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000).n;++ check: async () => {++ const failed = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000)).n;++ const overdue = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000)).n;if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;return true;},@@@ -48,19 +48,19 @@ function composeBody(facts) {* Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;* default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).*/--function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {++async function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {const { blogs, posts, store } = ctx;-- const blog = blogs.ensureOfficial();-- const owners = blogs.members(blog).filter((m) => m.role === 'owner').map((m) => m.subject);++ const blog = await blogs.ensureOfficial();++ const owners = (await blogs.members(blog)).filter((m) => m.role === 'owner').map((m) => m.subject);const subject = author || owners[0];if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };-- let post = posts.bySlug(blog, SLUG);++ let post = await posts.bySlug(blog, SLUG);let created = false;if (!post) {-- const out = posts.create(actor, blog, {++ const out = await posts.create(actor, blog, {title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,slug: SLUG,summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,@@@ -77,11 +77,11 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, factslet review = null;let published = false;if (publish) {-- const head = store.revisions.head(post.id);-- review = posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });-- published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;++ const head = await store.revisions.head(post.id);++ review = await posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });++ published = (await posts.publish(actor, await posts.get(post.id), { revision: head.number })).changed;}-- return { post: posts.get(post.id), created, review, published };++ return { post: await posts.get(post.id), created, review, published };}module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };@@@ -19,7 +19,7 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {running = true;try {const summary = await posts.runScheduled(config.worker.id);-- if (summary.done.length || summary.failed.length) outbox.kick();++ if (summary.done.length || summary.failed.length) await outbox.kick();return summary;} catch (err) {log.error('[Blog] schedule tick failed:', err.message);@@@ -30,8 +30,8 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {/** Verify the attachments of published posts (at most `limit` posts per run). */async function verifyMedia({ limit = 200 } = {}) {if (!media.enabled) return [];-- const ids = store.db.prepare(`SELECT DISTINCT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id-- WHERE p.state = 'published' ORDER BY COALESCE(a.checked_at, 0) LIMIT ?`).all(limit).map((r) => r.id);++ const ids = (await store.db.prepare(`SELECT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id++ WHERE p.state = 'published' GROUP BY a.entity_id ORDER BY MIN(COALESCE(a.checked_at, 0)), a.entity_id LIMIT ?`).all(limit)).map((r) => r.id);const out = [];for (const id of ids) {const results = await store.attachments.verify(id, { resolve: media.resolve });@@@ -73,12 +73,12 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);rev = r.json().revision;-- const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id).n;-- const before = stored();++ const stored = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id)).n;++ const before = await stored();r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);-- assert.strictEqual(stored(), before, 'nothing stored');++ assert.strictEqual(await stored(), before, 'nothing stored');r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });assert.strictEqual(r.status, 201, 'another person still writes');r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });@@@ -46,7 +46,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.deepStrictEqual(asked[0].body.on_behalf_of, { type: 'user', id: fay.subject });assert.strictEqual(asked[0].body.target.service, 'blog');assert.ok(/^Bearer /.test(asked[0].auth), 'Blog\'s service token');-- const cites = t.ctx.posts.citations(t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);++ const cites = await t.ctx.posts.citations(await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);assert.ok(cites.some((c) => c.url === 'https://example.org/self-hosting'), 'the run\'s citations are on revision 1');});@@@ -65,11 +65,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');await check('a failed run makes no post; a stranger cannot ask', async () => {mode = 'fail';-- const before = t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n;++ const before = (await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n;const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'x' } });assert.strictEqual(r.status, 502);assert.strictEqual(r.json().code, 'ai.run_failed');-- assert.strictEqual(t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n, before);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n, before);mode = 'ok';const stranger = t.network.addUser('stranger');assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: stranger, json: { topic: 'x' } })).status, 403);@@@ -48,7 +48,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7assert.strictEqual(draft.authorship.mode, 'ai');assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');-- const created = t.events('blog.post.created')[0];++ const created = (await t.events('blog.post.created'))[0];assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });assert.strictEqual(created.payload.authorship, 'ai');const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });@@@ -79,7 +79,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7const page = await t.get('/@fay/an-ai-draft');assert.match(page.text, /AI-generated/);assert.match(page.text, /reviewed by a person/);-- const doc = t.events('blog.index_document.upserted').pop().payload;++ const doc = (await t.events('blog.index_document.upserted')).pop().payload;assert.strictEqual(doc.authorship, 'ai_generated');assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));});@@@ -18,21 +18,21 @@ const H = (n) => n.toString(16).padStart(40, 'a');const fetchImpl = async (url) => {const u = new URL(url);const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });-- if (u.pathname === '/api/v1/registry/services') return json({ services: [++ if (u.pathname === '/api/v1/registry/services') return await json({ services: [{ id: 'live', name: 'OpenVibe.Live', repository: 'OpenVibers/OpenVibe.Live', publicOrigin: 'https://openvibe.live' },{ id: 'tools', name: 'OpenVibe.Tools', repository: 'OpenVibers/OpenVibe.Tools', publicOrigin: 'https://openvibe.tools' },{ id: 'norepo', name: 'X' },] });-- if (u.pathname === '/api/v1/registry/releases') return json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });++ if (u.pathname === '/api/v1/registry/releases') return await json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });if (u.hostname === 'api.github.com') {ghCalls.push(u.pathname);const m = u.pathname.match(/^\/repos\/(.+)\/compare\/(.+)\.\.\.(.+)$/);-- if (m && compares[m[3]]) return json(compares[m[3]]);++ if (m && compares[m[3]]) return await json(compares[m[3]]);const h = u.pathname.match(/^\/repos\/(.+)\/commits$/);-- if (h && history[h[1]]) return json(history[h[1]]);-- return json({ message: 'Not Found' }, 404);++ if (h && history[h[1]]) return await json(history[h[1]]);++ return await json({ message: 'Not Found' }, 404);}-- return json({}, 404);++ return await json({}, 404);};const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe', history: 0 } };const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });@@@ -40,7 +40,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');await check('a service seen for the first time only records its release', async () => {await cl.tick();-- assert.strictEqual(cl.entries().length, 0);++ assert.strictEqual((await cl.entries()).length, 0);assert.deepStrictEqual(ghCalls, [], 'no history dump');});@@@ -49,7 +49,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');compares.aaaaaaa00002 = { status: 'ahead', commits: [commit(1, 'Fix the chat scroll\n\nbody text'), commit(2, 'Merge branch main')], files: [{ additions: 10, deletions: 2 }] };const r = await cl.tick();assert.strictEqual(r, null, 'not due yet');-- const e = cl.entries({ service: 'live' });++ const e = await cl.entries({ service: 'live' });assert.deepStrictEqual(e.map((x) => x.subject), ['Fix the chat scroll'], 'first lines only; merges skipped');assert.strictEqual(e[0].url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);assert.strictEqual(e[0].major, false);@@@ -62,14 +62,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');t.clock.advance(31 * 60 * 1000);const r = await cl.tick();assert.ok(r && r.post, 'published');-- const post = t.ctx.posts.get(r.post.id);++ const post = await t.ctx.posts.get(r.post.id);assert.strictEqual(post.state, 'published');-- const head = t.ctx.posts.head(post);++ const head = await t.ctx.posts.head(post);assert.strictEqual(head.fields.title, 'Patch notes: Recent tools in the launcher');assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));assert.strictEqual(head.meta.authorship.mode, 'imported');-- assert.strictEqual(cl.entries().filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');++ assert.strictEqual((await cl.entries()).filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');});await check('the public feed names the entries and the latest post', async () => {@@@ -90,7 +90,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');t.clock.advance(6 * 3600 * 1000 + 1000);const r = await cl.tick();assert.ok(r && r.post);-- assert.ok(t.ctx.posts.head(t.ctx.posts.get(r.post.id)).fields.title.startsWith('Patch notes: 5 changes across 1 site'));++ assert.ok((await t.ctx.posts.head(await t.ctx.posts.get(r.post.id))).fields.title.startsWith('Patch notes: 5 changes across 1 site'));});await check('subjects are cut at a word; version bumps count as major', async () => {@@@ -100,26 +100,26 @@ const H = (n) => n.toString(16).padStart(40, 'a');releases.tools = 'bbbbbbb00003';compares.bbbbbbb00003 = { status: 'ahead', commits: [commit(10, '1.7.0: openvibe-shared/trace')], files: [{ additions: 3, deletions: 1 }] };await cl.tick();-- assert.strictEqual(cl.entries({ service: 'tools' })[0].major, true);++ assert.strictEqual((await cl.entries({ service: 'tools' }))[0].major, true);});await check('history: each site\'s earlier commits once, never pending, with authors; the feed pages by cursor', async () => {history['OpenVibers/OpenVibe.Live'] = [20, 21, 22].map((i) => ({ sha: H(i), commit: { message: `Earlier change ${i}\n\nbody`, author: { name: 'OpenVibers', date: new Date(t.clock.now() - (30 - i) * 86400000).toISOString() } } })).concat([{ sha: H(5), commit: { message: 'Small change 5', author: { name: 'OpenVibers', date: new Date(t.clock.now()).toISOString() } } }]);const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, fetchImpl, log: { log() {}, warn() {} } });-- const before = hc.stats().pending;++ const before = (await hc.stats()).pending;await hc.collect();-- assert.strictEqual(hc.stats().pending, before, 'history never waits for a patch notes post');-- const all = hc.entries({ service: 'live', limit: 100 });++ assert.strictEqual((await hc.stats()).pending, before, 'history never waits for a patch notes post');++ const all = await hc.entries({ service: 'live', limit: 100 });assert.ok(all.some((e) => e.subject === 'Earlier change 20' && e.author === 'OpenVibers' && e.post_id === null));assert.strictEqual(all.find((e) => e.sha === H(5)).author, 'OpenVibers', 'a known commit gains its author');const calls = ghCalls.length;await hc.collect();assert.strictEqual(ghCalls.length, calls, 'imported once (a repository GitHub will not list is not asked again either)');-- const p1 = hc.page({ service: 'live', limit: 2 });++ const p1 = await hc.page({ service: 'live', limit: 2 });assert.strictEqual(p1.entries.length, 2);assert.ok(p1.next);-- const p2 = hc.page({ service: 'live', limit: 2, before: p1.next });++ const p2 = await hc.page({ service: 'live', limit: 2, before: p1.next });assert.ok(p2.entries.length >= 1 && !p2.entries.some((e) => p1.entries.some((x) => x.sha === e.sha)), 'the next page continues without repeats');const r = (await t.get(`/api/v1/changelog?limit=2&before=${encodeURIComponent(p1.next)}`)).json();assert.ok(Array.isArray(r.sites) && r.sites.some((x) => x.service === 'live'), 'network-wide answers list the sites');@@@ -135,14 +135,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');const fx = async (url, opts = {}) => {const u = new URL(url);const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });-- if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }++ if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return await json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }if (u.pathname === '/internal/integrations/github-token') {seen.network++;assert.strictEqual(opts.headers.Authorization || opts.headers.authorization, 'Bearer svc-tok');-- return json(networkAnswer.body, networkAnswer.status);++ return await json(networkAnswer.body, networkAnswer.status);}if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);-- return fetchImpl(url, opts);++ return await fetchImpl(url, opts);};const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };@@@ -151,7 +151,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');await release(net, 1);assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');assert.ok(seen.oauth[0].includes('audience=openvibe.network') && seen.oauth[0].includes('network.integration.github.read'));-- assert.strictEqual(net.stats().github_token, 'network');++ assert.strictEqual((await net.stats()).github_token, 'network');await release(net, 2);assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');assert.strictEqual(seen.network, 1, 'kept ten minutes');@@@ -160,7 +160,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');t.clock.advance(11 * 60 * 1000);await release(net, 3);assert.strictEqual(seen.auth.pop(), null, 'none set: anonymous');-- assert.strictEqual(net.stats().github_token, 'none');++ assert.strictEqual((await net.stats()).github_token, 'none');const env = mk({ githubToken: 'ghp_fromenv' });const before = seen.network;@@@ -20,10 +20,10 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');const secret = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Private minutes', body: `Minutes ${LONG}`, visibility: 'private' } })).json().post;const caps = ['blog.post.read', 'blog.post.create'];const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.blog'], cap: caps, extra });-- const postsOf = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n;++ const postsOf = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n;await check('an app or module cannot act for someone else by naming them in X-OV-Subject', async () => {-- const before = postsOf();++ const before = await postsOf();for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {for (const extra of [{ on_behalf_of: appUser.subject }, {}]) {const read = await t.get(`/api/v1/posts/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject } });@@@ -34,7 +34,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');assert.strictEqual(write.status, 403, `${type} write: ${write.text}`);}}-- assert.strictEqual(postsOf(), before, 'nothing written as carol');++ assert.strictEqual(await postsOf(), before, 'nothing written as carol');});await check('an app acts for its on_behalf_of person, with that person\'s rights', async () => {@@@ -40,11 +40,14 @@ async function boot(opts = {}) {const { createApp } = require('../../server/app');const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };++ const { createStore } = require('../../server/db');++ // One database per boot (PGlite, or BLOG_TEST_STORE=pg: the containers); a restart keeps it, like a file did.++ const testdb = await require('./db').testDb();let server = null;let built = null;async function start() {const config = configLib.load(env);-- built = createApp({ config, now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });await built.ctx.auth.ensureKey();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;@@@ -53,7 +56,7 @@ async function boot(opts = {}) {}async function stop() {if (server) await new Promise((r) => server.close(r));-- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }++ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }server = null; built = null;}@@@ -71,15 +74,15 @@ async function boot(opts = {}) {}/** Rows of event_outbox as parsed envelopes. */-- function events(type = null) {-- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));++ async function events(type = null) {++ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));}const t = {network, community, media, clock, dbPath, official, get, events,csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),async restart() { await stop(); await start(); },-- async close() { await stop(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },++ async close() { await stop(); await testdb.close(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },};await start();return t;nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * BLOG_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.BLOG_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'blog', max });++++module.exports = { testDb, pgAvailable };@@@ -15,15 +15,15 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');let post;await check('the ten charter tables exist (package tables and views included)', async () => {-- const names = new Set(t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));++ const names = new Set((await t.ctx.store.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);});await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {-- const blog = t.ctx.blogs.official();++ const blog = await t.ctx.blogs.official();assert.strictEqual(blog.handle, 'openvibe');-- assert.strictEqual(t.ctx.blogs.membership(blog, t.official.subject).role, 'owner');++ assert.strictEqual((await t.ctx.blogs.membership(blog, t.official.subject)).role, 'owner');const r = await t.get('/');assert.strictEqual(r.status, 200);assert.match(r.text, /The OpenVibe blog/);@@@ -47,8 +47,8 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.strictEqual(post.state, 'draft');assert.strictEqual(post.revision, 1);assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);-- assert.strictEqual(t.events('blog.post.created').length, 1);-- assert.strictEqual(t.events(/index_document/).length, 0);++ assert.strictEqual((await t.events('blog.post.created')).length, 1);++ assert.strictEqual((await t.events(/index_document/)).length, 0);assert.strictEqual((await t.get('/@alice/first-light')).status, 404);assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);});@@@ -62,7 +62,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.strictEqual(stale.json().code, 'revision.conflict');const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });assert.strictEqual(missing.status, 428);-- assert.throws(() => t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);++ await assert.rejects(t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);});await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {@@@ -85,13 +85,13 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');assert.strictEqual(posting.aggregateRating, undefined);-- assert.strictEqual(t.events('blog.post.published').length, 1);-- const idx = t.events('blog.index_document.upserted');++ assert.strictEqual((await t.events('blog.post.published')).length, 1);++ const idx = await t.events('blog.index_document.upserted');assert.strictEqual(idx.length, 1);assert.strictEqual(idx[0].payload.visibility, 'public');assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);-- for (const e of t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);++ for (const e of await t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);});await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {@@@ -113,11 +113,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });assert.strictEqual(r.json().revision, 3);assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);-- assert.strictEqual(t.events('blog.post.updated').length, 0);++ assert.strictEqual((await t.events('blog.post.updated')).length, 0);await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);-- assert.strictEqual(t.events('blog.post.updated').length, 1);-- assert.strictEqual(t.events('blog.index_document.upserted').length, 2);++ assert.strictEqual((await t.events('blog.post.updated')).length, 1);++ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);});await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {@@@ -130,7 +130,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);assert.strictEqual(revs[0].kind, 'revert');assert.strictEqual(revs[0].revertedTo, 1);-- assert.strictEqual(t.ctx.store.revisions.get(post.id, 4).content, t.ctx.store.revisions.get(post.id, 1).content);++ assert.strictEqual((await t.ctx.store.revisions.get(post.id, 4)).content, (await t.ctx.store.revisions.get(post.id, 1)).content);});await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {@@@ -139,10 +139,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');assert.strictEqual((await t.get('/@alice/first-light')).status, 404);assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);-- const del = t.events('blog.index_document.deleted');++ const del = await t.events('blog.index_document.deleted');assert.strictEqual(del.length, 1);assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);-- assert.strictEqual(t.events('blog.post.unpublished').length, 1);++ assert.strictEqual((await t.events('blog.post.unpublished')).length, 1);});await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {@@@ -152,9 +152,9 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');const page = await t.get('/@alice/first-light');assert.strictEqual(page.status, 410);assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');-- assert.strictEqual(t.events('blog.post.deleted').length, 1);-- assert.strictEqual(t.ctx.store.revisions.list(post.id).length, 4);-- const last = t.events(/index_document/).pop();++ assert.strictEqual((await t.events('blog.post.deleted')).length, 1);++ assert.strictEqual((await t.ctx.store.revisions.list(post.id)).length, 4);++ const last = (await t.events(/index_document/)).pop();assert.strictEqual(last.event_type, 'blog.index_document.deleted');});@@@ -169,14 +169,14 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');const made = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Cited', body: `Sources below.\n\n${LONG}`, citations: [{ url: 'https://example.org/a', title: 'A', quote: 'quoted' }] } });assert.strictEqual(made.status, 201, made.text);const id = made.json().post.id;-- const citesAt = () => { const h = t.ctx.store.revisions.head(id); return t.ctx.store.citations.forRevision(id, h.number).map((c) => c.url); };-- assert.deepStrictEqual(citesAt(), ['https://example.org/a']);-- let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Edited in /write.\n\n${LONG}` } });++ const citesAt = async () => { const h = await t.ctx.store.revisions.head(id); return (await t.ctx.store.citations.forRevision(id, h.number)).map((c) => c.url); };++ assert.deepStrictEqual(await citesAt(), ['https://example.org/a']);++ let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Edited in /write.\n\n${LONG}` } });assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(citesAt(), ['https://example.org/a'], 'carried forward');-- r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Replaced.\n\n${LONG}`, citations: [] } });++ assert.deepStrictEqual(await citesAt(), ['https://example.org/a'], 'carried forward');++ r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Replaced.\n\n${LONG}`, citations: [] } });assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(citesAt(), [], 'an explicit empty list removes them');++ assert.deepStrictEqual(await citesAt(), [], 'an explicit empty list removes them');});await t.close();@@@ -146,11 +146,11 @@ function startVip() {for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/feed.xml', '/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed', '/llms.txt']) {for (const as of [undefined, fan]) noBody((await t.get(p, { as })).text, p);}-- const all = t.events();++ const all = await t.events();assert.ok(all.length > 0);for (const e of all) noBody(JSON.stringify(e), e.event_type);-- assert.strictEqual(t.events('blog.index_document.upserted').filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');-- const doc = t.ctx.publication.documentFor(t.ctx.blogs.byHandle('carol'), t.ctx.posts.get(mem.id), { forSearch: false }).doc;++ assert.strictEqual((await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');++ const doc = (await t.ctx.publication.documentFor(await t.ctx.blogs.byHandle('carol'), await t.ctx.posts.get(mem.id), { forSearch: false })).doc;assert.strictEqual(doc.body, '');assert.strictEqual(doc.summary, 'What happened backstage this week.');assert.deepStrictEqual(doc.acl.entitlements, ['vip:carol'], 'the default gate label names the blog');@@@ -20,8 +20,8 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);return p;};-- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()-- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');++ const moderation = async () => await (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');const valid = (e) => {assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));const r = contracts.validate('blog.moderation.action@1', e.payload);@@@ -37,14 +37,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');const b = await make('Own delete');assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);-- assert.strictEqual(moderation().length, 0);++ assert.strictEqual((await moderation()).length, 0);});await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {const p = await make('Staff unpublish');const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });assert.strictEqual(r.status, 200, r.text);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 1);valid(ev[0]);assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });@@@ -53,14 +53,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');assert.strictEqual(ev[0].payload.details.previous, 'published');const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });assert.strictEqual(again.status, 200);-- assert.strictEqual(moderation().length, 1, 'nothing changed, nothing reported');++ assert.strictEqual((await moderation()).length, 1, 'nothing changed, nothing reported');});await check('staff deleting someone else\'s post: exactly one valid event', async () => {const p = await make('Staff delete');const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });assert.ok(r.status < 300, r.text);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 2);valid(ev[1]);assert.strictEqual(ev[1].payload.action, 'post.deleted');@@@ -39,7 +39,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)await check('a form without the token is refused', async () => {const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });assert.strictEqual(r.status, 403);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n, 0);});await check('new draft, edit and publish with forms', async () => {@@@ -68,7 +68,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });assert.strictEqual(r.status, 412);assert.match(r.text, /Someone saved revision 2/);-- assert.strictEqual(t.ctx.store.revisions.head(postId).fields.title, 'Notes without script');++ assert.strictEqual((await t.ctx.store.revisions.head(postId)).fields.title, 'Notes without script');});await check('history, diff and preview are plain pages', async () => {@@@ -92,7 +92,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');-- const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('blog_post_discussion_refs')").all().map((c) => c.name);++ const cols = (await t.ctx.store.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'blog_post_discussion_refs'").all()).map((c) => c.name);assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');});@@@ -125,14 +125,14 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)await t.get('/write', { as: mallory }); // signs in once: now known by usernamelet r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });assert.strictEqual(r.status, 303, r.text);-- const blog = t.ctx.blogs.byHandle('eve');++ const blog = await t.ctx.blogs.byHandle('eve');assert.strictEqual(blog.theme, 'paper');-- assert.strictEqual(t.ctx.blogs.feedSettings(blog).json, 0);++ assert.strictEqual((await t.ctx.blogs.feedSettings(blog)).json, 0);assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });assert.strictEqual(r.status, 303, r.text);-- assert.strictEqual(t.ctx.blogs.membership(blog, mallory.subject).role, 'author');++ assert.strictEqual((await t.ctx.blogs.membership(blog, mallory.subject)).role, 'author');const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });assert.strictEqual(bad.status, 422);const notOwner = await t.get('/write/@eve/settings', { as: mallory });@@@ -92,9 +92,9 @@ function assertPrivate(r, what) {});await check('Search receives only the public post; the others were never sent', async () => {-- const ids = t.events('blog.index_document.upserted').map((e) => e.payload.id);++ const ids = (await t.events('blog.index_document.upserted')).map((e) => e.payload.id);assert.deepStrictEqual(ids, [pub.id]);-- const published = t.events('blog.post.published');++ const published = await t.events('blog.post.published');assert.strictEqual(published.length, 4);for (const e of published) {if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');@@@ -106,12 +106,12 @@ function assertPrivate(r, what) {await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });assert.strictEqual(r.status, 200, r.text);-- const del = t.events('blog.index_document.deleted');++ const del = await t.events('blog.index_document.deleted');assert.strictEqual(del.length, 1);assert.strictEqual(del[0].payload.id, pub.id);-- const up = t.events('blog.index_document.upserted').find((e) => e.payload.id === pub.id);++ const up = (await t.events('blog.index_document.upserted')).find((e) => e.payload.id === pub.id);assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');-- assert.strictEqual(t.events('blog.post.updated').length, 1);++ assert.strictEqual((await t.events('blog.post.updated')).length, 1);const a = await t.get('/@carol/open-post');assert.strictEqual(a.status, 403);assertPrivate(a, 'after privatising');@@@ -121,16 +121,16 @@ function assertPrivate(r, what) {await check('members → public again: re-indexed with a higher revision', async () => {await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });-- const ups = t.events('blog.index_document.upserted').filter((e) => e.payload.id === pub.id);-- const del = t.events('blog.index_document.deleted')[0];++ const ups = (await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === pub.id);++ const del = (await t.events('blog.index_document.deleted'))[0];assert.strictEqual(ups.length, 2);assert.ok(ups[1].payload.revision > del.payload.revision);});await check('without an entitlement service the check fails closed', async () => {const { createEntitlementChecker } = require('../server/domain/access');-- const blog = t.ctx.blogs.byHandle('carol');-- const post = t.ctx.posts.get(mem.id);++ const blog = await t.ctx.blogs.byHandle('carol');++ const post = await t.ctx.posts.get(mem.id);const args = { subject: vip.subject, blog, post };const none = createEntitlementChecker({ provider: 'none' });assert.strictEqual(await none.has(args), false);@@@ -27,7 +27,7 @@ const MIN = 60 * 1000;const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });assert.strictEqual(b.json().created, false);assert.strictEqual(b.json().job.id, a.json().job.id);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id).n, 1, 'charter view blog_schedules');++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id)).n, 1, 'charter view blog_schedules');assert.strictEqual(b.json().post.state, 'scheduled');assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);@@@ -41,11 +41,11 @@ const MIN = 60 * 1000;await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {t.clock.advance(11 * MIN);// Worker A: claim the job and apply its effect, then "crash" (no complete()).-- const [job] = t.ctx.store.scheduler.claim({ worker: 'worker-A' });++ const [job] = await t.ctx.store.scheduler.claim({ worker: 'worker-A' });assert.ok(job, 'claimed');-- t.ctx.posts.applyPublish(job.entityId, job.revision, null);-- assert.strictEqual(t.events('blog.post.published').length, 1);-- const idxBefore = t.events('blog.index_document.upserted').length;++ await t.ctx.posts.applyPublish(job.entityId, job.revision, null);++ assert.strictEqual((await t.events('blog.post.published')).length, 1);++ const idxBefore = (await t.events('blog.index_document.upserted')).length;assert.strictEqual(idxBefore, 1);await t.restart(); // same database file, new process state@@@ -56,8 +56,8 @@ const MIN = 60 * 1000;s = await t.ctx.worker.scheduleTick();assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');-- assert.strictEqual(t.events('blog.post.published').length, 1, 'exactly one published event');-- assert.strictEqual(t.events('blog.index_document.upserted').length, 1, 'exactly one Search upsert');++ assert.strictEqual((await t.events('blog.post.published')).length, 1, 'exactly one published event');++ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 1, 'exactly one Search upsert');const page = await t.get('/@ann/scheduled-one');assert.strictEqual(page.status, 200);s = await t.ctx.worker.scheduleTick();@@@ -70,7 +70,7 @@ const MIN = 60 * 1000;t.clock.advance(11 * MIN);const s = await t.ctx.worker.scheduleTick();assert.strictEqual(s.done.length, 1);-- assert.strictEqual(t.ctx.posts.get(p2.id).state, 'published');++ assert.strictEqual((await t.ctx.posts.get(p2.id)).state, 'published');assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);});@@@ -78,7 +78,7 @@ const MIN = 60 * 1000;const p3 = await mk('Scheduled three');await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });-- const jobs = t.ctx.store.scheduler.jobs(p3.id);++ const jobs = await t.ctx.store.scheduler.jobs(p3.id);assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);});@@@ -86,14 +86,14 @@ const MIN = 60 * 1000;const p4 = await mk('Doomed');await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });// The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.-- t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });++ await t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });t.clock.advance(11 * MIN);for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }-- const failed = t.events('blog.schedule.failed');-- assert.strictEqual(failed.length, 1, JSON.stringify(t.ctx.store.scheduler.jobs(p4.id)));++ const failed = await t.events('blog.schedule.failed');++ assert.strictEqual(failed.length, 1, JSON.stringify(await t.ctx.store.scheduler.jobs(p4.id)));assert.strictEqual(failed[0].subject.id, p4.id);assert.match(failed[0].payload.error, /revision/i);-- assert.strictEqual(t.ctx.posts.get(p4.id).state, 'draft');++ assert.strictEqual((await t.ctx.posts.get(p4.id)).state, 'draft');const ready = (await t.get('/api/ready')).json();assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');});@@@ -36,16 +36,16 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');await mk(bob, 'bob', 'Bob Post');const db = t.ctx.store.db;-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT IN ('event_outbox')").all().map((r) => r.name);-- const snapshot = () => Object.fromEntries(tables.map((n) => [n, db.prepare(`SELECT * FROM "${n}"`).all()]));-- const same = (before, label) => { const after = snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name NOT IN ('event_outbox', 'ov_migrations')").all()).map((r) => r.name);++ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (n) => [n, await db.prepare(`SELECT * FROM "${n}"`).all()])));++ const same = async (before, label) => { const after = await snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });// The editor's forms live under /write.const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {-- const before = snapshot();++ const before = await snapshot();for (const p of [annPost, annPrivate, annDraft]) {refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);@@@ -63,11 +63,11 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');-- same(before, 'API');++ await same(before, 'API');});await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {-- const before = snapshot();++ const before = await snapshot();for (const p of [annPost, annPrivate, annDraft]) {refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {@@@ -79,7 +79,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');-- same(before, 'forms');++ await same(before, 'forms');});await check('controls: Ann can do what Bob could not', async () => {@@@ -93,7 +93,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');});await check('public events and Search documents carry none of it', async () => {-- const text = JSON.stringify(t.events().filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));++ const text = JSON.stringify((await t.events()).filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);assert.deepStrictEqual(hits, []);@@@ -66,7 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');});await check('the events outbox carries no sentinel', async () => {-- const text = JSON.stringify(t.events());++ const text = JSON.stringify(await t.events());for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);});@@@ -21,26 +21,26 @@ const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');});await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {-- const out = seedOfficialPost(t.ctx, {});++ const out = await seedOfficialPost(t.ctx, {});assert.strictEqual(out.created, true);assert.strictEqual(out.post.state, 'draft');assert.strictEqual(out.post.author_subject, t.official.subject);-- const rev = t.ctx.store.revisions.head(out.post.id);++ const rev = await t.ctx.store.revisions.head(out.post.id);for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);-- const cites = t.ctx.store.citations.forRevision(out.post.id, rev.number);++ const cites = await t.ctx.store.citations.forRevision(out.post.id, rev.number);assert.strictEqual(cites.length, facts.items.length);assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));assert.strictEqual(rev.meta.authorship.mode, 'imported');assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');-- assert.strictEqual(t.events(/index_document/).length, 0);-- assert.strictEqual(seedOfficialPost(t.ctx, {}).created, false, 'idempotent');++ assert.strictEqual((await t.events(/index_document/)).length, 0);++ assert.strictEqual((await seedOfficialPost(t.ctx, {})).created, false, 'idempotent');});await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {-- assert.throws(() => seedOfficialPost(t.ctx, { publish: true }), /reviewer/);++ await assert.rejects(async () => await seedOfficialPost(t.ctx, { publish: true }), /reviewer/);const reviewer = t.network.addUser('reviewer');-- const out = seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });++ const out = await seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });assert.strictEqual(out.published, true);assert.strictEqual(out.review.reviewer, reviewer.subject);const page = await t.get(`/@openvibe/${SLUG}`);@@@ -37,8 +37,8 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';assert.strictEqual(jf.items[0].id, feedIdBefore);assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));-- assert.strictEqual(t.events('blog.post.updated').length, 1, 'a canonical change of a published post is an update');-- const docs = t.events('blog.index_document.upserted');++ assert.strictEqual((await t.events('blog.post.updated')).length, 1, 'a canonical change of a published post is an update');++ const docs = await t.events('blog.index_document.upserted');assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');});@@@ -46,7 +46,7 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');-- assert.deepStrictEqual(t.ctx.store.redirects.history(post.id).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);++ assert.deepStrictEqual((await t.ctx.store.redirects.history(post.id)).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);});await check('a new post may take a freed slug; the redirect then yields to it', async () => {
@@ -18,6 +18,14 @@ TRUST_PROXY=2
@@ -18,6 +18,14 @@ TRUST_PROXY=2
#BLOG_LIMITS_HOUR=3000
#BLOG_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
# SQLite database (production unit: /var/lib/openvibe-blog/blog.db)
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh blog: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
BLOG_DB_PATH=./data/blog.db
BLOG_DB_PATH=./data/blog.db
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
# OpenVibe.Network — SSO (OAuth2), JWKS, client-credentials tokens
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: blog
contracts-service: blog
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -25,7 +25,7 @@ It also uses:
@@ -25,7 +25,7 @@ It also uses:
## Owns
## Owns
-The ten charter tables live in Blog's own SQLite (`BLOG_DB_PATH`). Where a publishing package
+The ten charter tables live in Blog's own PostgreSQL database (`ov_blog` on the host's data role, ADR-035; schema in [migrations/](migrations/)). Where a publishing package
applies, it creates the table with the `blog` prefix:
applies, it creates the table with the `blog` prefix:
| Charter table | What it is |
| Charter table | What it is |
@@ -240,10 +240,12 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
@@ -240,10 +240,12 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
## Depends on
## Depends on
-- **Packages** (all pinned by release tarball): `openvibe-publishing` v0.4.0 (revisions, schedule,
- taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
- v0.53.0, `openvibe-shared` v1.25.0 (chrome, app icon, footer, legal, release, metrics, ready,
- theme presets), `openvibe-sdk` v0.12.0 (events outbox, service tokens, per-actor limits).
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
+ `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
+- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,
+ schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr), `openvibe-contracts`
+ v0.76.0, `openvibe-shared` v1.25.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
+ `openvibe-sdk` v0.19.0 (db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
- **OpenVibe.Network:**
- **OpenVibe.Network:**
- SSO: the OAuth client `blog` is already seeded with redirect
- SSO: the OAuth client `blog` is already seeded with redirect
`https://openvibe.blog/auth/callback`.
`https://openvibe.blog/auth/callback`.
@@ -412,7 +414,11 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTH
@@ -412,7 +414,11 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4810 (set OV_OAUTH
Production deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy blog` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.blog`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. nginx serves
+The unit is `openvibe-blog.service` on `127.0.0.1:4810`, the env file `/etc/openvibe/blog.env`. The database is
+`ov_blog` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh blog` writes its settings); the
+release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
+`/var/lib/openvibe-blog/blog.db` stays read-only for 7 days as the rollback. nginx serves
`openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf).
`openvibe.blog` from [deploy/nginx/openvibe.blog.conf](deploy/nginx/openvibe.blog.conf).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema
restart; afterwards `sudo ovhost rollback blog --to <sha>`. Nothing blocks a rollback: the schema
@@ -4,11 +4,16 @@
@@ -4,11 +4,16 @@
"code": true,
"code": true,
"wave": "W16",
"wave": "W16",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express 4, better-sqlite3 11",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4810,
"port": 4810,
"domain": "openvibe.blog",
"domain": "openvibe.blog",
- "contracts": "openvibe-contracts v0.53.0",
- "packages": ["openvibe-publishing v0.4.0", "openvibe-contracts v0.53.0", "openvibe-shared v1.25.0", "openvibe-sdk v0.12.0"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-publishing v1.0.0",
+ "openvibe-contracts v0.76.0",
+ "openvibe-shared v1.25.0",
+ "openvibe-sdk v0.19.0"
+ ],
"capabilities": [
"capabilities": [
"blog.blog.create",
"blog.blog.create",
"blog.blog.configure",
"blog.blog.configure",
@@ -47,5 +52,6 @@
@@ -47,5 +52,6 @@
"/api/v1 by capability",
"/api/v1 by capability",
"SDK outbox events and Search index documents",
"SDK outbox events and Search index documents",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
- ]
+ ],
+ "database": "PostgreSQL ov_blog (ADR-035), schema migrations/0001_initial.sql; the SQLite file of earlier releases is imported once by scripts/migrate-to-postgres.js and kept read-only 7 days"
}
}
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member
Description=OpenVibe.Blog — the official OpenVibe blog and a blog for every member
# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog
# Network (sign-in, JWKS, service tokens) and Events (the outbox relay) should be up first, but Blog
# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).
# degrades gracefully without them: pages and feeds serve, sign-in and events wait (see /api/ready).
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Blog's SQLite database (BLOG_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
+ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row
+ * counts and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table
+ * keeps its name and columns; the three charter views are views on both sides and are not copied.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const configLib = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = configLib.load();
+ runSqliteMigration({ service: 'blog', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -16,15 +16,17 @@ const { seedOfficialPost } = require('../server/seed');
@@ -16,15 +16,17 @@ const { seedOfficialPost } = require('../server/seed');
const args = process.argv.slice(2);
const args = process.argv.slice(2);
const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
-const { ctx } = createApp();
-try {
- const out = seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });
- console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(ctx.blogs.get(out.post.blog_id), out.post)}`);
- if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);
- if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');
-} catch (err) {
- console.error(`[seed] ${err.message}`);
- process.exitCode = 1;
-} finally {
- ctx.store.close();
-}
+(async () => {
+ const { ctx } = await createApp();
+ try {
+ const out = await seedOfficialPost(ctx, { author: opt('author'), reviewer: opt('reviewer'), publish: args.includes('--publish') });
+ console.log(`${out.created ? 'created' : 'exists'}: ${out.post.id} (${out.post.state}) ${ctx.publication.postUrl(await ctx.blogs.get(out.post.blog_id), out.post)}`);
+ if (out.review) console.log(`review recorded by ${out.review.reviewer}: ${out.review.decision}`);
+ if (!out.published && out.post.state !== 'published') console.log('left as a draft: publish it after a person has checked the quotes (--publish --reviewer usr_…)');
+ } catch (err) {
+ console.error(`[seed] ${err.message}`);
+ process.exitCode = 1;
+ } finally {
+ await ctx.store.close();
+ }
+})();
@@ -47,11 +47,12 @@ const VERSION = require('../package.json').version;
@@ -47,11 +47,12 @@ const VERSION = require('../package.json').version;
* entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,
* entitlementCheck ({ subject, key, blog, post }) → bool (replaces VIP), log,
* limitsNow (the per-actor limiter's clock, tests)
* limitsNow (the per-actor limiter's clock, tests)
*/
*/
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
+ const store = opts.store || await openStore(config, { now: opts.now, log });
const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const outbox = createBlogOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const blogs = createBlogs({ store, config });
const blogs = createBlogs({ store, config });
@@ -67,7 +68,7 @@ function createApp(opts = {}) {
@@ -67,7 +68,7 @@ function createApp(opts = {}) {
const auth = opts.auth || createAuthClient(config);
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config, people });
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, store, posts, media, outbox, log });
const worker = createWorker({ config, store, posts, media, outbox, log });
- blogs.ensureOfficial();
+ await blogs.ensureOfficial();
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 });
@@ -82,7 +83,10 @@ function createApp(opts = {}) {
@@ -82,7 +83,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each
// Per-actor limits (http/actor-limits.js) for the API, the editor and comment posts, counted once each
// router resolved req.viewer; the per-address limits below stay.
// router resolved req.viewer; the per-address limits below stay.
- ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+ ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -117,7 +121,7 @@ function createApp(opts = {}) {
@@ -117,7 +121,7 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-blog', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createBlogReadiness({ store, auth, outbox, release: release.release });
+ const readiness = createBlogReadiness({ store, auth, outbox, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {
@@ -66,7 +66,7 @@ function createViewerResolver({ auth, config, people }) {
const claims = await auth.verify(token);
const claims = await auth.verify(token);
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
- if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
+ if (subject && people) { try { await people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
// Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.
// Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.
return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };
return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };
}
}
@@ -79,7 +79,7 @@ function createViewerResolver({ auth, config, people }) {
@@ -79,7 +79,7 @@ function createViewerResolver({ auth, config, people }) {
const payload = decodeJwtPayload(token);
const payload = decodeJwtPayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (opts.services === false) return ANONYMOUS;
if (opts.services === false) return ANONYMOUS;
- return fromServiceToken(req, token);
+ return await fromServiceToken(req, token);
}
}
}
}
const token = extractToken(req);
const token = extractToken(req);
@@ -31,50 +31,8 @@ const { serviceAuth } = require('openvibe-contracts');
@@ -31,50 +31,8 @@ const { serviceAuth } = require('openvibe-contracts');
const GITHUB = 'https://api.github.com';
const GITHUB = 'https://api.github.com';
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS changelog_state (
- service TEXT PRIMARY KEY,
- release TEXT NOT NULL,
- head TEXT,
- checked_at INTEGER NOT NULL
- );
- CREATE TABLE IF NOT EXISTS changelog_entries (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- service TEXT NOT NULL,
- repo TEXT NOT NULL,
- sha TEXT NOT NULL,
- subject TEXT NOT NULL,
- committed_at TEXT,
- deployed_at TEXT NOT NULL,
- release TEXT NOT NULL,
- release_lines INTEGER NOT NULL DEFAULT 0,
- major INTEGER NOT NULL DEFAULT 0,
- post_id TEXT,
- UNIQUE (service, sha)
- );
- CREATE INDEX IF NOT EXISTS idx_changelog_pending ON changelog_entries(post_id, deployed_at);
- CREATE TABLE IF NOT EXISTS changelog_posts (
- post_id TEXT PRIMARY KEY,
- blog_id TEXT NOT NULL,
- entries INTEGER NOT NULL,
- from_at TEXT,
- to_at TEXT,
- reason TEXT NOT NULL,
- ai_draft_id TEXT,
- created_at INTEGER NOT NULL
- );
- CREATE TABLE IF NOT EXISTS changelog_history (
- service TEXT PRIMARY KEY,
- imported INTEGER NOT NULL,
- imported_at INTEGER NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_changelog_service ON changelog_entries(service, deployed_at);
- `);
- // Added after the first release: who wrote each change (the commit's author name).
- const cols = db.prepare('PRAGMA table_info(changelog_entries)').all().map((c) => c.name);
- if (!cols.includes('author')) db.exec('ALTER TABLE changelog_entries ADD COLUMN author TEXT');
-}
+/** The changelog tables are in migrations/0001_initial.sql (PostgreSQL, ADR-035); kept for callers of the old helper. */
+async function ensureSchema() {}
/** The feed's page cursor: "<deployed_at>|<id>" of the last entry shown. */
/** The feed's page cursor: "<deployed_at>|<id>" of the last entry shown. */
function encodeCursor(e) { return Buffer.from(`${e.deployed_at}|${e.id}`).toString('base64url'); }
function encodeCursor(e) { return Buffer.from(`${e.deployed_at}|${e.id}`).toString('base64url'); }
@@ -98,7 +56,6 @@ const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subject
@@ -98,7 +56,6 @@ const isNoise = (subject) => /^Merge (branch|pull request|remote)/i.test(subject
function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {
function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchImpl = globalThis.fetch, log = console }) {
const c = config.changelog;
const c = config.changelog;
const db = store.db;
const db = store.db;
- ensureSchema(db);
const nowMs = () => store.now();
const nowMs = () => store.now();
const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };
const stats = { runs: 0, entries: 0, posts: 0, lastError: null, lastRunAt: null };
@@ -106,11 +63,11 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -106,11 +63,11 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
state: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),
state: db.prepare('SELECT * FROM changelog_state WHERE service = ?'),
putState: db.prepare(`INSERT INTO changelog_state (service, release, head, checked_at) VALUES (@service, @release, @head, @at)
putState: db.prepare(`INSERT INTO changelog_state (service, release, head, checked_at) VALUES (@service, @release, @head, @at)
ON CONFLICT(service) DO UPDATE SET release = excluded.release, head = excluded.head, checked_at = excluded.checked_at`),
ON CONFLICT(service) DO UPDATE SET release = excluded.release, head = excluded.head, checked_at = excluded.checked_at`),
- insert: db.prepare(`INSERT OR IGNORE INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)
- VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id)`),
+ insert: db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id)
+ VALUES (@service, @repo, @sha, @subject, @author, @committed_at, @deployed_at, @release, @release_lines, @major, @post_id) ON CONFLICT (service, sha) DO NOTHING`),
fillAuthor: db.prepare('UPDATE changelog_entries SET author = ? WHERE service = ? AND sha = ? AND author IS NULL'),
fillAuthor: db.prepare('UPDATE changelog_entries SET author = ? WHERE service = ? AND sha = ? AND author IS NULL'),
history: db.prepare('SELECT * FROM changelog_history WHERE service = ?'),
history: db.prepare('SELECT * FROM changelog_history WHERE service = ?'),
- putHistory: db.prepare('INSERT OR REPLACE INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?)'),
+ putHistory: db.prepare('INSERT INTO changelog_history (service, imported, imported_at) VALUES (?, ?, ?) ON CONFLICT (service) DO UPDATE SET imported = excluded.imported, imported_at = excluded.imported_at'),
recentPosts: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT ?'),
recentPosts: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT ?'),
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
@@ -184,7 +141,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -184,7 +141,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
// history: the commits a site shipped before the changelog watched it, so its updates page is not
// history: the commits a site shipped before the changelog watched it, so its updates page is not
// empty on day one. They carry post_id 'history' (never pending, never in a patch notes post) and
// empty on day one. They carry post_id 'history' (never pending, never in a patch notes post) and
// their commit time stands in for the deploy time nobody recorded.
// their commit time stands in for the deploy time nobody recorded.
- function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {
+ async function addEntries(svc, commits, { releaseLines = 0, major = false, history = false } = {}) {
const deployedAt = svc.releasedAt || new Date(nowMs()).toISOString();
const deployedAt = svc.releasedAt || new Date(nowMs()).toISOString();
let n = 0;
let n = 0;
for (const cm of commits) {
for (const cm of commits) {
@@ -194,12 +151,12 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -194,12 +151,12 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
const committedAt = (cm.commit && cm.commit.author && cm.commit.author.date) || null;
const committedAt = (cm.commit && cm.commit.author && cm.commit.author.date) || null;
if (history && !committedAt) continue;
if (history && !committedAt) continue;
const author = authorOf(cm);
const author = authorOf(cm);
- const added = q.insert.run({
+ const added = (await q.insert.run({
service: svc.id, repo: svc.repo, sha: cm.sha, subject, author, committed_at: committedAt,
service: svc.id, repo: svc.repo, sha: cm.sha, subject, author, committed_at: committedAt,
deployed_at: history ? new Date(committedAt).toISOString() : deployedAt, release: svc.release, release_lines: releaseLines, major: flagged ? 1 : 0,
deployed_at: history ? new Date(committedAt).toISOString() : deployedAt, release: svc.release, release_lines: releaseLines, major: flagged ? 1 : 0,
post_id: history ? 'history' : null,
post_id: history ? 'history' : null,
- }).changes;
- if (!added && author) q.fillAuthor.run(author, svc.id, cm.sha);
+ })).changes;
+ if (!added && author) await q.fillAuthor.run(author, svc.id, cm.sha);
n += added;
n += added;
}
}
return n;
return n;
@@ -207,36 +164,36 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -207,36 +164,36 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
/** Once per service: its last c.history commits on the deployed release. */
/** Once per service: its last c.history commits on the deployed release. */
async function importHistory(svc) {
async function importHistory(svc) {
- if (!(c.history > 0) || q.history.get(svc.id)) return 0;
+ if (!(c.history > 0) || await q.history.get(svc.id)) return 0;
let list;
let list;
try { list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&per_page=${Math.min(c.history, 100)}`); } catch (err) {
try { list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&per_page=${Math.min(c.history, 100)}`); } catch (err) {
// A repository GitHub will not list (private, empty, gone) is not asked again; anything else is.
// A repository GitHub will not list (private, empty, gone) is not asked again; anything else is.
- if ([404, 409, 422].includes(err.status)) { q.putHistory.run(svc.id, 0, nowMs()); return 0; }
+ if ([404, 409, 422].includes(err.status)) { await q.putHistory.run(svc.id, 0, nowMs()); return 0; }
throw err;
throw err;
}
}
- const n = addEntries(svc, Array.isArray(list) ? list : [], { history: true });
- q.putHistory.run(svc.id, n, nowMs());
+ const n = await addEntries(svc, Array.isArray(list) ? list : [], { history: true });
+ await q.putHistory.run(svc.id, n, nowMs());
return n;
return n;
}
}
async function collectOne(svc) {
async function collectOne(svc) {
- const st = q.state.get(svc.id);
+ const st = await q.state.get(svc.id);
if (st && st.release === svc.release) return 0;
if (st && st.release === svc.release) return 0;
if (!st) {
if (!st) {
let n = 0;
let n = 0;
if (c.since) {
if (c.since) {
const list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&since=${encodeURIComponent(c.since)}&per_page=100`);
const list = await gh(`/repos/${svc.repo}/commits?sha=${svc.release}&since=${encodeURIComponent(c.since)}&per_page=100`);
- n = addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());
+ n = await addEntries(svc, (Array.isArray(list) ? list : []).slice().reverse());
}
}
- q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });
+ await q.putState.run({ service: svc.id, release: svc.release, head: null, at: nowMs() });
return n;
return n;
}
}
const cmp = await gh(`/repos/${svc.repo}/compare/${st.head || st.release}...${svc.release}`);
const cmp = await gh(`/repos/${svc.repo}/compare/${st.head || st.release}...${svc.release}`);
const commits = Array.isArray(cmp.commits) ? cmp.commits : [];
const commits = Array.isArray(cmp.commits) ? cmp.commits : [];
const lines = (Array.isArray(cmp.files) ? cmp.files : []).reduce((a, f) => a + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0);
const lines = (Array.isArray(cmp.files) ? cmp.files : []).reduce((a, f) => a + (Number(f.additions) || 0) + (Number(f.deletions) || 0), 0);
- const n = cmp.status === 'behind' ? 0 : addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });
+ const n = cmp.status === 'behind' ? 0 : await addEntries(svc, commits, { releaseLines: lines, major: lines >= c.majorLines });
const head = commits.length ? commits[commits.length - 1].sha : st.head;
const head = commits.length ? commits[commits.length - 1].sha : st.head;
- q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });
+ await q.putState.run({ service: svc.id, release: svc.release, head, at: nowMs() });
return n;
return n;
}
}
@@ -247,7 +204,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -247,7 +204,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
try {
try {
added += await collectOne(svc);
added += await collectOne(svc);
// A few history imports per run keep an unauthenticated GitHub budget intact.
// A few history imports per run keep an unauthenticated GitHub budget intact.
- if (histories < 6 && !q.history.get(svc.id)) { histories++; await importHistory(svc); }
+ if (histories < 6 && !await q.history.get(svc.id)) { histories++; await importHistory(svc); }
} catch (err) {
} catch (err) {
stats.lastError = `${svc.id}: ${err.message}`;
stats.lastError = `${svc.id}: ${err.message}`;
if (err.status === 403 || err.status === 429) break; // GitHub's rate limit: try again next run
if (err.status === 403 || err.status === 429) break; // GitHub's rate limit: try again next run
@@ -258,9 +215,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -258,9 +215,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
}
}
/** { due, reason } for the pending entries. */
/** { due, reason } for the pending entries. */
- function due(list, at = nowMs()) {
+ async function due(list, at = nowMs()) {
if (!list.length) return { due: false, reason: 'nothing pending' };
if (!list.length) return { due: false, reason: 'nothing pending' };
- const last = q.lastPost.get();
+ const last = await q.lastPost.get();
const sinceLast = last ? at - last.created_at : Infinity;
const sinceLast = last ? at - last.created_at : Infinity;
const newest = Math.max(...list.map((e) => Date.parse(e.deployed_at) || 0));
const newest = Math.max(...list.map((e) => Date.parse(e.deployed_at) || 0));
const oldest = Math.min(...list.map((e) => Date.parse(e.deployed_at) || at));
const oldest = Math.min(...list.map((e) => Date.parse(e.deployed_at) || at));
@@ -307,19 +264,19 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -307,19 +264,19 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
}
}
async function publish(list, reason) {
async function publish(list, reason) {
- const blog = blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? blogs.ensureOfficial() : null);
+ const blog = await blogs.byHandle(c.blogHandle) || (c.blogHandle === config.official.handle ? await blogs.ensureOfficial() : null);
const owner = config.official.owners.find((s) => /^usr_/.test(s));
const owner = config.official.owners.find((s) => /^usr_/.test(s));
if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }
if (!blog || !owner) { stats.lastError = !blog ? `no blog @${c.blogHandle}` : 'BLOG_OFFICIAL_OWNERS names no owner to publish as'; return null; }
const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));
const services = new Map((await registry().catch(() => [])).map((s) => [s.id, s]));
for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });
for (const e of list) if (!services.has(e.service)) services.set(e.service, { id: e.service, name: e.service, repo: e.repo, origin: null });
const doc = render(list, services);
const doc = render(list, services);
const viewer = { kind: 'user', subject: owner, staff: false };
const viewer = { kind: 'user', subject: owner, staff: false };
- const { post } = posts.create(viewer, blog, {
+ const { post } = await posts.create(viewer, blog, {
title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',
title: doc.title, summary: doc.summary, body: doc.body, tags: doc.tags.filter((t) => t.length <= 50).slice(0, 20), series: 'Patch notes',
authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },
authorship: { mode: 'imported', importedFrom: { label: 'Commit messages from the OpenVibers repositories on GitHub', originalAuthor: 'OpenVibers' } },
message: `Patch notes from ${list.length} commits (${reason})`,
message: `Patch notes from ${list.length} commits (${reason})`,
});
});
- posts.publish(viewer, posts.get ? posts.get(post.id) || post : post);
+ await posts.publish(viewer, posts.get ? await posts.get(post.id) || post : post);
let aiDraftId = null;
let aiDraftId = null;
if (c.aiDraft && aiDrafts && aiDrafts.enabled) {
if (c.aiDraft && aiDrafts && aiDrafts.enabled) {
try {
try {
@@ -328,9 +285,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -328,9 +285,9 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
aiDraftId = d.post.id;
aiDraftId = d.post.id;
} catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }
} catch (err) { log.warn && log.warn(`[changelog] AI dev-blog draft not made: ${err.message}`); }
}
}
- store.tx(() => {
- for (const e of list) q.mark.run(post.id, e.id);
- q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
+ await store.tx(async () => {
+ for (const e of list) await q.mark.run(post.id, e.id);
+ await q.putPost.run({ post_id: post.id, blog_id: blog.id, entries: list.length, from_at: doc.from, to_at: doc.to, reason, ai_draft_id: aiDraftId, created_at: nowMs() });
});
});
stats.posts++;
stats.posts++;
log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);
log.log && log.log(`[changelog] published ${post.id} (${list.length} entries, ${reason})`);
@@ -341,8 +298,8 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -341,8 +298,8 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
try {
try {
await collect();
await collect();
- const list = q.pending.all();
- const d = due(list);
+ const list = await q.pending.all();
+ const d = await due(list);
if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);
if (d.due) return await publish(list.slice(0, c.batchSize * 3), d.reason);
return null;
return null;
} catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }
} catch (err) { stats.lastError = err.message; log.warn && log.warn(`[changelog] ${err.message}`); return null; }
@@ -359,14 +316,14 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -359,14 +316,14 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
/** The public feed, newest first: { entries, next } (next = the cursor for the page after, or null). */
/** The public feed, newest first: { entries, next } (next = the cursor for the page after, or null). */
- function page({ service = null, limit = 20, before = null } = {}) {
+ async function page({ service = null, limit = 20, before = null } = {}) {
const n = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const n = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const cur = decodeCursor(before);
const cur = decodeCursor(before);
const where = [];
const where = [];
const args = [];
const args = [];
if (service) { where.push('service = ?'); args.push(String(service)); }
if (service) { where.push('service = ?'); args.push(String(service)); }
if (cur) { where.push('(deployed_at < ? OR (deployed_at = ? AND id < ?))'); args.push(cur.at, cur.at, cur.id); }
if (cur) { where.push('(deployed_at < ? OR (deployed_at = ? AND id < ?))'); args.push(cur.at, cur.at, cur.id); }
- const rows = db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);
+ const rows = await db.prepare(`SELECT * FROM changelog_entries ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY deployed_at DESC, id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const more = rows.length > n;
const shown = rows.slice(0, n);
const shown = rows.slice(0, n);
return {
return {
@@ -377,13 +334,13 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -377,13 +334,13 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
next: more && shown.length ? encodeCursor(shown[shown.length - 1]) : null,
next: more && shown.length ? encodeCursor(shown[shown.length - 1]) : null,
};
};
}
}
- function entries(opts = {}) { return page(opts).entries; }
- function latestPost() { return q.lastPost.get() || null; }
- function recentPosts(limit = 5) { return q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }
+ async function entries(opts = {}) { return (await page(opts)).entries; }
+ async function latestPost() { return await q.lastPost.get() || null; }
+ async function recentPosts(limit = 5) { return await q.recentPosts.all(Math.min(Math.max(parseInt(limit, 10) || 5, 1), 20)); }
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
- function sites() { return q.sites.all(); }
+ async function sites() { return await q.sites.all(); }
- return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: q.pending.all().length }) };
+ return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
}
}
/** A stable id for a batch (tests). */
/** A stable id for a batch (tests). */
@@ -54,7 +54,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -54,7 +54,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
/** The stored thread id, or resolve it once through Community. Public posts only. */
/** The stored thread id, or resolve it once through Community. Public posts only. */
async threadFor(post, label, ctx) {
async threadFor(post, label, ctx) {
if (post.visibility !== 'public') return null;
if (post.visibility !== 'public') return null;
- const known = store.discussion.get(post.id);
+ const known = await store.discussion.get(post.id);
if (known) return known.threadId;
if (known) return known.threadId;
if (!discussion) return null;
if (!discussion) return null;
const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
const out = await store.discussion.threadFor(post.id, refFor(post, label), { client: discussion, traceparent: ctx && ctx.traceparent, requestId: ctx && ctx.requestId });
@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -64,13 +64,13 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
/** { thread, comments, next_cursor } read as an anonymous visitor (public data only). */
async readThread(threadId, { after, ctx } = {}) {
async readThread(threadId, { after, ctx } = {}) {
const qs = after ? `?after=${encodeURIComponent(after)}` : '';
const qs = after ? `?after=${encodeURIComponent(after)}` : '';
- return call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
+ return await call('GET', `/api/v1/comments/threads/${encodeURIComponent(threadId)}${qs}`, { ctx });
},
},
/** Comment as the signed-in member. */
/** Comment as the signed-in member. */
async comment(threadId, subject, { message, parentId } = {}, ctx) {
async comment(threadId, subject, { message, parentId } = {}, ctx) {
if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
- return call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
+ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(threadId)}/comments`, {
tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
tokens: writeTokens, subject, body: { message, ...(parentId ? { parent_id: parentId } : {}) }, ctx,
});
});
},
},
@@ -80,7 +80,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -80,7 +80,7 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
* again when it is public again. Needs community.comment.moderate.
* again when it is public again. Needs community.comment.moderate.
*/
*/
async setThreadVisibility(post, visibility, ctx) {
async setThreadVisibility(post, visibility, ctx) {
- const known = store.discussion.get(post.id);
+ const known = await store.discussion.get(post.id);
if (!known || !modTokens) return false;
if (!known || !modTokens) return false;
try {
try {
await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
await call('PUT', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/visibility`, { tokens: modTokens, body: { visibility }, ctx });
@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -23,6 +23,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
}) : null;
}) : null;
const q = {
const q = {
get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
get: db.prepare('SELECT * FROM subject_projections WHERE subject = ?'),
+ getMany: db.prepare('SELECT * FROM subject_projections WHERE subject = ANY(?)'),
byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
byUsername: db.prepare('SELECT * FROM subject_projections WHERE username = ? ORDER BY refreshed_at DESC LIMIT 1'),
put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
put: db.prepare(`INSERT INTO subject_projections (subject, username, display_name, avatar_url, refreshed_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
@@ -30,9 +31,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -30,9 +31,9 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
};
};
const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
- function remember(subject, p) {
+ async function remember(subject, p) {
if (!ids.isSubjectId('user', subject) || !p) return;
if (!ids.isSubjectId('user', subject) || !p) return;
- q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
+ await q.put.run(subject, p.username ? String(p.username).toLowerCase() : null, p.display_name || p.username || null, absAvatar(p.avatar_url), store.now());
}
}
async function resolve(subjects) {
async function resolve(subjects) {
@@ -46,7 +47,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -46,7 +47,7 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
if (res.status === 401) tokens.invalidate && tokens.invalidate();
if (res.status === 401) tokens.invalidate && tokens.invalidate();
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch answered ${res.status}`);
- for (const s of subjects) if (data.results[s]) remember(s, data.results[s]);
+ for (const s of subjects) if (data.results[s]) await remember(s, data.results[s]);
}
}
const shape = (row, subject) => (row
const shape = (row, subject) => (row
@@ -56,12 +57,14 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -56,12 +57,14 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
return {
return {
enabled,
enabled,
/** From a verified sign-in: the member's own claims are a fresh projection. */
/** From a verified sign-in: the member's own claims are a fresh projection. */
- rememberClaims(subject, claims) { remember(subject, claims); },
+ async rememberClaims(subject, claims) { await remember(subject, claims); },
/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
/** Map subject → { name, username, avatarUrl, known }. Missing entries are fetched (bounded wait). */
async many(subjects) {
async many(subjects) {
const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
const list = [...new Set((subjects || []).filter((s) => ids.isSubjectId('user', s)))];
- const missing = list.filter((s) => { const r = q.get.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
+ const read = async () => new Map((list.length ? await q.getMany.all(list) : []).map((r) => [r.subject, r]));
+ let rows = await read();
+ const missing = list.filter((s) => { const r = rows.get(s); return !r || store.now() - r.refreshed_at > TTL_MS; });
if (missing.length && tokens) {
if (missing.length && tokens) {
let timer;
let timer;
await Promise.race([
await Promise.race([
@@ -69,19 +72,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
@@ -69,19 +72,20 @@ function createPeople({ store, config, fetchImpl = globalThis.fetch }) {
new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
new Promise((r) => { timer = setTimeout(r, WAIT_MS); }),
]);
]);
clearTimeout(timer);
clearTimeout(timer);
+ rows = await read();
}
}
- return new Map(list.map((s) => [s, shape(q.get.get(s), s)]));
+ return new Map(list.map((s) => [s, shape(rows.get(s) || null, s)]));
},
},
async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
async one(subject) { return (await this.many([subject])).get(subject) || shape(null, subject); },
/** The subject a username belongs to, from people Blog has seen. */
/** The subject a username belongs to, from people Blog has seen. */
- byUsername(username) {
- const row = q.byUsername.get(String(username || '').toLowerCase());
+ async byUsername(username) {
+ const row = await q.byUsername.get(String(username || '').toLowerCase());
return row ? shape(row, row.subject) : null;
return row ? shape(row, row.subject) : null;
},
},
- cached: (subject) => shape(q.get.get(subject), subject),
+ cached: async (subject) => shape(await q.get.get(subject), subject),
};
};
}
}
@@ -30,15 +30,15 @@
@@ -30,15 +30,15 @@
const ROLE_RANK = { author: 1, editor: 2, owner: 3 };
const ROLE_RANK = { author: 1, editor: 2, owner: 3 };
-function roleOf(store, blogId, subject) {
+async function roleOf(store, blogId, subject) {
if (!subject) return null;
if (!subject) return null;
- const row = store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);
+ const row = await store.db.prepare('SELECT role FROM blog_memberships WHERE blog_id = ? AND subject = ?').get(blogId, subject);
return row ? row.role : null;
return row ? row.role : null;
}
}
/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */
/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */
-function effectiveRole(store, viewer, blog) {
- const role = roleOf(store, blog.id, viewer && viewer.subject);
+async function effectiveRole(store, viewer, blog) {
+ const role = await roleOf(store, blog.id, viewer && viewer.subject);
if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';
if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';
return role;
return role;
}
}
@@ -48,8 +48,8 @@ const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[min
@@ -48,8 +48,8 @@ const atLeast = (role, min) => Boolean(role) && ROLE_RANK[role] >= ROLE_RANK[min
function isStaff(viewer) { return Boolean(viewer && viewer.staff); }
function isStaff(viewer) { return Boolean(viewer && viewer.staff); }
/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */
/** Is this viewer a member of the blog who may see this (unpublished or restricted) post? */
-function memberCanSee(store, viewer, blog, post) {
- const role = effectiveRole(store, viewer, blog);
+async function memberCanSee(store, viewer, blog, post) {
+ const role = await effectiveRole(store, viewer, blog);
if (!role) return false;
if (!role) return false;
if (atLeast(role, 'editor')) return true;
if (atLeast(role, 'editor')) return true;
return post.author_subject === viewer.subject;
return post.author_subject === viewer.subject;
@@ -103,7 +103,7 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null
@@ -103,7 +103,7 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null
async function canReadPost(store, viewer, blog, post, entitlements) {
async function canReadPost(store, viewer, blog, post, entitlements) {
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
- const member = memberCanSee(store, viewer, blog, post) || isStaff(viewer);
+ const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);
if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
if (member) return { allowed: true, status: 200, reason: 'member' };
if (member) return { allowed: true, status: 200, reason: 'member' };
@@ -115,10 +115,10 @@ async function canReadPost(store, viewer, blog, post, entitlements) {
@@ -115,10 +115,10 @@ async function canReadPost(store, viewer, blog, post, entitlements) {
}
}
/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */
/** Write decisions. action ∈ create | edit | publish | delete | configure | members. */
-function canWrite(store, viewer, blog, action, post = null) {
+async function canWrite(store, viewer, blog, action, post = null) {
if (!viewer || !viewer.subject) return false;
if (!viewer || !viewer.subject) return false;
if (blog.status !== 'active' && !isStaff(viewer)) return false;
if (blog.status !== 'active' && !isStaff(viewer)) return false;
- const role = effectiveRole(store, viewer, blog);
+ const role = await effectiveRole(store, viewer, blog);
switch (action) {
switch (action) {
case 'create': return atLeast(role, 'author');
case 'create': return atLeast(role, 'author');
case 'edit':
case 'edit':
@@ -25,7 +25,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
@@ -25,7 +25,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
async function draft(viewer, blog, input = {}, { traceparent } = {}) {
async function draft(viewer, blog, input = {}, { traceparent } = {}) {
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to ask for an AI draft');
- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
+ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const topic = clean(input.topic, 300);
const topic = clean(input.topic, 300);
if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');
if (!topic) throw new ApiError(422, 'ai_draft.topic_required', 'Say what the post should be about');
const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);
const brief = String(input.brief == null ? '' : input.brief).trim().slice(0, 4000);
@@ -49,7 +49,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
@@ -49,7 +49,7 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}));
}));
// The member acts (their blog, their draft); the text is the AI's (authorship mode 'ai').
// The member acts (their blog, their draft); the text is the AI's (authorship mode 'ai').
const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };
const aiViewer = { kind: 'service', service: 'svc:ai', origin: 'ai', subject: viewer.subject };
- return posts.create(aiViewer, blog, {
+ return await posts.create(aiViewer, blog, {
title: clean(out.title, 200) || topic,
title: clean(out.title, 200) || topic,
summary: clean(out.dek, 400) || null,
summary: clean(out.dek, 400) || null,
body,
body,
@@ -39,7 +39,7 @@ function createBlogs({ store, config }) {
@@ -39,7 +39,7 @@ function createBlogs({ store, config }) {
official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),
official: db.prepare("SELECT * FROM blogs WHERE kind = 'official'"),
insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)
insert: db.prepare(`INSERT INTO blogs (id, kind, handle, owner_subject, title, description, theme, language, created_at, updated_at)
VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),
VALUES (@id, @kind, @handle, @owner_subject, @title, @description, @theme, @language, @now, @now)`),
- feedDefaults: db.prepare('INSERT OR IGNORE INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?)'),
+ feedDefaults: db.prepare('INSERT INTO blog_feed_settings (blog_id, updated_at) VALUES (?, ?) ON CONFLICT DO NOTHING'),
member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),
member: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? AND subject = ?'),
members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),
members: db.prepare('SELECT * FROM blog_memberships WHERE blog_id = ? ORDER BY CASE role WHEN \'owner\' THEN 0 WHEN \'editor\' THEN 1 ELSE 2 END, created_at'),
putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
putMember: db.prepare(`INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)
@@ -53,59 +53,59 @@ function createBlogs({ store, config }) {
@@ -53,59 +53,59 @@ function createBlogs({ store, config }) {
seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),
seriesById: db.prepare('SELECT * FROM blog_series WHERE id = ?'),
};
};
- function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {
+ async function create({ kind, handle, owner, title, description = null, theme = 'vibe', language = 'en' }) {
const now = store.now();
const now = store.now();
const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };
const row = { id: newBlogId(now), kind, handle, owner_subject: owner || null, title, description, theme, language, now };
- q.insert.run(row);
- q.feedDefaults.run(row.id, now);
- if (owner) q.putMember.run(row.id, owner, 'owner', owner, now, now);
- return q.byId.get(row.id);
+ await q.insert.run(row);
+ await q.feedDefaults.run(row.id, now);
+ if (owner) await q.putMember.run(row.id, owner, 'owner', owner, now, now);
+ return await q.byId.get(row.id);
}
}
const api = {
const api = {
THEMES, THEME_PRESETS, ROLES, validHandle,
THEMES, THEME_PRESETS, ROLES, validHandle,
- get: (id) => q.byId.get(id) || null,
- byHandle: (handle) => q.byHandle.get(String(handle || '').toLowerCase()) || null,
- ownedBy: (subject) => (subject ? q.byOwner.get(subject) || null : null),
- blogsOf: (subject) => (subject ? q.blogsOf.all(subject) : []),
+ get: async (id) => await q.byId.get(id) || null,
+ byHandle: async (handle) => await q.byHandle.get(String(handle || '').toLowerCase()) || null,
+ ownedBy: async (subject) => (subject ? await q.byOwner.get(subject) || null : null),
+ blogsOf: async (subject) => (subject ? await q.blogsOf.all(subject) : []),
/** The official blog, created on first boot; config owners become owner memberships. */
/** The official blog, created on first boot; config owners become owner memberships. */
- ensureOfficial() {
- return store.tx(() => {
- let blog = q.official.get();
+ async ensureOfficial() {
+ return await store.tx(async () => {
+ let blog = await q.official.get();
if (!blog) {
if (!blog) {
- blog = create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });
+ blog = await create({ kind: 'official', handle: config.official.handle, owner: null, title: config.official.title, description: config.official.description });
}
}
const now = store.now();
const now = store.now();
for (const s of config.official.owners) {
for (const s of config.official.owners) {
if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }
if (!ids.isSubjectId('user', s)) { console.warn(`[Blog] BLOG_OFFICIAL_OWNERS: ignoring "${s}" (not a usr_ subject)`); continue; }
- if (!q.member.get(blog.id, s)) q.putMember.run(blog.id, s, 'owner', 'config', now, now);
+ if (!await q.member.get(blog.id, s)) await q.putMember.run(blog.id, s, 'owner', 'config', now, now);
}
}
return blog;
return blog;
});
});
},
},
- official: () => q.official.get() || null,
+ official: async () => await q.official.get() || null,
/**
/**
* The member's own blog, created on first use. Handle: the requested one, else the Network
* The member's own blog, created on first use. Handle: the requested one, else the Network
* username. A taken or reserved handle is a 409 the member resolves by choosing another.
* username. A taken or reserved handle is a 409 the member resolves by choosing another.
*/
*/
- ensureMemberBlog({ subject, username, handle, title, description } = {}) {
+ async ensureMemberBlog({ subject, username, handle, title, description } = {}) {
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');
if (!ids.isSubjectId('user', subject)) throw new ApiError(403, 'subject.required', 'Only a signed-in member (usr_ subject) can own a blog');
- return store.tx(() => {
- const existing = q.byOwner.get(subject);
+ return await store.tx(async () => {
+ const existing = await q.byOwner.get(subject);
if (existing) return { blog: existing, created: false };
if (existing) return { blog: existing, created: false };
const h = String(handle || username || '').toLowerCase();
const h = String(handle || username || '').toLowerCase();
if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);
if (!validHandle(h)) throw new ApiError(422, 'blog.invalid_handle', `"${h}" cannot be a blog handle (2–40 of a–z, 0–9, _ and -, not a reserved word)`);
- if (q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);
- const blog = create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });
+ if (await q.byHandle.get(h)) throw new ApiError(409, 'blog.handle_taken', `@${h} is taken; choose another handle`);
+ const blog = await create({ kind: 'member', handle: h, owner: subject, title: String(title || `@${h}`).slice(0, 120), description: description ? String(description).slice(0, 500) : null });
return { blog, created: true };
return { blog, created: true };
});
});
},
},
- update(blog, { title, description, language } = {}) {
+ async update(blog, { title, description, language } = {}) {
const sets = [];
const sets = [];
const vals = [];
const vals = [];
if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }
if (title != null) { const t = String(title).trim().slice(0, 120); if (!t) throw new ApiError(422, 'blog.invalid_title', 'A blog needs a title'); sets.push('title = ?'); vals.push(t); }
@@ -115,77 +115,77 @@ function createBlogs({ store, config }) {
@@ -115,77 +115,77 @@ function createBlogs({ store, config }) {
sets.push('language = ?'); vals.push(String(language));
sets.push('language = ?'); vals.push(String(language));
}
}
if (!sets.length) return blog;
if (!sets.length) return blog;
- db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);
- return q.byId.get(blog.id);
+ await db.prepare(`UPDATE blogs SET ${sets.join(', ')}, updated_at = ? WHERE id = ?`).run(...vals, store.now(), blog.id);
+ return await q.byId.get(blog.id);
},
},
- setTheme(blog, theme) {
+ async setTheme(blog, theme) {
if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);
if (!THEMES[theme]) throw new ApiError(422, 'blog.invalid_theme', `theme must be one of ${THEME_PRESETS.join(', ')}`);
- db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);
- return q.byId.get(blog.id);
+ await db.prepare('UPDATE blogs SET theme = ?, updated_at = ? WHERE id = ?').run(theme, store.now(), blog.id);
+ return await q.byId.get(blog.id);
},
},
/** CSS custom properties of the blog's preset, for its content surface. */
/** CSS custom properties of the blog's preset, for its content surface. */
themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },
themeVars(blog) { return (THEMES[blog.theme] || THEMES.vibe).variables; },
- feedSettings(blog) { return q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },
+ async feedSettings(blog) { return await q.feed.get(blog.id) || { blog_id: blog.id, rss: 1, atom: 1, json: 1, item_count: 20, full_content: 1 }; },
- setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {
- const cur = api.feedSettings(blog);
+ async setFeedSettings(blog, { rss, atom, json, itemCount, fullContent } = {}) {
+ const cur = await api.feedSettings(blog);
const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
const b = (v, d) => (v == null ? d : (v === true || v === 1 || v === '1' || v === 'on' || v === 'true') ? 1 : 0);
const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);
const n = itemCount == null ? cur.item_count : parseInt(itemCount, 10);
if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');
if (!Number.isInteger(n) || n < 1 || n > 50) throw new ApiError(422, 'feed.invalid_item_count', 'item count must be between 1 and 50');
- db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
+ await db.prepare(`INSERT INTO blog_feed_settings (blog_id, rss, atom, json, item_count, full_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,
ON CONFLICT (blog_id) DO UPDATE SET rss = excluded.rss, atom = excluded.atom, json = excluded.json,
item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`)
item_count = excluded.item_count, full_content = excluded.full_content, updated_at = excluded.updated_at`)
.run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());
.run(blog.id, b(rss, cur.rss), b(atom, cur.atom), b(json, cur.json), n, b(fullContent, cur.full_content), store.now());
- return api.feedSettings(blog);
+ return await api.feedSettings(blog);
},
},
- members: (blog) => q.members.all(blog.id),
- membership: (blog, subject) => q.member.get(blog.id, subject) || null,
+ members: async (blog) => await q.members.all(blog.id),
+ membership: async (blog, subject) => await q.member.get(blog.id, subject) || null,
- setMember(blog, subject, role, addedBy) {
+ async setMember(blog, subject, role, addedBy) {
if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');
if (!ids.isSubjectId('user', subject)) throw new ApiError(422, 'member.invalid_subject', 'members are Network user subjects (usr_…)');
if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);
if (!ROLES.includes(role)) throw new ApiError(422, 'member.invalid_role', `role must be one of ${ROLES.join(', ')}`);
- return store.tx(() => {
- const cur = q.member.get(blog.id, subject);
- if (cur && cur.role === 'owner' && role !== 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') {
+ return await store.tx(async () => {
+ const cur = await q.member.get(blog.id, subject);
+ if (cur && cur.role === 'owner' && role !== 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') {
throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
}
}
const now = store.now();
const now = store.now();
- q.putMember.run(blog.id, subject, role, addedBy || null, now, now);
- return q.member.get(blog.id, subject);
+ await q.putMember.run(blog.id, subject, role, addedBy || null, now, now);
+ return await q.member.get(blog.id, subject);
});
});
},
},
- removeMember(blog, subject) {
- return store.tx(() => {
- const cur = q.member.get(blog.id, subject);
+ async removeMember(blog, subject) {
+ return await store.tx(async () => {
+ const cur = await q.member.get(blog.id, subject);
if (!cur) return false;
if (!cur) return false;
- if (cur.role === 'owner' && q.ownerCount.get(blog.id).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
- q.dropMember.run(blog.id, subject);
+ if (cur.role === 'owner' && (await q.ownerCount.get(blog.id)).n <= 1 && blog.kind === 'member') throw new ApiError(409, 'member.last_owner', 'A blog keeps at least one owner');
+ await q.dropMember.run(blog.id, subject);
return true;
return true;
});
});
},
},
- series: (blog) => q.series.all(blog.id),
- seriesBySlug: (blog, slug) => q.seriesBySlug.get(blog.id, slug) || null,
- seriesById: (id) => (id ? q.seriesById.get(id) || null : null),
+ series: async (blog) => await q.series.all(blog.id),
+ seriesBySlug: async (blog, slug) => await q.seriesBySlug.get(blog.id, slug) || null,
+ seriesById: async (id) => (id ? await q.seriesById.get(id) || null : null),
/** Get-or-create a series by title. */
/** Get-or-create a series by title. */
- ensureSeries(blog, title, description = null) {
+ async ensureSeries(blog, title, description = null) {
const t = String(title || '').trim().slice(0, 120);
const t = String(title || '').trim().slice(0, 120);
if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');
if (!t) throw new ApiError(422, 'series.invalid_title', 'A series needs a title');
const slug = slugify(t);
const slug = slugify(t);
- return store.tx(() => {
- const cur = q.seriesBySlug.get(blog.id, slug);
+ return await store.tx(async () => {
+ const cur = await q.seriesBySlug.get(blog.id, slug);
if (cur) return cur;
if (cur) return cur;
const now = store.now();
const now = store.now();
const id = newSeriesId(now);
const id = newSeriesId(now);
- db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO blog_series (id, blog_id, slug, title, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, blog.id, slug, t, description, now, now);
.run(id, blog.id, slug, t, description, now, now);
- return q.seriesById.get(id);
+ return await q.seriesById.get(id);
});
});
},
},
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
*/
*/
function createEffects({ outbox, community }) {
function createEffects({ outbox, community }) {
return {
return {
- after(before, post, ctx) {
- outbox.kick();
+ async after(before, post, ctx) {
+ await outbox.kick();
if (!post || !before) return;
if (!post || !before) return;
// Public = readable by everyone: published and public. Unpublishing hides the thread too.
// Public = readable by everyone: published and public. Unpublishing hides the thread too.
const wasPublic = before.visibility === 'public' && before.state === 'published';
const wasPublic = before.visibility === 'public' && before.state === 'published';
@@ -52,11 +52,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -52,11 +52,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
VALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,
VALUES (@id, @blog_id, @slug, 'draft', @visibility, @entitlement_key, @author_subject, @series_id, @series_position,
@allow_comments, @noindex, @now, @now)`),
@allow_comments, @noindex, @now, @now)`),
};
};
- const blogOf = (post) => blogs.get(post.blog_id);
+ const blogOf = async (post) => await blogs.get(post.blog_id);
// Staff acting where only their staff powers allow it (not a role on the blog, not their own post)
// Staff acting where only their staff powers allow it (not a role on the blog, not their own post)
// is moderation: it also goes to Network's moderation audit log (ADR-022).
// is moderation: it also goes to Network's moderation audit log (ADR-022).
- const byStaff = (viewer, blog, action, post) => access.isStaff(viewer) && !access.canWrite(store, { ...viewer, staff: false }, blog, action, post);
- const moderated = (action, post, viewer, ctx, details) => outbox.moderationAction({
+ const byStaff = async (viewer, blog, action, post) => access.isStaff(viewer) && !await access.canWrite(store, { ...viewer, staff: false }, blog, action, post);
+ const moderated = async (action, post, viewer, ctx, details) => await outbox.moderationAction({
action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },
action, target: { type: 'post', id: post.id, owner_subject: post.author_subject }, actorSubject: viewer.subject, details: { blog_id: post.blog_id, ...details },
}, { traceparent: ctx.traceparent });
}, { traceparent: ctx.traceparent });
@@ -86,13 +86,13 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -86,13 +86,13 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');
if (!KEY_RE.test(k)) throw new ApiError(422, 'post.invalid_entitlement', 'entitlement key must match ^[a-z][a-z0-9_.:-]{0,127}$');
return { visibility: vis, entitlement_key: k };
return { visibility: vis, entitlement_key: k };
}
}
- function slugFor(blog, wanted, postId = null) {
+ async function slugFor(blog, wanted, postId = null) {
let base;
let base;
try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }
try { base = slugify(wanted); } catch { throw new ApiError(422, 'post.invalid_slug', 'That title or slug has no letters or digits to make a URL from'); }
if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');
if (!SLUG_RE.test(base)) throw new ApiError(422, 'post.invalid_slug', 'slug must be lowercase letters, digits and dashes');
for (let i = 1; i < 1000; i++) {
for (let i = 1; i < 1000; i++) {
const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;
const s = i === 1 ? base : `${base.slice(0, 75)}-${i}`;
- const hit = q.slugTaken.get(blog.id, s);
+ const hit = await q.slugTaken.get(blog.id, s);
if (!hit || hit.id === postId) return s;
if (!hit || hit.id === postId) return s;
}
}
throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');
throw new ApiError(409, 'post.slug_taken', 'No free slug for that title');
@@ -127,12 +127,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -127,12 +127,12 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
return viewer.subject;
return viewer.subject;
}
}
- function setTerms(blog, post, input) {
+ async function setTerms(blog, post, input) {
if (input.tags !== undefined) {
if (input.tags !== undefined) {
const tags = listOf(input.tags);
const tags = listOf(input.tags);
if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);
if (tags.length > MAX_TAGS) throw new ApiError(422, 'post.too_many_tags', `At most ${MAX_TAGS} tags`);
for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');
for (const t of tags) if (t.length > 50) throw new ApiError(422, 'post.invalid_tag', 'A tag is at most 50 characters');
- store.taxonomy.setTerms(post.id, 'tag', tags);
+ await store.taxonomy.setTerms(post.id, 'tag', tags);
}
}
if (input.categories !== undefined) {
if (input.categories !== undefined) {
const vocab = blogs.categoryVocabulary(blog);
const vocab = blogs.categoryVocabulary(blog);
@@ -141,20 +141,20 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -141,20 +141,20 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
for (const pathText of listOf(input.categories).slice(0, 10)) {
for (const pathText of listOf(input.categories).slice(0, 10)) {
let parentId = null;
let parentId = null;
for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {
for (const name of pathText.split('>').map((s) => s.trim()).filter(Boolean).slice(0, 4)) {
- const term = store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });
+ const term = await store.taxonomy.ensureTerm({ vocabulary: vocab, name: name.slice(0, 80), parentId });
parentId = term.id;
parentId = term.id;
}
}
if (parentId != null) termIds.push(parentId);
if (parentId != null) termIds.push(parentId);
}
}
- store.taxonomy.setTerms(post.id, vocab, termIds);
+ await store.taxonomy.setTerms(post.id, vocab, termIds);
}
}
}
}
- function seriesOf(blog, input, current) {
+ async function seriesOf(blog, input, current) {
if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;
if (input.series === undefined && input.seriesId === undefined && input.series_id === undefined) return current;
const v = input.seriesId ?? input.series_id ?? input.series;
const v = input.seriesId ?? input.series_id ?? input.series;
if (v == null || v === '') return { series_id: null, series_position: null };
if (v == null || v === '') return { series_id: null, series_position: null };
- let series = typeof v === 'string' && v.startsWith('ser_') ? blogs.seriesById(v) : blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);
+ let series = typeof v === 'string' && v.startsWith('ser_') ? await blogs.seriesById(v) : await blogs.ensureSeries(blog, typeof v === 'object' ? v.title : v);
if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');
if (!series || series.blog_id !== blog.id) throw new ApiError(422, 'post.invalid_series', 'That series is not on this blog');
const posRaw = input.seriesPosition ?? input.series_position;
const posRaw = input.seriesPosition ?? input.series_position;
const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);
const pos = posRaw == null || posRaw === '' ? null : parseInt(posRaw, 10);
@@ -162,9 +162,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -162,9 +162,9 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
return { series_id: series.id, series_position: pos };
return { series_id: series.id, series_position: pos };
}
}
- function attachCitations(post, revision, list) {
+ async function attachCitations(post, revision, list) {
if (!Array.isArray(list) || !list.length) return;
if (!Array.isArray(list) || !list.length) return;
- store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({
+ await store.citations.attachMany(post.id, revision, list.slice(0, 100).map((c) => ({
url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,
url: c.url || null, sourceItemId: c.sourceItemId || c.source_item_id || null, title: c.title || null,
retrievedAt: c.retrievedAt || c.retrieved_at || null,
retrievedAt: c.retrievedAt || c.retrieved_at || null,
quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,
quote: c.quote ? (typeof c.quote === 'string' ? { text: c.quote } : c.quote) : null,
@@ -177,14 +177,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -177,14 +177,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
const api = {
const api = {
VISIBILITIES, MEDIA_ROLES,
VISIBILITIES, MEDIA_ROLES,
- get: (id) => q.byId.get(String(id || '')) || null,
- bySlug: (blog, slug) => q.bySlug.get(blog.id, String(slug || '')) || null,
- deletedBySlug: (blog, slug) => q.deletedBySlug.get(blog.id, String(slug || '')) || null,
+ get: async (id) => await q.byId.get(String(id || '')) || null,
+ bySlug: async (blog, slug) => await q.bySlug.get(blog.id, String(slug || '')) || null,
+ deletedBySlug: async (blog, slug) => await q.deletedBySlug.get(blog.id, String(slug || '')) || null,
blogOf,
blogOf,
/** Must exist and not be deleted, else 404. */
/** Must exist and not be deleted, else 404. */
- mustGet(id) {
- const p = api.get(id);
+ async mustGet(id) {
+ const p = await api.get(id);
if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
if (!p || p.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
return p;
return p;
},
},
@@ -194,7 +194,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -194,7 +194,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* member of the blog or staff). Unlisted posts are never listed.
* member of the blog or staff). Unlisted posts are never listed.
* filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset
* filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset
*/
*/
- listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
+ async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
const where = ["p.state = 'published'"];
const where = ["p.state = 'published'"];
const args = [];
const args = [];
if (restricted) where.push("p.visibility IN ('public','members','private')");
if (restricted) where.push("p.visibility IN ('public','members','private')");
@@ -209,63 +209,63 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -209,63 +209,63 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
}
}
where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");
where.push("EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')");
const w = where.join(' AND ');
const w = where.join(' AND ');
- const total = db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args).n;
+ const total = (await db.prepare(`SELECT COUNT(*) AS n FROM blog_posts p WHERE ${w}`).get(...args)).n;
const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';
const orderBy = order === 'series' ? 'COALESCE(p.series_position, 1e9), p.first_published_at, p.id' : 'p.published_at DESC, p.id DESC';
- const posts = db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);
+ const posts = await db.prepare(`SELECT p.* FROM blog_posts p WHERE ${w} ORDER BY ${orderBy} LIMIT ? OFFSET ?`).all(...args, limit, offset);
return { total, posts };
return { total, posts };
},
},
/** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */
/** Every non-deleted post of a blog, for its members' dashboard (authors: their own). */
- listForDashboard(blog, viewer) {
- const role = access.effectiveRole(store, viewer, blog);
+ async listForDashboard(blog, viewer) {
+ const role = await access.effectiveRole(store, viewer, blog);
if (!role && !access.isStaff(viewer)) return [];
if (!role && !access.isStaff(viewer)) return [];
const own = role === 'author' ? ' AND author_subject = ?' : '';
const own = role === 'author' ? ' AND author_subject = ?' : '';
const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];
const args = role === 'author' ? [blog.id, viewer.subject] : [blog.id];
- return db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);
+ return await db.prepare(`SELECT * FROM blog_posts WHERE blog_id = ? AND state <> 'deleted'${own} ORDER BY updated_at DESC LIMIT 500`).all(...args);
},
},
- head: (post) => store.revisions.head(post.id),
- revision: (post, n) => store.revisions.get(post.id, n),
- revisions: (post, opts) => store.revisions.list(post.id, opts),
- diff: (post, from, to, mode) => store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
- pendingJobs: (post) => store.scheduler.jobs(post.id).filter((j) => j.status === 'pending' || j.status === 'running'),
- attachments: (post) => store.attachments.list(post.id),
- citations: (post, rev) => (rev ? store.citations.forRevision(post.id, rev) : []),
+ head: async (post) => await store.revisions.head(post.id),
+ revision: async (post, n) => await store.revisions.get(post.id, n),
+ revisions: async (post, opts) => await store.revisions.list(post.id, opts),
+ diff: async (post, from, to, mode) => await store.revisions.diff(post.id, from, to, { mode: mode === 'word' ? 'word' : 'line' }),
+ pendingJobs: async (post) => (await store.scheduler.jobs(post.id)).filter((j) => j.status === 'pending' || j.status === 'running'),
+ attachments: async (post) => await store.attachments.list(post.id),
+ citations: async (post, rev) => (rev ? await store.citations.forRevision(post.id, rev) : []),
// ── Writes ──────────────────────────────────────────
// ── Writes ──────────────────────────────────────────
/** A new draft (revision 1). Returns { post, revision }. */
/** A new draft (revision 1). Returns { post, revision }. */
- create(viewer, blog, input = {}, { traceparent } = {}) {
+ async create(viewer, blog, input = {}, { traceparent } = {}) {
const subject = actingSubject(viewer);
const subject = actingSubject(viewer);
- if (!access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
+ if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const t = title(input.title);
const t = title(input.title);
const content = body(input.body);
const content = body(input.body);
const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
const rec = authorshipFor(viewer, input, null);
const rec = authorshipFor(viewer, input, null);
- return store.tx(() => {
+ return await store.tx(async () => {
const now = store.now();
const now = store.now();
const id = newPostId(now);
const id = newPostId(now);
- const slug = slugFor(blog, input.slug || t);
- store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path
- const ser = seriesOf(blog, input, { series_id: null, series_position: null });
- q.insert.run({
+ const slug = await slugFor(blog, input.slug || t);
+ await store.redirects.release(publication.postPath(blog, { slug })); // a live post owns its path
+ const ser = await seriesOf(blog, input, { series_id: null, series_position: null });
+ await q.insert.run({
id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,
id, blog_id: blog.id, slug, ...vis, author_subject: subject, ...ser,
allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),
allow_comments: input.allowComments === undefined && input.allow_comments === undefined ? 1 : (truthy(input.allowComments ?? input.allow_comments) ? 1 : 0),
noindex: truthy(input.noindex) ? 1 : 0, now,
noindex: truthy(input.noindex) ? 1 : 0, now,
});
});
- const { revision } = store.revisions.create({
+ const { revision } = await store.revisions.create({
entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },
entityId: id, expectedRevision: 0, content, fields: { title: t, summary: summary(input.summary) },
meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
});
});
- attachCitations({ id }, revision.number, input.citations);
- const post = q.byId.get(id);
- setTerms(blog, post, input);
- outbox.emit({
+ await attachCitations({ id }, revision.number, input.citations);
+ const post = await q.byId.get(id);
+ await setTerms(blog, post, input);
+ await outbox.emit({
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'post', id, revision: revision.number },
subject: { type: 'post', id, revision: revision.number },
payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },
payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },
}, { traceparent });
}, { traceparent });
- return { post: q.byId.get(id), revision };
+ return { post: await q.byId.get(id), revision };
});
});
},
},
@@ -274,14 +274,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -274,14 +274,14 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug
* on conflict); slug, visibility, taxonomy, series and flags change the post row. A slug
* change leaves a 301 from the old path.
* change leaves a 301 from the old path.
*/
*/
- update(viewer, post, input = {}, { traceparent } = {}) {
+ async update(viewer, post, input = {}, { traceparent } = {}) {
actingSubject(viewer);
actingSubject(viewer);
- const blog = blogOf(post);
- if (!access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ const blog = await blogOf(post);
+ if (!await access.canWrite(store, viewer, blog, 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
if (post.state === 'deleted') throw new ApiError(404, 'post.not_found', 'No such post');
- return store.tx(() => {
+ return await store.tx(async () => {
const before = publication.snapshot(blog, post);
const before = publication.snapshot(blog, post);
- const head = store.revisions.head(post.id);
+ const head = await store.revisions.head(post.id);
let revision = head;
let revision = head;
let created = false;
let created = false;
const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;
const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;
@@ -293,7 +293,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -293,7 +293,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
summary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),
summary: input.summary !== undefined ? summary(input.summary) : (head.fields.summary || null),
};
};
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
- const out = store.revisions.create({
+ const out = await store.revisions.create({
entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,
entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,
fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
});
});
@@ -302,88 +302,88 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -302,88 +302,88 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (created) {
if (created) {
// An edit that does not send citations keeps the ones it had (the /write editor does not
// An edit that does not send citations keeps the ones it had (the /write editor does not
// resend them); sending a list, even an empty one, replaces them.
// resend them); sending a list, even an empty one, replaces them.
- if (input.citations !== undefined) attachCitations(post, revision.number, input.citations);
- else store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });
+ if (input.citations !== undefined) await attachCitations(post, revision.number, input.citations);
+ else await store.citations.carryForward({ entityId: post.id, fromRevision: head.number, toRevision: revision.number, attachedBy: viewer.subject });
}
}
}
}
const sets = {};
const sets = {};
if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {
if (input.slug !== undefined && input.slug !== '' && input.slug !== post.slug) {
- const slug = slugFor(blog, input.slug, post.id);
+ const slug = await slugFor(blog, input.slug, post.id);
if (slug !== post.slug) {
if (slug !== post.slug) {
- store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });
+ await store.redirects.recordMove(post.id, publication.postPath(blog, post), publication.postPath(blog, { ...post, slug }), { reason: 'slug_changed' });
sets.slug = slug;
sets.slug = slug;
}
}
}
}
if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {
if (input.visibility !== undefined || input.entitlementKey !== undefined || input.entitlement_key !== undefined) {
Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));
Object.assign(sets, visibilityOf(blog, input.visibility ?? post.visibility, input.entitlementKey ?? input.entitlement_key ?? post.entitlement_key));
}
}
- Object.assign(sets, seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));
+ Object.assign(sets, await seriesOf(blog, input, { series_id: post.series_id, series_position: post.series_position }));
if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;
if (input.allowComments !== undefined || input.allow_comments !== undefined) sets.allow_comments = truthy(input.allowComments ?? input.allow_comments) ? 1 : 0;
if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;
if (input.noindex !== undefined) sets.noindex = truthy(input.noindex) ? 1 : 0;
const keys = Object.keys(sets);
const keys = Object.keys(sets);
- db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
+ await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
.run({ ...sets, now: store.now(), id: post.id });
.run({ ...sets, now: store.now(), id: post.id });
- setTerms(blog, post, input);
- const { post: after } = publication.afterChange(before, post.id, { actor: viewer, traceparent });
+ await setTerms(blog, post, input);
+ const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });
return { post: after, revision, created };
return { post: after, revision, created };
});
});
},
},
/** The idempotent effect: revision N is the published one. Returns { changed, post }. */
/** The idempotent effect: revision N is the published one. Returns { changed, post }. */
- applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {
- return store.tx(() => {
- const post = q.byId.get(postId);
+ async applyPublish(postId, revisionNumber, actor, { traceparent } = {}) {
+ return await store.tx(async () => {
+ const post = await q.byId.get(postId);
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
- const rev = revisionNumber ? store.revisions.get(post.id, revisionNumber) : store.revisions.head(post.id);
+ const rev = revisionNumber ? await store.revisions.get(post.id, revisionNumber) : await store.revisions.head(post.id);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (!rev) throw new ApiError(404, 'revision.not_found', `No revision ${revisionNumber} of this post`);
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
if (post.state === 'published' && post.published_revision === rev.number) return { changed: false, post };
- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
+ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} is AI-generated and needs a person's review before it can be published (${ok.reason})`);
- const before = publication.snapshot(blogOf(post), post);
+ const before = publication.snapshot(await blogOf(post), post);
const now = store.now();
const now = store.now();
- db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
+ await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
- const out = publication.afterChange(before, post.id, { actor, traceparent });
+ const out = await publication.afterChange(before, post.id, { actor, traceparent });
return { changed: true, post: out.post };
return { changed: true, post: out.post };
});
});
},
},
- applyUnpublish(postId, actor, { traceparent } = {}) {
- return store.tx(() => {
- const post = q.byId.get(postId);
+ async applyUnpublish(postId, actor, { traceparent } = {}) {
+ return await store.tx(async () => {
+ const post = await q.byId.get(postId);
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
if (!post || post.state === 'deleted') throw new ApiError(409, 'post.deleted', 'The post was deleted');
if (post.state !== 'published') return { changed: false, post };
if (post.state !== 'published') return { changed: false, post };
- const before = publication.snapshot(blogOf(post), post);
- db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
- return { changed: true, post: publication.afterChange(before, post.id, { actor, traceparent }).post };
+ const before = publication.snapshot(await blogOf(post), post);
+ await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };
});
});
},
},
- publish(viewer, post, { revision } = {}, ctx = {}) {
+ async publish(viewer, post, { revision } = {}, ctx = {}) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot publish this post');
const n = revision == null || revision === '' ? null : parseInt(revision, 10);
const n = revision == null || revision === '' ? null : parseInt(revision, 10);
- return store.tx(() => {
- store.scheduler.cancelPending(post.id, 'publish');
- return api.applyPublish(post.id, n, viewer, ctx);
+ return await store.tx(async () => {
+ await store.scheduler.cancelPending(post.id, 'publish');
+ return await api.applyPublish(post.id, n, viewer, ctx);
});
});
},
},
- unpublish(viewer, post, ctx = {}) {
+ async unpublish(viewer, post, ctx = {}) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
- const moderation = byStaff(viewer, blogOf(post), 'unpublish', post);
- return store.tx(() => {
- store.scheduler.cancelPending(post.id);
- const cur = q.byId.get(post.id);
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'unpublish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot unpublish this post');
+ const moderation = await byStaff(viewer, await blogOf(post), 'unpublish', post);
+ return await store.tx(async () => {
+ await store.scheduler.cancelPending(post.id);
+ const cur = await q.byId.get(post.id);
let out;
let out;
if (cur.state === 'scheduled') {
if (cur.state === 'scheduled') {
- db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
- out = { changed: true, post: q.byId.get(post.id) };
+ await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ out = { changed: true, post: await q.byId.get(post.id) };
} else {
} else {
- out = api.applyUnpublish(post.id, viewer, ctx);
+ out = await api.applyUnpublish(post.id, viewer, ctx);
}
}
- if (moderation && out.changed) moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });
+ if (moderation && out.changed) await moderated('post.unpublished', cur, viewer, ctx, { previous: cur.state });
return out;
return out;
});
});
},
},
@@ -392,38 +392,38 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -392,38 +392,38 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
* Schedule a publish (of revision N, default the head) or an unpublish at a future time.
* Schedule a publish (of revision N, default the head) or an unpublish at a future time.
* Idempotent: the same (post, action, time, revision) is one job.
* Idempotent: the same (post, action, time, revision) is one job.
*/
*/
- schedule(viewer, post, { at, revision, action = 'publish' } = {}) {
+ async schedule(viewer, post, { at, revision, action = 'publish' } = {}) {
actingSubject(viewer);
actingSubject(viewer);
- const blog = blogOf(post);
- if (!access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');
+ const blog = await blogOf(post);
+ if (!await access.canWrite(store, viewer, blog, action === 'unpublish' ? 'unpublish' : 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot schedule this post');
if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');
if (!['publish', 'unpublish'].includes(action)) throw new ApiError(422, 'schedule.invalid_action', 'action is publish or unpublish');
const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);
const t = at instanceof Date ? at.getTime() : typeof at === 'number' ? at : Date.parse(at);
if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');
if (!Number.isFinite(t)) throw new ApiError(422, 'schedule.invalid_time', 'at must be an ISO 8601 time');
if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');
if (t <= store.now()) throw new ApiError(422, 'schedule.in_past', 'Scheduled time must be in the future');
- const rev = action === 'publish' ? (revision ? store.revisions.get(post.id, parseInt(revision, 10)) : store.revisions.head(post.id)) : null;
+ const rev = action === 'publish' ? (revision ? await store.revisions.get(post.id, parseInt(revision, 10)) : await store.revisions.head(post.id)) : null;
if (action === 'publish') {
if (action === 'publish') {
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
- const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, publication.reviewOf(post, rev));
+ const ok = authorship.canPublish(publication.authorshipOf(rev) || { mode: 'human' }, await publication.reviewOf(post, rev));
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
if (!ok.ok) throw new ApiError(409, 'post.review_required', `Revision ${rev.number} needs a person's review before it can be scheduled (${ok.reason})`);
}
}
- return store.tx(() => {
- const { job, created } = store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
- const cur = q.byId.get(post.id);
+ return await store.tx(async () => {
+ const { job, created } = await store.scheduler.schedule({ entityId: post.id, action, runAt: t, revision: rev ? rev.number : null });
+ const cur = await q.byId.get(post.id);
if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {
if (action === 'publish' && (cur.state === 'draft' || cur.state === 'unpublished')) {
- db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ await db.prepare("UPDATE blog_posts SET state = 'scheduled', updated_at = ? WHERE id = ?").run(store.now(), post.id);
}
}
- return { job, created, post: q.byId.get(post.id) };
+ return { job, created, post: await q.byId.get(post.id) };
});
});
},
},
- cancelSchedule(viewer, post) {
+ async cancelSchedule(viewer, post) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');
- return store.tx(() => {
- const n = store.scheduler.cancelPending(post.id);
- const cur = q.byId.get(post.id);
- if (cur.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
- return { cancelled: n, post: q.byId.get(post.id) };
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot change this post');
+ return await store.tx(async () => {
+ const n = await store.scheduler.cancelPending(post.id);
+ const cur = await q.byId.get(post.id);
+ if (cur.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ return { cancelled: n, post: await q.byId.get(post.id) };
});
});
},
},
@@ -434,15 +434,15 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -434,15 +434,15 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
async runScheduled(worker) {
async runScheduled(worker) {
const summary = await store.scheduler.runDue({
const summary = await store.scheduler.runDue({
worker,
worker,
- handler: (job) => (job.action === 'publish'
- ? api.applyPublish(job.entityId, job.revision, null).changed
- : api.applyUnpublish(job.entityId, null).changed),
+ handler: async (job) => (job.action === 'publish'
+ ? (await api.applyPublish(job.entityId, job.revision, null)).changed
+ : (await api.applyUnpublish(job.entityId, null)).changed),
});
});
for (const job of summary.failed) {
for (const job of summary.failed) {
- store.tx(() => {
- const post = q.byId.get(job.entityId);
- if (post && post.state === 'scheduled') db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
- outbox.emit({
+ await store.tx(async () => {
+ const post = await q.byId.get(job.entityId);
+ if (post && post.state === 'scheduled') await db.prepare("UPDATE blog_posts SET state = 'draft', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ await outbox.emit({
event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',
event_type: 'blog.schedule.failed', actor: { type: 'service', id: 'blog' }, visibility: 'internal', priority: 'important',
subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },
subject: { type: 'post', id: job.entityId, ...(job.revision ? { revision: job.revision } : {}) },
payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },
payload: { job_id: job.id, action: job.action, revision: job.revision, run_at: job.runAt, attempts: job.attempts, error: job.lastError, blog_id: post ? post.blog_id : null },
@@ -454,68 +454,68 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -454,68 +454,68 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
},
},
/** Soft delete: readers get 410, Search a tombstone, revisions are kept. */
/** Soft delete: readers get 410, Search a tombstone, revisions are kept. */
- remove(viewer, post, ctx = {}) {
+ async remove(viewer, post, ctx = {}) {
actingSubject(viewer);
actingSubject(viewer);
- const blog = blogOf(post);
- if (!access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
- const moderation = byStaff(viewer, blog, 'delete', post);
- return store.tx(() => {
- store.scheduler.cancelPending(post.id);
- const cur = q.byId.get(post.id);
+ const blog = await blogOf(post);
+ if (!await access.canWrite(store, viewer, blog, 'delete', post)) throw new ApiError(403, 'post.forbidden', 'You cannot delete this post');
+ const moderation = await byStaff(viewer, blog, 'delete', post);
+ return await store.tx(async () => {
+ await store.scheduler.cancelPending(post.id);
+ const cur = await q.byId.get(post.id);
const before = publication.snapshot(blog, cur);
const before = publication.snapshot(blog, cur);
const now = store.now();
const now = store.now();
- db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
- if (moderation && cur.state !== 'deleted') moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });
- return publication.afterChange(before, post.id, { actor: viewer, ...ctx });
+ await db.prepare("UPDATE blog_posts SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?").run(now, now, post.id);
+ if (moderation && cur.state !== 'deleted') await moderated('post.deleted', cur, viewer, ctx, { previous: cur.state });
+ return await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
});
});
},
},
/** Revert = a new revision copying revision N (content, fields and its authorship). */
/** Revert = a new revision copying revision N (content, fields and its authorship). */
- revert(viewer, post, { toRevision, expectedRevision } = {}) {
+ async revert(viewer, post, { toRevision, expectedRevision } = {}) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
- const target = store.revisions.get(post.id, parseInt(toRevision, 10));
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ const target = await store.revisions.get(post.id, parseInt(toRevision, 10));
if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);
if (!target) throw new ApiError(404, 'revision.not_found', `No revision ${toRevision}`);
- return store.tx(() => {
- const { revision } = store.revisions.revert({
+ return await store.tx(async () => {
+ const { revision } = await store.revisions.revert({
entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),
entityId: post.id, toRevision: target.number, expectedRevision: parseInt(expectedRevision, 10),
author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },
author: authorLabel(viewer), meta: { authorship: publication.authorshipOf(target) },
});
});
- store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });
- db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);
- return { revision, post: q.byId.get(post.id) };
+ await store.citations.carryForward({ entityId: post.id, fromRevision: target.number, toRevision: revision.number, attachedBy: viewer.subject });
+ await db.prepare('UPDATE blog_posts SET updated_at = ? WHERE id = ?').run(store.now(), post.id);
+ return { revision, post: await q.byId.get(post.id) };
});
});
},
},
/** A person's review of one revision (what lets an AI draft be published and indexed). */
/** A person's review of one revision (what lets an AI draft be published and indexed). */
- review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
+ async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
- const blog = blogOf(post);
- if (!access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
+ const blog = await blogOf(post);
+ if (!await access.canWrite(store, viewer, blog, 'publish', post)) throw new ApiError(403, 'post.forbidden', 'You cannot review this post');
const n = parseInt(revision, 10);
const n = parseInt(revision, 10);
- if (!store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
- return store.tx(() => {
+ if (!await store.revisions.get(post.id, n)) throw new ApiError(404, 'revision.not_found', `No revision ${revision}`);
+ return await store.tx(async () => {
const before = publication.snapshot(blog, post);
const before = publication.snapshot(blog, post);
- const row = store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
- publication.afterChange(before, post.id, { actor: viewer, ...ctx });
+ const row = await store.reviews.record({ entityId: post.id, revision: n, reviewer: viewer.subject, decision, note });
+ await publication.afterChange(before, post.id, { actor: viewer, ...ctx });
return row;
return row;
});
});
},
},
- attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {
+ async attach(viewer, post, { mediaId, role = 'inline', alt = null, caption = null, position = 0 } = {}) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
const id = String(mediaId || '').trim();
const id = String(mediaId || '').trim();
if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');
if (!isMediaId(id) || !id.startsWith('med_')) throw new ApiError(422, 'media.invalid_id', 'Attach an OpenVibe.Media object id (med_…)');
if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);
if (!MEDIA_ROLES.includes(role)) throw new ApiError(422, 'media.invalid_role', `role must be one of ${MEDIA_ROLES.join(', ')}`);
const pos = parseInt(position, 10);
const pos = parseInt(position, 10);
- return store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
+ return await store.attachments.attach({ entityId: post.id, mediaId: id, role, alt: alt || null, caption: caption || null, position: Number.isInteger(pos) ? pos : 0 });
},
},
- detach(viewer, post, attachmentId) {
+ async detach(viewer, post, attachmentId) {
actingSubject(viewer);
actingSubject(viewer);
- if (!access.canWrite(store, viewer, blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
- return store.attachments.detach(post.id, attachmentId);
+ if (!await access.canWrite(store, viewer, await blogOf(post), 'edit', post)) throw new ApiError(403, 'post.forbidden', 'You cannot edit this post');
+ return await store.attachments.detach(post.id, attachmentId);
},
},
};
};
return api;
return api;
@@ -40,12 +40,12 @@ function createPublication({ store, config, outbox }) {
@@ -40,12 +40,12 @@ function createPublication({ store, config, outbox }) {
return (rev && rev.meta && rev.meta.authorship) || null;
return (rev && rev.meta && rev.meta.authorship) || null;
}
}
- function reviewOf(post, rev) {
- return rev ? store.reviews.latest(post.id, rev.number) : null;
+ async function reviewOf(post, rev) {
+ return rev ? await store.reviews.latest(post.id, rev.number) : null;
}
}
/** The gate's decision for a post at one revision (the published one by default). */
/** The gate's decision for a post at one revision (the published one by default). */
- function decide(blog, post, rev, { state } = {}) {
+ async function decide(blog, post, rev, { state } = {}) {
const rec = authorshipOf(rev);
const rec = authorshipOf(rev);
const facts = {
const facts = {
state: state || post.state,
state: state || post.state,
@@ -54,24 +54,24 @@ function createPublication({ store, config, outbox }) {
@@ -54,24 +54,24 @@ function createPublication({ store, config, outbox }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
noindex: Boolean(post.noindex),
};
};
- if (rec) Object.assign(facts, authorship.gateFacts(rec, reviewOf(post, rev)));
+ if (rec) Object.assign(facts, authorship.gateFacts(rec, await reviewOf(post, rev)));
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
}
/** The revision readers see (published) or, for a never-published post, the head. */
/** The revision readers see (published) or, for a never-published post, the head. */
- function visibleRevision(post) {
- return post.published_revision ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
+ async function visibleRevision(post) {
+ return post.published_revision ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);
}
}
- function aclFor(blog, post) {
- const subjects = [...new Set([...membersOf.all(blog.id).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));
+ async function aclFor(blog, post) {
+ const subjects = [...new Set([...(await membersOf.all(blog.id)).map((r) => r.subject), post.author_subject])].filter((s) => /^usr_/.test(s));
if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };
if (post.visibility === 'members') return { entitlements: [post.entitlement_key], subjects };
if (post.visibility === 'private') return { subjects };
if (post.visibility === 'private') return { subjects };
return {};
return {};
}
}
- function termsOf(post) {
- const terms = store.taxonomy.termsFor(post.id);
+ async function termsOf(post) {
+ const terms = await store.taxonomy.termsFor(post.id);
return {
return {
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => t.name),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => t.name),
@@ -82,16 +82,16 @@ function createPublication({ store, config, outbox }) {
@@ -82,16 +82,16 @@ function createPublication({ store, config, outbox }) {
* The index document as it would describe this post (real visibility, for the product events),
* The index document as it would describe this post (real visibility, for the product events),
* or a tombstone when it is not published.
* or a tombstone when it is not published.
*/
*/
- function documentFor(blog, post, { forSearch }) {
- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
+ async function documentFor(blog, post, { forSearch }) {
+ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;
const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };
const identity = { owner: OWNER, type: 'post', id: post.id, revision: 0 };
- if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? decide(blog, post, rev) : null };
- const decision = decide(blog, post, rev);
+ if (!rev || post.state !== 'published') return { doc: hooks.tombstone(identity), decision: rev ? await decide(blog, post, rev) : null };
+ const decision = await decide(blog, post, rev);
if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };
if (forSearch && (post.visibility !== 'public' || !decision.listable)) return { doc: hooks.tombstone(identity), decision };
- const { tags, categories } = termsOf(post);
- const series = post.series_id ? db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
+ const { tags, categories } = await termsOf(post);
+ const series = post.series_id ? await db.prepare('SELECT slug FROM blog_series WHERE id = ?').get(post.series_id) : null;
const rec = authorshipOf(rev);
const rec = authorshipOf(rev);
- const acl = aclFor(blog, post);
+ const acl = await aclFor(blog, post);
// Members-only: whatever carries this document (the blog.post.* events) gets the teaser — the
// Members-only: whatever carries this document (the blog.post.* events) gets the teaser — the
// title and the author's summary — never the body. (Search itself only ever gets a tombstone.)
// title and the author's summary — never the body. (Search itself only ever gets a tombstone.)
const gated = post.visibility === 'members';
const gated = post.visibility === 'members';
@@ -107,7 +107,7 @@ function createPublication({ store, config, outbox }) {
@@ -107,7 +107,7 @@ function createPublication({ store, config, outbox }) {
body: gated ? '' : ssr.markdownToText(rev.content),
body: gated ? '' : ssr.markdownToText(rev.content),
facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },
facets: { blog: blog.handle, tags, categories, ...(series ? { series: series.slug } : {}) },
authorship: rec,
authorship: rec,
- citations: store.citations.forRevision(post.id, rev.number),
+ citations: await store.citations.forRevision(post.id, rev.number),
decision,
decision,
publishedAt: post.first_published_at,
publishedAt: post.first_published_at,
updatedAt: rev.createdAt,
updatedAt: rev.createdAt,
@@ -117,13 +117,13 @@ function createPublication({ store, config, outbox }) {
@@ -117,13 +117,13 @@ function createPublication({ store, config, outbox }) {
}
}
/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
/** Stamp and enqueue the Search document when it changed. Inside the caller's transaction. */
- function syncIndex(blog, post, { traceparent } = {}) {
- const { doc } = documentFor(blog, post, { forSearch: true });
- const prev = store.sequencer.current(OWNER, 'post', post.id);
+ async function syncIndex(blog, post, { traceparent } = {}) {
+ const { doc } = await documentFor(blog, post, { forSearch: true });
+ const prev = await store.sequencer.current(OWNER, 'post', post.id);
if (doc.deleted && prev == null) return null; // never indexed: nothing to remove
if (doc.deleted && prev == null) return null; // never indexed: nothing to remove
- const stamped = store.sequencer.stamp(doc);
+ const stamped = await store.sequencer.stamp(db, doc); // db: joins the ambient transaction
if (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send
if (prev != null && stamped.revision === prev) return null; // unchanged: nothing to send
- return outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
+ return await outbox.emit(hooks.indexEvent({ document: stamped, now: store.now() }), { traceparent });
}
}
/** Snapshot for actionFor(before, after). */
/** Snapshot for actionFor(before, after). */
@@ -135,22 +135,22 @@ function createPublication({ store, config, outbox }) {
@@ -135,22 +135,22 @@ function createPublication({ store, config, outbox }) {
* Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),
* Emit the product event for a transition (blog.post.published|updated|unpublished|deleted),
* and re-sync Search. Inside the caller's transaction, after the row changed.
* and re-sync Search. Inside the caller's transaction, after the row changed.
*/
*/
- function afterChange(before, postId, { actor, traceparent } = {}) {
- const post = db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);
- const blog = blogById.get(post.blog_id);
+ async function afterChange(before, postId, { actor, traceparent } = {}) {
+ const post = await db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(postId);
+ const blog = await blogById.get(post.blog_id);
const after = snapshot(blog, post);
const after = snapshot(blog, post);
let action = hooks.actionFor(before, after);
let action = hooks.actionFor(before, after);
if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';
if (!action && before && before.state === 'published' && after.state === 'published' && before.url !== after.url) action = 'updated';
let event = null;
let event = null;
if (action) {
if (action) {
- const { doc, decision } = documentFor(blog, post, { forSearch: false });
- event = outbox.emit(hooks.publicationEvent({
+ const { doc, decision } = await documentFor(blog, post, { forSearch: false });
+ event = await outbox.emit(hooks.publicationEvent({
product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,
product: OWNER, type: 'post', action, id: post.id, revision: post.published_revision || 0,
actor: actorRef(actor), document: doc, decision, now: store.now(),
actor: actorRef(actor), document: doc, decision, now: store.now(),
extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
extra: { blog: { id: blog.id, handle: blog.handle }, visibility: post.visibility },
}), { traceparent });
}), { traceparent });
}
}
- syncIndex(blog, post, { traceparent });
+ await syncIndex(blog, post, { traceparent });
return { action, event, post, blog };
return { action, event, post, blog };
}
}
@@ -24,8 +24,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -24,8 +24,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
feed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,
feed: (blog, kind) => `${blog.kind === 'official' ? '' : base(blog)}/${{ rss: 'feed.xml', atom: 'atom.xml', json: 'feed.json' }[kind]}`,
};
};
- function feedsOf(blog) {
- const s = blogs.feedSettings(blog);
+ async function feedsOf(blog) {
+ const s = await blogs.feedSettings(blog);
const out = [];
const out = [];
if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });
if (s.rss) out.push({ type: 'rss', href: urls.feed(blog, 'rss'), title: `${blog.title} (RSS)`, label: 'RSS', mime: 'application/rss+xml' });
if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });
if (s.atom) out.push({ type: 'atom', href: urls.feed(blog, 'atom'), title: `${blog.title} (Atom)`, label: 'Atom', mime: 'application/atom+xml' });
@@ -33,49 +33,61 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -33,49 +33,61 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
return out;
return out;
}
}
- function termsView(post, blog, { networkTags = false } = {}) {
- const terms = store.taxonomy.termsFor(post.id);
+ async function termsView(post, blog, { networkTags = false } = {}) {
+ const terms = await store.taxonomy.termsFor(post.id);
return {
return {
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),
tags: terms.filter((t) => t.vocabulary === 'tag').map((t) => ({ ...t, url: urls.tag(t, networkTags ? null : blog) })),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),
categories: terms.filter((t) => t.vocabulary !== 'tag').map((t) => ({ ...t, url: urls.category(blog, t) })),
};
};
}
}
- /** List items for rows of published posts (possibly from several blogs). */
+ /** Blogs of these rows, one lookup per distinct blog. */
+ async function blogsFor(rows) {
+ const out = new Map();
+ for (const id of new Set(rows.map((p) => p.blog_id))) out.set(id, await blogs.get(id));
+ return out;
+ }
+ const revRefs = (rows) => rows.map((p) => ({ entityId: p.id, revision: p.published_revision }));
+
+ /** List items for rows of published posts (possibly from several blogs): revisions in one query (no N+1). */
async function listItems(rows, { perBlogLinks = true } = {}) {
async function listItems(rows, { perBlogLinks = true } = {}) {
- const who = await people.many(rows.map((p) => p.author_subject));
- return rows.map((post) => {
- const blog = blogs.get(post.blog_id);
- const rev = store.revisions.get(post.id, post.published_revision);
+ const [who, revs, blogById] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows)), blogsFor(rows)]);
+ const out = [];
+ for (const [i, post] of rows.entries()) {
+ const rev = revs[i];
+ if (!rev) continue;
+ const blog = blogById.get(post.blog_id);
const person = who.get(post.author_subject);
const person = who.get(post.author_subject);
- const { tags } = termsView(post, blog, { networkTags: !perBlogLinks });
- return {
+ const { tags } = await termsView(post, blog, { networkTags: !perBlogLinks });
+ out.push({
post, rev, blog,
post, rev, blog,
url: publication.postPath(blog, post),
url: publication.postPath(blog, post),
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
tags,
tags,
badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
- };
- }).filter((it) => it.rev);
+ });
+ }
+ return out;
}
}
- function categoriesTree(blog) {
- return store.taxonomy.tree(blogs.categoryVocabulary(blog));
+ async function categoriesTree(blog) {
+ return await store.taxonomy.tree(blogs.categoryVocabulary(blog));
}
}
/** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */
/** Feed items (openvibe-publishing/seo feed builders skip anything the gate does not list). */
async function feedItems(blog, { limit, fullContent }) {
async function feedItems(blog, { limit, fullContent }) {
- const { posts: rows } = postsApi.listPublished({ blogId: blog.id, limit, restricted: false });
- const who = await people.many(rows.map((p) => p.author_subject));
- return rows.map((post) => {
- const rev = store.revisions.get(post.id, post.published_revision);
- if (!rev) return null;
- const decision = publication.decide(blog, post, rev);
+ const { posts: rows } = await postsApi.listPublished({ blogId: blog.id, limit, restricted: false });
+ const [who, revs] = await Promise.all([people.many(rows.map((p) => p.author_subject)), store.revisions.getMany(revRefs(rows))]);
+ const out = [];
+ for (const [i, post] of rows.entries()) {
+ const rev = revs[i];
+ if (!rev) continue;
+ const decision = await publication.decide(blog, post, rev);
const person = who.get(post.author_subject);
const person = who.get(post.author_subject);
- const atts = store.attachments.list(post.id);
+ const atts = await store.attachments.list(post.id);
const cover = atts.find((a) => a.role === 'cover' && !a.broken);
const cover = atts.find((a) => a.role === 'cover' && !a.broken);
const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
- return {
+ out.push({
id: publication.feedId(post),
id: publication.feedId(post),
url: publication.postUrl(blog, post),
url: publication.postUrl(blog, post),
title: rev.fields.title,
title: rev.fields.title,
@@ -85,18 +97,19 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -85,18 +97,19 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
updated: rev.createdAt,
updated: rev.createdAt,
decision,
decision,
authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
- tags: termsView(post, blog).tags.map((t) => t.name),
+ tags: (await termsView(post, blog)).tags.map((t) => t.name),
image: cover ? media.urlFor(cover.mediaId) : null,
image: cover ? media.urlFor(cover.mediaId) : null,
- };
- }).filter(Boolean);
+ });
+ }
+ return out;
}
}
/** The machine-readable representation of a post (same content as the page). */
/** The machine-readable representation of a post (same content as the page). */
- function postJson({ blog, post, rev, person, decision }) {
+ async function postJson({ blog, post, rev, person, decision }) {
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
- const review = publication.reviewOf(post, rev);
- const { tags, categories } = termsView(post, blog);
- const series = blogs.seriesById(post.series_id);
+ const review = await publication.reviewOf(post, rev);
+ const { tags, categories } = await termsView(post, blog);
+ const series = await blogs.seriesById(post.series_id);
return {
return {
id: post.id,
id: post.id,
url: publication.postUrl(blog, post),
url: publication.postUrl(blog, post),
@@ -114,8 +127,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -114,8 +127,8 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
tags: tags.map((t) => t.name),
tags: tags.map((t) => t.name),
categories: categories.map((c) => c.name),
categories: categories.map((c) => c.name),
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
series: series ? { title: series.title, url: publication.abs(urls.series(blog, series)), position: post.series_position } : null,
- media: store.attachments.list(post.id).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),
- citations: store.citations.forRevision(post.id, rev.number).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),
+ media: (await store.attachments.list(post.id)).map((a) => ({ media_id: a.mediaId, role: a.role, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, url: a.broken ? null : media.urlFor(a.mediaId) })),
+ citations: (await store.citations.forRevision(post.id, rev.number)).map((c) => ({ url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote ? c.quote.text : null })),
indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },
indexability: { indexable: decision.indexable, robots: decision.robots, reasons: decision.codes },
};
};
}
}
@@ -147,7 +160,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -147,7 +160,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
* OpenVibe.VIP. The official blog has no VIP owner: no join link.
* OpenVibe.VIP. The official blog has no VIP owner: no join link.
*/
*/
async function teaser(blog, post, { reason = null } = {}) {
async function teaser(blog, post, { reason = null } = {}) {
- const rev = post.published_revision ? store.revisions.get(post.id, post.published_revision) : null;
+ const rev = post.published_revision ? await store.revisions.get(post.id, post.published_revision) : null;
const owner = blog.owner_subject ? await people.one(blog.owner_subject) : null;
const owner = blog.owner_subject ? await people.one(blog.owner_subject) : null;
return {
return {
id: post.id,
id: post.id,
@@ -61,9 +61,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -61,9 +61,9 @@ function createBlogOutbox({ db, config, fetchImpl, now, log = console }) {
* blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;
* blog.moderation.action, inside the caller's transaction. actorSubject: the staff member;
* target: { type, id, owner_subject? }. Never the content.
* target: { type, id, owner_subject? }. Never the content.
*/
*/
- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
- return emit({
+ return await emit({
event_type: 'blog.moderation.action',
event_type: 'blog.moderation.action',
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'blog' },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the pages and feeds
* people read.
* people read.
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
const FIRST_PARTY = /^svc:/;
const FIRST_PARTY = /^svc:/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
const LOOPBACK = /^(::1$|127\.|::ffff:127\.)/;
@@ -86,7 +86,7 @@ const BUDGETS = {
@@ -86,7 +86,7 @@ const BUDGETS = {
* limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every
* limits(name, own) middleware for one app, plus limits.reads(name, { skip }) (the defaults on every
* GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).
* GET/HEAD, a first-party service reading for itself not counted) and limits.budget(name).
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'blog_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'blog_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -94,6 +94,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -94,6 +94,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.limits.minute, hour: config.limits.hour },
limits: { minute: config.limits.minute, hour: config.limits.hour },
actor,
actor,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a subject id, a principal or an address, never a token.
// The actor is a subject id, a principal or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -66,30 +66,30 @@ function createApi(ctx) {
@@ -66,30 +66,30 @@ function createApi(ctx) {
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
- function mustBlog(req) {
- const blog = blogs.byHandle(req.params.handle);
+ async function mustBlog(req) {
+ const blog = await blogs.byHandle(req.params.handle);
if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');
if (!blog || (blog.status !== 'active' && !access.isStaff(req.viewer))) throw new ApiError(404, 'blog.not_found', 'No such blog');
return blog;
return blog;
}
}
- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
+ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
- function blogDto(blog, { full = false } = {}) {
+ async function blogDto(blog, { full = false } = {}) {
const out = {
const out = {
id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,
id: blog.id, kind: blog.kind, handle: blog.handle, title: blog.title, description: blog.description,
url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,
url: publication.abs(publication.blogPath(blog)), theme: blog.theme, language: blog.language,
- feeds: reading.feedsOf(blog).map((f) => ({ type: f.type, url: publication.abs(f.href) })),
+ feeds: (await reading.feedsOf(blog)).map((f) => ({ type: f.type, url: publication.abs(f.href) })),
};
};
- if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: blogs.feedSettings(blog) });
+ if (full) Object.assign(out, { status: blog.status, owner_subject: blog.owner_subject, feed_settings: await blogs.feedSettings(blog) });
return out;
return out;
}
}
- function postDto(post, { full = false } = {}) {
- const blog = posts.blogOf(post);
- const rev = full ? posts.head(post) : posts.revision(post, post.published_revision);
- const shown = post.published_revision ? posts.revision(post, post.published_revision) : rev;
- const decision = shown ? publication.decide(blog, post, shown) : null;
- const { tags, categories } = publication.termsOf(post);
- const series = blogs.seriesById(post.series_id);
+ async function postDto(post, { full = false } = {}) {
+ const blog = await posts.blogOf(post);
+ const rev = full ? await posts.head(post) : await posts.revision(post, post.published_revision);
+ const shown = post.published_revision ? await posts.revision(post, post.published_revision) : rev;
+ const decision = shown ? await publication.decide(blog, post, shown) : null;
+ const { tags, categories } = await publication.termsOf(post);
+ const series = await blogs.seriesById(post.series_id);
const out = {
const out = {
id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),
id: post.id, blog: { id: blog.id, handle: blog.handle }, slug: post.slug, url: publication.postUrl(blog, post),
state: post.state, visibility: post.visibility, author_subject: post.author_subject,
state: post.state, visibility: post.visibility, author_subject: post.author_subject,
@@ -105,127 +105,127 @@ function createApi(ctx) {
@@ -105,127 +105,127 @@ function createApi(ctx) {
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
Object.assign(out, {
Object.assign(out, {
entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
- authorship: rec, review: rev ? publication.reviewOf(post, rev) : null,
- scheduled: posts.pendingJobs(post), attachments: posts.attachments(post),
- citations: rev ? posts.citations(post, rev.number) : [],
+ authorship: rec, review: rev ? await publication.reviewOf(post, rev) : null,
+ scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),
+ citations: rev ? await posts.citations(post, rev.number) : [],
});
});
}
}
return out;
return out;
}
}
- function mustPost(req, cap) {
- const post = posts.mustGet(req.params.id);
- return { post, blog: posts.blogOf(post), cap };
+ async function mustPost(req, cap) {
+ const post = await posts.mustGet(req.params.id);
+ return { post, blog: await posts.blogOf(post), cap };
}
}
- function after(before, postId, req) {
- effects.after(before, posts.get(postId), req.ov);
+ async function after(before, postId, req) {
+ await effects.after(before, await posts.get(postId), req.ov);
}
}
// ── Blogs ───────────────────────────────────────────────
// ── Blogs ───────────────────────────────────────────────
- router.get('/blogs/:handle', run((req) => {
- const blog = mustBlog(req);
- return { blog: blogDto(blog, { full: isMember(blog, req.viewer) }) };
+ router.get('/blogs/:handle', run(async (req) => {
+ const blog = await mustBlog(req);
+ return { blog: await blogDto(blog, { full: await isMember(blog, req.viewer) }) };
}));
}));
- router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run((req) => {
+ router.post('/blogs', guard('blog.blog.create'), B('blog.blog.create'), jsonBody, run(async (req) => {
const b = req.body || {};
const b = req.body || {};
const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
const username = req.viewer.kind === 'user' && req.viewer.user ? req.viewer.user.username : null;
- const { blog, created } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
- return { blog: blogDto(blog, { full: true }), created };
+ const { blog, created } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username, handle: b.handle, title: b.title, description: b.description });
+ return { blog: await blogDto(blog, { full: true }), created };
}, (out) => (out.created ? 201 : 200)));
}, (out) => (out.created ? 201 : 200)));
- router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run((req) => {
- const blog = mustBlog(req);
- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
+ router.patch('/blogs/:handle', guard('blog.blog.configure'), B('blog.blog.configure'), jsonBody, run(async (req) => {
+ const blog = await mustBlog(req);
+ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can configure this blog');
const b = req.body || {};
const b = req.body || {};
- const updated = store.tx(() => {
- const u = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
- if (b.feeds) blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });
- return blogs.get(u.id);
+ const updated = await store.tx(async () => {
+ const u = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });
+ if (b.feeds) await blogs.setFeedSettings(u, { rss: b.feeds.rss, atom: b.feeds.atom, json: b.feeds.json, itemCount: b.feeds.item_count, fullContent: b.feeds.full_content });
+ return await blogs.get(u.id);
});
});
- return { blog: blogDto(updated, { full: true }) };
+ return { blog: await blogDto(updated, { full: true }) };
}));
}));
- router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run((req) => {
- const blog = mustBlog(req);
- if (!access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
- return { blog: blogDto(blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
+ router.put('/blogs/:handle/theme', guard('blog.theme.set'), B('blog.blog.configure'), jsonBody, run(async (req) => {
+ const blog = await mustBlog(req);
+ if (!await access.canWrite(store, req.viewer, blog, 'configure')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can change the theme');
+ return { blog: await blogDto(await blogs.setTheme(blog, String((req.body || {}).theme || '')), { full: true }), themes: blogs.THEME_PRESETS };
}));
}));
router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {
router.get('/blogs/:handle/members', guard('blog.member.manage'), run(async (req) => {
- const blog = mustBlog(req);
- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');
- const list = blogs.members(blog);
+ const blog = await mustBlog(req);
+ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can see the members');
+ const list = await blogs.members(blog);
const who = await people.many(list.map((m) => m.subject));
const who = await people.many(list.map((m) => m.subject));
return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
return { members: list.map((m) => ({ subject: m.subject, role: m.role, name: who.get(m.subject).known ? who.get(m.subject).name : null, username: who.get(m.subject).username })) };
}));
}));
- router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run((req) => {
- const blog = mustBlog(req);
- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
- return { member: blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
+ router.put('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), jsonBody, run(async (req) => {
+ const blog = await mustBlog(req);
+ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
+ return { member: await blogs.setMember(blog, req.params.subject, (req.body || {}).role, req.viewer.subject) };
}));
}));
- router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run((req) => {
- const blog = mustBlog(req);
- if (!access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
- return { removed: blogs.removeMember(blog, req.params.subject) };
+ router.delete('/blogs/:handle/members/:subject', guard('blog.member.manage'), B('blog.member.manage'), run(async (req) => {
+ const blog = await mustBlog(req);
+ if (!await access.canWrite(store, req.viewer, blog, 'members')) throw new ApiError(403, 'blog.forbidden', 'Only an owner can manage members');
+ return { removed: await blogs.removeMember(blog, req.params.subject) };
}));
}));
- router.get('/blogs/:handle/posts', run((req) => {
- const blog = mustBlog(req);
+ router.get('/blogs/:handle/posts', run(async (req) => {
+ const blog = await mustBlog(req);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
if (req.query.all === '1') {
if (req.query.all === '1') {
if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');
if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');
- if (!isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
- return { posts: posts.listForDashboard(blog, req.viewer).slice(offset, offset + limit).map((p) => postDto(p, { full: false })) };
+ if (!await isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
+ return { posts: await Promise.all((await posts.listForDashboard(blog, req.viewer)).slice(offset, offset + limit).map((p) => postDto(p, { full: false }))) };
}
}
- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted: isMember(blog, req.viewer), limit, offset });
- return { total, posts: rows.map((p) => postDto(p)) };
+ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted: await isMember(blog, req.viewer), limit, offset });
+ return { total, posts: await Promise.all(rows.map((p) => postDto(p))) };
}));
}));
// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
// The network changelog (server/changelog.js): what shipped on every OpenVibe site, newest first, and
// the latest patch notes post. Public: every site's "recently shipped" reads it.
// the latest patch notes post. Public: every site's "recently shipped" reads it.
- router.get('/changelog', run((req, res) => {
+ router.get('/changelog', run(async (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
const service = /^[a-z][a-z0-9-]{1,39}$/.test(String(req.query.service || '')) ? String(req.query.service) : null;
const service = /^[a-z][a-z0-9-]{1,39}$/.test(String(req.query.service || '')) ? String(req.query.service) : null;
- const postOf = (row) => {
- const p = row && posts.get(row.post_id);
- const blog = p ? blogs.get(p.blog_id) : null;
+ const postOf = async (row) => {
+ const p = row && await posts.get(row.post_id);
+ const blog = p ? await blogs.get(p.blog_id) : null;
if (!p || !blog || p.state !== 'published') return null;
if (!p || !blog || p.state !== 'published') return null;
- return { id: p.id, title: (posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };
+ return { id: p.id, title: (await posts.head(p) || { fields: {} }).fields.title || null, url: publication.abs(publication.postPath(blog, p)), published_at: p.published_at || null, entries: row.entries };
};
};
- const page = ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });
- const recent = ctx.changelog.recentPosts(5).map(postOf).filter(Boolean);
+ const page = await ctx.changelog.page({ service, limit: req.query.limit, before: req.query.before });
+ const recent = (await Promise.all((await ctx.changelog.recentPosts(5)).map(postOf))).filter(Boolean);
return {
return {
service, entries: page.entries, next: page.next, latest_post: recent[0] || null, posts: recent,
service, entries: page.entries, next: page.next, latest_post: recent[0] || null, posts: recent,
- ...(service ? {} : { sites: ctx.changelog.sites() }),
+ ...(service ? {} : { sites: await ctx.changelog.sites() }),
};
};
}));
}));
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
// Draft with AI: OpenVibe.AI's blog.draft_post writes a draft (AI-authored, noindex until reviewed).
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
router.post('/blogs/:handle/posts/ai-draft', guard('blog.post.create'), B('blog.post.ai_draft'), jsonBody, run(async (req) => {
- const blog = mustBlog(req);
+ const blog = await mustBlog(req);
if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
if (!ctx.aiDrafts || !ctx.aiDrafts.enabled) throw new ApiError(503, 'ai.not_configured', 'Drafting with AI is not available on this blog right now');
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, req.body || {}, tp(req));
- after(null, post.id, req);
- return { post: postDto(post, { full: true }), revision: revision.number };
+ await after(null, post.id, req);
+ return { post: await postDto(post, { full: true }), revision: revision.number };
}, 201));
}, 201));
- router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run((req) => {
- const blog = mustBlog(req);
- const { post, revision } = posts.create(req.viewer, blog, req.body || {}, tp(req));
- after(null, post.id, req);
- return { post: postDto(post, { full: true }), revision: revision.number };
+ router.post('/blogs/:handle/posts', guard('blog.post.create'), B('blog.post.create'), jsonBody, run(async (req) => {
+ const blog = await mustBlog(req);
+ const { post, revision } = await posts.create(req.viewer, blog, req.body || {}, tp(req));
+ await after(null, post.id, req);
+ return { post: await postDto(post, { full: true }), revision: revision.number };
}, 201));
}, 201));
router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {
router.get('/blogs/:handle/feed', guard('blog.feed.read'), run(async (req, res) => {
- const blog = mustBlog(req);
- const s = blogs.feedSettings(blog);
+ const blog = await mustBlog(req);
+ const s = await blogs.feedSettings(blog);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
res.removeHeader('X-Robots-Tag');
res.removeHeader('X-Robots-Tag');
@@ -235,82 +235,82 @@ function createApi(ctx) {
@@ -235,82 +235,82 @@ function createApi(ctx) {
// ── Posts ───────────────────────────────────────────────
// ── Posts ───────────────────────────────────────────────
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
- const { post, blog } = mustPost(req);
- if (access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: postDto(post, { full: true }) };
+ const { post, blog } = await mustPost(req);
+ if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };
const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
// Members only: the teaser (title, the author's summary) and the join link, never the body.
// Members only: the teaser (title, the author's summary) and the join link, never the body.
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
- return { post: postDto(post) };
+ return { post: await postDto(post) };
}));
}));
const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {
const write = (method, path, capGuard, limit, fn, status = 200) => router[method](path, capGuard, limit, jsonBody, run(async (req) => {
- const { post } = mustPost(req);
+ const { post } = await mustPost(req);
const before = { ...post };
const before = { ...post };
const out = await fn(req, post, req.body || {});
const out = await fn(req, post, req.body || {});
- after(before, post.id, req);
+ await after(before, post.id, req);
return out;
return out;
}, status));
}, status));
- write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
+ write('patch', '/posts/:id', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {
const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
const input = { ...b, expectedRevision: b.expected_revision ?? b.expectedRevision };
- const out = posts.update(req.viewer, post, input, tp(req));
- return { post: postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
+ const out = await posts.update(req.viewer, post, input, tp(req));
+ return { post: await postDto(out.post, { full: true }), revision: out.revision.number, created: out.created };
});
});
- write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), (req, post, b) => {
- const out = posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
- return { post: postDto(out.post, { full: true }), changed: out.changed };
+ write('post', '/posts/:id/publish', guard('blog.post.publish'), B('blog.post.publish'), async (req, post, b) => {
+ const out = await posts.publish(req.viewer, post, { revision: b.revision }, tp(req));
+ return { post: await postDto(out.post, { full: true }), changed: out.changed };
});
});
- write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post, b) => {
- const out = posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
- return { job: out.job, created: out.created, post: postDto(out.post, { full: true }) };
+ write('post', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post, b) => {
+ const out = await posts.schedule(req.viewer, post, { at: b.at, revision: b.revision, action: b.action || 'publish' });
+ return { job: out.job, created: out.created, post: await postDto(out.post, { full: true }) };
}, 201);
}, 201);
- write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), (req, post) => {
- const out = posts.cancelSchedule(req.viewer, post);
- return { cancelled: out.cancelled, post: postDto(out.post, { full: true }) };
+ write('delete', '/posts/:id/schedule', guard('blog.post.schedule'), B('blog.post.publish'), async (req, post) => {
+ const out = await posts.cancelSchedule(req.viewer, post);
+ return { cancelled: out.cancelled, post: await postDto(out.post, { full: true }) };
});
});
- write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), (req, post) => {
- const out = posts.unpublish(req.viewer, post, tp(req));
- return { post: postDto(out.post, { full: true }), changed: out.changed };
+ write('post', '/posts/:id/unpublish', guard('blog.post.unpublish'), B('blog.post.publish'), async (req, post) => {
+ const out = await posts.unpublish(req.viewer, post, tp(req));
+ return { post: await postDto(out.post, { full: true }), changed: out.changed };
});
});
- write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), (req, post) => {
- posts.remove(req.viewer, post, tp(req));
+ write('delete', '/posts/:id', guard('blog.post.delete'), B('blog.post.delete'), async (req, post) => {
+ await posts.remove(req.viewer, post, tp(req));
return { deleted: true, id: post.id };
return { deleted: true, id: post.id };
});
});
- write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), (req, post, b) => {
- const out = posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
- return { revision: out.revision.number, post: postDto(out.post, { full: true }) };
+ write('post', '/posts/:id/revert', guard('blog.post.update'), B('blog.post.update'), async (req, post, b) => {
+ const out = await posts.revert(req.viewer, post, { toRevision: b.to_revision ?? b.toRevision, expectedRevision: b.expected_revision ?? b.expectedRevision });
+ return { revision: out.revision.number, post: await postDto(out.post, { full: true }) };
}, 201);
}, 201);
router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
router.post('/posts/:id/reviews', B('blog.post.publish'), jsonBody, run(async (req) => {
- const { post } = mustPost(req);
+ const { post } = await mustPost(req);
const b = req.body || {};
const b = req.body || {};
- const review = posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
- after({ ...post }, post.id, req);
+ const review = await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note }, tp(req));
+ await after({ ...post }, post.id, req);
return { review };
return { review };
}, 201));
}, 201));
- write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), (req, post, b) => ({
- attachment: posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
+ write('post', '/posts/:id/attachments', guard('blog.post.update'), B('blog.post.media'), async (req, post, b) => ({
+ attachment: await posts.attach(req.viewer, post, { mediaId: b.media_id ?? b.mediaId, role: b.role, alt: b.alt, caption: b.caption, position: b.position }),
}), 201);
}), 201);
- write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), (req, post) => ({ removed: posts.detach(req.viewer, post, req.params.aid) }));
+ write('delete', '/posts/:id/attachments/:aid', guard('blog.post.update'), B('blog.post.media'), async (req, post) => ({ removed: await posts.detach(req.viewer, post, req.params.aid) }));
- function readable(req) {
- const { post, blog } = mustPost(req);
- if (!(access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');
+ async function readable(req) {
+ const { post, blog } = await mustPost(req);
+ if (!(await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer))) throw new ApiError(404, 'post.not_found', 'No such post');
return post;
return post;
}
}
- router.get('/posts/:id/revisions', guard('blog.post.read'), run((req) => {
- const post = readable(req);
- return { revisions: posts.revisions(post, { limit: req.query.limit, before: req.query.before }).map(({ content, ...r }) => ({ ...r, length: content.length })) };
+ router.get('/posts/:id/revisions', guard('blog.post.read'), run(async (req) => {
+ const post = await readable(req);
+ return { revisions: (await posts.revisions(post, { limit: req.query.limit, before: req.query.before })).map(({ content, ...r }) => ({ ...r, length: content.length })) };
}));
}));
- router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run((req) => {
- const post = readable(req);
- const rev = posts.revision(post, parseInt(req.params.n, 10));
+ router.get('/posts/:id/revisions/:n', guard('blog.post.read'), run(async (req) => {
+ const post = await readable(req);
+ const rev = await posts.revision(post, parseInt(req.params.n, 10));
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
if (!rev) throw new ApiError(404, 'revision.not_found', 'No such revision');
- return { revision: rev, citations: posts.citations(post, rev.number) };
+ return { revision: rev, citations: await posts.citations(post, rev.number) };
}));
}));
- router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run((req) => {
- const post = readable(req);
- return { diff: posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
+ router.get('/posts/:id/diff', guard('blog.post.read'), B('blog.post.diff'), run(async (req) => {
+ const post = await readable(req);
+ return { diff: await posts.diff(post, parseInt(req.query.from, 10), parseInt(req.query.to, 10), req.query.mode) };
}));
}));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
@@ -22,16 +22,22 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -22,16 +22,22 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
const { db } = store;
const { db } = store;
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
const abs = (p) => seo.canonicalUrl(config.baseUrl, p);
- const publicPosts = () => db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
+ const publicPosts = async () => await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
WHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'
WHERE p.state = 'published' AND p.visibility = 'public' AND b.status = 'active'
ORDER BY p.published_at DESC LIMIT 50000`).all();
ORDER BY p.published_at DESC LIMIT 50000`).all();
- function postEntries() {
- return publicPosts().map((post) => {
- const blog = blogs.get(post.blog_id);
- const rev = store.revisions.get(post.id, post.published_revision);
- return { post, blog, rev, decision: publication.decide(blog, post, rev) };
- });
+ async function postEntries() {
+ // Revisions in one query and each blog once, whatever the number of posts (no N+1).
+ const rows = await publicPosts();
+ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
+ const blogById = new Map();
+ for (const id of new Set(rows.map((p) => p.blog_id))) blogById.set(id, await blogs.get(id));
+ const out = [];
+ for (const [i, post] of rows.entries()) {
+ const blog = blogById.get(post.blog_id);
+ out.push({ post, blog, rev: revs[i], decision: await publication.decide(blog, post, revs[i]) });
+ }
+ return out;
}
}
const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
const xml = (res, body) => res.type('application/xml').set('Cache-Control', 'public, max-age=300').send(body);
@@ -46,8 +52,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -46,8 +52,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(body);
});
});
- router.get('/llms.txt', (_req, res) => {
- const official = blogs.official();
+ router.get('/llms.txt', async (_req, res) => {
+ const official = await blogs.official();
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(sharedSeo.llmsTxt({
name: 'OpenVibe.Blog',
name: 'OpenVibe.Blog',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
@@ -66,8 +72,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -66,8 +72,8 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
}));
}));
});
});
- router.get('/sitemap.xml', (_req, res) => {
- const entries = postEntries().filter((e) => e.decision.indexable);
+ router.get('/sitemap.xml', async (_req, res) => {
+ const entries = (await postEntries()).filter((e) => e.decision.indexable);
const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
xml(res, seo.sitemapIndex([
xml(res, seo.sitemapIndex([
{ loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },
{ loc: abs('/sitemaps/posts.xml'), ...(newest ? { lastmod: newest } : {}) },
@@ -75,15 +81,15 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -75,15 +81,15 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
]));
]));
});
});
- router.get('/sitemaps/posts.xml', (_req, res) => {
- const out = seo.sitemap(postEntries().map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));
+ router.get('/sitemaps/posts.xml', async (_req, res) => {
+ const out = seo.sitemap((await postEntries()).map((e) => ({ loc: publication.postUrl(e.blog, e.post), lastmod: e.rev.createdAt, decision: e.decision })));
xml(res, out.files[0]);
xml(res, out.files[0]);
});
});
- router.get('/sitemaps/blogs.xml', (_req, res) => {
+ router.get('/sitemaps/blogs.xml', async (_req, res) => {
const byBlog = new Map();
const byBlog = new Map();
const bySeries = new Map();
const bySeries = new Map();
- for (const e of postEntries()) {
+ for (const e of await postEntries()) {
if (!e.decision.indexable) continue;
if (!e.decision.indexable) continue;
const t = e.rev.createdAt;
const t = e.rev.createdAt;
if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });
if (!byBlog.has(e.blog.id) || byBlog.get(e.blog.id).lastmod < t) byBlog.set(e.blog.id, { blog: e.blog, lastmod: t });
@@ -96,7 +102,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -96,7 +102,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
}
}
for (const [id, { blog, lastmod }] of bySeries) {
for (const [id, { blog, lastmod }] of bySeries) {
- const s = blogs.seriesById(id);
+ const s = await blogs.seriesById(id);
if (!s) continue;
if (!s) continue;
const path = reading.urls.series(blog, s);
const path = reading.urls.series(blog, s);
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
entries.push({ loc: abs(path), lastmod, decision: listing(path) });
@@ -85,17 +85,17 @@ function createEditorRoutes(ctx) {
@@ -85,17 +85,17 @@ function createEditorRoutes(ctx) {
const csrf = (req) => csrfToken(config, req.viewer);
const csrf = (req) => csrfToken(config, req.viewer);
const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);
const flashOf = (req) => (req.query.saved ? { kind: 'ok', text: String(req.query.saved).slice(0, 200) } : null);
- function blogFor(req) {
- const blog = blogs.byHandle(req.params.handle);
+ async function blogFor(req) {
+ const blog = await blogs.byHandle(req.params.handle);
if (!blog) return null;
if (!blog) return null;
- return access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;
+ return await access.effectiveRole(store, req.viewer, blog) || access.isStaff(req.viewer) ? blog : null;
}
}
- function postFor(req) {
- const post = posts.get(req.params.id);
+ async function postFor(req) {
+ const post = await posts.get(req.params.id);
if (!post || post.state === 'deleted') return null;
if (!post || post.state === 'deleted') return null;
- const blog = posts.blogOf(post);
- if (!access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;
+ const blog = await posts.blogOf(post);
+ if (!await access.canWrite(store, req.viewer, blog, 'edit', post) && !access.isStaff(req.viewer)) return null;
return { post, blog };
return { post, blog };
}
}
@@ -104,176 +104,176 @@ function createEditorRoutes(ctx) {
@@ -104,176 +104,176 @@ function createEditorRoutes(ctx) {
// ── Home, first use ─────────────────────────────────────
// ── Home, first use ─────────────────────────────────────
router.get('/', wrap(async (req, res) => {
router.get('/', wrap(async (req, res) => {
- const mine = blogs.blogsOf(req.viewer.subject);
- const official = blogs.official();
+ const mine = await blogs.blogsOf(req.viewer.subject);
+ const official = await blogs.official();
if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
}));
}));
router.post('/start', wrap(async (req, res) => {
router.post('/start', wrap(async (req, res) => {
const b = formInput(req.body);
const b = formInput(req.body);
- const { blog } = blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });
+ const { blog } = await blogs.ensureMemberBlog({ subject: req.viewer.subject, username: req.viewer.user && req.viewer.user.username, handle: b.handle, title: b.title });
res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);
res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent('Your blog is ready.')}`);
}));
}));
// ── A blog ──────────────────────────────────────────────
// ── A blog ──────────────────────────────────────────────
router.get('/@:handle', wrap(async (req, res) => {
router.get('/@:handle', wrap(async (req, res) => {
- const blog = blogFor(req);
+ const blog = await blogFor(req);
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
- const list = posts.listForDashboard(blog, req.viewer);
- const heads = new Map(list.map((p) => [p.id, (posts.head(p) || { fields: {} }).fields]));
- page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
+ const list = await posts.listForDashboard(blog, req.viewer);
+ const heads = new Map(await Promise.all(list.map(async (p) => [p.id, ((await posts.head(p)) || { fields: {} }).fields])));
+ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: await access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
}));
}));
router.get('/@:handle/new', wrap(async (req, res) => {
router.get('/@:handle/new', wrap(async (req, res) => {
- const blog = blogFor(req);
- if (!blog || !access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
+ const blog = await blogFor(req);
+ if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';
const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';
page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
}));
}));
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
router.post('/@:handle/ai-draft', wrap(async (req, res) => {
router.post('/@:handle/ai-draft', wrap(async (req, res) => {
- const blog = blogFor(req);
+ const blog = await blogFor(req);
if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
if (!blog || !ctx.aiDrafts || !ctx.aiDrafts.enabled) return notFound(req, res);
const b = formInput(req.body);
const b = formInput(req.body);
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, { topic: b.topic, brief: b.brief, tone: b.tone, audience: b.audience }, { traceparent: req.ov && req.ov.traceparent });
const { post, revision } = await ctx.aiDrafts.draft(req.viewer, blog, { topic: b.topic, brief: b.brief, tone: b.tone, audience: b.audience }, { traceparent: req.ov && req.ov.traceparent });
- effects.after(null, post, req.ov);
+ await effects.after(null, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(`AI draft saved (revision ${revision.number}). It stays a draft, marked as AI-written, until you review and publish it.`)}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(`AI draft saved (revision ${revision.number}). It stays a draft, marked as AI-written, until you review and publish it.`)}`);
}));
}));
router.post('/@:handle/new', wrap(async (req, res) => {
router.post('/@:handle/new', wrap(async (req, res) => {
- const blog = blogFor(req);
+ const blog = await blogFor(req);
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
- const { post } = posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
- effects.after(null, post, req.ov);
+ const { post } = await posts.create(req.viewer, blog, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
+ await effects.after(null, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent('Draft saved (revision 1).')}`);
}));
}));
router.get('/@:handle/settings', wrap(async (req, res) => {
router.get('/@:handle/settings', wrap(async (req, res) => {
- const blog = blogFor(req);
- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
- const members = blogs.members(blog);
+ const blog = await blogFor(req);
+ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
+ const members = await blogs.members(blog);
const who = await people.many(members.map((m) => m.subject));
const who = await people.many(members.map((m) => m.subject));
- page(req, res, 'Settings', editor.settingsPage({ blog, feed: blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));
+ page(req, res, 'Settings', editor.settingsPage({ blog, feed: await blogs.feedSettings(blog), members, people: who, themes: Object.values(blogs.THEMES), csrf: csrf(req), message: flashOf(req) }));
}));
}));
router.post('/@:handle/settings', wrap(async (req, res) => {
router.post('/@:handle/settings', wrap(async (req, res) => {
- const blog = blogFor(req);
- if (!blog || !access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
+ const blog = await blogFor(req);
+ if (!blog || !await access.canWrite(store, req.viewer, blog, 'configure')) return notFound(req, res);
const b = formInput(req.body);
const b = formInput(req.body);
- store.tx(() => {
- const updated = blogs.update(blog, { title: b.title, description: b.description, language: b.language });
- if (b.theme) blogs.setTheme(updated, b.theme);
- blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });
+ await store.tx(async () => {
+ const updated = await blogs.update(blog, { title: b.title, description: b.description, language: b.language });
+ if (b.theme) await blogs.setTheme(updated, b.theme);
+ await blogs.setFeedSettings(updated, { rss: b.rss === '1', atom: b.atom === '1', json: b.json === '1', itemCount: b.itemCount, fullContent: b.fullContent === '1' });
});
});
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
}));
}));
router.post('/@:handle/members', wrap(async (req, res) => {
router.post('/@:handle/members', wrap(async (req, res) => {
- const blog = blogFor(req);
- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
+ const blog = await blogFor(req);
+ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
const b = formInput(req.body);
const b = formInput(req.body);
const who = String(b.member || '').trim().replace(/^@/, '');
const who = String(b.member || '').trim().replace(/^@/, '');
- const subject = ids.isSubjectId('user', who) ? who : (people.byUsername(who) || {}).subject;
+ const subject = ids.isSubjectId('user', who) ? who : (await people.byUsername(who) || {}).subject;
if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });
if (!subject) return publicRoutes.messagePage(req, res, 404, 'Member not found', `Nobody called "${who}" has signed in to OpenVibe.Blog yet. Ask them to sign in once, or add them by their usr_ subject id.`, { href: `/write/@${blog.handle}/settings`, label: 'Back' });
- blogs.setMember(blog, subject, b.role, req.viewer.subject);
+ await blogs.setMember(blog, subject, b.role, req.viewer.subject);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member saved.')}`);
}));
}));
router.post('/@:handle/members/remove', wrap(async (req, res) => {
router.post('/@:handle/members/remove', wrap(async (req, res) => {
- const blog = blogFor(req);
- if (!blog || !access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
- blogs.removeMember(blog, formInput(req.body).subject);
+ const blog = await blogFor(req);
+ if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
+ await blogs.removeMember(blog, formInput(req.body).subject);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Member removed.')}`);
}));
}));
// ── A post ──────────────────────────────────────────────
// ── A post ──────────────────────────────────────────────
router.get('/posts/:id', wrap(async (req, res) => {
router.get('/posts/:id', wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found) return notFound(req, res);
if (!found) return notFound(req, res);
const { post, blog } = found;
const { post, blog } = found;
- const head = posts.head(post);
- const published = post.published_revision ? posts.revision(post, post.published_revision) : null;
+ const head = await posts.head(post);
+ const published = post.published_revision ? await posts.revision(post, post.published_revision) : null;
const rec = publication.authorshipOf(head);
const rec = publication.authorshipOf(head);
- const review = publication.reviewOf(post, head);
- const terms = publication.termsOf(post);
- const decision = publication.decide(blog, { ...post, state: 'published' }, head);
+ const review = await publication.reviewOf(post, head);
+ const terms = await publication.termsOf(post);
+ const decision = await publication.decide(blog, { ...post, state: 'published' }, head);
const body = editor.editPage({
const body = editor.editPage({
- blog, post, head, published, decision, jobs: posts.pendingJobs(post), attachments: posts.attachments(post), csrf: csrf(req),
+ blog, post, head, published, decision, jobs: await posts.pendingJobs(post), attachments: await posts.attachments(post), csrf: csrf(req),
disclosure: rec ? authorship.disclosure(rec, review) : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
needsReview: rec ? !authorship.canPublish(rec, review).ok : false,
publicUrl: publication.postPath(blog, post),
publicUrl: publication.postPath(blog, post),
- canDelete: access.canWrite(store, req.viewer, blog, 'delete', post),
- form: editor.postForm({ blog, post, head, terms, series: blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
+ canDelete: await access.canWrite(store, req.viewer, blog, 'delete', post),
+ form: editor.postForm({ blog, post, head, terms, series: await blogs.seriesById(post.series_id), csrf: csrf(req), message: flashOf(req), action: `/write/posts/${post.id}` }),
});
});
page(req, res, `Edit: ${head.fields.title}`, body);
page(req, res, `Edit: ${head.fields.title}`, body);
}));
}));
router.post('/posts/:id', wrap(async (req, res) => {
router.post('/posts/:id', wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found) return notFound(req, res);
if (!found) return notFound(req, res);
const before = { ...found.post };
const before = { ...found.post };
- const { post, revision, created } = posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
- effects.after(before, post, req.ov);
+ const { post, revision, created } = await posts.update(req.viewer, found.post, formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
+ await effects.after(before, post, req.ov);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(created ? `Saved as revision ${revision.number}.` : 'Saved (the text did not change, so no new revision).')}`);
}));
}));
const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {
const action = (name, fn, message) => router.post(`/posts/:id/${name}`, wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);
if (!found && name !== 'unpublish' && name !== 'delete') return notFound(req, res);
- const post = found ? found.post : posts.get(req.params.id);
+ const post = found ? found.post : await posts.get(req.params.id);
if (!post || post.state === 'deleted') return notFound(req, res);
if (!post || post.state === 'deleted') return notFound(req, res);
const before = { ...post };
const before = { ...post };
const out = await fn(req, post, formInput(req.body));
const out = await fn(req, post, formInput(req.body));
- const after = posts.get(post.id);
- effects.after(before, after, req.ov);
- if (name === 'delete') return res.redirect(303, `/write/@${posts.blogOf(post).handle}?saved=${encodeURIComponent('Post deleted.')}`);
+ const after = await posts.get(post.id);
+ await effects.after(before, after, req.ov);
+ if (name === 'delete') return res.redirect(303, `/write/@${(await posts.blogOf(post)).handle}?saved=${encodeURIComponent('Post deleted.')}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);
res.redirect(303, `/write/posts/${post.id}?saved=${encodeURIComponent(typeof message === 'function' ? message(out) : message)}`);
}));
}));
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
- action('publish', (req, post, b) => posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));
- action('schedule', (req, post, b) => {
+ action('publish', async (req, post, b) => await posts.publish(req.viewer, post, { revision: b.revision }, tp(req)), (out) => (out.changed ? 'Published.' : 'That revision was already published.'));
+ action('schedule', async (req, post, b) => {
// <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.
// <input type="datetime-local"> has no zone: the form says UTC, so it is read as UTC.
const raw = String(b.at || '');
const raw = String(b.at || '');
const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;
const at = /^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(raw) ? `${raw}:00Z` : /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?$/.test(raw) ? `${raw}Z` : raw;
- return posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });
+ return await posts.schedule(req.viewer, post, { at, revision: b.revision, action: b.action || 'publish' });
}, (out) => `Scheduled for ${out.job.runAt}.`);
}, (out) => `Scheduled for ${out.job.runAt}.`);
- action('unschedule', (req, post) => posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');
- action('unpublish', (req, post) => posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');
- action('delete', (req, post, b) => {
+ action('unschedule', async (req, post) => await posts.cancelSchedule(req.viewer, post), 'Schedule cancelled.');
+ action('unpublish', async (req, post) => await posts.unpublish(req.viewer, post, tp(req)), 'Unpublished.');
+ action('delete', async (req, post, b) => {
if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });
if (b.confirm !== '1') throw Object.assign(new Error('Tick the box to confirm the deletion.'), { name: 'PublishingError', status: 422, code: 'post.confirm_required' });
- return posts.remove(req.viewer, post, tp(req));
+ return await posts.remove(req.viewer, post, tp(req));
}, 'Deleted.');
}, 'Deleted.');
- action('review', (req, post, b) => posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
- action('revert', (req, post, b) => posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
- action('media', (req, post, b) => posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
+ action('review', async (req, post, b) => await posts.review(req.viewer, post, { revision: b.revision, decision: b.decision, note: b.note || null }, tp(req)), (out) => `Review recorded: ${out.decision}.`);
+ action('revert', async (req, post, b) => await posts.revert(req.viewer, post, { toRevision: b.toRevision, expectedRevision: b.expectedRevision }), (out) => `Restored as revision ${out.revision.number}.`);
+ action('media', async (req, post, b) => await posts.attach(req.viewer, post, { mediaId: b.mediaId, role: b.role, alt: b.alt, caption: b.caption }), 'Media attached.');
router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {
router.post('/posts/:id/media/:aid/remove', wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found) return notFound(req, res);
if (!found) return notFound(req, res);
- posts.detach(req.viewer, found.post, req.params.aid);
+ await posts.detach(req.viewer, found.post, req.params.aid);
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
}));
}));
router.get('/posts/:id/revisions', wrap(async (req, res) => {
router.get('/posts/:id/revisions', wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found) return notFound(req, res);
if (!found) return notFound(req, res);
const { post } = found;
const { post } = found;
- const head = posts.head(post);
+ const head = await posts.head(post);
const from = parseInt(req.query.from, 10);
const from = parseInt(req.query.from, 10);
const to = parseInt(req.query.to, 10);
const to = parseInt(req.query.to, 10);
- const diff = Number.isInteger(from) && Number.isInteger(to) ? posts.diff(post, from, to, 'word') : null;
- page(req, res, 'History', editor.revisionsPage({ post, head, revisions: posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));
+ const diff = Number.isInteger(from) && Number.isInteger(to) ? await posts.diff(post, from, to, 'word') : null;
+ page(req, res, 'History', editor.revisionsPage({ post, head, revisions: await posts.revisions(post, { limit: 200 }), diff, from, to, csrf: csrf(req) }));
}));
}));
router.get('/posts/:id/preview', wrap(async (req, res) => {
router.get('/posts/:id/preview', wrap(async (req, res) => {
- const found = postFor(req);
+ const found = await postFor(req);
if (!found) return notFound(req, res);
if (!found) return notFound(req, res);
const n = parseInt(req.query.revision, 10);
const n = parseInt(req.query.revision, 10);
- const rev = Number.isInteger(n) ? posts.revision(found.post, n) : posts.head(found.post);
+ const rev = Number.isInteger(n) ? await posts.revision(found.post, n) : await posts.head(found.post);
if (!rev) return notFound(req, res);
if (!rev) return notFound(req, res);
return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });
return publicRoutes.renderPost(req, res, { blog: found.blog, post: found.post, rev, preview: true });
}));
}));
@@ -61,15 +61,15 @@ function createPublicRoutes(ctx) {
@@ -61,15 +61,15 @@ function createPublicRoutes(ctx) {
}
}
const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });
const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });
- const isMember = (blog, viewer) => Boolean(access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
+ const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
function pageNumber(req) {
function pageNumber(req) {
const n = parseInt(req.query.page, 10);
const n = parseInt(req.query.page, 10);
return Number.isInteger(n) && n > 0 ? n : 1;
return Number.isInteger(n) && n > 0 ? n : 1;
}
}
- function activeBlog(req) {
- const blog = blogs.byHandle(req.params.handle);
+ async function activeBlog(req) {
+ const blog = await blogs.byHandle(req.params.handle);
if (!blog) return null;
if (!blog) return null;
if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;
if (blog.status !== 'active' && !access.isStaff(req.viewer)) return null;
return blog;
return blog;
@@ -81,19 +81,27 @@ function createPublicRoutes(ctx) {
@@ -81,19 +81,27 @@ function createPublicRoutes(ctx) {
async function blogFront(req, res, blog, { home = false } = {}) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
const page = pageNumber(req);
- const restricted = isMember(blog, req.viewer);
+ const restricted = await isMember(blog, req.viewer);
const path = publication.blogPath(blog);
const path = publication.blogPath(blog);
- const { total, posts: rows } = posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
+ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
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);
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows);
const items = await reading.listItems(rows);
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
- const feeds = reading.feedsOf(blog);
+ const feeds = await reading.feedsOf(blog);
// Series and categories are named when a draft or restricted post uses them: list only those
// Series and categories are named when a draft or restricted post uses them: list only those
// holding a post this viewer may see in the list, so their names never leak ahead of a post.
// holding a post this viewer may see in the list, so their names never leak ahead of a post.
- const shows = (filter) => posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 }).total > 0;
- const prune = (nodes) => nodes.map((n) => ({ ...n, children: prune(n.children || []) }))
- .filter((n) => n.children.length || shows({ termIds: [n.id] }));
+ const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 })).total > 0;
+ const prune = async (nodes) => {
+ const out = [];
+ for (const n of nodes) {
+ const children = await prune(n.children || []);
+ if (children.length || await shows({ termIds: [n.id] })) out.push({ ...n, children });
+ }
+ return out;
+ };
+ const seriesShown = [];
+ for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);
send(req, res, 200, {
send(req, res, 200, {
title: blog.title,
title: blog.title,
description: blog.description,
description: blog.description,
@@ -109,13 +117,13 @@ function createPublicRoutes(ctx) {
@@ -109,13 +117,13 @@ function createPublicRoutes(ctx) {
: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
body: pages.blogIndex({
body: pages.blogIndex({
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
- series: blogs.series(blog).filter((sr) => shows({ seriesId: sr.id })), categories: prune(reading.categoriesTree(blog)),
- canWrite: access.canWrite(store, req.viewer, blog, 'create'),
+ series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
+ canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
}, { cacheable: !restricted });
}, { cacheable: !restricted });
}
}
- router.get('/', wrap(async (req, res) => blogFront(req, res, blogs.official(), { home: true })));
+ router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
// What shipped on OpenVibe.Blog: the shared update log (openvibe-shared shipped.js, fed by the
// What shipped on OpenVibe.Blog: the shared update log (openvibe-shared shipped.js, fed by the
// network changelog this service keeps), like every OpenVibe site's /updates.
// network changelog this service keeps), like every OpenVibe site's /updates.
@@ -128,16 +136,16 @@ function createPublicRoutes(ctx) {
@@ -128,16 +136,16 @@ function createPublicRoutes(ctx) {
}, { cacheable: true })));
}, { cacheable: true })));
router.get('/@:handle', wrap(async (req, res) => {
router.get('/@:handle', wrap(async (req, res) => {
- const blog = activeBlog(req);
+ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
if (blog.kind === 'official') return res.redirect(301, '/');
if (blog.kind === 'official') return res.redirect(301, '/');
- return blogFront(req, res, blog);
+ return await blogFront(req, res, blog);
}));
}));
// ── Feeds (public posts only, never viewer-dependent) ───
// ── Feeds (public posts only, never viewer-dependent) ───
async function feed(req, res, blog, kind) {
async function feed(req, res, blog, kind) {
- const s = blogs.feedSettings(blog);
+ const s = await blogs.feedSettings(blog);
if (!s[kind]) return notFound(req, res);
if (!s[kind]) return notFound(req, res);
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const items = await reading.feedItems(blog, { limit: s.item_count, fullContent: Boolean(s.full_content) });
const link = publication.abs(publication.blogPath(blog));
const link = publication.abs(publication.blogPath(blog));
@@ -152,12 +160,12 @@ function createPublicRoutes(ctx) {
@@ -152,12 +160,12 @@ function createPublicRoutes(ctx) {
}
}
const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };
const FEEDS = { 'feed.xml': 'rss', 'atom.xml': 'atom', 'feed.json': 'json' };
for (const [file, kind] of Object.entries(FEEDS)) {
for (const [file, kind] of Object.entries(FEEDS)) {
- router.get(`/${file}`, wrap((req, res) => feed(req, res, blogs.official(), kind)));
- router.get(`/@:handle/${file}`, wrap((req, res) => {
- const blog = activeBlog(req);
+ router.get(`/${file}`, wrap(async (req, res) => await feed(req, res, await blogs.official(), kind)));
+ router.get(`/@:handle/${file}`, wrap(async (req, res) => {
+ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
if (blog.kind === 'official') return res.redirect(301, `/${file}`);
if (blog.kind === 'official') return res.redirect(301, `/${file}`);
- return feed(req, res, blog, kind);
+ return await feed(req, res, blog, kind);
}));
}));
}
}
@@ -165,8 +173,8 @@ function createPublicRoutes(ctx) {
@@ -165,8 +173,8 @@ function createPublicRoutes(ctx) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
const page = pageNumber(req);
const page = pageNumber(req);
- const restricted = blog ? isMember(blog, req.viewer) : false;
- const { total, posts: rows } = posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
+ const restricted = blog ? await isMember(blog, req.viewer) : false;
+ const { total, posts: rows } = await posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
// A series or category named only by drafts or posts this viewer cannot see does not exist for them.
// A series or category named only by drafts or posts this viewer cannot see does not exist for them.
if (hideEmpty && !total) return notFound(req, res);
if (hideEmpty && !total) return notFound(req, res);
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
@@ -186,56 +194,56 @@ function createPublicRoutes(ctx) {
@@ -186,56 +194,56 @@ function createPublicRoutes(ctx) {
const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
router.get('/@:handle/tags/:tag', wrap(async (req, res) => {
router.get('/@:handle/tags/:tag', wrap(async (req, res) => {
- const blog = activeBlog(req);
- const tag = blog && store.taxonomy.bySlug('tag', req.params.tag);
+ const blog = await activeBlog(req);
+ const tag = blog && await store.taxonomy.bySlug('tag', req.params.tag);
if (!tag) return notFound(req, res);
if (!tag) return notFound(req, res);
- return collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),
+ return await collection(req, res, { blog, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} on ${blog.title}.`, path: reading.urls.tag(tag, blog),
crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
crumbs: [blogCrumb(blog), { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
}));
}));
router.get('/tags/:tag', wrap(async (req, res) => {
router.get('/tags/:tag', wrap(async (req, res) => {
- const tag = store.taxonomy.bySlug('tag', req.params.tag);
+ const tag = await store.taxonomy.bySlug('tag', req.params.tag);
if (!tag) return notFound(req, res);
if (!tag) return notFound(req, res);
- return collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),
+ return await collection(req, res, { blog: null, heading: `#${tag.name}`, intro: `Posts tagged ${tag.name} across every blog on openvibe.blog.`, path: reading.urls.tag(tag),
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: `#${tag.name}` }], filter: { termIds: [tag.id] }, empty: 'No public posts with this tag yet.' });
}));
}));
router.get('/@:handle/categories/:category', wrap(async (req, res) => {
router.get('/@:handle/categories/:category', wrap(async (req, res) => {
- const blog = activeBlog(req);
- const term = blog && store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);
+ const blog = await activeBlog(req);
+ const term = blog && await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), req.params.category);
if (!term) return notFound(req, res);
if (!term) return notFound(req, res);
- const trail = store.taxonomy.ancestors(term.id);
- const ids = [term.id, ...store.taxonomy.descendants(term.id).map((t) => t.id)];
- return collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
+ const trail = await store.taxonomy.ancestors(term.id);
+ const ids = [term.id, ...(await store.taxonomy.descendants(term.id)).map((t) => t.id)];
+ return await collection(req, res, { blog, heading: term.name, intro: `Posts in ${trail.map((t) => t.name).join(' › ')}.`, path: reading.urls.category(blog, term),
crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],
crumbs: [blogCrumb(blog), ...trail.map((t, i) => (i === trail.length - 1 ? { name: t.name } : { name: t.name, url: reading.urls.category(blog, t) }))],
filter: { termIds: ids }, empty: 'No public posts in this category yet.', hideEmpty: true });
filter: { termIds: ids }, empty: 'No public posts in this category yet.', hideEmpty: true });
}));
}));
router.get('/@:handle/series/:series', wrap(async (req, res) => {
router.get('/@:handle/series/:series', wrap(async (req, res) => {
- const blog = activeBlog(req);
- const series = blog && blogs.seriesBySlug(blog, req.params.series);
+ const blog = await activeBlog(req);
+ const series = blog && await blogs.seriesBySlug(blog, req.params.series);
if (!series) return notFound(req, res);
if (!series) return notFound(req, res);
- return collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
+ return await collection(req, res, { blog, heading: series.title, intro: series.description || `A series on ${blog.title}.`, path: reading.urls.series(blog, series),
crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.', hideEmpty: true });
crumbs: [blogCrumb(blog), { name: series.title }], filter: { seriesId: series.id }, order: 'series', empty: 'No public parts of this series yet.', hideEmpty: true });
}));
}));
async function personFor(who) {
async function personFor(who) {
- if (/^usr_/.test(who)) return people.one(who);
- return people.byUsername(who);
+ if (/^usr_/.test(who)) return await people.one(who);
+ return await people.byUsername(who);
}
}
router.get('/@:handle/authors/:who', wrap(async (req, res) => {
router.get('/@:handle/authors/:who', wrap(async (req, res) => {
- const blog = activeBlog(req);
+ const blog = await activeBlog(req);
const person = blog && await personFor(req.params.who);
const person = blog && await personFor(req.params.who);
- if (!person || !blogs.membership(blog, person.subject) && !posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 }).total) return notFound(req, res);
- return collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),
+ if (!person || !await blogs.membership(blog, person.subject) && !(await posts.listPublished({ blogId: blog.id, authorSubject: person.subject, limit: 1 })).total) return notFound(req, res);
+ return await collection(req, res, { blog, heading: person.name, intro: `Posts by ${person.name} on ${blog.title}.`, path: reading.urls.author(person, blog),
crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
crumbs: [blogCrumb(blog), { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
}));
}));
router.get('/authors/:who', wrap(async (req, res) => {
router.get('/authors/:who', wrap(async (req, res) => {
const person = await personFor(req.params.who);
const person = await personFor(req.params.who);
if (!person) return notFound(req, res);
if (!person) return notFound(req, res);
- return collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),
+ return await collection(req, res, { blog: null, heading: person.name, intro: `Posts by ${person.name} across openvibe.blog.`, path: reading.urls.author(person),
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
crumbs: [{ name: 'OpenVibe.Blog', url: '/' }, { name: person.name }], filter: { authorSubject: person.subject }, empty: 'No public posts by this author yet.' });
}));
}));
@@ -256,28 +264,29 @@ function createPublicRoutes(ctx) {
@@ -256,28 +264,29 @@ function createPublicRoutes(ctx) {
}
}
}
}
- function seriesView(blog, post) {
- const series = blogs.seriesById(post.series_id);
+ async function seriesView(blog, post) {
+ const series = await blogs.seriesById(post.series_id);
if (!series) return null;
if (!series) return null;
- const { posts: rows } = posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
+ const { posts: rows } = await posts.listPublished({ blogId: blog.id, seriesId: series.id, limit: 50, order: 'series' });
+ const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
return {
return {
title: series.title, url: reading.urls.series(blog, series), position: post.series_position,
title: series.title, url: reading.urls.series(blog, series), position: post.series_position,
- items: rows.map((p) => ({ title: (store.revisions.get(p.id, p.published_revision) || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
+ items: rows.map((p, i) => ({ title: (revs[i] || { fields: {} }).fields.title, url: publication.postPath(blog, p), current: p.id === post.id })),
};
};
}
}
/** Render one post (also used by the editor's preview with a chosen revision). */
/** Render one post (also used by the editor's preview with a chosen revision). */
async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {
async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {
- const decision = preview ? publication.decide(blog, post, rev, { state: 'draft' }) : publication.decide(blog, post, rev);
+ const decision = preview ? await publication.decide(blog, post, rev, { state: 'draft' }) : await publication.decide(blog, post, rev);
const person = await people.one(post.author_subject);
const person = await people.one(post.author_subject);
- const { tags, categories } = reading.termsView(post, blog);
- const attachments = store.attachments.list(post.id);
- const citations = store.citations.forRevision(post.id, rev.number);
+ const { tags, categories } = await reading.termsView(post, blog);
+ const attachments = await store.attachments.list(post.id);
+ const citations = await store.citations.forRevision(post.id, rev.number);
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
- const review = publication.reviewOf(post, rev);
+ const review = await publication.reviewOf(post, rev);
const path = publication.postPath(blog, post);
const path = publication.postPath(blog, post);
const firstCategory = categories[0];
const firstCategory = categories[0];
- const crumbs = [blogCrumb(blog), ...(firstCategory ? store.taxonomy.ancestors(firstCategory.id).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
+ const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
const comments = preview ? null : await commentsFor(req, blog, post, rev);
const comments = preview ? null : await commentsFor(req, blog, post, rev);
const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
@@ -291,17 +300,17 @@ function createPublicRoutes(ctx) {
@@ -291,17 +300,17 @@ function createPublicRoutes(ctx) {
image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),
image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),
published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
modified: post.published_revision ? rev.createdAt : null,
modified: post.published_revision ? rev.createdAt : null,
- feeds: reading.feedsOf(blog),
+ feeds: await reading.feedsOf(blog),
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
lang: blog.language,
lang: blog.language,
jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
body: pages.postPage({
body: pages.postPage({
blog, blogUrl: publication.blogPath(blog), post, rev,
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
author: { name: person.name, url: reading.urls.author(person, blog) },
- tags, categories, series: seriesView(blog, post), attachments, citations,
+ tags, categories, series: await seriesView(blog, post), attachments, citations,
disclosure: rec ? authorship.disclosure(rec, review) : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
decisionNote: preview ? `preview · ${decision.robots}` : null,
- comments, canEdit: access.canWrite(store, req.viewer, blog, 'edit', post),
+ comments, canEdit: await access.canWrite(store, req.viewer, blog, 'edit', post),
urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,
urlFor: media.urlFor, breadcrumbs: crumbs, jsonUrl: `${path}.json`,
postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
@@ -330,22 +339,22 @@ function createPublicRoutes(ctx) {
@@ -330,22 +339,22 @@ function createPublicRoutes(ctx) {
/** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */
/** Resolve /@handle/:slug to a readable post, or answer (301 / 404 / 403 / 410) and return null. */
async function resolvePost(req, res, blog, slug) {
async function resolvePost(req, res, blog, slug) {
- const post = posts.bySlug(blog, slug);
+ const post = await posts.bySlug(blog, slug);
if (!post) {
if (!post) {
const path = `/@${blog.handle}/${slug}`;
const path = `/@${blog.handle}/${slug}`;
- const r = store.redirects.resolve(path, {
- currentPath: (id) => { const p = posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(blogs.get(p.blog_id), p) : null; },
+ const r = await store.redirects.resolve(path, {
+ currentPath: async (id) => { const p = await posts.get(id); return p && p.state !== 'deleted' ? publication.postPath(await blogs.get(p.blog_id), p) : null; },
});
});
if (r && r.status === 301) {
if (r && r.status === 301) {
- const target = posts.get(r.entityId);
- const decision = await access.canReadPost(store, req.viewer, blogs.get(target.blog_id), target, entitlements);
+ const target = await posts.get(r.entityId);
+ const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements);
if (decision.allowed) {
if (decision.allowed) {
cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
return null;
return null;
}
}
}
}
- if ((r && r.status === 410) || posts.deletedBySlug(blog, slug)) {
+ if ((r && r.status === 410) || await posts.deletedBySlug(blog, slug)) {
messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });
messagePage(req, res, 410, 'Gone', 'This post was deleted.', { href: publication.blogPath(blog), label: `More from ${blog.title}` });
return null;
return null;
}
}
@@ -362,27 +371,27 @@ function createPublicRoutes(ctx) {
@@ -362,27 +371,27 @@ function createPublicRoutes(ctx) {
}
}
router.get('/@:handle/:slug', wrap(async (req, res) => {
router.get('/@:handle/:slug', wrap(async (req, res) => {
- const blog = activeBlog(req);
+ const blog = await activeBlog(req);
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
const asJson = req.params.slug.endsWith('.json');
const asJson = req.params.slug.endsWith('.json');
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const post = await resolvePost(req, res, blog, slug);
const post = await resolvePost(req, res, blog, slug);
if (!post) return;
if (!post) return;
// Readers see the published revision; members looking at an unpublished post see its head.
// Readers see the published revision; members looking at an unpublished post see its head.
- const rev = post.state === 'published' ? store.revisions.get(post.id, post.published_revision) : store.revisions.head(post.id);
+ const rev = post.state === 'published' ? await store.revisions.get(post.id, post.published_revision) : await store.revisions.head(post.id);
if (asJson) {
if (asJson) {
- const decision = publication.decide(blog, post, rev);
+ const decision = await publication.decide(blog, post, rev);
cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });
cacheHeaders(res, { cacheable: post.state === 'published' && post.visibility === 'public' && req.viewer.kind === 'anonymous', robots: decision.robots });
- return res.json(reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));
+ return res.json(await reading.postJson({ blog, post, rev, person: await people.one(post.author_subject), decision }));
}
}
- return renderPost(req, res, { blog, post, rev });
+ return await renderPost(req, res, { blog, post, rev });
}));
}));
// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
// the person's name.
// the person's name.
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
- const blog = activeBlog(req);
- const post = blog && posts.bySlug(blog, req.params.slug);
+ const blog = await activeBlog(req);
+ const post = blog && await posts.bySlug(blog, req.params.slug);
if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
@@ -390,7 +399,7 @@ function createPublicRoutes(ctx) {
@@ -390,7 +399,7 @@ function createPublicRoutes(ctx) {
const message = String((req.body && req.body.message) || '').trim();
const message = String((req.body && req.body.message) || '').trim();
if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);
if (!message) return res.redirect(303, `${publication.postPath(blog, post)}#comments`);
try {
try {
- const rev = store.revisions.get(post.id, post.published_revision);
+ const rev = await store.revisions.get(post.id, post.published_revision);
const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);
const threadId = await community.threadFor(post, rev && rev.fields.title, req.ov);
await community.comment(threadId, req.viewer.subject, { message }, req.ov);
await community.comment(threadId, req.viewer.subject, { message }, req.ov);
} catch (err) {
} catch (err) {
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
-const { app, ctx } = createApp();
+(async () => {
+const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Blog] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
console.log(`[Blog] events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}; worker ${config.worker.enabled ? 'on' : 'off'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -26,10 +27,11 @@ function shutdown(signal) {
@@ -26,10 +27,11 @@ function shutdown(signal) {
ctx.changelog.stop();
ctx.changelog.stop();
server.close(async () => {
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.store.close(); } catch { /* already closed */ }
+ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
process.exit(0);
});
});
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[Blog] failed to start:', err); process.exit(1); });
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createBlogReadiness({ store, auth, outbox, release = null }) {
+function createBlogReadiness({ store, auth, outbox, valkey = null, release = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'blog',
service: 'blog',
@@ -22,12 +22,16 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
@@ -22,12 +22,16 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
checks: [
checks: [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
+ check: async () => {
+ // A real round trip that names the store (postgresql / pglite), and the charter tables and views present.
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ const names = new Set((await db.prepare('SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()').all()).map((x) => x.name));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
const missing = CHARTER_TABLES.filter((t) => !names.has(t));
- return missing.length ? `missing ${missing.join(', ')}` : true;
+ return missing.length ? `missing ${missing.join(', ')} (migrations did not run)` : { ok: true, detail: r.detail };
},
},
},
},
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -38,8 +42,8 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
@@ -38,8 +42,8 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
},
},
{
{
name: 'events_relay', required: false,
name: 'events_relay', required: false,
- check: () => {
- const s = outbox.status();
+ check: async () => {
+ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
return { ok: true, detail: { pending: s.pending } };
@@ -47,9 +51,9 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
@@ -47,9 +51,9 @@ function createBlogReadiness({ store, auth, outbox, release = null }) {
},
},
{
{
name: 'scheduler', required: false,
name: 'scheduler', required: false,
- check: () => {
- const failed = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000).n;
- const overdue = db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000).n;
+ check: async () => {
+ const failed = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'failed' AND updated_at > ?").get(store.now() - 24 * 3600 * 1000)).n;
+ const overdue = (await db.prepare("SELECT COUNT(*) AS n FROM blog_schedule_jobs WHERE status = 'pending' AND run_at < ?").get(store.now() - 5 * 60 * 1000)).n;
if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;
if (failed || overdue) return `${failed} failed in the last 24 h, ${overdue} overdue`;
return true;
return true;
},
},
@@ -48,19 +48,19 @@ function composeBody(facts) {
@@ -48,19 +48,19 @@ function composeBody(facts) {
* Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;
* Create the draft (idempotent by slug). opts: author (usr_ subject accountable for the post;
* default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).
* default the first official owner), reviewer (usr_), publish (bool), facts (parsed file).
*/
*/
-function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {
+async function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts = loadFacts() } = {}) {
const { blogs, posts, store } = ctx;
const { blogs, posts, store } = ctx;
- const blog = blogs.ensureOfficial();
- const owners = blogs.members(blog).filter((m) => m.role === 'owner').map((m) => m.subject);
+ const blog = await blogs.ensureOfficial();
+ const owners = (await blogs.members(blog)).filter((m) => m.role === 'owner').map((m) => m.subject);
const subject = author || owners[0];
const subject = author || owners[0];
if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };
const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };
- let post = posts.bySlug(blog, SLUG);
+ let post = await posts.bySlug(blog, SLUG);
let created = false;
let created = false;
if (!post) {
if (!post) {
- const out = posts.create(actor, blog, {
+ const out = await posts.create(actor, blog, {
title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,
title: `Released in the OpenVibe repositories, ${facts.week.from.slice(8)}–${facts.week.to.slice(8)} September 2026`,
slug: SLUG,
slug: SLUG,
summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,
summary: `Commit messages and release notes quoted from the OpenVibers repositories for ${facts.week.from} to ${facts.week.to}, each with a link to its source.`,
@@ -77,11 +77,11 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
@@ -77,11 +77,11 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
let review = null;
let review = null;
let published = false;
let published = false;
if (publish) {
if (publish) {
- const head = store.revisions.head(post.id);
- review = posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
- published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;
+ const head = await store.revisions.head(post.id);
+ review = await posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
+ published = (await posts.publish(actor, await posts.get(post.id), { revision: head.number })).changed;
}
}
- return { post: posts.get(post.id), created, review, published };
+ return { post: await posts.get(post.id), created, review, published };
}
}
module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };
module.exports = { seedOfficialPost, loadFacts, composeBody, SLUG };
@@ -19,7 +19,7 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
@@ -19,7 +19,7 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
running = true;
running = true;
try {
try {
const summary = await posts.runScheduled(config.worker.id);
const summary = await posts.runScheduled(config.worker.id);
- if (summary.done.length || summary.failed.length) outbox.kick();
+ if (summary.done.length || summary.failed.length) await outbox.kick();
return summary;
return summary;
} catch (err) {
} catch (err) {
log.error('[Blog] schedule tick failed:', err.message);
log.error('[Blog] schedule tick failed:', err.message);
@@ -30,8 +30,8 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
@@ -30,8 +30,8 @@ function createWorker({ config, store, posts, media, outbox, log = console }) {
/** Verify the attachments of published posts (at most `limit` posts per run). */
/** Verify the attachments of published posts (at most `limit` posts per run). */
async function verifyMedia({ limit = 200 } = {}) {
async function verifyMedia({ limit = 200 } = {}) {
if (!media.enabled) return [];
if (!media.enabled) return [];
- const ids = store.db.prepare(`SELECT DISTINCT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id
- WHERE p.state = 'published' ORDER BY COALESCE(a.checked_at, 0) LIMIT ?`).all(limit).map((r) => r.id);
+ const ids = (await store.db.prepare(`SELECT a.entity_id AS id FROM blog_post_attachments a JOIN blog_posts p ON p.id = a.entity_id
+ WHERE p.state = 'published' GROUP BY a.entity_id ORDER BY MIN(COALESCE(a.checked_at, 0)), a.entity_id LIMIT ?`).all(limit)).map((r) => r.id);
const out = [];
const out = [];
for (const id of ids) {
for (const id of ids) {
const results = await store.attachments.verify(id, { resolve: media.resolve });
const results = await store.attachments.verify(id, { resolve: media.resolve });
@@ -73,12 +73,12 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -73,12 +73,12 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });
r = await t.get(`/api/v1/posts/${id}/revert`, { as: fay, json: { to_revision: 1, expected_revision: rev } });
assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);
assert.strictEqual(r.status, 201, `the 30th, a revert: ${r.text}`);
rev = r.json().revision;
rev = r.json().revision;
- const stored = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id).n;
- const before = stored();
+ const stored = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions WHERE entity_id = ?').get(id)).n;
+ const before = await stored();
r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });
r = await t.get(`/write/posts/${id}`, { as: fay, form: { _csrf: t.csrf({ subject: fay.subject }), title: 'Counted', body: `${LONG} one more`, expectedRevision: String(rev) } });
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60'], 'the form shares the API budget');
assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);
assert.ok(r.json().detail.includes('blog.post.update'), r.json().detail);
- assert.strictEqual(stored(), before, 'nothing stored');
+ assert.strictEqual(await stored(), before, 'nothing stored');
r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });
r = await t.get('/api/v1/blogs/gus/posts', { as: gus, json: { title: 'Mine', body: LONG } });
assert.strictEqual(r.status, 201, 'another person still writes');
assert.strictEqual(r.status, 201, 'another person still writes');
r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });
r = await t.get(`/api/v1/posts/${r.json().post.id}`, { as: gus, method: 'PATCH', json: { expected_revision: r.json().revision, body: `${LONG} edited` } });
@@ -46,7 +46,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -46,7 +46,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.deepStrictEqual(asked[0].body.on_behalf_of, { type: 'user', id: fay.subject });
assert.deepStrictEqual(asked[0].body.on_behalf_of, { type: 'user', id: fay.subject });
assert.strictEqual(asked[0].body.target.service, 'blog');
assert.strictEqual(asked[0].body.target.service, 'blog');
assert.ok(/^Bearer /.test(asked[0].auth), 'Blog\'s service token');
assert.ok(/^Bearer /.test(asked[0].auth), 'Blog\'s service token');
- const cites = t.ctx.posts.citations(t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);
+ const cites = await t.ctx.posts.citations(await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(draft.id) || { id: draft.id }, 1);
assert.ok(cites.some((c) => c.url === 'https://example.org/self-hosting'), 'the run\'s citations are on revision 1');
assert.ok(cites.some((c) => c.url === 'https://example.org/self-hosting'), 'the run\'s citations are on revision 1');
});
});
@@ -65,11 +65,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -65,11 +65,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
await check('a failed run makes no post; a stranger cannot ask', async () => {
await check('a failed run makes no post; a stranger cannot ask', async () => {
mode = 'fail';
mode = 'fail';
- const before = t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n;
+ const before = (await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n;
const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'x' } });
const r = await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: fay, json: { topic: 'x' } });
assert.strictEqual(r.status, 502);
assert.strictEqual(r.status, 502);
assert.strictEqual(r.json().code, 'ai.run_failed');
assert.strictEqual(r.json().code, 'ai.run_failed');
- assert.strictEqual(t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get().n, before);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM blog_posts').get()).n, before);
mode = 'ok';
mode = 'ok';
const stranger = t.network.addUser('stranger');
const stranger = t.network.addUser('stranger');
assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: stranger, json: { topic: 'x' } })).status, 403);
assert.strictEqual((await t.get('/api/v1/blogs/fay/posts/ai-draft', { as: stranger, json: { topic: 'x' } })).status, 403);
@@ -48,7 +48,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
@@ -48,7 +48,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
assert.strictEqual(draft.authorship.mode, 'ai');
assert.strictEqual(draft.authorship.mode, 'ai');
assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');
assert.strictEqual(draft.authorship.workflow.id, 'blog.draft_post');
assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');
assert.deepStrictEqual(draft.authorship.authors, [], 'AI output is never attributed to the person');
- const created = t.events('blog.post.created')[0];
+ const created = (await t.events('blog.post.created'))[0];
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.strictEqual(created.payload.authorship, 'ai');
assert.strictEqual(created.payload.authorship, 'ai');
const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });
const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });
@@ -79,7 +79,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
@@ -79,7 +79,7 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
const page = await t.get('/@fay/an-ai-draft');
const page = await t.get('/@fay/an-ai-draft');
assert.match(page.text, /AI-generated/);
assert.match(page.text, /AI-generated/);
assert.match(page.text, /reviewed by a person/);
assert.match(page.text, /reviewed by a person/);
- const doc = t.events('blog.index_document.upserted').pop().payload;
+ const doc = (await t.events('blog.index_document.upserted')).pop().payload;
assert.strictEqual(doc.authorship, 'ai_generated');
assert.strictEqual(doc.authorship, 'ai_generated');
assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));
assert.ok(doc.provenance.some((r) => r.service === 'ai' && r.type === 'run' && r.id === WORKFLOW.runId));
});
});
@@ -18,21 +18,21 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -18,21 +18,21 @@ const H = (n) => n.toString(16).padStart(40, 'a');
const fetchImpl = async (url) => {
const fetchImpl = async (url) => {
const u = new URL(url);
const u = new URL(url);
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
- if (u.pathname === '/api/v1/registry/services') return json({ services: [
+ if (u.pathname === '/api/v1/registry/services') return await json({ services: [
{ id: 'live', name: 'OpenVibe.Live', repository: 'OpenVibers/OpenVibe.Live', publicOrigin: 'https://openvibe.live' },
{ id: 'live', name: 'OpenVibe.Live', repository: 'OpenVibers/OpenVibe.Live', publicOrigin: 'https://openvibe.live' },
{ id: 'tools', name: 'OpenVibe.Tools', repository: 'OpenVibers/OpenVibe.Tools', publicOrigin: 'https://openvibe.tools' },
{ id: 'tools', name: 'OpenVibe.Tools', repository: 'OpenVibers/OpenVibe.Tools', publicOrigin: 'https://openvibe.tools' },
{ id: 'norepo', name: 'X' },
{ id: 'norepo', name: 'X' },
] });
] });
- if (u.pathname === '/api/v1/registry/releases') return json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });
+ if (u.pathname === '/api/v1/registry/releases') return await json({ checked_at: null, libraries: [], behind: [], services: Object.entries(releases).map(([id, r]) => ({ id, release: r, released_at: new Date(t.clock.now()).toISOString() })) });
if (u.hostname === 'api.github.com') {
if (u.hostname === 'api.github.com') {
ghCalls.push(u.pathname);
ghCalls.push(u.pathname);
const m = u.pathname.match(/^\/repos\/(.+)\/compare\/(.+)\.\.\.(.+)$/);
const m = u.pathname.match(/^\/repos\/(.+)\/compare\/(.+)\.\.\.(.+)$/);
- if (m && compares[m[3]]) return json(compares[m[3]]);
+ if (m && compares[m[3]]) return await json(compares[m[3]]);
const h = u.pathname.match(/^\/repos\/(.+)\/commits$/);
const h = u.pathname.match(/^\/repos\/(.+)\/commits$/);
- if (h && history[h[1]]) return json(history[h[1]]);
- return json({ message: 'Not Found' }, 404);
+ if (h && history[h[1]]) return await json(history[h[1]]);
+ return await json({ message: 'Not Found' }, 404);
}
}
- return json({}, 404);
+ return await json({}, 404);
};
};
const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe', history: 0 } };
const config = { ...t.ctx.config, changelog: { ...t.ctx.config.changelog, enabled: true, batchSize: 5, majorLines: 500, quietMs: 30 * 60 * 1000, maxAgeMs: 7 * 24 * 3600 * 1000, minGapMs: 6 * 3600 * 1000, since: null, aiDraft: false, blogHandle: 'openvibe', history: 0 } };
const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });
const cl = createChangelog({ config, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl, log: { log() {}, warn() {} } });
@@ -40,7 +40,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -40,7 +40,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
await check('a service seen for the first time only records its release', async () => {
await check('a service seen for the first time only records its release', async () => {
await cl.tick();
await cl.tick();
- assert.strictEqual(cl.entries().length, 0);
+ assert.strictEqual((await cl.entries()).length, 0);
assert.deepStrictEqual(ghCalls, [], 'no history dump');
assert.deepStrictEqual(ghCalls, [], 'no history dump');
});
});
@@ -49,7 +49,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -49,7 +49,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
compares.aaaaaaa00002 = { status: 'ahead', commits: [commit(1, 'Fix the chat scroll\n\nbody text'), commit(2, 'Merge branch main')], files: [{ additions: 10, deletions: 2 }] };
compares.aaaaaaa00002 = { status: 'ahead', commits: [commit(1, 'Fix the chat scroll\n\nbody text'), commit(2, 'Merge branch main')], files: [{ additions: 10, deletions: 2 }] };
const r = await cl.tick();
const r = await cl.tick();
assert.strictEqual(r, null, 'not due yet');
assert.strictEqual(r, null, 'not due yet');
- const e = cl.entries({ service: 'live' });
+ const e = await cl.entries({ service: 'live' });
assert.deepStrictEqual(e.map((x) => x.subject), ['Fix the chat scroll'], 'first lines only; merges skipped');
assert.deepStrictEqual(e.map((x) => x.subject), ['Fix the chat scroll'], 'first lines only; merges skipped');
assert.strictEqual(e[0].url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
assert.strictEqual(e[0].url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
assert.strictEqual(e[0].major, false);
assert.strictEqual(e[0].major, false);
@@ -62,14 +62,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -62,14 +62,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(31 * 60 * 1000);
t.clock.advance(31 * 60 * 1000);
const r = await cl.tick();
const r = await cl.tick();
assert.ok(r && r.post, 'published');
assert.ok(r && r.post, 'published');
- const post = t.ctx.posts.get(r.post.id);
+ const post = await t.ctx.posts.get(r.post.id);
assert.strictEqual(post.state, 'published');
assert.strictEqual(post.state, 'published');
- const head = t.ctx.posts.head(post);
+ const head = await t.ctx.posts.head(post);
assert.strictEqual(head.fields.title, 'Patch notes: Recent tools in the launcher');
assert.strictEqual(head.fields.title, 'Patch notes: Recent tools in the launcher');
assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));
assert.ok(head.content.includes('## Highlights') && head.content.includes('**OpenVibe.Tools:** Recent tools in the launcher'));
assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));
assert.ok(head.content.includes('## OpenVibe.Live') && head.content.includes('Fix the chat scroll'));
assert.strictEqual(head.meta.authorship.mode, 'imported');
assert.strictEqual(head.meta.authorship.mode, 'imported');
- assert.strictEqual(cl.entries().filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
+ assert.strictEqual((await cl.entries()).filter((x) => !x.post_id).length, 0, 'every entry now belongs to the post');
});
});
await check('the public feed names the entries and the latest post', async () => {
await check('the public feed names the entries and the latest post', async () => {
@@ -90,7 +90,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -90,7 +90,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(6 * 3600 * 1000 + 1000);
t.clock.advance(6 * 3600 * 1000 + 1000);
const r = await cl.tick();
const r = await cl.tick();
assert.ok(r && r.post);
assert.ok(r && r.post);
- assert.ok(t.ctx.posts.head(t.ctx.posts.get(r.post.id)).fields.title.startsWith('Patch notes: 5 changes across 1 site'));
+ assert.ok((await t.ctx.posts.head(await t.ctx.posts.get(r.post.id))).fields.title.startsWith('Patch notes: 5 changes across 1 site'));
});
});
await check('subjects are cut at a word; version bumps count as major', async () => {
await check('subjects are cut at a word; version bumps count as major', async () => {
@@ -100,26 +100,26 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -100,26 +100,26 @@ const H = (n) => n.toString(16).padStart(40, 'a');
releases.tools = 'bbbbbbb00003';
releases.tools = 'bbbbbbb00003';
compares.bbbbbbb00003 = { status: 'ahead', commits: [commit(10, '1.7.0: openvibe-shared/trace')], files: [{ additions: 3, deletions: 1 }] };
compares.bbbbbbb00003 = { status: 'ahead', commits: [commit(10, '1.7.0: openvibe-shared/trace')], files: [{ additions: 3, deletions: 1 }] };
await cl.tick();
await cl.tick();
- assert.strictEqual(cl.entries({ service: 'tools' })[0].major, true);
+ assert.strictEqual((await cl.entries({ service: 'tools' }))[0].major, true);
});
});
await check('history: each site\'s earlier commits once, never pending, with authors; the feed pages by cursor', async () => {
await check('history: each site\'s earlier commits once, never pending, with authors; the feed pages by cursor', async () => {
history['OpenVibers/OpenVibe.Live'] = [20, 21, 22].map((i) => ({ sha: H(i), commit: { message: `Earlier change ${i}\n\nbody`, author: { name: 'OpenVibers', date: new Date(t.clock.now() - (30 - i) * 86400000).toISOString() } } }))
history['OpenVibers/OpenVibe.Live'] = [20, 21, 22].map((i) => ({ sha: H(i), commit: { message: `Earlier change ${i}\n\nbody`, author: { name: 'OpenVibers', date: new Date(t.clock.now() - (30 - i) * 86400000).toISOString() } } }))
.concat([{ sha: H(5), commit: { message: 'Small change 5', author: { name: 'OpenVibers', date: new Date(t.clock.now()).toISOString() } } }]);
.concat([{ sha: H(5), commit: { message: 'Small change 5', author: { name: 'OpenVibers', date: new Date(t.clock.now()).toISOString() } } }]);
const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, fetchImpl, log: { log() {}, warn() {} } });
const hc = createChangelog({ config: { ...config, changelog: { ...config.changelog, history: 50 } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, fetchImpl, log: { log() {}, warn() {} } });
- const before = hc.stats().pending;
+ const before = (await hc.stats()).pending;
await hc.collect();
await hc.collect();
- assert.strictEqual(hc.stats().pending, before, 'history never waits for a patch notes post');
- const all = hc.entries({ service: 'live', limit: 100 });
+ assert.strictEqual((await hc.stats()).pending, before, 'history never waits for a patch notes post');
+ const all = await hc.entries({ service: 'live', limit: 100 });
assert.ok(all.some((e) => e.subject === 'Earlier change 20' && e.author === 'OpenVibers' && e.post_id === null));
assert.ok(all.some((e) => e.subject === 'Earlier change 20' && e.author === 'OpenVibers' && e.post_id === null));
assert.strictEqual(all.find((e) => e.sha === H(5)).author, 'OpenVibers', 'a known commit gains its author');
assert.strictEqual(all.find((e) => e.sha === H(5)).author, 'OpenVibers', 'a known commit gains its author');
const calls = ghCalls.length;
const calls = ghCalls.length;
await hc.collect();
await hc.collect();
assert.strictEqual(ghCalls.length, calls, 'imported once (a repository GitHub will not list is not asked again either)');
assert.strictEqual(ghCalls.length, calls, 'imported once (a repository GitHub will not list is not asked again either)');
- const p1 = hc.page({ service: 'live', limit: 2 });
+ const p1 = await hc.page({ service: 'live', limit: 2 });
assert.strictEqual(p1.entries.length, 2);
assert.strictEqual(p1.entries.length, 2);
assert.ok(p1.next);
assert.ok(p1.next);
- const p2 = hc.page({ service: 'live', limit: 2, before: p1.next });
+ const p2 = await hc.page({ service: 'live', limit: 2, before: p1.next });
assert.ok(p2.entries.length >= 1 && !p2.entries.some((e) => p1.entries.some((x) => x.sha === e.sha)), 'the next page continues without repeats');
assert.ok(p2.entries.length >= 1 && !p2.entries.some((e) => p1.entries.some((x) => x.sha === e.sha)), 'the next page continues without repeats');
const r = (await t.get(`/api/v1/changelog?limit=2&before=${encodeURIComponent(p1.next)}`)).json();
const r = (await t.get(`/api/v1/changelog?limit=2&before=${encodeURIComponent(p1.next)}`)).json();
assert.ok(Array.isArray(r.sites) && r.sites.some((x) => x.service === 'live'), 'network-wide answers list the sites');
assert.ok(Array.isArray(r.sites) && r.sites.some((x) => x.service === 'live'), 'network-wide answers list the sites');
@@ -135,14 +135,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -135,14 +135,14 @@ const H = (n) => n.toString(16).padStart(40, 'a');
const fx = async (url, opts = {}) => {
const fx = async (url, opts = {}) => {
const u = new URL(url);
const u = new URL(url);
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
- if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }
+ if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return await json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }
if (u.pathname === '/internal/integrations/github-token') {
if (u.pathname === '/internal/integrations/github-token') {
seen.network++;
seen.network++;
assert.strictEqual(opts.headers.Authorization || opts.headers.authorization, 'Bearer svc-tok');
assert.strictEqual(opts.headers.Authorization || opts.headers.authorization, 'Bearer svc-tok');
- return json(networkAnswer.body, networkAnswer.status);
+ return await json(networkAnswer.body, networkAnswer.status);
}
}
if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);
if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);
- return fetchImpl(url, opts);
+ return await fetchImpl(url, opts);
};
};
const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });
const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });
const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };
const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };
@@ -151,7 +151,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -151,7 +151,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
await release(net, 1);
await release(net, 1);
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.ok(seen.oauth[0].includes('audience=openvibe.network') && seen.oauth[0].includes('network.integration.github.read'));
assert.ok(seen.oauth[0].includes('audience=openvibe.network') && seen.oauth[0].includes('network.integration.github.read'));
- assert.strictEqual(net.stats().github_token, 'network');
+ assert.strictEqual((await net.stats()).github_token, 'network');
await release(net, 2);
await release(net, 2);
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
assert.strictEqual(seen.network, 1, 'kept ten minutes');
assert.strictEqual(seen.network, 1, 'kept ten minutes');
@@ -160,7 +160,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -160,7 +160,7 @@ const H = (n) => n.toString(16).padStart(40, 'a');
t.clock.advance(11 * 60 * 1000);
t.clock.advance(11 * 60 * 1000);
await release(net, 3);
await release(net, 3);
assert.strictEqual(seen.auth.pop(), null, 'none set: anonymous');
assert.strictEqual(seen.auth.pop(), null, 'none set: anonymous');
- assert.strictEqual(net.stats().github_token, 'none');
+ assert.strictEqual((await net.stats()).github_token, 'none');
const env = mk({ githubToken: 'ghp_fromenv' });
const env = mk({ githubToken: 'ghp_fromenv' });
const before = seen.network;
const before = seen.network;
@@ -20,10 +20,10 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -20,10 +20,10 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
const secret = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Private minutes', body: `Minutes ${LONG}`, visibility: 'private' } })).json().post;
const secret = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Private minutes', body: `Minutes ${LONG}`, visibility: 'private' } })).json().post;
const caps = ['blog.post.read', 'blog.post.create'];
const caps = ['blog.post.read', 'blog.post.create'];
const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.blog'], cap: caps, extra });
const token = (sub, actorType, extra) => t.network.signService({ sub, actorType, aud: ['openvibe.blog'], cap: caps, extra });
- const postsOf = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n;
+ const postsOf = async () => (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n;
await check('an app or module cannot act for someone else by naming them in X-OV-Subject', async () => {
await check('an app or module cannot act for someone else by naming them in X-OV-Subject', async () => {
- const before = postsOf();
+ const before = await postsOf();
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
for (const [sub, type] of [[APP, 'app'], [MOD, 'mod']]) {
for (const extra of [{ on_behalf_of: appUser.subject }, {}]) {
for (const extra of [{ on_behalf_of: appUser.subject }, {}]) {
const read = await t.get(`/api/v1/posts/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject } });
const read = await t.get(`/api/v1/posts/${secret.id}`, { as: token(sub, type, extra), headers: { 'x-ov-subject': carol.subject } });
@@ -34,7 +34,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -34,7 +34,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual(write.status, 403, `${type} write: ${write.text}`);
assert.strictEqual(write.status, 403, `${type} write: ${write.text}`);
}
}
}
}
- assert.strictEqual(postsOf(), before, 'nothing written as carol');
+ assert.strictEqual(await postsOf(), before, 'nothing written as carol');
});
});
await check('an app acts for its on_behalf_of person, with that person\'s rights', async () => {
await check('an app acts for its on_behalf_of person, with that person\'s rights', async () => {
@@ -40,11 +40,14 @@ async function boot(opts = {}) {
@@ -40,11 +40,14 @@ async function boot(opts = {}) {
const { createApp } = require('../../server/app');
const { createApp } = require('../../server/app');
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
const quiet = { log() {}, warn() {}, error: (...a) => { if (process.env.VERBOSE) console.error(...a); } };
+ const { createStore } = require('../../server/db');
+ // One database per boot (PGlite, or BLOG_TEST_STORE=pg: the containers); a restart keeps it, like a file did.
+ const testdb = await require('./db').testDb();
let server = null;
let server = null;
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = createApp({ config, now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow });
await built.ctx.auth.ensureKey();
await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;
@@ -53,7 +56,7 @@ async function boot(opts = {}) {
@@ -53,7 +56,7 @@ async function boot(opts = {}) {
}
}
async function stop() {
async function stop() {
if (server) await new Promise((r) => server.close(r));
if (server) await new Promise((r) => server.close(r));
- if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); built.ctx.store.close(); }
+ if (built) { built.ctx.worker.stop(); await built.ctx.outbox.stop(); }
server = null; built = null;
server = null; built = null;
}
}
@@ -71,15 +74,15 @@ async function boot(opts = {}) {
@@ -71,15 +74,15 @@ async function boot(opts = {}) {
}
}
/** Rows of event_outbox as parsed envelopes. */
/** Rows of event_outbox as parsed envelopes. */
- function events(type = null) {
- return t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
+ async function events(type = null) {
+ return (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type || (type instanceof RegExp && type.test(e.event_type)));
}
}
const t = {
const t = {
network, community, media, clock, dbPath, official, get, events,
network, community, media, clock, dbPath, official, get, events,
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),
csrf: (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.BLOG_FORM_SECRET }, user),
async restart() { await stop(); await start(); },
async restart() { await stop(); await start(); },
- async close() { await stop(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },
+ async close() { await stop(); await testdb.close(); await network.close(); await community.close(); await media.close(); fs.rmSync(dir, { recursive: true, force: true }); },
};
};
await start();
await start();
return t;
return t;
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * BLOG_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.BLOG_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'blog', max });
+
+module.exports = { testDb, pgAvailable };
@@ -15,15 +15,15 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -15,15 +15,15 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
let post;
let post;
await check('the ten charter tables exist (package tables and views included)', async () => {
await check('the ten charter tables exist (package tables and views included)', async () => {
- const names = new Set(t.ctx.store.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map((r) => r.name));
+ const names = new Set((await t.ctx.store.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));
for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);
for (const n of CHARTER_TABLES) assert.ok(names.has(n), `missing ${n}`);
for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);
for (const n of ['blog_schedule_jobs', 'blog_terms', 'blog_term_links', 'blog_post_citations', 'blog_post_attachments', 'blog_index_revisions', 'event_outbox']) assert.ok(names.has(n), `missing ${n}`);
});
});
await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {
await check('the official blog exists at / and its owner comes from BLOG_OFFICIAL_OWNERS', async () => {
- const blog = t.ctx.blogs.official();
+ const blog = await t.ctx.blogs.official();
assert.strictEqual(blog.handle, 'openvibe');
assert.strictEqual(blog.handle, 'openvibe');
- assert.strictEqual(t.ctx.blogs.membership(blog, t.official.subject).role, 'owner');
+ assert.strictEqual((await t.ctx.blogs.membership(blog, t.official.subject)).role, 'owner');
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /The OpenVibe blog/);
assert.match(r.text, /The OpenVibe blog/);
@@ -47,8 +47,8 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -47,8 +47,8 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(post.state, 'draft');
assert.strictEqual(post.state, 'draft');
assert.strictEqual(post.revision, 1);
assert.strictEqual(post.revision, 1);
assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);
assert.deepStrictEqual(post.tags, ['Astronomy', 'night sky']);
- assert.strictEqual(t.events('blog.post.created').length, 1);
- assert.strictEqual(t.events(/index_document/).length, 0);
+ assert.strictEqual((await t.events('blog.post.created')).length, 1);
+ assert.strictEqual((await t.events(/index_document/)).length, 0);
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /First light/);
});
});
@@ -62,7 +62,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -62,7 +62,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(stale.json().code, 'revision.conflict');
assert.strictEqual(stale.json().code, 'revision.conflict');
const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });
const missing = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { body: 'no base' } });
assert.strictEqual(missing.status, 428);
assert.strictEqual(missing.status, 428);
- assert.throws(() => t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);
+ await assert.rejects(t.ctx.store.db.prepare('UPDATE blog_post_revisions SET content = ? WHERE entity_id = ?').run('x', post.id), /immutable/);
});
});
await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {
await check('publish: page served without JavaScript, BlogPosting from real fields, events + Search document', async () => {
@@ -85,13 +85,13 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -85,13 +85,13 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());
assert.strictEqual(posting.datePublished, new Date(t.clock.now()).toISOString());
assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');
assert.strictEqual(posting.publisher, undefined, 'no publisher invented for a member blog');
assert.strictEqual(posting.aggregateRating, undefined);
assert.strictEqual(posting.aggregateRating, undefined);
- assert.strictEqual(t.events('blog.post.published').length, 1);
- const idx = t.events('blog.index_document.upserted');
+ assert.strictEqual((await t.events('blog.post.published')).length, 1);
+ const idx = await t.events('blog.index_document.upserted');
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx.length, 1);
assert.strictEqual(idx[0].payload.visibility, 'public');
assert.strictEqual(idx[0].payload.visibility, 'public');
assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');
assert.strictEqual(idx[0].payload.canonical_url, 'https://openvibe.blog/@alice/first-light');
assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);
assert.ok(require('openvibe-contracts').validate('search.index-document@1', idx[0].payload).valid);
- for (const e of t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);
+ for (const e of await t.events()) assert.ok(require('openvibe-contracts').validate('events.event-envelope@1', e).valid, `${e.event_type} envelope`);
});
});
await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {
await check('feeds (RSS, Atom, JSON Feed), sitemaps, tag/category/author pages list it', async () => {
@@ -113,11 +113,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -113,11 +113,11 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });
const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });
assert.strictEqual(r.json().revision, 3);
assert.strictEqual(r.json().revision, 3);
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);
- assert.strictEqual(t.events('blog.post.updated').length, 0);
+ assert.strictEqual((await t.events('blog.post.updated')).length, 0);
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);
assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);
- assert.strictEqual(t.events('blog.post.updated').length, 1);
- assert.strictEqual(t.events('blog.index_document.upserted').length, 2);
+ assert.strictEqual((await t.events('blog.post.updated')).length, 1);
+ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);
});
});
await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {
await check('history: diff and revert as a new revision; revisions 1–3 are untouched', async () => {
@@ -130,7 +130,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -130,7 +130,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);
assert.deepStrictEqual(revs.map((x) => x.number), [4, 3, 2, 1]);
assert.strictEqual(revs[0].kind, 'revert');
assert.strictEqual(revs[0].kind, 'revert');
assert.strictEqual(revs[0].revertedTo, 1);
assert.strictEqual(revs[0].revertedTo, 1);
- assert.strictEqual(t.ctx.store.revisions.get(post.id, 4).content, t.ctx.store.revisions.get(post.id, 1).content);
+ assert.strictEqual((await t.ctx.store.revisions.get(post.id, 4)).content, (await t.ctx.store.revisions.get(post.id, 1)).content);
});
});
await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {
await check('unpublish: gone from pages, feeds, sitemap; Search gets a tombstone', async () => {
@@ -139,10 +139,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -139,10 +139,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.strictEqual((await t.get('/@alice/first-light')).status, 404);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);
assert.doesNotMatch((await t.get('/@alice/feed.xml')).text, /first-light/);
assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);
assert.doesNotMatch((await t.get('/sitemaps/posts.xml')).text, /first-light/);
- const del = t.events('blog.index_document.deleted');
+ const del = await t.events('blog.index_document.deleted');
assert.strictEqual(del.length, 1);
assert.strictEqual(del.length, 1);
assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);
assert.deepStrictEqual(Object.keys(del[0].payload).sort(), ['id', 'revision', 'type']);
- assert.strictEqual(t.events('blog.post.unpublished').length, 1);
+ assert.strictEqual((await t.events('blog.post.unpublished')).length, 1);
});
});
await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {
await check('delete: 410 Gone for readers, blog.post.deleted, revisions kept', async () => {
@@ -152,9 +152,9 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -152,9 +152,9 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const page = await t.get('/@alice/first-light');
const page = await t.get('/@alice/first-light');
assert.strictEqual(page.status, 410);
assert.strictEqual(page.status, 410);
assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
- assert.strictEqual(t.events('blog.post.deleted').length, 1);
- assert.strictEqual(t.ctx.store.revisions.list(post.id).length, 4);
- const last = t.events(/index_document/).pop();
+ assert.strictEqual((await t.events('blog.post.deleted')).length, 1);
+ assert.strictEqual((await t.ctx.store.revisions.list(post.id)).length, 4);
+ const last = (await t.events(/index_document/)).pop();
assert.strictEqual(last.event_type, 'blog.index_document.deleted');
assert.strictEqual(last.event_type, 'blog.index_document.deleted');
});
});
@@ -169,14 +169,14 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -169,14 +169,14 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const made = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Cited', body: `Sources below.\n\n${LONG}`, citations: [{ url: 'https://example.org/a', title: 'A', quote: 'quoted' }] } });
const made = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Cited', body: `Sources below.\n\n${LONG}`, citations: [{ url: 'https://example.org/a', title: 'A', quote: 'quoted' }] } });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
const id = made.json().post.id;
const id = made.json().post.id;
- const citesAt = () => { const h = t.ctx.store.revisions.head(id); return t.ctx.store.citations.forRevision(id, h.number).map((c) => c.url); };
- assert.deepStrictEqual(citesAt(), ['https://example.org/a']);
- let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Edited in /write.\n\n${LONG}` } });
+ const citesAt = async () => { const h = await t.ctx.store.revisions.head(id); return (await t.ctx.store.citations.forRevision(id, h.number)).map((c) => c.url); };
+ assert.deepStrictEqual(await citesAt(), ['https://example.org/a']);
+ let r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Edited in /write.\n\n${LONG}` } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(citesAt(), ['https://example.org/a'], 'carried forward');
- r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: t.ctx.store.revisions.head(id).number, body: `Replaced.\n\n${LONG}`, citations: [] } });
+ assert.deepStrictEqual(await citesAt(), ['https://example.org/a'], 'carried forward');
+ r = await t.get(`/api/v1/posts/${id}`, { as: alice, method: 'PATCH', json: { expected_revision: (await t.ctx.store.revisions.head(id)).number, body: `Replaced.\n\n${LONG}`, citations: [] } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(citesAt(), [], 'an explicit empty list removes them');
+ assert.deepStrictEqual(await citesAt(), [], 'an explicit empty list removes them');
});
});
await t.close();
await t.close();
@@ -146,11 +146,11 @@ function startVip() {
@@ -146,11 +146,11 @@ function startVip() {
for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/feed.xml', '/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed', '/llms.txt']) {
for (const p of ['/@carol/feed.xml', '/@carol/atom.xml', '/@carol/feed.json', '/feed.xml', '/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/@carol', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol/feed', '/llms.txt']) {
for (const as of [undefined, fan]) noBody((await t.get(p, { as })).text, p);
for (const as of [undefined, fan]) noBody((await t.get(p, { as })).text, p);
}
}
- const all = t.events();
+ const all = await t.events();
assert.ok(all.length > 0);
assert.ok(all.length > 0);
for (const e of all) noBody(JSON.stringify(e), e.event_type);
for (const e of all) noBody(JSON.stringify(e), e.event_type);
- assert.strictEqual(t.events('blog.index_document.upserted').filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');
- const doc = t.ctx.publication.documentFor(t.ctx.blogs.byHandle('carol'), t.ctx.posts.get(mem.id), { forSearch: false }).doc;
+ assert.strictEqual((await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === mem.id).length, 0, 'Search never got the members post');
+ const doc = (await t.ctx.publication.documentFor(await t.ctx.blogs.byHandle('carol'), await t.ctx.posts.get(mem.id), { forSearch: false })).doc;
assert.strictEqual(doc.body, '');
assert.strictEqual(doc.body, '');
assert.strictEqual(doc.summary, 'What happened backstage this week.');
assert.strictEqual(doc.summary, 'What happened backstage this week.');
assert.deepStrictEqual(doc.acl.entitlements, ['vip:carol'], 'the default gate label names the blog');
assert.deepStrictEqual(doc.acl.entitlements, ['vip:carol'], 'the default gate label names the blog');
@@ -20,8 +20,8 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -20,8 +20,8 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);
assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: dana, json: {} })).status, 200);
return p;
return p;
};
};
- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');
+ const moderation = async () => await (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())
+ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'blog.moderation.action');
const valid = (e) => {
const valid = (e) => {
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
const r = contracts.validate('blog.moderation.action@1', e.payload);
const r = contracts.validate('blog.moderation.action@1', e.payload);
@@ -37,14 +37,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -37,14 +37,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
const b = await make('Own delete');
const b = await make('Own delete');
assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);
assert.strictEqual((await t.get(`/api/v1/posts/${a.id}/unpublish`, { as: dana, json: {} })).status, 200);
assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);
assert.strictEqual((await t.get(`/api/v1/posts/${b.id}`, { as: dana, method: 'DELETE' })).status < 300, true);
- assert.strictEqual(moderation().length, 0);
+ assert.strictEqual((await moderation()).length, 0);
});
});
await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {
await check('staff unpublishing someone else\'s post: exactly one valid event', async () => {
const p = await make('Staff unpublish');
const p = await make('Staff unpublish');
const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
const r = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
valid(ev[0]);
valid(ev[0]);
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `post:${p.id}` });
@@ -53,14 +53,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -53,14 +53,14 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
assert.strictEqual(ev[0].payload.details.previous, 'published');
assert.strictEqual(ev[0].payload.details.previous, 'published');
const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
const again = await t.get(`/api/v1/posts/${p.id}/unpublish`, { as: admin, json: {} });
assert.strictEqual(again.status, 200);
assert.strictEqual(again.status, 200);
- assert.strictEqual(moderation().length, 1, 'nothing changed, nothing reported');
+ assert.strictEqual((await moderation()).length, 1, 'nothing changed, nothing reported');
});
});
await check('staff deleting someone else\'s post: exactly one valid event', async () => {
await check('staff deleting someone else\'s post: exactly one valid event', async () => {
const p = await make('Staff delete');
const p = await make('Staff delete');
const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });
const r = await t.get(`/api/v1/posts/${p.id}`, { as: admin, method: 'DELETE' });
assert.ok(r.status < 300, r.text);
assert.ok(r.status < 300, r.text);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 2);
assert.strictEqual(ev.length, 2);
valid(ev[1]);
valid(ev[1]);
assert.strictEqual(ev[1].payload.action, 'post.deleted');
assert.strictEqual(ev[1].payload.action, 'post.deleted');
@@ -39,7 +39,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
@@ -39,7 +39,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
await check('a form without the token is refused', async () => {
await check('a form without the token is refused', async () => {
const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });
const r = await t.get('/write/@eve/new', { as: eve, form: { title: 'No token', body: 'x' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_posts').get()).n, 0);
});
});
await check('new draft, edit and publish with forms', async () => {
await check('new draft, edit and publish with forms', async () => {
@@ -68,7 +68,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
@@ -68,7 +68,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });
const r = await t.get(`/write/posts/${postId}`, { as: eve, form: { _csrf: csrf, expectedRevision: '1', title: 'Stale', body: 'stale' } });
assert.strictEqual(r.status, 412);
assert.strictEqual(r.status, 412);
assert.match(r.text, /Someone saved revision 2/);
assert.match(r.text, /Someone saved revision 2/);
- assert.strictEqual(t.ctx.store.revisions.head(postId).fields.title, 'Notes without script');
+ assert.strictEqual((await t.ctx.store.revisions.head(postId)).fields.title, 'Notes without script');
});
});
await check('history, diff and preview are plain pages', async () => {
await check('history, diff and preview are plain pages', async () => {
@@ -92,7 +92,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
@@ -92,7 +92,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });
assert.deepStrictEqual(thread.ref, { service: 'blog', type: 'post', id: postId, label: 'Notes without script' });
const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));
const posted = t.community.calls.find((c) => c.method === 'POST' && /\/comments$/.test(c.url));
assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');
assert.strictEqual(posted.subject, eve.subject, 'commented as the member (X-OV-Subject)');
- const cols = t.ctx.store.db.prepare("SELECT name FROM pragma_table_info('blog_post_discussion_refs')").all().map((c) => c.name);
+ const cols = (await t.ctx.store.db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'blog_post_discussion_refs'").all()).map((c) => c.name);
assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');
assert.deepStrictEqual(cols.sort(), ['entity_id', 'ref', 'resolved_at', 'thread_id'], 'only the reference is stored');
});
});
@@ -125,14 +125,14 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
@@ -125,14 +125,14 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
await t.get('/write', { as: mallory }); // signs in once: now known by username
await t.get('/write', { as: mallory }); // signs in once: now known by username
let r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });
let r = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'Eve’s notes', description: 'Field notes', language: 'en', theme: 'paper', rss: '1', atom: '1', itemCount: '10', fullContent: '1' } });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
- const blog = t.ctx.blogs.byHandle('eve');
+ const blog = await t.ctx.blogs.byHandle('eve');
assert.strictEqual(blog.theme, 'paper');
assert.strictEqual(blog.theme, 'paper');
- assert.strictEqual(t.ctx.blogs.feedSettings(blog).json, 0);
+ assert.strictEqual((await t.ctx.blogs.feedSettings(blog)).json, 0);
assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);
assert.strictEqual((await t.get('/@eve/feed.json')).status, 404);
assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);
assert.match((await t.get('/@eve')).text, /data-blog-theme="paper"/);
r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });
r = await t.get('/write/@eve/members', { as: eve, form: { _csrf: csrf, member: 'mallory', role: 'author' } });
assert.strictEqual(r.status, 303, r.text);
assert.strictEqual(r.status, 303, r.text);
- assert.strictEqual(t.ctx.blogs.membership(blog, mallory.subject).role, 'author');
+ assert.strictEqual((await t.ctx.blogs.membership(blog, mallory.subject)).role, 'author');
const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });
const bad = await t.get('/write/@eve/settings', { as: eve, form: { _csrf: csrf, title: 'x', theme: 'my-own-css-engine' } });
assert.strictEqual(bad.status, 422);
assert.strictEqual(bad.status, 422);
const notOwner = await t.get('/write/@eve/settings', { as: mallory });
const notOwner = await t.get('/write/@eve/settings', { as: mallory });
@@ -92,9 +92,9 @@ function assertPrivate(r, what) {
@@ -92,9 +92,9 @@ function assertPrivate(r, what) {
});
});
await check('Search receives only the public post; the others were never sent', async () => {
await check('Search receives only the public post; the others were never sent', async () => {
- const ids = t.events('blog.index_document.upserted').map((e) => e.payload.id);
+ const ids = (await t.events('blog.index_document.upserted')).map((e) => e.payload.id);
assert.deepStrictEqual(ids, [pub.id]);
assert.deepStrictEqual(ids, [pub.id]);
- const published = t.events('blog.post.published');
+ const published = await t.events('blog.post.published');
assert.strictEqual(published.length, 4);
assert.strictEqual(published.length, 4);
for (const e of published) {
for (const e of published) {
if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');
if (e.subject.id === pub.id) assert.strictEqual(e.visibility, 'public');
@@ -106,12 +106,12 @@ function assertPrivate(r, what) {
@@ -106,12 +106,12 @@ function assertPrivate(r, what) {
await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {
await check('public → members: a Search tombstone, gone from feeds/sitemaps, anonymous 403, no public cache', async () => {
const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });
const r = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'members', entitlement_key: 'vip:carol' } });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- const del = t.events('blog.index_document.deleted');
+ const del = await t.events('blog.index_document.deleted');
assert.strictEqual(del.length, 1);
assert.strictEqual(del.length, 1);
assert.strictEqual(del[0].payload.id, pub.id);
assert.strictEqual(del[0].payload.id, pub.id);
- const up = t.events('blog.index_document.upserted').find((e) => e.payload.id === pub.id);
+ const up = (await t.events('blog.index_document.upserted')).find((e) => e.payload.id === pub.id);
assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');
assert.ok(del[0].payload.revision > up.payload.revision, 'the tombstone outranks the old document');
- assert.strictEqual(t.events('blog.post.updated').length, 1);
+ assert.strictEqual((await t.events('blog.post.updated')).length, 1);
const a = await t.get('/@carol/open-post');
const a = await t.get('/@carol/open-post');
assert.strictEqual(a.status, 403);
assert.strictEqual(a.status, 403);
assertPrivate(a, 'after privatising');
assertPrivate(a, 'after privatising');
@@ -121,16 +121,16 @@ function assertPrivate(r, what) {
@@ -121,16 +121,16 @@ function assertPrivate(r, what) {
await check('members → public again: re-indexed with a higher revision', async () => {
await check('members → public again: re-indexed with a higher revision', async () => {
await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });
await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { visibility: 'public' } });
- const ups = t.events('blog.index_document.upserted').filter((e) => e.payload.id === pub.id);
- const del = t.events('blog.index_document.deleted')[0];
+ const ups = (await t.events('blog.index_document.upserted')).filter((e) => e.payload.id === pub.id);
+ const del = (await t.events('blog.index_document.deleted'))[0];
assert.strictEqual(ups.length, 2);
assert.strictEqual(ups.length, 2);
assert.ok(ups[1].payload.revision > del.payload.revision);
assert.ok(ups[1].payload.revision > del.payload.revision);
});
});
await check('without an entitlement service the check fails closed', async () => {
await check('without an entitlement service the check fails closed', async () => {
const { createEntitlementChecker } = require('../server/domain/access');
const { createEntitlementChecker } = require('../server/domain/access');
- const blog = t.ctx.blogs.byHandle('carol');
- const post = t.ctx.posts.get(mem.id);
+ const blog = await t.ctx.blogs.byHandle('carol');
+ const post = await t.ctx.posts.get(mem.id);
const args = { subject: vip.subject, blog, post };
const args = { subject: vip.subject, blog, post };
const none = createEntitlementChecker({ provider: 'none' });
const none = createEntitlementChecker({ provider: 'none' });
assert.strictEqual(await none.has(args), false);
assert.strictEqual(await none.has(args), false);
@@ -27,7 +27,7 @@ const MIN = 60 * 1000;
@@ -27,7 +27,7 @@ const MIN = 60 * 1000;
const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });
const b = await t.get(`/api/v1/posts/${p1.id}/schedule`, { as: ann, json: { at: when } });
assert.strictEqual(b.json().created, false);
assert.strictEqual(b.json().created, false);
assert.strictEqual(b.json().job.id, a.json().job.id);
assert.strictEqual(b.json().job.id, a.json().job.id);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id).n, 1, 'charter view blog_schedules');
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM blog_schedules WHERE post_id = ?').get(p1.id)).n, 1, 'charter view blog_schedules');
assert.strictEqual(b.json().post.state, 'scheduled');
assert.strictEqual(b.json().post.state, 'scheduled');
assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');
assert.strictEqual((await t.get('/@ann/scheduled-one')).status, 404, 'not public before its time');
assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);
assert.doesNotMatch((await t.get('/@ann/feed.xml')).text, /Scheduled one/);
@@ -41,11 +41,11 @@ const MIN = 60 * 1000;
@@ -41,11 +41,11 @@ const MIN = 60 * 1000;
await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {
await check('a worker dies after publishing and before completing; after a restart the job re-runs once, harmlessly', async () => {
t.clock.advance(11 * MIN);
t.clock.advance(11 * MIN);
// Worker A: claim the job and apply its effect, then "crash" (no complete()).
// Worker A: claim the job and apply its effect, then "crash" (no complete()).
- const [job] = t.ctx.store.scheduler.claim({ worker: 'worker-A' });
+ const [job] = await t.ctx.store.scheduler.claim({ worker: 'worker-A' });
assert.ok(job, 'claimed');
assert.ok(job, 'claimed');
- t.ctx.posts.applyPublish(job.entityId, job.revision, null);
- assert.strictEqual(t.events('blog.post.published').length, 1);
- const idxBefore = t.events('blog.index_document.upserted').length;
+ await t.ctx.posts.applyPublish(job.entityId, job.revision, null);
+ assert.strictEqual((await t.events('blog.post.published')).length, 1);
+ const idxBefore = (await t.events('blog.index_document.upserted')).length;
assert.strictEqual(idxBefore, 1);
assert.strictEqual(idxBefore, 1);
await t.restart(); // same database file, new process state
await t.restart(); // same database file, new process state
@@ -56,8 +56,8 @@ const MIN = 60 * 1000;
@@ -56,8 +56,8 @@ const MIN = 60 * 1000;
s = await t.ctx.worker.scheduleTick();
s = await t.ctx.worker.scheduleTick();
assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');
assert.strictEqual(s.done.length, 1, 'the job re-ran after the lease expired');
assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');
assert.strictEqual(s.done[0].result, false, 'the effect changed nothing the second time');
- assert.strictEqual(t.events('blog.post.published').length, 1, 'exactly one published event');
- assert.strictEqual(t.events('blog.index_document.upserted').length, 1, 'exactly one Search upsert');
+ assert.strictEqual((await t.events('blog.post.published')).length, 1, 'exactly one published event');
+ assert.strictEqual((await t.events('blog.index_document.upserted')).length, 1, 'exactly one Search upsert');
const page = await t.get('/@ann/scheduled-one');
const page = await t.get('/@ann/scheduled-one');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
s = await t.ctx.worker.scheduleTick();
s = await t.ctx.worker.scheduleTick();
@@ -70,7 +70,7 @@ const MIN = 60 * 1000;
@@ -70,7 +70,7 @@ const MIN = 60 * 1000;
t.clock.advance(11 * MIN);
t.clock.advance(11 * MIN);
const s = await t.ctx.worker.scheduleTick();
const s = await t.ctx.worker.scheduleTick();
assert.strictEqual(s.done.length, 1);
assert.strictEqual(s.done.length, 1);
- assert.strictEqual(t.ctx.posts.get(p2.id).state, 'published');
+ assert.strictEqual((await t.ctx.posts.get(p2.id)).state, 'published');
assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);
assert.match((await t.get('/@ann/feed.xml')).text, /Scheduled two/);
});
});
@@ -78,7 +78,7 @@ const MIN = 60 * 1000;
@@ -78,7 +78,7 @@ const MIN = 60 * 1000;
const p3 = await mk('Scheduled three');
const p3 = await mk('Scheduled three');
await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });
await t.get(`/api/v1/posts/${p3.id}/schedule`, { as: ann, json: { at: at() } });
await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });
await t.get(`/api/v1/posts/${p3.id}/publish`, { as: ann, json: {} });
- const jobs = t.ctx.store.scheduler.jobs(p3.id);
+ const jobs = await t.ctx.store.scheduler.jobs(p3.id);
assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);
assert.deepStrictEqual(jobs.map((j) => j.status), ['cancelled']);
});
});
@@ -86,14 +86,14 @@ const MIN = 60 * 1000;
@@ -86,14 +86,14 @@ const MIN = 60 * 1000;
const p4 = await mk('Doomed');
const p4 = await mk('Doomed');
await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });
await t.get(`/api/v1/posts/${p4.id}/schedule`, { as: ann, json: { at: at() } });
// The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.
// The revision disappears from under it: simulate a purge (audited erasure) of the post's revisions.
- t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });
+ await t.ctx.store.revisions.purgeEntity(p4.id, { reason: 'test', purgedBy: 'test' });
t.clock.advance(11 * MIN);
t.clock.advance(11 * MIN);
for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }
for (let i = 0; i < 4; i++) { await t.ctx.worker.scheduleTick(); t.clock.advance(5 * MIN); }
- const failed = t.events('blog.schedule.failed');
- assert.strictEqual(failed.length, 1, JSON.stringify(t.ctx.store.scheduler.jobs(p4.id)));
+ const failed = await t.events('blog.schedule.failed');
+ assert.strictEqual(failed.length, 1, JSON.stringify(await t.ctx.store.scheduler.jobs(p4.id)));
assert.strictEqual(failed[0].subject.id, p4.id);
assert.strictEqual(failed[0].subject.id, p4.id);
assert.match(failed[0].payload.error, /revision/i);
assert.match(failed[0].payload.error, /revision/i);
- assert.strictEqual(t.ctx.posts.get(p4.id).state, 'draft');
+ assert.strictEqual((await t.ctx.posts.get(p4.id)).state, 'draft');
const ready = (await t.get('/api/ready')).json();
const ready = (await t.get('/api/ready')).json();
assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');
assert.ok(ready.degraded.includes('scheduler'), 'readiness reports the failed job');
});
});
@@ -36,16 +36,16 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -36,16 +36,16 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
await mk(bob, 'bob', 'Bob Post');
await mk(bob, 'bob', 'Bob Post');
const db = t.ctx.store.db;
const db = t.ctx.store.db;
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT IN ('event_outbox')").all().map((r) => r.name);
- const snapshot = () => Object.fromEntries(tables.map((n) => [n, db.prepare(`SELECT * FROM "${n}"`).all()]));
- const same = (before, label) => { const after = snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };
+ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' AND table_name NOT IN ('event_outbox', 'ov_migrations')").all()).map((r) => r.name);
+ const snapshot = async () => Object.fromEntries(await Promise.all(tables.map(async (n) => [n, await db.prepare(`SELECT * FROM "${n}"`).all()])));
+ const same = async (before, label) => { const after = await snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });
const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });
// The editor's forms live under /write.
// The editor's forms live under /write.
const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });
const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });
await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {
await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {
- const before = snapshot();
+ const before = await snapshot();
for (const p of [annPost, annPrivate, annDraft]) {
for (const p of [annPost, annPrivate, annDraft]) {
refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);
refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);
refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);
refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);
@@ -63,11 +63,11 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -63,11 +63,11 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');
refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');
refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');
refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');
refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');
refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');
- same(before, 'API');
+ await same(before, 'API');
});
});
await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {
await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {
- const before = snapshot();
+ const before = await snapshot();
for (const p of [annPost, annPrivate, annDraft]) {
for (const p of [annPost, annPrivate, annDraft]) {
refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);
refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);
for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {
for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {
@@ -79,7 +79,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -79,7 +79,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');
refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');
refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');
refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');
refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');
refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');
- same(before, 'forms');
+ await same(before, 'forms');
});
});
await check('controls: Ann can do what Bob could not', async () => {
await check('controls: Ann can do what Bob could not', async () => {
@@ -93,7 +93,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -93,7 +93,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
});
});
await check('public events and Search documents carry none of it', async () => {
await check('public events and Search documents carry none of it', async () => {
- const text = JSON.stringify(t.events().filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));
+ const text = JSON.stringify((await t.events()).filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));
assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');
assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
assert.deepStrictEqual(hits, []);
assert.deepStrictEqual(hits, []);
@@ -66,7 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -66,7 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
});
});
await check('the events outbox carries no sentinel', async () => {
await check('the events outbox carries no sentinel', async () => {
- const text = JSON.stringify(t.events());
+ const text = JSON.stringify(await t.events());
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
});
});
@@ -21,26 +21,26 @@ const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');
@@ -21,26 +21,26 @@ const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');
});
});
await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {
await check('the seed creates a DRAFT on the official blog, with one citation per quote', async () => {
- const out = seedOfficialPost(t.ctx, {});
+ const out = await seedOfficialPost(t.ctx, {});
assert.strictEqual(out.created, true);
assert.strictEqual(out.created, true);
assert.strictEqual(out.post.state, 'draft');
assert.strictEqual(out.post.state, 'draft');
assert.strictEqual(out.post.author_subject, t.official.subject);
assert.strictEqual(out.post.author_subject, t.official.subject);
- const rev = t.ctx.store.revisions.head(out.post.id);
+ const rev = await t.ctx.store.revisions.head(out.post.id);
for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);
for (const it of facts.items) assert.ok(rev.content.includes(`> ${it.quote}`), `quote verbatim: ${it.repo}`);
- const cites = t.ctx.store.citations.forRevision(out.post.id, rev.number);
+ const cites = await t.ctx.store.citations.forRevision(out.post.id, rev.number);
assert.strictEqual(cites.length, facts.items.length);
assert.strictEqual(cites.length, facts.items.length);
assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));
assert.ok(cites.every((c) => c.retrievedAt === new Date(facts.collected_at).toISOString() && c.quote && c.quote.text));
assert.strictEqual(rev.meta.authorship.mode, 'imported');
assert.strictEqual(rev.meta.authorship.mode, 'imported');
assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);
assert.doesNotMatch(rev.content, /\bfree\b|\$0/i);
assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');
assert.strictEqual((await t.get(`/@openvibe/${SLUG}`)).status, 404, 'not public');
- assert.strictEqual(t.events(/index_document/).length, 0);
- assert.strictEqual(seedOfficialPost(t.ctx, {}).created, false, 'idempotent');
+ assert.strictEqual((await t.events(/index_document/)).length, 0);
+ assert.strictEqual((await seedOfficialPost(t.ctx, {})).created, false, 'idempotent');
});
});
await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {
await check('publishing the seed needs a named reviewer; with one it is published and cites its sources', async () => {
- assert.throws(() => seedOfficialPost(t.ctx, { publish: true }), /reviewer/);
+ await assert.rejects(async () => await seedOfficialPost(t.ctx, { publish: true }), /reviewer/);
const reviewer = t.network.addUser('reviewer');
const reviewer = t.network.addUser('reviewer');
- const out = seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });
+ const out = await seedOfficialPost(t.ctx, { publish: true, reviewer: reviewer.subject });
assert.strictEqual(out.published, true);
assert.strictEqual(out.published, true);
assert.strictEqual(out.review.reviewer, reviewer.subject);
assert.strictEqual(out.review.reviewer, reviewer.subject);
const page = await t.get(`/@openvibe/${SLUG}`);
const page = await t.get(`/@openvibe/${SLUG}`);
@@ -37,8 +37,8 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
@@ -37,8 +37,8 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
assert.strictEqual(jf.items[0].id, feedIdBefore);
assert.strictEqual(jf.items[0].id, feedIdBefore);
assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');
assert.strictEqual(jf.items[0].url, 'https://openvibe.blog/@dan/new-name');
assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));
assert.match((await t.get('/@dan/feed.xml')).text, new RegExp(`<guid isPermaLink="false">${feedIdBefore}</guid>`));
- assert.strictEqual(t.events('blog.post.updated').length, 1, 'a canonical change of a published post is an update');
- const docs = t.events('blog.index_document.upserted');
+ assert.strictEqual((await t.events('blog.post.updated')).length, 1, 'a canonical change of a published post is an update');
+ const docs = await t.events('blog.index_document.upserted');
assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');
assert.strictEqual(docs[docs.length - 1].payload.canonical_url, 'https://openvibe.blog/@dan/new-name');
});
});
@@ -46,7 +46,7 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
@@ -46,7 +46,7 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });
await t.get(`/api/v1/posts/${post.id}`, { as: dan, method: 'PATCH', json: { slug: 'newest-name' } });
assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');
assert.strictEqual((await t.get('/@dan/old-name')).headers.get('location'), '/@dan/newest-name');
assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');
assert.strictEqual((await t.get('/@dan/new-name')).headers.get('location'), '/@dan/newest-name');
- assert.deepStrictEqual(t.ctx.store.redirects.history(post.id).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);
+ assert.deepStrictEqual((await t.ctx.store.redirects.history(post.id)).map((h) => h.path).sort(), ['/@dan/new-name', '/@dan/old-name']);
});
});
await check('a new post may take a freed slug; the redirect then yields to it', async () => {
await check('a new post may take a freed slug; the redirect then yields to it', async () => {