openvibers/openvibe.wiki Commits
Space import: an owner imports a JSON bundle of pages (the seed file's page shape) into their space through POST /api/v1/spaces/:space/import or the /s/:space/import form, in one transaction (one bad page imports nothing, events included). Strict validation first: unknown fields, duplicate slugs, parents and loops, at most 200 pages, 2 MB and 200,000 characters per page, 200 Sources items; existing slugs stop the import (409) unless on_existing is skip. Pages are created like hand-made ones (drafts unless publish, same renderer, link and citation records, events, Search documents), with imported authorship naming the importer; ai_assisted keeps them noindex until reviewed. 20 imports per hour per address; oversized API bodies answer 413. createPage's body moved into insertPage for both. test/import.test.js
Changes
@@@ -119,6 +119,17 @@ records who attached each Media object and what Media said about it at that momeAI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps andthe Search index; members/private/deleted pages are tombstones and never appear in sitemaps,feeds or search, and are served `Cache-Control: private, no-store`.++- **Importing a space**: an owner imports a bundle of pages into their space —++ `POST /api/v1/spaces/:space/import` or the `/s/:space/import` form (paste the JSON; no++ JavaScript needed). The bundle is JSON in the seed file's page shape (`title`, Markdown `body`,++ `summary`, `parent` by title, `infobox`, `citations`, `visibility`), validated strictly first++ (unknown fields, duplicate slugs, parents, sizes: at most 200 pages, 2 MB, 200,000 characters per++ page) and imported in one transaction: one bad page imports nothing. Pages are created exactly like++ hand-made ones — drafts unless the bundle says `publish`, the same sanitising renderer, link and++ citation records, events and Search documents — with `imported` authorship naming the importer as++ the accountable person and where the text comes from; `ai_assisted: true` keeps every page noindex++ until a person reviews it. Existing slugs stop the import (409) unless `on_existing: "skip"`.++ Twenty imports per hour per address. Format: [server/wiki/import.js](server/wiki/import.js).- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pagesabout the network's repositories, summarised from their README/STATUS files at pinned commits,each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with@@@ -141,7 +152,7 @@ match; network search is OpenVibe.Search).## RoutesPages (SSR, useful without JavaScript): `/`, `/recent`, `/search?q=`, `/new-space`, `/s/:space`,--`/s/:space/new`, `/s/:space/settings`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),++`/s/:space/new`, `/s/:space/settings`, `/s/:space/import`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/sources` (`?rev=N`), `/w/:space/:slug/diff/:a/:b` (`?mode=line`),`/w/:space/:slug/edit`, `/w/:space/:slug/revert?to=N`, `/w/:space/:slug/settings`,`POST /w/:space/:slug/watch|discuss`. Sign-in: `/auth/login|callback|logout|me|refresh|fedcm`@@@ -164,7 +175,7 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of| Capability (proposed) | Routes ||---|---|| `wiki.space.create` | `POST /spaces`, `PATCH /spaces/:space`, `PUT /spaces/:space/roles/:subject` |--| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |++| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /spaces/:space/import`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` || `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` || `wiki.revision.propose` | `POST /proposals` || `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` |@@@ -231,6 +242,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unitshown as broken or withheld on the public page (`media-handoff.test.js`)- permissions are enforced; visitors without SSO read public content only; AI proposals need aperson's approval (`permissions.test.js`)++- only owners import into a space; invalid, oversized and colliding bundles are refused and any++ failure imports nothing (`import.test.js`)- every event is a valid `events.event-envelope@1` and every index document a valid`search.index-document@1` (`visibility.test.js`); the proposals validate against the contractsschemas (`proposals.test.js`); user text is escaped everywhere (`content.test.js`)@@@ -4,7 +4,7 @@"owner": "wiki","status": "active","visibility": "first-party",-- "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages and changing page visibility need the owner role.",++ "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects the person can read in Media and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages, changing page visibility and importing a bundle of pages into the space need the owner role.","permissions": ["wiki:page:write"],@@@ -19,6 +19,7 @@],"implementedBy": ["POST /api/v1/spaces/:space/pages",++ "POST /api/v1/spaces/:space/import","POST /api/v1/pages/:id/revisions","PATCH /api/v1/pages/:id","DELETE /api/v1/pages/:id",@@@ -74,6 +74,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra});app.get('/api/ready', ready.handler);++ // Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.++ app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));app.use('/api/', limiter(60000, 300));app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));@@@ -12,6 +12,7 @@* GET /spaces/:space/roles owner* PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } owner* POST /spaces/:space/pages wiki.page.create { title, body, summary?, infobox?, parent_id?, visibility?, citations?, message? }++ * POST /spaces/:space/import wiki.page.create { pages: [...], publish?, on_existing?, source?, original_author?, ai_assisted? } owner; all or nothing (server/wiki/import.js)* GET /pages/:id wiki.page.read page + the revision the caller may read (?revision=)* PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }* DELETE /pages/:id wiki.page.create owner@@@ -36,7 +37,8 @@*/const express = require('express');const contracts = require('openvibe-contracts');--const { actorMiddleware, guard, run, resolveCitations } = require('./common');++const { actorMiddleware, guard, run, resolveCitations, resolveBundleCitations } = require('./common');++const importer = require('../wiki/import');const { http } = contracts;@@@ -80,8 +82,14 @@ function createApi({ svc, viewers, platform, config, log = console }) {const router = express.Router();const ownOrigin = new URL(config.baseUrl).origin;router.use(http.middleware());++ // An import bundle may be larger than any other request body (server/wiki/import.js).++ router.post('/spaces/:space/import', express.json({ limit: importer.MAX_BUNDLE_BYTES }));router.use(express.json({ limit: '512kb' }));-- router.use((err, req, res, next) => (err ? http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));++ router.use((err, req, res, next) => {++ if (!err) return next();++ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { detail: `The request body is larger than ${err.limit} bytes`, ctx: req.ov });++ return http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov });++ });// Cookie-authenticated writes from another site are refused (Bearer callers are not browsers' ambient credentials).router.use((req, res, next) => {if (req.method === 'GET' || req.method === 'HEAD' || req.headers.authorization) return next();@@@ -122,6 +130,16 @@ function createApi({ svc, viewers, platform, config, log = console }) {return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };}, 201));++ router.post('/spaces/:space/import', guard('wiki.page.create'), R(async (req) => {++ const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);++ await resolveBundleCitations(bundle, platform);++ const out = svc.importPages(space.id, bundle, req.actor);++ return {++ space: serializeSpace(out.space), published: out.published, skipped: out.skipped,++ created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),++ };++ }, 201));++// Pagesrouter.get('/pages/:id', guard('wiki.page.read'), R((req) => {const { page, space } = readable(req, req.params.id);@@@ -94,6 +94,19 @@ async function resolveCitations(list, platform) {return out;}++/** Every page's citations of an import bundle, resolved before the import's transaction. */++async function resolveBundleCitations(bundle, platform) {++ for (const p of bundle.pages) {++ try {++ p.citations = await resolveCitations(p.citations, platform);++ } catch (err) {++ if (err && err.status && err.status < 500) err.message = `"${p.title}": ${err.message}`;++ throw err;++ }++ }++ return bundle;++}++/** The no-JS form's citation rows (cite_url_N, cite_title_N, cite_retrieved_N, cite_quote_N, cite_item_N). */function citationsFromForm(body) {const out = [];@@@ -112,4 +125,4 @@ function citationsFromForm(body) {return out;}--module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, citationsFromForm };++module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };@@@ -9,6 +9,7 @@* GET /s/:space the page tree* GET|POST /s/:space/new new page (?title= prefill, from a red link)* GET|POST /s/:space/settings owner: settings, roles, delete++ * GET|POST /s/:space/import owner: import a JSON bundle of pages (all or nothing)* GET /s/:space/proposals editors: AI proposals to review; POST /proposals/:id* GET /w/:space/:slug the article (?rev=N for an old revision)* GET /w/:space/:slug.json the same data as JSON (same visibility rules)@@@ -31,7 +32,8 @@ const ssr = require('openvibe-publishing/ssr');const { renderPage } = require('../render/layout');const views = require('../render/views');const content = require('../wiki/content');--const { actorMiddleware, resolveCitations, citationsFromForm } = require('./common');++const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');++const importer = require('../wiki/import');function createPages({ svc, viewers, platform, config, log = console }) {const router = express.Router();@@@ -197,6 +199,40 @@ function createPages({ svc, viewers, platform, config, log = console }) {}}));++ // Import pages into a space (owners): the same JSON bundle as POST /api/v1/spaces/:space/import,++ // pasted into a form; the options come from the form's controls.++ const importForm = express.urlencoded({ extended: false, limit: '6mb' });++ function ownersOnly(req, res, space) {++ if (svc.access.canManage(space, req.actor)) return true;++ if (!req.actor.subject) needSignIn(req, res, 'Sign in as an owner of this space to import pages.');++ else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');++ return false;++ }++ router.get('/s/:space/import', (req, res) => {++ const space = locateSpace(req, res);++ if (!space || !ownersOnly(req, res, space)) return;++ send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });++ });++ router.post('/s/:space/import', importForm, wrap(async (req, res) => {++ const space = locateSpace(req, res);++ if (!space || !ownersOnly(req, res, space)) return;++ const b = req.body || {};++ const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };++ const again = (status, error, result = null) => send(req, res, status, views.importPage({ space, values, error, result }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });++ try {++ const parsed = importer.parseBundleText(values.bundle);++ const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };++ Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });++ const { bundle } = svc.prepareImport(space.id, input, req.actor);++ await resolveBundleCitations(bundle, platform);++ const out = svc.importPages(space.id, bundle, req.actor);++ again(200, null, out);++ } catch (err) {++ if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;++ again(err.status, err.message);++ }++ }));++router.get('/s/:space/proposals', (req, res) => {const space = locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;@@@ -45,7 +45,7 @@ function spacePage({ space, tree, canEdit, canManage, proposals }) {<section><h1>${space.name}</h1><p class="wk-muted">${space.kind === 'official' ? 'Official space' : 'Community space'} · ${VIS_LABEL[space.visibility]}</p>${space.description ? html`<p>${space.description}</p>` : ''}--${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}++${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/import">Import pages</a> <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length} AI proposal(s) wait for review.`)} <a href="/s/${e(space.slug)}/proposals">Review them</a></p>` : ''}<h2>Pages</h2>${tree.length ? raw(treeHtml(tree, space)) : html`<p class="wk-muted">No pages yet.</p>`}</section>`;@@@ -243,6 +243,30 @@ ${staff ? html`<label class="wk-check"><input type="checkbox" name="official" va<p><button type="submit">Create space</button></p></form></section>`;}++const IMPORT_EXAMPLE = JSON.stringify({ pages: [++ { title: 'Bread', body: 'All about bread. See [[Rye]].', summary: 'One sentence.' },++ { title: 'Rye', parent: 'Bread', body: 'Rye bread is dense.', infobox: [{ label: 'Gluten', type: 'text', value: 'low' }], citations: [{ url: 'https://example.org/rye', title: 'Rye', retrievedAt: '2026-09-01' }] },++] }, null, 2);++++function importPage({ space, values = {}, error = null, result = null }) {++ const base = `/s/${e(space.slug)}`;++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: base }, { name: 'Import' }])}++<section><h1>Import pages into ${space.name}</h1>++${error ? notice('error', `${error} Nothing was imported.`) : ''}++${result ? html`${notice('ok', `Imported ${result.created.length} page(s)${result.published ? ' and published them' : ' as drafts'}${result.skipped.length ? `; left ${result.skipped.length} existing page(s) alone` : ''}.`)}++<ul>${result.created.map((c) => html`<li><a href="${wpath(space, c.page)}">${c.page.title}</a> <span class="wk-tag">${c.page.state}</span></li>`)}</ul>++${result.skipped.length ? html`<p>Left alone: ${result.skipped.map((x, i) => html`${i ? ', ' : ''}${x.title} (${x.reason})`)}</p>` : ''}` : ''}++<p>Paste a bundle: JSON with a <code>pages</code> list (the same shape as the wiki's seed file). Each page has a <code>title</code> and a Markdown <code>body</code>, and optionally <code>summary</code>, <code>parent</code> (the title of another page), <code>infobox</code>, <code>citations</code> and <code>visibility</code>. Everything is checked first and imported in one step: if one page is invalid, nothing is imported. At most 200 pages and 2 MB.</p>++<details><summary>Example</summary><pre>${IMPORT_EXAMPLE}</pre></details>++<form method="post" action="${base}/import" class="wk-form">++<label>Bundle (JSON) <textarea name="bundle" rows="18" required>${values.bundle || ''}</textarea></label>++<label>Where the text comes from (shown on each page as "Imported from …") <input type="text" name="source" maxlength="200" value="${values.source || ''}"></label>++<label>Pages that already exist <select name="on_existing"><option value="fail"${values.on_existing !== 'skip' ? raw(' selected') : ''}>stop: import nothing</option><option value="skip"${values.on_existing === 'skip' ? raw(' selected') : ''}>leave them alone, import the rest</option></select></label>++<label class="wk-check"><input type="checkbox" name="publish" value="1"${values.publish ? raw(' checked') : ''}> Publish the imported pages (otherwise they are drafts only editors see)</label>++<label class="wk-check"><input type="checkbox" name="ai_assisted" value="1"${values.ai_assisted ? raw(' checked') : ''}> The text was written with AI assistance (search engines skip each page until a person reviews it)</label>++<p><button type="submit">Import</button></p></form></section>`;++}++function spaceSettingsPage({ space, roles, error = null, flash = null }) {return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: 'Settings' }])}<section><h1>Settings of ${space.name}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}@@@ -260,6 +284,7 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td><label>Person (usr_… subject id) <input type="text" name="subject" required pattern="usr_[0-9A-Z]{26}"></label><label>Role <select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="owner">owner</option></select></label><p><button type="submit">Grant role</button></p></form>++<h2>Import</h2><p><a href="/s/${e(space.slug)}/import">Import pages from a bundle</a></p><h2>Delete</h2><form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this space and take every page offline (addresses answer 410 Gone)</label><p><button type="submit">Delete space</button></p></form></section>`;}@@@ -349,6 +374,6 @@ function signInPage({ next, message }) {module.exports = {home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,-- spaceSettingsPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,++ spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,signInPage, errorBody, wpath, VIS_LABEL,};nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * Space import: the bundle format and its strict validation (the import itself is++ * service.importPages, one transaction).++ *++ * A bundle is JSON, the same page shape as the seed file (seeds/openvibe.json):++ *++ * {++ * "pages": [++ * { "title": "Rye", "body": "Markdown with [[links]]", "summary": "…", "parent": "Bread",++ * "infobox": [{ "label": "Gluten", "type": "text", "value": "low" }],++ * "citations": [{ "url": "https://…", "title": "…", "retrievedAt": "2026-09-01" }],++ * "visibility": "public", "message": "…" }++ * ],++ * "publish": false, // default: every page is created as a draft++ * "on_existing": "fail", // "fail" (default: nothing is imported) | "skip" (leave those pages alone)++ * "source": "my old wiki", // where the text comes from (shown as "Imported from …")++ * "original_author": "…", // optional++ * "ai_assisted": false // true: noindex until a person reviews each page, like AI output++ * }++ *++ * Slugs come from titles exactly as for pages created by hand; `parent` is the title of another page++ * of the bundle or of the space. A `space` key (as in the seed file) is ignored: the target space is++ * the one in the URL. Anything else unknown is refused, so a typo never imports silently.++ */++const content = require('./content');++++const MAX_BUNDLE_BYTES = 2 * 1024 * 1024;++const MAX_PAGES = 200;++const MAX_PAGE_BODY = 200000;++const MAX_SUMMARY = 300;++const MAX_MESSAGE = 300;++const MAX_PAGE_CITATIONS = 50;++const MAX_SOURCE_ITEMS = 200;++const BUNDLE_KEYS = new Set(['pages', 'publish', 'on_existing', 'source', 'original_author', 'ai_assisted', 'space']);++const PAGE_KEYS = new Set(['title', 'body', 'summary', 'parent', 'infobox', 'citations', 'visibility', 'message']);++const ON_EXISTING = ['fail', 'skip'];++++class ImportError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++++const bad = (code, message, status = 422) => { throw new ImportError(status, code, message); };++const optString = (v, max, what) => {++ if (v == null || v === '') return null;++ if (typeof v !== 'string') bad('import.invalid', `${what} must be text`);++ if (v.length > max) bad('import.invalid', `${what} is at most ${max} characters`);++ return v;++};++const optBool = (v, what) => {++ if (v == null) return false;++ if (typeof v !== 'boolean') bad('import.invalid', `${what} must be true or false`);++ return v;++};++++/** The bundle as text (the form) → object; enforces the byte limit before parsing. */++function parseBundleText(text) {++ const s = String(text == null ? '' : text);++ if (Buffer.byteLength(s, 'utf8') > MAX_BUNDLE_BYTES) bad('import.too_large', `A bundle is at most ${MAX_BUNDLE_BYTES / 1024 / 1024} MB`, 413);++ if (!s.trim()) bad('import.invalid', 'Paste a bundle: JSON with a "pages" list');++ try { return JSON.parse(s); } catch { return bad('import.invalid_json', 'The bundle is not valid JSON', 400); }++}++++/**++ * Validates a bundle object → { pages: [{ title, slug, body, summary, parentSlug, infobox, citations,++ * visibility, message }], publish, onExisting, source, originalAuthor, aiAssisted }. Throws++ * ImportError (422 invalid, 413 too large). Citations are left as given (resolved by the caller).++ */++function validateBundle(input) {++ const b = Array.isArray(input) ? { pages: input } : input;++ if (!b || typeof b !== 'object' || Array.isArray(b)) bad('import.invalid', 'A bundle is a JSON object with a "pages" list');++ for (const k of Object.keys(b)) if (!BUNDLE_KEYS.has(k)) bad('import.unknown_field', `Unknown bundle field "${k}"`);++ if (!Array.isArray(b.pages) || !b.pages.length) bad('import.invalid', 'A bundle needs a non-empty "pages" list');++ if (b.pages.length > MAX_PAGES) bad('import.too_many_pages', `A bundle holds at most ${MAX_PAGES} pages`);++ const onExisting = b.on_existing == null || b.on_existing === '' ? 'fail' : b.on_existing;++ if (!ON_EXISTING.includes(onExisting)) bad('import.invalid', `on_existing is one of ${ON_EXISTING.join(', ')}`);++ const out = {++ publish: optBool(b.publish, 'publish'),++ onExisting,++ source: optString(b.source, 200, 'source'),++ originalAuthor: optString(b.original_author, 120, 'original_author'),++ aiAssisted: optBool(b.ai_assisted, 'ai_assisted'),++ pages: [],++ };++ const seen = new Map();++ b.pages.forEach((p, i) => {++ const where = `page ${i + 1}`;++ if (!p || typeof p !== 'object' || Array.isArray(p)) bad('import.invalid', `${where} is not an object`);++ for (const k of Object.keys(p)) if (!PAGE_KEYS.has(k)) bad('import.unknown_field', `${where}: unknown field "${k}"`);++ if (typeof p.title !== 'string' || !p.title.trim()) bad('import.invalid', `${where} needs a title`);++ const title = p.title.replace(/\s+/g, ' ').trim();++ if (title.length > 200) bad('import.invalid', `${where}: a title is at most 200 characters`);++ let slug;++ try { slug = content.pageSlug(title); } catch (err) { bad('import.invalid', `${where} ("${title}"): ${err.message}`); }++ if (seen.has(slug)) bad('import.duplicate_slug', `"${title}" and "${seen.get(slug)}" would both be /${slug}`);++ seen.set(slug, title);++ if (typeof p.body !== 'string') bad('import.invalid', `${where} ("${title}"): body must be Markdown text`);++ if (p.body.length > MAX_PAGE_BODY) bad('import.page_too_large', `${where} ("${title}"): a page is at most ${MAX_PAGE_BODY} characters`, 413);++ let parentSlug = null;++ if (p.parent != null && p.parent !== '') {++ if (typeof p.parent !== 'string') bad('import.invalid', `${where} ("${title}"): parent is a page title`);++ try { parentSlug = content.pageSlug(p.parent); } catch { bad('import.invalid', `${where} ("${title}"): parent "${p.parent}" is not a page title`); }++ if (parentSlug === slug) bad('import.invalid_parent', `${where} ("${title}") cannot be its own parent`);++ }++ if (p.infobox != null && !Array.isArray(p.infobox)) bad('import.invalid', `${where} ("${title}"): infobox must be a list`);++ if (p.citations != null && !Array.isArray(p.citations)) bad('import.invalid', `${where} ("${title}"): citations must be a list`);++ if (p.visibility != null && !['public', 'members', 'private'].includes(p.visibility)) bad('import.invalid', `${where} ("${title}"): visibility is public, members or private`);++ if ((p.citations || []).length > MAX_PAGE_CITATIONS) bad('import.invalid', `${where} ("${title}"): at most ${MAX_PAGE_CITATIONS} citations per page`);++ out.pages.push({++ title, slug, body: p.body, parentSlug,++ summary: optString(p.summary, MAX_SUMMARY, `${where} summary`),++ infobox: p.infobox || [], citations: p.citations || [],++ visibility: p.visibility || 'public',++ message: optString(p.message, MAX_MESSAGE, `${where} message`),++ });++ });++ const cites = out.pages.flatMap((p) => p.citations);++ // Every Sources item citation is one call to OpenVibe.Sources before the import.++ const items = cites.filter((c) => c && typeof c === 'object' && (c.source_item_id || c.sourceItemId)).length;++ if (items > MAX_SOURCE_ITEMS) bad('import.too_many_citations', `A bundle cites at most ${MAX_SOURCE_ITEMS} OpenVibe.Sources items`);++ return out;++}++++module.exports = { parseBundleText, validateBundle, ImportError, MAX_BUNDLE_BYTES, MAX_PAGES, MAX_PAGE_BODY };@@@ -19,6 +19,7 @@ const ssr = require('openvibe-publishing/ssr');const { isMediaId } = require('openvibe-publishing/media');const { ulid } = require('openvibe-contracts').ids;const content = require('./content');++const importer = require('./import');const { createAccess, VISIBILITIES } = require('./access');class WikiError extends Error {@@@ -396,6 +397,34 @@ function createWikiService({ db, stores, outbox, config, community = null, now =return authorship.record({ mode: 'human', authors: [requirePerson(actor)] });}++ /** A new page with its first revision (permission checks and authorship are the caller's). */++ function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {++ const cleanTitle = checkTitle(title);++ const slug = wrapContentError(() => content.pageSlug(cleanTitle));++ const text = checkBody(body == null ? '' : body);++ const box = wrapContentError(() => content.normalizeInfobox(infobox));++ const vis = checkVisibility(visibility, 'public');++ return tx(() => {++ const existing = q.pageBySlug.get(space.id, slug);++ if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });++ if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }++ const t = now();++ const id = pageIdNew(t);++ q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });++ redirects.release(`/w/${space.slug}/${slug}`);++ const { revision } = wrapContentError(() => revisions.create({++ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },++ meta: { authorship: rec }, author: actorId(actor), message,++ }));++ const page = q.pageById.get(id);++ writeRevisionExtras(space, page, revision, box);++ attachCitationList(page, revision.number, cites, actor);++ revisionEvent(space, page, revision, actor);++ if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);++ return { page, revision };++ });++ }++// ── Publication (internal: no permission checks) ─────────function publishRevision(page, revisionNumber, actor) {const rev = revisions.get(page.id, revisionNumber);@@@ -557,29 +586,75 @@ function createWikiService({ db, stores, outbox, config, community = null, now =const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);// authorship.record() keeps label and original author only; the AI-assistance flag is ours.if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;-- const cleanTitle = checkTitle(title);-- const slug = wrapContentError(() => content.pageSlug(cleanTitle));-- const text = checkBody(body == null ? '' : body);-- const box = wrapContentError(() => content.normalizeInfobox(infobox));-- const vis = checkVisibility(visibility, 'public');++ return insertPage(space, { title, body, infobox, parentId, visibility, citations: cites, message, summary }, actor, rec);++ },++++ /**++ * Space import, step 1 (before any outbound call): who may import, and a strictly validated++ * bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the++ * caller cannot read is "not found", exactly like everywhere else.++ */++ prepareImport(spaceIdOrSlug, input, actor) {++ const space = spaceOrFail(spaceIdOrSlug);++ if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');++ if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');++ requirePerson(actor);++ return { space, bundle: importer.validateBundle(input) };++ },++++ /**++ * Space import, step 2: every page of the bundle (citations already resolved) in ONE++ * transaction — any failure imports nothing, events included. Each page is created exactly++ * like one made by hand (same slug rule, sanitising renderer at view time, [[link]] and++ * citation records, wiki.revision.created, the importer watching it), as a draft unless the++ * bundle says publish (then the usual publication events and Search documents follow).++ * Authorship is `imported` with the importer as the accountable person; an AI-assisted bundle++ * stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing++ * imported) or "skip" (left alone, reported).++ */++ importPages(spaceIdOrSlug, bundle, actor) {++ const space = spaceOrFail(spaceIdOrSlug);++ if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');++ const who = requirePerson(actor);++ const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));++ if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;return tx(() => {-- const existing = q.pageBySlug.get(space.id, slug);-- if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });-- if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }-- const t = now();-- const id = pageIdNew(t);-- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });-- redirects.release(`/w/${space.slug}/${slug}`);-- const { revision } = wrapContentError(() => revisions.create({-- entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },-- meta: { authorship: rec }, author: actorId(actor), message,-- }));-- const page = q.pageById.get(id);-- writeRevisionExtras(space, page, revision, box);-- attachCitationList(page, revision.number, cites, actor);-- revisionEvent(space, page, revision, actor);-- if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);-- return { page, revision };++ const existing = bundle.pages.map((p) => ({ p, page: q.pageBySlug.get(space.id, p.slug) })).filter((x) => x.page);++ if (existing.length && bundle.onExisting !== 'skip') {++ fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });++ }++ const skipped = existing.map((x) => ({ slug: x.p.slug, title: x.p.title, reason: x.page.state === 'deleted' ? 'deleted' : 'exists' }));++ const skip = new Set(skipped.map((x) => x.slug));++ const toCreate = new Set(bundle.pages.map((p) => p.slug).filter((sl) => !skip.has(sl)));++ const made = new Map();++ const created = [];++ let pending = bundle.pages.filter((p) => toCreate.has(p.slug));++ // Parents first: a page whose parent (in the bundle) is not created yet waits for the next pass.++ while (pending.length) {++ const next = [];++ for (const p of pending) {++ let parentId = null;++ if (p.parentSlug) {++ if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }++ const parent = made.get(p.parentSlug) || q.pageBySlug.get(space.id, p.parentSlug);++ if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);++ parentId = parent.id;++ }++ let out;++ try {++ out = insertPage(space, { ...p, parentId }, actor, rec);++ } catch (err) {++ if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);++ throw err;++ }++ made.set(p.slug, out.page);++ created.push(out);++ }++ if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);++ pending = next;++ }++ if (bundle.publish) for (const c of created) publishRevision(q.pageById.get(c.page.id), c.revision.number, actor);++ return { space, created: created.map((c) => ({ page: q.pageById.get(c.page.id), revision: c.revision })), skipped, published: !!bundle.publish };});},nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++/**++ * Space import (POST /api/v1/spaces/:space/import and the /s/:space/import form): an owner imports a++ * JSON bundle of pages in one transaction. Pages are created exactly like hand-made ones (drafts by++ * default, imported authorship with the importer accountable, links, citations, events, escaping);++ * only owners import; oversized, malformed, unknown-field, duplicate-slug and colliding bundles are++ * refused; any failure imports nothing at all.++ */++const assert = require('assert');++const H = require('./helpers');++++const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;++++(async () => {++ const h = await H.boot();++ const owner = H.subject();++ const editor = H.subject();++ const tokOwner = H.userToken({ subject: owner });++ const tokEditor = H.userToken({ subject: editor });++ const imp = (token, body, space = 'bakery') => H.req(h, 'POST', `/api/v1/spaces/${space}/import`, { token, body });++ const bundle = {++ source: 'the old bakery wiki',++ pages: [++ { title: 'Rye', parent: 'Bread', body: `Rye is dense. Back to [[Bread]]. [click](javascript:alert(1)) <script>alert(1)</script> ${H.LONG}`, summary: 'Dense bread.', infobox: [{ label: 'Gluten', type: 'text', value: 'low' }], citations: [{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01' }] },++ { title: 'Bread', body: `All about bread; see [[Rye]] and [[Spelt]]. ${H.LONG}` },++ { title: 'Private notes', body: 'Only for us.', visibility: 'private' },++ ],++ };++ try {++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tokOwner, body: { name: 'Bakery', slug: 'bakery' } });++ assert.strictEqual(r.status, 201);++ await H.req(h, 'PUT', `/api/v1/spaces/bakery/roles/${editor}`, { token: tokOwner, body: { role: 'editor' } });++ await H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });++++ // Only an owner, acting as a person, imports.++ const pagesBefore = count(h, 'wiki_pages');++ r = await imp(tokEditor, bundle);++ assert.strictEqual(r.status, 403, 'an editor is not an owner');++ assert.strictEqual(r.json.code, 'import.forbidden');++ assert.strictEqual((await imp(null, bundle)).status, 403, 'anonymous');++ assert.strictEqual((await imp(tokOwner, bundle, 'secret')).status, 404, 'a private space someone else owns is not found');++ r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] }), bundle);++ assert.strictEqual(r.status, 403, 'a service acting as itself is no owner');++ r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);++ assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');++ assert.strictEqual(count(h, 'wiki_pages'), pagesBefore);++++ // Invalid and oversized bundles are refused before anything is written.++ const refusals = [++ [{ pages: [] }, 422, 'import.invalid'],++ [{ pages: 'x' }, 422, 'import.invalid'],++ [{ pages: [{ title: 'A', body: 'x', colour: 'red' }] }, 422, 'import.unknown_field'],++ [{ pages: [{ title: 'A', body: 'x' }], overwrite: true }, 422, 'import.unknown_field'],++ [{ pages: [{ body: 'no title' }] }, 422, 'import.invalid'],++ [{ pages: [{ title: 'A', body: 42 }] }, 422, 'import.invalid'],++ [{ pages: [{ title: 'A', body: 'x', visibility: 'world' }] }, 422, 'import.invalid'],++ [{ pages: [{ title: 'A', body: 'x' }], publish: 'yes' }, 422, 'import.invalid'],++ [{ pages: [{ title: 'A', body: 'x' }], on_existing: 'overwrite' }, 422, 'import.invalid'],++ [{ pages: [{ title: 'Rye bread', body: 'x' }, { title: 'rye BREAD', body: 'y' }] }, 422, 'import.duplicate_slug'],++ [{ pages: Array.from({ length: 201 }, (_x, i) => ({ title: `P${i}`, body: 'x' })) }, 422, 'import.too_many_pages'],++ [{ pages: [{ title: 'Big', body: 'x'.repeat(200001) }] }, 413, 'import.page_too_large'],++ [{ pages: [{ title: 'A', body: 'x', summary: 's'.repeat(301) }] }, 422, 'import.invalid'],++ ];++ for (const [body, status, code] of refusals) {++ r = await imp(tokOwner, body);++ assert.strictEqual(r.status, status, `${JSON.stringify(body).slice(0, 80)} → ${r.text.slice(0, 200)}`);++ assert.strictEqual(r.json.code, code, JSON.stringify(body).slice(0, 80));++ }++ r = await H.req(h, 'POST', '/api/v1/spaces/bakery/import', { token: tokOwner, headers: { 'Content-Type': 'application/json' } });++ assert.strictEqual(r.status, 422, 'no body');++ r = await fetch(`${h.base}/api/v1/spaces/bakery/import`, { method: 'POST', headers: { Authorization: `Bearer ${tokOwner}`, 'Content-Type': 'application/json' }, body: '{"pages": [' });++ assert.strictEqual(r.status, 400, 'malformed JSON');++ r = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });++ assert.strictEqual(r.status, 413, 'a bundle over 2 MB');++ assert.strictEqual(r.json.code, 'request.too_large');++ assert.strictEqual(count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');++++ // Atomicity: the third page is invalid (an infobox value that is not a number; a parent that++ // is nowhere) → none of the pages, revisions, citations or events exist afterwards.++ const snapshot = () => ['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t));++ const before = snapshot();++ for (const broken of [++ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },++ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },++ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', citations: [{ url: 'ftp://files.example/spelt' }] }] },++ { pages: [{ title: 'Egg', parent: 'Hen', body: 'x' }, { title: 'Hen', parent: 'Egg', body: 'y' }] },++ ]) {++ r = await imp(tokOwner, broken);++ assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);++ assert.deepStrictEqual(snapshot(), before, `all or nothing: ${r.json.code}`);++ }++++ // Success: drafts, attributed to the importer, links and citations recorded, events emitted.++ r = await imp(tokOwner, bundle);++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');++ assert.strictEqual(r.json.published, false);++ const space = h.svc.findSpace('bakery');++ const rye = h.svc.findPage(space.id, 'rye');++ const bread = h.svc.findPage(space.id, 'bread');++ assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');++ assert.strictEqual(h.svc.findPage(space.id, 'private-notes').visibility, 'private');++ const rev = h.stores.revisions.get(rye.id, 1);++ assert.strictEqual(rev.author, owner);++ assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });++ assert.strictEqual(h.svc.citationsOf(rye, 1)[0].url, 'https://flour.example/rye');++ assert.ok(h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');++ const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));++ assert.strictEqual(created.length, 2);++ assert.ok(h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');++ // Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.++ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);++ r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('Imported from the old bakery wiki'));++ assert.ok(!/<script>alert|href="javascript/.test(r.text), 'escaped, no javascript: link');++ assert.ok(!r.text.includes('<a class="ov-wikilink" href="/w/bakery/bread">'), 'links go through the usual resolver: a draft target is not a blue link');++++ // Existing pages: "fail" imports nothing, "skip" leaves them alone and imports the rest.++ const again = { pages: [{ title: 'Rye', body: 'replaced?' }, { title: 'Spelt', body: `Spelt is old. ${H.LONG}`, parent: 'Bread', citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-03' }] }] };++ r = await imp(tokOwner, again);++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json.code, 'import.pages_exist');++ assert.strictEqual(h.svc.findPage(space.id, 'spelt'), null);++ r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);++ assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);++ assert.strictEqual(h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');++ // Published through the import: readable, indexed like any page (upsert event).++ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);++ const spelt = h.svc.findPage(space.id, 'spelt');++ assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');++ assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));++ assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));++++ // AI-assisted bundles stay noindex until a person reviews each page.++ r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });++ assert.strictEqual(r.status, 201);++ r = await H.req(h, 'GET', '/w/bakery/barley');++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));++ assert.ok(r.text.includes('Not yet reviewed by a person.'));++++ // The form (no JavaScript): owners only, same validation, same all-or-nothing.++ const cookie = H.cookieFor(tokOwner);++ assert.strictEqual((await H.req(h, 'GET', '/s/bakery/import', { cookie: H.cookieFor(tokEditor) })).status, 403);++ assert.strictEqual((await H.req(h, 'GET', '/s/bakery/import')).status, 401);++ r = await H.req(h, 'GET', '/s/bakery/import', { cookie });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('name="bundle"') && r.text.includes('action="/s/bakery/import"'));++ assert.ok((await H.req(h, 'GET', '/s/bakery', { cookie })).text.includes('href="/s/bakery/import"'), 'linked from the space page for owners');++ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, form: { bundle: '{"pages": [', on_existing: 'fail' } });++ assert.strictEqual(r.status, 400);++ assert.ok(r.text.includes('The bundle is not valid JSON') && r.text.includes('Nothing was imported.'));++ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, form: { bundle: JSON.stringify([{ title: 'Oats', body: `Oat bread. ${H.LONG}` }]), on_existing: 'fail', publish: '1', source: 'my notes' } });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('Imported 1 page(s) and published them.') && r.text.includes('href="/w/bakery/oats"'));++ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/oats')).status, 200);++ r = await H.req(h, 'POST', '/s/bakery/import', { cookie: H.cookieFor(tokEditor), form: { bundle: JSON.stringify([{ title: 'Nope', body: 'x' }]) } });++ assert.strictEqual(r.status, 403);++ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });++ assert.strictEqual(r.status, 403, 'cross-site form posts are refused');++ assert.strictEqual(h.svc.findPage(space.id, 'nope'), null);++ assert.strictEqual(h.svc.findPage(space.id, 'csrf'), null);++ console.log('import ok');++ } finally {++ await h.stop();++ }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -119,6 +119,17 @@ records who attached each Media object and what Media said about it at that mome
@@ -119,6 +119,17 @@ records who attached each Media object and what Media said about it at that mome
AI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
AI-assisted imports only after a person's review; owner-requested noindex). Only public, published, indexable pages enter sitemaps and
the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
the Search index; members/private/deleted pages are tombstones and never appear in sitemaps,
feeds or search, and are served `Cache-Control: private, no-store`.
feeds or search, and are served `Cache-Control: private, no-store`.
+- **Importing a space**: an owner imports a bundle of pages into their space —
+ `POST /api/v1/spaces/:space/import` or the `/s/:space/import` form (paste the JSON; no
+ JavaScript needed). The bundle is JSON in the seed file's page shape (`title`, Markdown `body`,
+ `summary`, `parent` by title, `infobox`, `citations`, `visibility`), validated strictly first
+ (unknown fields, duplicate slugs, parents, sizes: at most 200 pages, 2 MB, 200,000 characters per
+ page) and imported in one transaction: one bad page imports nothing. Pages are created exactly like
+ hand-made ones — drafts unless the bundle says `publish`, the same sanitising renderer, link and
+ citation records, events and Search documents — with `imported` authorship naming the importer as
+ the accountable person and where the text comes from; `ai_assisted: true` keeps every page noindex
+ until a person reviews it. Existing slugs stop the import (409) unless `on_existing: "skip"`.
+ Twenty imports per hour per address. Format: [server/wiki/import.js](server/wiki/import.js).
- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
- **Seed**: `npm run seed` imports the official "OpenVibe" space (`seeds/openvibe.json`): ten pages
about the network's repositories, summarised from their README/STATUS files at pinned commits,
about the network's repositories, summarised from their README/STATUS files at pinned commits,
each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with
each cited with a GitHub permalink and retrieval time, authorship recorded as `imported` with
@@ -141,7 +152,7 @@ match; network search is OpenVibe.Search).
@@ -141,7 +152,7 @@ match; network search is OpenVibe.Search).
## Routes
## Routes
Pages (SSR, useful without JavaScript): `/`, `/recent`, `/search?q=`, `/new-space`, `/s/:space`,
Pages (SSR, useful without JavaScript): `/`, `/recent`, `/search?q=`, `/new-space`, `/s/:space`,
-`/s/:space/new`, `/s/:space/settings`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),
+`/s/:space/new`, `/s/:space/settings`, `/s/:space/import`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),
`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/sources` (`?rev=N`), `/w/:space/:slug/diff/:a/:b` (`?mode=line`),
`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/sources` (`?rev=N`), `/w/:space/:slug/diff/:a/:b` (`?mode=line`),
`/w/:space/:slug/edit`, `/w/:space/:slug/revert?to=N`, `/w/:space/:slug/settings`,
`/w/:space/:slug/edit`, `/w/:space/:slug/revert?to=N`, `/w/:space/:slug/settings`,
`POST /w/:space/:slug/watch|discuss`. Sign-in: `/auth/login|callback|logout|me|refresh|fedcm`
`POST /w/:space/:slug/watch|discuss`. Sign-in: `/auth/login|callback|logout|me|refresh|fedcm`
@@ -164,7 +175,7 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
@@ -164,7 +175,7 @@ applies). Errors are RFC 9457 problem+json. The full route list is at the top of
| Capability (proposed) | Routes |
| Capability (proposed) | Routes |
|---|---|
|---|---|
| `wiki.space.create` | `POST /spaces`, `PATCH /spaces/:space`, `PUT /spaces/:space/roles/:subject` |
| `wiki.space.create` | `POST /spaces`, `PATCH /spaces/:space`, `PUT /spaces/:space/roles/:subject` |
-| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
+| `wiki.page.create` | `POST /spaces/:space/pages`, `POST /spaces/:space/import`, `POST /pages/:id/revisions`, `PATCH`/`DELETE /pages/:id`, `POST /pages/:id/media[/verify]` |
| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
| `wiki.page.read` | `GET /pages/:id`, `/revisions`, `/revisions/:n`, `/diff`, `/revisions/:n/citations`, `GET /proposals/:id` |
| `wiki.revision.propose` | `POST /proposals` |
| `wiki.revision.propose` | `POST /proposals` |
| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` |
| `wiki.revision.publish` | `POST /pages/:id/publish`, `/schedule`, `/unpublish`, `POST /pages/:id/revisions/:n/review`, `POST /proposals/:id/review` |
@@ -231,6 +242,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -231,6 +242,8 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
shown as broken or withheld on the public page (`media-handoff.test.js`)
shown as broken or withheld on the public page (`media-handoff.test.js`)
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
person's approval (`permissions.test.js`)
person's approval (`permissions.test.js`)
+- only owners import into a space; invalid, oversized and colliding bundles are refused and any
+ failure imports nothing (`import.test.js`)
- every event is a valid `events.event-envelope@1` and every index document a valid
- every event is a valid `events.event-envelope@1` and every index document a valid
`search.index-document@1` (`visibility.test.js`); the proposals validate against the contracts
`search.index-document@1` (`visibility.test.js`); the proposals validate against the contracts
schemas (`proposals.test.js`); user text is escaped everywhere (`content.test.js`)
schemas (`proposals.test.js`); user text is escaped everywhere (`content.test.js`)
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"owner": "wiki",
"owner": "wiki",
"status": "active",
"status": "active",
"visibility": "first-party",
"visibility": "first-party",
- "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages and changing page visibility need the owner role.",
+ "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects the person can read in Media and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages, changing page visibility and importing a bundle of pages into the space need the owner role.",
"permissions": [
"permissions": [
"wiki:page:write"
"wiki:page:write"
],
],
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
],
],
"implementedBy": [
"implementedBy": [
"POST /api/v1/spaces/:space/pages",
"POST /api/v1/spaces/:space/pages",
+ "POST /api/v1/spaces/:space/import",
"POST /api/v1/pages/:id/revisions",
"POST /api/v1/pages/:id/revisions",
"PATCH /api/v1/pages/:id",
"PATCH /api/v1/pages/:id",
"DELETE /api/v1/pages/:id",
"DELETE /api/v1/pages/:id",
@@ -74,6 +74,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -74,6 +74,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
});
});
app.get('/api/ready', ready.handler);
app.get('/api/ready', ready.handler);
+ // Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.
+ app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));
app.use('/api/', limiter(60000, 300));
app.use('/api/', limiter(60000, 300));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* GET /spaces/:space/roles owner
* GET /spaces/:space/roles owner
* PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } owner
* PUT /spaces/:space/roles/:subject wiki.space.create { role: owner|editor|viewer|null } owner
* POST /spaces/:space/pages wiki.page.create { title, body, summary?, infobox?, parent_id?, visibility?, citations?, message? }
* POST /spaces/:space/pages wiki.page.create { title, body, summary?, infobox?, parent_id?, visibility?, citations?, message? }
+ * POST /spaces/:space/import wiki.page.create { pages: [...], publish?, on_existing?, source?, original_author?, ai_assisted? } owner; all or nothing (server/wiki/import.js)
* GET /pages/:id wiki.page.read page + the revision the caller may read (?revision=)
* GET /pages/:id wiki.page.read page + the revision the caller may read (?revision=)
* PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }
* PATCH /pages/:id wiki.page.create { slug?, parent_id?, visibility?, noindex? }
* DELETE /pages/:id wiki.page.create owner
* DELETE /pages/:id wiki.page.create owner
@@ -36,7 +37,8 @@
@@ -36,7 +37,8 @@
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { actorMiddleware, guard, run, resolveCitations } = require('./common');
+const { actorMiddleware, guard, run, resolveCitations, resolveBundleCitations } = require('./common');
+const importer = require('../wiki/import');
const { http } = contracts;
const { http } = contracts;
@@ -80,8 +82,14 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -80,8 +82,14 @@ function createApi({ svc, viewers, platform, config, log = console }) {
const router = express.Router();
const router = express.Router();
const ownOrigin = new URL(config.baseUrl).origin;
const ownOrigin = new URL(config.baseUrl).origin;
router.use(http.middleware());
router.use(http.middleware());
+ // An import bundle may be larger than any other request body (server/wiki/import.js).
+ router.post('/spaces/:space/import', express.json({ limit: importer.MAX_BUNDLE_BYTES }));
router.use(express.json({ limit: '512kb' }));
router.use(express.json({ limit: '512kb' }));
- router.use((err, req, res, next) => (err ? http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov }) : next()));
+ router.use((err, req, res, next) => {
+ if (!err) return next();
+ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { detail: `The request body is larger than ${err.limit} bytes`, ctx: req.ov });
+ return http.sendProblem(res, 400, 'request.invalid_json', { detail: 'Malformed JSON body', ctx: req.ov });
+ });
// Cookie-authenticated writes from another site are refused (Bearer callers are not browsers' ambient credentials).
// Cookie-authenticated writes from another site are refused (Bearer callers are not browsers' ambient credentials).
router.use((req, res, next) => {
router.use((req, res, next) => {
if (req.method === 'GET' || req.method === 'HEAD' || req.headers.authorization) return next();
if (req.method === 'GET' || req.method === 'HEAD' || req.headers.authorization) return next();
@@ -122,6 +130,16 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -122,6 +130,16 @@ function createApi({ svc, viewers, platform, config, log = console }) {
return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
return { page: serializePage(out.page, svc, space), revision: serializeRevision(out.revision), citations: svc.citationsOf(out.page, out.revision.number).map(serializeCitation) };
}, 201));
}, 201));
+ router.post('/spaces/:space/import', guard('wiki.page.create'), R(async (req) => {
+ const { space, bundle } = svc.prepareImport(req.params.space, req.body, req.actor);
+ await resolveBundleCitations(bundle, platform);
+ const out = svc.importPages(space.id, bundle, req.actor);
+ return {
+ space: serializeSpace(out.space), published: out.published, skipped: out.skipped,
+ created: out.created.map((c) => ({ ...serializePage(c.page, svc, out.space), revision: c.revision.number })),
+ };
+ }, 201));
+
// Pages
// Pages
router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
const { page, space } = readable(req, req.params.id);
const { page, space } = readable(req, req.params.id);
@@ -94,6 +94,19 @@ async function resolveCitations(list, platform) {
@@ -94,6 +94,19 @@ async function resolveCitations(list, platform) {
return out;
return out;
}
}
+/** Every page's citations of an import bundle, resolved before the import's transaction. */
+async function resolveBundleCitations(bundle, platform) {
+ for (const p of bundle.pages) {
+ try {
+ p.citations = await resolveCitations(p.citations, platform);
+ } catch (err) {
+ if (err && err.status && err.status < 500) err.message = `"${p.title}": ${err.message}`;
+ throw err;
+ }
+ }
+ return bundle;
+}
+
/** The no-JS form's citation rows (cite_url_N, cite_title_N, cite_retrieved_N, cite_quote_N, cite_item_N). */
/** The no-JS form's citation rows (cite_url_N, cite_title_N, cite_retrieved_N, cite_quote_N, cite_item_N). */
function citationsFromForm(body) {
function citationsFromForm(body) {
const out = [];
const out = [];
@@ -112,4 +125,4 @@ function citationsFromForm(body) {
@@ -112,4 +125,4 @@ function citationsFromForm(body) {
return out;
return out;
}
}
-module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, citationsFromForm };
+module.exports = { actorMiddleware, guard, sendError, run, resolveCitations, resolveBundleCitations, citationsFromForm };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* GET /s/:space the page tree
* GET /s/:space the page tree
* GET|POST /s/:space/new new page (?title= prefill, from a red link)
* GET|POST /s/:space/new new page (?title= prefill, from a red link)
* GET|POST /s/:space/settings owner: settings, roles, delete
* GET|POST /s/:space/settings owner: settings, roles, delete
+ * GET|POST /s/:space/import owner: import a JSON bundle of pages (all or nothing)
* GET /s/:space/proposals editors: AI proposals to review; POST /proposals/:id
* GET /s/:space/proposals editors: AI proposals to review; POST /proposals/:id
* GET /w/:space/:slug the article (?rev=N for an old revision)
* GET /w/:space/:slug the article (?rev=N for an old revision)
* GET /w/:space/:slug.json the same data as JSON (same visibility rules)
* GET /w/:space/:slug.json the same data as JSON (same visibility rules)
@@ -31,7 +32,8 @@ const ssr = require('openvibe-publishing/ssr');
@@ -31,7 +32,8 @@ const ssr = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const views = require('../render/views');
const views = require('../render/views');
const content = require('../wiki/content');
const content = require('../wiki/content');
-const { actorMiddleware, resolveCitations, citationsFromForm } = require('./common');
+const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
+const importer = require('../wiki/import');
function createPages({ svc, viewers, platform, config, log = console }) {
function createPages({ svc, viewers, platform, config, log = console }) {
const router = express.Router();
const router = express.Router();
@@ -197,6 +199,40 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -197,6 +199,40 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
}
}));
}));
+ // Import pages into a space (owners): the same JSON bundle as POST /api/v1/spaces/:space/import,
+ // pasted into a form; the options come from the form's controls.
+ const importForm = express.urlencoded({ extended: false, limit: '6mb' });
+ function ownersOnly(req, res, space) {
+ if (svc.access.canManage(space, req.actor)) return true;
+ if (!req.actor.subject) needSignIn(req, res, 'Sign in as an owner of this space to import pages.');
+ else errorPage(req, res, 403, 'Owners only', 'Only an owner of this space imports pages into it.');
+ return false;
+ }
+ router.get('/s/:space/import', (req, res) => {
+ const space = locateSpace(req, res);
+ if (!space || !ownersOnly(req, res, space)) return;
+ send(req, res, 200, views.importPage({ space, values: {} }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
+ });
+ router.post('/s/:space/import', importForm, wrap(async (req, res) => {
+ const space = locateSpace(req, res);
+ if (!space || !ownersOnly(req, res, space)) return;
+ const b = req.body || {};
+ const values = { bundle: String(b.bundle || ''), publish: b.publish === '1', on_existing: b.on_existing, source: b.source, ai_assisted: b.ai_assisted === '1' };
+ const again = (status, error, result = null) => send(req, res, status, views.importPage({ space, values, error, result }), { title: `Import pages into ${space.name}`, robots: 'noindex, nofollow' });
+ try {
+ const parsed = importer.parseBundleText(values.bundle);
+ const input = Array.isArray(parsed) ? { pages: parsed } : { ...parsed };
+ Object.assign(input, { publish: values.publish, on_existing: values.on_existing || 'fail', source: values.source || input.source || null, ai_assisted: values.ai_assisted });
+ const { bundle } = svc.prepareImport(space.id, input, req.actor);
+ await resolveBundleCitations(bundle, platform);
+ const out = svc.importPages(space.id, bundle, req.actor);
+ again(200, null, out);
+ } catch (err) {
+ if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
+ again(err.status, err.message);
+ }
+ }));
+
router.get('/s/:space/proposals', (req, res) => {
router.get('/s/:space/proposals', (req, res) => {
const space = locateSpace(req, res);
const space = locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
@@ -45,7 +45,7 @@ function spacePage({ space, tree, canEdit, canManage, proposals }) {
@@ -45,7 +45,7 @@ function spacePage({ space, tree, canEdit, canManage, proposals }) {
<section><h1>${space.name}</h1>
<section><h1>${space.name}</h1>
<p class="wk-muted">${space.kind === 'official' ? 'Official space' : 'Community space'} · ${VIS_LABEL[space.visibility]}</p>
<p class="wk-muted">${space.kind === 'official' ? 'Official space' : 'Community space'} · ${VIS_LABEL[space.visibility]}</p>
${space.description ? html`<p>${space.description}</p>` : ''}
${space.description ? html`<p>${space.description}</p>` : ''}
-${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}
+${canEdit ? html`<p><a class="wk-button" href="/s/${e(space.slug)}/new">New page</a>${canManage ? html` <a href="/s/${e(space.slug)}/import">Import pages</a> <a href="/s/${e(space.slug)}/settings">Space settings</a>` : ''}</p>` : ''}
${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length} AI proposal(s) wait for review.`)} <a href="/s/${e(space.slug)}/proposals">Review them</a></p>` : ''}
${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length} AI proposal(s) wait for review.`)} <a href="/s/${e(space.slug)}/proposals">Review them</a></p>` : ''}
<h2>Pages</h2>${tree.length ? raw(treeHtml(tree, space)) : html`<p class="wk-muted">No pages yet.</p>`}
<h2>Pages</h2>${tree.length ? raw(treeHtml(tree, space)) : html`<p class="wk-muted">No pages yet.</p>`}
</section>`;
</section>`;
@@ -243,6 +243,30 @@ ${staff ? html`<label class="wk-check"><input type="checkbox" name="official" va
@@ -243,6 +243,30 @@ ${staff ? html`<label class="wk-check"><input type="checkbox" name="official" va
<p><button type="submit">Create space</button></p></form></section>`;
<p><button type="submit">Create space</button></p></form></section>`;
}
}
+const IMPORT_EXAMPLE = JSON.stringify({ pages: [
+ { title: 'Bread', body: 'All about bread. See [[Rye]].', summary: 'One sentence.' },
+ { title: 'Rye', parent: 'Bread', body: 'Rye bread is dense.', infobox: [{ label: 'Gluten', type: 'text', value: 'low' }], citations: [{ url: 'https://example.org/rye', title: 'Rye', retrievedAt: '2026-09-01' }] },
+] }, null, 2);
+
+function importPage({ space, values = {}, error = null, result = null }) {
+ const base = `/s/${e(space.slug)}`;
+ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: base }, { name: 'Import' }])}
+<section><h1>Import pages into ${space.name}</h1>
+${error ? notice('error', `${error} Nothing was imported.`) : ''}
+${result ? html`${notice('ok', `Imported ${result.created.length} page(s)${result.published ? ' and published them' : ' as drafts'}${result.skipped.length ? `; left ${result.skipped.length} existing page(s) alone` : ''}.`)}
+<ul>${result.created.map((c) => html`<li><a href="${wpath(space, c.page)}">${c.page.title}</a> <span class="wk-tag">${c.page.state}</span></li>`)}</ul>
+${result.skipped.length ? html`<p>Left alone: ${result.skipped.map((x, i) => html`${i ? ', ' : ''}${x.title} (${x.reason})`)}</p>` : ''}` : ''}
+<p>Paste a bundle: JSON with a <code>pages</code> list (the same shape as the wiki's seed file). Each page has a <code>title</code> and a Markdown <code>body</code>, and optionally <code>summary</code>, <code>parent</code> (the title of another page), <code>infobox</code>, <code>citations</code> and <code>visibility</code>. Everything is checked first and imported in one step: if one page is invalid, nothing is imported. At most 200 pages and 2 MB.</p>
+<details><summary>Example</summary><pre>${IMPORT_EXAMPLE}</pre></details>
+<form method="post" action="${base}/import" class="wk-form">
+<label>Bundle (JSON) <textarea name="bundle" rows="18" required>${values.bundle || ''}</textarea></label>
+<label>Where the text comes from (shown on each page as "Imported from …") <input type="text" name="source" maxlength="200" value="${values.source || ''}"></label>
+<label>Pages that already exist <select name="on_existing"><option value="fail"${values.on_existing !== 'skip' ? raw(' selected') : ''}>stop: import nothing</option><option value="skip"${values.on_existing === 'skip' ? raw(' selected') : ''}>leave them alone, import the rest</option></select></label>
+<label class="wk-check"><input type="checkbox" name="publish" value="1"${values.publish ? raw(' checked') : ''}> Publish the imported pages (otherwise they are drafts only editors see)</label>
+<label class="wk-check"><input type="checkbox" name="ai_assisted" value="1"${values.ai_assisted ? raw(' checked') : ''}> The text was written with AI assistance (search engines skip each page until a person reviews it)</label>
+<p><button type="submit">Import</button></p></form></section>`;
+}
+
function spaceSettingsPage({ space, roles, error = null, flash = null }) {
function spaceSettingsPage({ space, roles, error = null, flash = null }) {
return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: 'Settings' }])}
return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: 'Settings' }])}
<section><h1>Settings of ${space.name}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
<section><h1>Settings of ${space.name}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
@@ -260,6 +284,7 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td>
@@ -260,6 +284,7 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td>
<label>Person (usr_… subject id) <input type="text" name="subject" required pattern="usr_[0-9A-Z]{26}"></label>
<label>Person (usr_… subject id) <input type="text" name="subject" required pattern="usr_[0-9A-Z]{26}"></label>
<label>Role <select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="owner">owner</option></select></label>
<label>Role <select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="owner">owner</option></select></label>
<p><button type="submit">Grant role</button></p></form>
<p><button type="submit">Grant role</button></p></form>
+<h2>Import</h2><p><a href="/s/${e(space.slug)}/import">Import pages from a bundle</a></p>
<h2>Delete</h2><form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this space and take every page offline (addresses answer 410 Gone)</label><p><button type="submit">Delete space</button></p></form>
<h2>Delete</h2><form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this space and take every page offline (addresses answer 410 Gone)</label><p><button type="submit">Delete space</button></p></form>
</section>`;
</section>`;
}
}
@@ -349,6 +374,6 @@ function signInPage({ next, message }) {
@@ -349,6 +374,6 @@ function signInPage({ next, message }) {
module.exports = {
module.exports = {
home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
- spaceSettingsPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
+ spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
signInPage, errorBody, wpath, VIS_LABEL,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * Space import: the bundle format and its strict validation (the import itself is
+ * service.importPages, one transaction).
+ *
+ * A bundle is JSON, the same page shape as the seed file (seeds/openvibe.json):
+ *
+ * {
+ * "pages": [
+ * { "title": "Rye", "body": "Markdown with [[links]]", "summary": "…", "parent": "Bread",
+ * "infobox": [{ "label": "Gluten", "type": "text", "value": "low" }],
+ * "citations": [{ "url": "https://…", "title": "…", "retrievedAt": "2026-09-01" }],
+ * "visibility": "public", "message": "…" }
+ * ],
+ * "publish": false, // default: every page is created as a draft
+ * "on_existing": "fail", // "fail" (default: nothing is imported) | "skip" (leave those pages alone)
+ * "source": "my old wiki", // where the text comes from (shown as "Imported from …")
+ * "original_author": "…", // optional
+ * "ai_assisted": false // true: noindex until a person reviews each page, like AI output
+ * }
+ *
+ * Slugs come from titles exactly as for pages created by hand; `parent` is the title of another page
+ * of the bundle or of the space. A `space` key (as in the seed file) is ignored: the target space is
+ * the one in the URL. Anything else unknown is refused, so a typo never imports silently.
+ */
+const content = require('./content');
+
+const MAX_BUNDLE_BYTES = 2 * 1024 * 1024;
+const MAX_PAGES = 200;
+const MAX_PAGE_BODY = 200000;
+const MAX_SUMMARY = 300;
+const MAX_MESSAGE = 300;
+const MAX_PAGE_CITATIONS = 50;
+const MAX_SOURCE_ITEMS = 200;
+const BUNDLE_KEYS = new Set(['pages', 'publish', 'on_existing', 'source', 'original_author', 'ai_assisted', 'space']);
+const PAGE_KEYS = new Set(['title', 'body', 'summary', 'parent', 'infobox', 'citations', 'visibility', 'message']);
+const ON_EXISTING = ['fail', 'skip'];
+
+class ImportError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+
+const bad = (code, message, status = 422) => { throw new ImportError(status, code, message); };
+const optString = (v, max, what) => {
+ if (v == null || v === '') return null;
+ if (typeof v !== 'string') bad('import.invalid', `${what} must be text`);
+ if (v.length > max) bad('import.invalid', `${what} is at most ${max} characters`);
+ return v;
+};
+const optBool = (v, what) => {
+ if (v == null) return false;
+ if (typeof v !== 'boolean') bad('import.invalid', `${what} must be true or false`);
+ return v;
+};
+
+/** The bundle as text (the form) → object; enforces the byte limit before parsing. */
+function parseBundleText(text) {
+ const s = String(text == null ? '' : text);
+ if (Buffer.byteLength(s, 'utf8') > MAX_BUNDLE_BYTES) bad('import.too_large', `A bundle is at most ${MAX_BUNDLE_BYTES / 1024 / 1024} MB`, 413);
+ if (!s.trim()) bad('import.invalid', 'Paste a bundle: JSON with a "pages" list');
+ try { return JSON.parse(s); } catch { return bad('import.invalid_json', 'The bundle is not valid JSON', 400); }
+}
+
+/**
+ * Validates a bundle object → { pages: [{ title, slug, body, summary, parentSlug, infobox, citations,
+ * visibility, message }], publish, onExisting, source, originalAuthor, aiAssisted }. Throws
+ * ImportError (422 invalid, 413 too large). Citations are left as given (resolved by the caller).
+ */
+function validateBundle(input) {
+ const b = Array.isArray(input) ? { pages: input } : input;
+ if (!b || typeof b !== 'object' || Array.isArray(b)) bad('import.invalid', 'A bundle is a JSON object with a "pages" list');
+ for (const k of Object.keys(b)) if (!BUNDLE_KEYS.has(k)) bad('import.unknown_field', `Unknown bundle field "${k}"`);
+ if (!Array.isArray(b.pages) || !b.pages.length) bad('import.invalid', 'A bundle needs a non-empty "pages" list');
+ if (b.pages.length > MAX_PAGES) bad('import.too_many_pages', `A bundle holds at most ${MAX_PAGES} pages`);
+ const onExisting = b.on_existing == null || b.on_existing === '' ? 'fail' : b.on_existing;
+ if (!ON_EXISTING.includes(onExisting)) bad('import.invalid', `on_existing is one of ${ON_EXISTING.join(', ')}`);
+ const out = {
+ publish: optBool(b.publish, 'publish'),
+ onExisting,
+ source: optString(b.source, 200, 'source'),
+ originalAuthor: optString(b.original_author, 120, 'original_author'),
+ aiAssisted: optBool(b.ai_assisted, 'ai_assisted'),
+ pages: [],
+ };
+ const seen = new Map();
+ b.pages.forEach((p, i) => {
+ const where = `page ${i + 1}`;
+ if (!p || typeof p !== 'object' || Array.isArray(p)) bad('import.invalid', `${where} is not an object`);
+ for (const k of Object.keys(p)) if (!PAGE_KEYS.has(k)) bad('import.unknown_field', `${where}: unknown field "${k}"`);
+ if (typeof p.title !== 'string' || !p.title.trim()) bad('import.invalid', `${where} needs a title`);
+ const title = p.title.replace(/\s+/g, ' ').trim();
+ if (title.length > 200) bad('import.invalid', `${where}: a title is at most 200 characters`);
+ let slug;
+ try { slug = content.pageSlug(title); } catch (err) { bad('import.invalid', `${where} ("${title}"): ${err.message}`); }
+ if (seen.has(slug)) bad('import.duplicate_slug', `"${title}" and "${seen.get(slug)}" would both be /${slug}`);
+ seen.set(slug, title);
+ if (typeof p.body !== 'string') bad('import.invalid', `${where} ("${title}"): body must be Markdown text`);
+ if (p.body.length > MAX_PAGE_BODY) bad('import.page_too_large', `${where} ("${title}"): a page is at most ${MAX_PAGE_BODY} characters`, 413);
+ let parentSlug = null;
+ if (p.parent != null && p.parent !== '') {
+ if (typeof p.parent !== 'string') bad('import.invalid', `${where} ("${title}"): parent is a page title`);
+ try { parentSlug = content.pageSlug(p.parent); } catch { bad('import.invalid', `${where} ("${title}"): parent "${p.parent}" is not a page title`); }
+ if (parentSlug === slug) bad('import.invalid_parent', `${where} ("${title}") cannot be its own parent`);
+ }
+ if (p.infobox != null && !Array.isArray(p.infobox)) bad('import.invalid', `${where} ("${title}"): infobox must be a list`);
+ if (p.citations != null && !Array.isArray(p.citations)) bad('import.invalid', `${where} ("${title}"): citations must be a list`);
+ if (p.visibility != null && !['public', 'members', 'private'].includes(p.visibility)) bad('import.invalid', `${where} ("${title}"): visibility is public, members or private`);
+ if ((p.citations || []).length > MAX_PAGE_CITATIONS) bad('import.invalid', `${where} ("${title}"): at most ${MAX_PAGE_CITATIONS} citations per page`);
+ out.pages.push({
+ title, slug, body: p.body, parentSlug,
+ summary: optString(p.summary, MAX_SUMMARY, `${where} summary`),
+ infobox: p.infobox || [], citations: p.citations || [],
+ visibility: p.visibility || 'public',
+ message: optString(p.message, MAX_MESSAGE, `${where} message`),
+ });
+ });
+ const cites = out.pages.flatMap((p) => p.citations);
+ // Every Sources item citation is one call to OpenVibe.Sources before the import.
+ const items = cites.filter((c) => c && typeof c === 'object' && (c.source_item_id || c.sourceItemId)).length;
+ if (items > MAX_SOURCE_ITEMS) bad('import.too_many_citations', `A bundle cites at most ${MAX_SOURCE_ITEMS} OpenVibe.Sources items`);
+ return out;
+}
+
+module.exports = { parseBundleText, validateBundle, ImportError, MAX_BUNDLE_BYTES, MAX_PAGES, MAX_PAGE_BODY };
@@ -19,6 +19,7 @@ const ssr = require('openvibe-publishing/ssr');
@@ -19,6 +19,7 @@ const ssr = require('openvibe-publishing/ssr');
const { isMediaId } = require('openvibe-publishing/media');
const { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const content = require('./content');
+const importer = require('./import');
const { createAccess, VISIBILITIES } = require('./access');
const { createAccess, VISIBILITIES } = require('./access');
class WikiError extends Error {
class WikiError extends Error {
@@ -396,6 +397,34 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -396,6 +397,34 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return authorship.record({ mode: 'human', authors: [requirePerson(actor)] });
return authorship.record({ mode: 'human', authors: [requirePerson(actor)] });
}
}
+ /** A new page with its first revision (permission checks and authorship are the caller's). */
+ function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
+ const cleanTitle = checkTitle(title);
+ const slug = wrapContentError(() => content.pageSlug(cleanTitle));
+ const text = checkBody(body == null ? '' : body);
+ const box = wrapContentError(() => content.normalizeInfobox(infobox));
+ const vis = checkVisibility(visibility, 'public');
+ return tx(() => {
+ const existing = q.pageBySlug.get(space.id, slug);
+ if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
+ if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
+ const t = now();
+ const id = pageIdNew(t);
+ q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
+ redirects.release(`/w/${space.slug}/${slug}`);
+ const { revision } = wrapContentError(() => revisions.create({
+ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },
+ meta: { authorship: rec }, author: actorId(actor), message,
+ }));
+ const page = q.pageById.get(id);
+ writeRevisionExtras(space, page, revision, box);
+ attachCitationList(page, revision.number, cites, actor);
+ revisionEvent(space, page, revision, actor);
+ if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);
+ return { page, revision };
+ });
+ }
+
// ── Publication (internal: no permission checks) ─────────
// ── Publication (internal: no permission checks) ─────────
function publishRevision(page, revisionNumber, actor) {
function publishRevision(page, revisionNumber, actor) {
const rev = revisions.get(page.id, revisionNumber);
const rev = revisions.get(page.id, revisionNumber);
@@ -557,29 +586,75 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -557,29 +586,75 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
const rec = actor && actor.kind === 'system' && importRecord ? wrapContentError(() => authorship.record(importRecord)) : humanRecord(actor);
// authorship.record() keeps label and original author only; the AI-assistance flag is ours.
// authorship.record() keeps label and original author only; the AI-assistance flag is ours.
if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;
if (importRecord && rec.mode === 'imported' && importRecord.importedFrom && importRecord.importedFrom.aiAssisted === true) rec.importedFrom.aiAssisted = true;
- const cleanTitle = checkTitle(title);
- const slug = wrapContentError(() => content.pageSlug(cleanTitle));
- const text = checkBody(body == null ? '' : body);
- const box = wrapContentError(() => content.normalizeInfobox(infobox));
- const vis = checkVisibility(visibility, 'public');
+ return insertPage(space, { title, body, infobox, parentId, visibility, citations: cites, message, summary }, actor, rec);
+ },
+
+ /**
+ * Space import, step 1 (before any outbound call): who may import, and a strictly validated
+ * bundle (server/wiki/import.js). Only an owner of the space, acting as a person. A space the
+ * caller cannot read is "not found", exactly like everywhere else.
+ */
+ prepareImport(spaceIdOrSlug, input, actor) {
+ const space = spaceOrFail(spaceIdOrSlug);
+ if (!access.canReadSpace(space, actor)) fail(404, 'space.not_found', 'No such space');
+ if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
+ requirePerson(actor);
+ return { space, bundle: importer.validateBundle(input) };
+ },
+
+ /**
+ * Space import, step 2: every page of the bundle (citations already resolved) in ONE
+ * transaction — any failure imports nothing, events included. Each page is created exactly
+ * like one made by hand (same slug rule, sanitising renderer at view time, [[link]] and
+ * citation records, wiki.revision.created, the importer watching it), as a draft unless the
+ * bundle says publish (then the usual publication events and Search documents follow).
+ * Authorship is `imported` with the importer as the accountable person; an AI-assisted bundle
+ * stays noindex until a person reviews each page. Existing slugs: "fail" (409, nothing
+ * imported) or "skip" (left alone, reported).
+ */
+ importPages(spaceIdOrSlug, bundle, actor) {
+ const space = spaceOrFail(spaceIdOrSlug);
+ if (!access.canManage(space, actor)) fail(403, 'import.forbidden', 'Only an owner of this space imports pages into it');
+ const who = requirePerson(actor);
+ const rec = wrapContentError(() => authorship.record({ mode: 'imported', authors: [who], importedFrom: { label: bundle.source || 'a page bundle', originalAuthor: bundle.originalAuthor || null } }));
+ if (bundle.aiAssisted) rec.importedFrom.aiAssisted = true;
return tx(() => {
return tx(() => {
- const existing = q.pageBySlug.get(space.id, slug);
- if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
- if (parentId) { const par = q.pageById.get(parentId); if (!par || par.space_id !== space.id || par.state === 'deleted') fail(422, 'page.invalid_parent', 'The parent must be a page of the same space'); }
- const t = now();
- const id = pageIdNew(t);
- q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
- redirects.release(`/w/${space.slug}/${slug}`);
- const { revision } = wrapContentError(() => revisions.create({
- entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },
- meta: { authorship: rec }, author: actorId(actor), message,
- }));
- const page = q.pageById.get(id);
- writeRevisionExtras(space, page, revision, box);
- attachCitationList(page, revision.number, cites, actor);
- revisionEvent(space, page, revision, actor);
- if (/^usr_/.test(actorId(actor))) q.watch.run(id, actorId(actor), t);
- return { page, revision };
+ const existing = bundle.pages.map((p) => ({ p, page: q.pageBySlug.get(space.id, p.slug) })).filter((x) => x.page);
+ if (existing.length && bundle.onExisting !== 'skip') {
+ fail(409, 'import.pages_exist', `Already in this space: ${existing.map((x) => x.p.slug).join(', ')}. Nothing was imported (import with on_existing "skip" to leave them alone)`, { slugs: existing.map((x) => x.p.slug) });
+ }
+ const skipped = existing.map((x) => ({ slug: x.p.slug, title: x.p.title, reason: x.page.state === 'deleted' ? 'deleted' : 'exists' }));
+ const skip = new Set(skipped.map((x) => x.slug));
+ const toCreate = new Set(bundle.pages.map((p) => p.slug).filter((sl) => !skip.has(sl)));
+ const made = new Map();
+ const created = [];
+ let pending = bundle.pages.filter((p) => toCreate.has(p.slug));
+ // Parents first: a page whose parent (in the bundle) is not created yet waits for the next pass.
+ while (pending.length) {
+ const next = [];
+ for (const p of pending) {
+ let parentId = null;
+ if (p.parentSlug) {
+ if (toCreate.has(p.parentSlug) && !made.has(p.parentSlug)) { next.push(p); continue; }
+ const parent = made.get(p.parentSlug) || q.pageBySlug.get(space.id, p.parentSlug);
+ if (!parent || parent.state === 'deleted') fail(422, 'import.invalid_parent', `"${p.title}": its parent is neither in the bundle nor a page of this space`);
+ parentId = parent.id;
+ }
+ let out;
+ try {
+ out = insertPage(space, { ...p, parentId }, actor, rec);
+ } catch (err) {
+ if (err instanceof WikiError) fail(err.status, err.code, `"${p.title}": ${err.message}`, err.extra);
+ throw err;
+ }
+ made.set(p.slug, out.page);
+ created.push(out);
+ }
+ if (next.length === pending.length) fail(422, 'import.invalid_parent', `Parents form a loop: ${next.map((p) => p.title).join(', ')}`);
+ pending = next;
+ }
+ if (bundle.publish) for (const c of created) publishRevision(q.pageById.get(c.page.id), c.revision.number, actor);
+ return { space, created: created.map((c) => ({ page: q.pageById.get(c.page.id), revision: c.revision })), skipped, published: !!bundle.publish };
});
});
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * Space import (POST /api/v1/spaces/:space/import and the /s/:space/import form): an owner imports a
+ * JSON bundle of pages in one transaction. Pages are created exactly like hand-made ones (drafts by
+ * default, imported authorship with the importer accountable, links, citations, events, escaping);
+ * only owners import; oversized, malformed, unknown-field, duplicate-slug and colliding bundles are
+ * refused; any failure imports nothing at all.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+
+const count = (h, table) => h.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
+
+(async () => {
+ const h = await H.boot();
+ const owner = H.subject();
+ const editor = H.subject();
+ const tokOwner = H.userToken({ subject: owner });
+ const tokEditor = H.userToken({ subject: editor });
+ const imp = (token, body, space = 'bakery') => H.req(h, 'POST', `/api/v1/spaces/${space}/import`, { token, body });
+ const bundle = {
+ source: 'the old bakery wiki',
+ pages: [
+ { title: 'Rye', parent: 'Bread', body: `Rye is dense. Back to [[Bread]]. [click](javascript:alert(1)) <script>alert(1)</script> ${H.LONG}`, summary: 'Dense bread.', infobox: [{ label: 'Gluten', type: 'text', value: 'low' }], citations: [{ url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01' }] },
+ { title: 'Bread', body: `All about bread; see [[Rye]] and [[Spelt]]. ${H.LONG}` },
+ { title: 'Private notes', body: 'Only for us.', visibility: 'private' },
+ ],
+ };
+ try {
+ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tokOwner, body: { name: 'Bakery', slug: 'bakery' } });
+ assert.strictEqual(r.status, 201);
+ await H.req(h, 'PUT', `/api/v1/spaces/bakery/roles/${editor}`, { token: tokOwner, body: { role: 'editor' } });
+ await H.req(h, 'POST', '/api/v1/spaces', { token: tokEditor, body: { name: 'Secret', slug: 'secret', visibility: 'private' } });
+
+ // Only an owner, acting as a person, imports.
+ const pagesBefore = count(h, 'wiki_pages');
+ r = await imp(tokEditor, bundle);
+ assert.strictEqual(r.status, 403, 'an editor is not an owner');
+ assert.strictEqual(r.json.code, 'import.forbidden');
+ assert.strictEqual((await imp(null, bundle)).status, 403, 'anonymous');
+ assert.strictEqual((await imp(tokOwner, bundle, 'secret')).status, 404, 'a private space someone else owns is not found');
+ r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] }), bundle);
+ assert.strictEqual(r.status, 403, 'a service acting as itself is no owner');
+ r = await imp(H.serviceToken({ client: 'ai', cap: ['wiki.page.read'] }), bundle);
+ assert.strictEqual(r.status, 403, 'the route needs wiki.page.create');
+ assert.strictEqual(count(h, 'wiki_pages'), pagesBefore);
+
+ // Invalid and oversized bundles are refused before anything is written.
+ const refusals = [
+ [{ pages: [] }, 422, 'import.invalid'],
+ [{ pages: 'x' }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'A', body: 'x', colour: 'red' }] }, 422, 'import.unknown_field'],
+ [{ pages: [{ title: 'A', body: 'x' }], overwrite: true }, 422, 'import.unknown_field'],
+ [{ pages: [{ body: 'no title' }] }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'A', body: 42 }] }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'A', body: 'x', visibility: 'world' }] }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'A', body: 'x' }], publish: 'yes' }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'A', body: 'x' }], on_existing: 'overwrite' }, 422, 'import.invalid'],
+ [{ pages: [{ title: 'Rye bread', body: 'x' }, { title: 'rye BREAD', body: 'y' }] }, 422, 'import.duplicate_slug'],
+ [{ pages: Array.from({ length: 201 }, (_x, i) => ({ title: `P${i}`, body: 'x' })) }, 422, 'import.too_many_pages'],
+ [{ pages: [{ title: 'Big', body: 'x'.repeat(200001) }] }, 413, 'import.page_too_large'],
+ [{ pages: [{ title: 'A', body: 'x', summary: 's'.repeat(301) }] }, 422, 'import.invalid'],
+ ];
+ for (const [body, status, code] of refusals) {
+ r = await imp(tokOwner, body);
+ assert.strictEqual(r.status, status, `${JSON.stringify(body).slice(0, 80)} → ${r.text.slice(0, 200)}`);
+ assert.strictEqual(r.json.code, code, JSON.stringify(body).slice(0, 80));
+ }
+ r = await H.req(h, 'POST', '/api/v1/spaces/bakery/import', { token: tokOwner, headers: { 'Content-Type': 'application/json' } });
+ assert.strictEqual(r.status, 422, 'no body');
+ r = await fetch(`${h.base}/api/v1/spaces/bakery/import`, { method: 'POST', headers: { Authorization: `Bearer ${tokOwner}`, 'Content-Type': 'application/json' }, body: '{"pages": [' });
+ assert.strictEqual(r.status, 400, 'malformed JSON');
+ r = await imp(tokOwner, { pages: Array.from({ length: 150 }, (_x, i) => ({ title: `Page ${i}`, body: 'y'.repeat(15000) })) });
+ assert.strictEqual(r.status, 413, 'a bundle over 2 MB');
+ assert.strictEqual(r.json.code, 'request.too_large');
+ assert.strictEqual(count(h, 'wiki_pages'), pagesBefore, 'nothing was written by a refused bundle');
+
+ // Atomicity: the third page is invalid (an infobox value that is not a number; a parent that
+ // is nowhere) → none of the pages, revisions, citations or events exist afterwards.
+ const snapshot = () => ['wiki_pages', 'wiki_page_revisions', 'wiki_citations', 'wiki_page_links', 'wiki_event_outbox', 'wiki_watchers'].map((t) => count(h, t));
+ const before = snapshot();
+ for (const broken of [
+ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', infobox: [{ label: 'Weight', type: 'number', value: 'heavy' }] }] },
+ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', parent: 'Nowhere' }] },
+ { pages: [...bundle.pages.slice(0, 2), { title: 'Spelt', body: 'x', citations: [{ url: 'ftp://files.example/spelt' }] }] },
+ { pages: [{ title: 'Egg', parent: 'Hen', body: 'x' }, { title: 'Hen', parent: 'Egg', body: 'y' }] },
+ ]) {
+ r = await imp(tokOwner, broken);
+ assert.ok(r.status === 422, `${r.status} ${r.text.slice(0, 200)}`);
+ assert.deepStrictEqual(snapshot(), before, `all or nothing: ${r.json.code}`);
+ }
+
+ // Success: drafts, attributed to the importer, links and citations recorded, events emitted.
+ r = await imp(tokOwner, bundle);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state, p.revision]), [['bread', 'draft', 1], ['private-notes', 'draft', 1], ['rye', 'draft', 1]], 'parents first');
+ assert.strictEqual(r.json.published, false);
+ const space = h.svc.findSpace('bakery');
+ const rye = h.svc.findPage(space.id, 'rye');
+ const bread = h.svc.findPage(space.id, 'bread');
+ assert.strictEqual(rye.parent_id, bread.id, 'the parent came from the bundle');
+ assert.strictEqual(h.svc.findPage(space.id, 'private-notes').visibility, 'private');
+ const rev = h.stores.revisions.get(rye.id, 1);
+ assert.strictEqual(rev.author, owner);
+ assert.deepStrictEqual(rev.meta.authorship, { mode: 'imported', authors: [owner], importedFrom: { label: 'the old bakery wiki', originalAuthor: null } });
+ assert.strictEqual(h.svc.citationsOf(rye, 1)[0].url, 'https://flour.example/rye');
+ assert.ok(h.db.prepare('SELECT 1 FROM wiki_page_links WHERE from_page_id = ? AND target_slug = ?').get(bread.id, 'spelt'), 'links recorded (red link to Spelt)');
+ const created = H.outbox(h).filter((e) => e.event_type === 'wiki.revision.created' && [rye.id, bread.id].includes(e.subject.id));
+ assert.strictEqual(created.length, 2);
+ assert.ok(h.db.prepare('SELECT 1 FROM wiki_watchers WHERE page_id = ? AND subject = ?').get(rye.id, owner), 'the importer watches the pages');
+ // Drafts: invisible to readers, visible to editors; the markdown is rendered by the usual sanitiser.
+ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/rye')).status, 404);
+ r = await H.req(h, 'GET', '/w/bakery/rye', { cookie: H.cookieFor(tokEditor) });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('Imported from the old bakery wiki'));
+ assert.ok(!/<script>alert|href="javascript/.test(r.text), 'escaped, no javascript: link');
+ assert.ok(!r.text.includes('<a class="ov-wikilink" href="/w/bakery/bread">'), 'links go through the usual resolver: a draft target is not a blue link');
+
+ // Existing pages: "fail" imports nothing, "skip" leaves them alone and imports the rest.
+ const again = { pages: [{ title: 'Rye', body: 'replaced?' }, { title: 'Spelt', body: `Spelt is old. ${H.LONG}`, parent: 'Bread', citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-03' }] }] };
+ r = await imp(tokOwner, again);
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json.code, 'import.pages_exist');
+ assert.strictEqual(h.svc.findPage(space.id, 'spelt'), null);
+ r = await imp(tokOwner, { ...again, on_existing: 'skip', publish: true });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.json.skipped, [{ slug: 'rye', title: 'Rye', reason: 'exists' }]);
+ assert.deepStrictEqual(r.json.created.map((p) => [p.slug, p.state]), [['spelt', 'published']]);
+ assert.strictEqual(h.stores.revisions.headNumber(rye.id), 1, 'the existing page was left alone');
+ // Published through the import: readable, indexed like any page (upsert event).
+ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/spelt')).status, 200);
+ const spelt = h.svc.findPage(space.id, 'spelt');
+ assert.strictEqual(spelt.parent_id, bread.id, 'a parent that already exists in the space');
+ assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.page.published' && e.subject.id === spelt.id));
+ assert.ok(H.outbox(h).some((e) => e.event_type === 'wiki.index_document.upserted' && e.subject.id === spelt.id));
+
+ // AI-assisted bundles stay noindex until a person reviews each page.
+ r = await imp(tokOwner, { ai_assisted: true, publish: true, source: 'a model', pages: [{ title: 'Barley', body: `Barley bread. ${H.LONG}`, citations: [{ url: 'https://barley.example/', retrievedAt: '2026-09-04' }] }] });
+ assert.strictEqual(r.status, 201);
+ r = await H.req(h, 'GET', '/w/bakery/barley');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));
+ assert.ok(r.text.includes('Not yet reviewed by a person.'));
+
+ // The form (no JavaScript): owners only, same validation, same all-or-nothing.
+ const cookie = H.cookieFor(tokOwner);
+ assert.strictEqual((await H.req(h, 'GET', '/s/bakery/import', { cookie: H.cookieFor(tokEditor) })).status, 403);
+ assert.strictEqual((await H.req(h, 'GET', '/s/bakery/import')).status, 401);
+ r = await H.req(h, 'GET', '/s/bakery/import', { cookie });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('name="bundle"') && r.text.includes('action="/s/bakery/import"'));
+ assert.ok((await H.req(h, 'GET', '/s/bakery', { cookie })).text.includes('href="/s/bakery/import"'), 'linked from the space page for owners');
+ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, form: { bundle: '{"pages": [', on_existing: 'fail' } });
+ assert.strictEqual(r.status, 400);
+ assert.ok(r.text.includes('The bundle is not valid JSON') && r.text.includes('Nothing was imported.'));
+ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, form: { bundle: JSON.stringify([{ title: 'Oats', body: `Oat bread. ${H.LONG}` }]), on_existing: 'fail', publish: '1', source: 'my notes' } });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('Imported 1 page(s) and published them.') && r.text.includes('href="/w/bakery/oats"'));
+ assert.strictEqual((await H.req(h, 'GET', '/w/bakery/oats')).status, 200);
+ r = await H.req(h, 'POST', '/s/bakery/import', { cookie: H.cookieFor(tokEditor), form: { bundle: JSON.stringify([{ title: 'Nope', body: 'x' }]) } });
+ assert.strictEqual(r.status, 403);
+ r = await H.req(h, 'POST', '/s/bakery/import', { cookie, headers: { Origin: 'https://evil.example' }, form: { bundle: JSON.stringify([{ title: 'Csrf', body: 'x' }]) } });
+ assert.strictEqual(r.status, 403, 'cross-site form posts are refused');
+ assert.strictEqual(h.svc.findPage(space.id, 'nope'), null);
+ assert.strictEqual(h.svc.findPage(space.id, 'csrf'), null);
+ console.log('import ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });