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

OP OpenVibers committed 23 Sept 2026 · 96b8e43d3a · 10 files, +507 −31


Changes

ddiff --git a/README.md b/README.md
iindex 493ac8a..481be65 100644
---- a/README.md
++++ b/README.md
@@@ -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
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`.
++- **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
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
@@@ -141,7 +152,7 @@ match; network search is OpenVibe.Search).
## Routes
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/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`, unit
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
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
`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`)
ddiff --git a/docs/capabilities-proposal/wiki.page.create.json b/docs/capabilities-proposal/wiki.page.create.json
iindex 521cf1f..9b9983a 100644
---- a/docs/capabilities-proposal/wiki.page.create.json
++++ b/docs/capabilities-proposal/wiki.page.create.json
@@@ -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",
ddiff --git a/server/app.js b/server/app.js
iindex c1e41b4..005db9a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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' }));
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a143c47..f0e49e8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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));
++
// Pages
router.get('/pages/:id', guard('wiki.page.read'), R((req) => {
const { page, space } = readable(req, req.params.id);
ddiff --git a/server/http/common.js b/server/http/common.js
iindex 28bafdb..352ecd0 100644
---- a/server/http/common.js
++++ b/server/http/common.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 279ef98..e34deca 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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;
ddiff --git a/server/render/views.js b/server/render/views.js
iindex c1555fd..35e7d23 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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,
};
ddiff --git a/server/wiki/import.js b/server/wiki/import.js
nnew file mode 100644
iindex 0000000..fad13f0
---- /dev/null
++++ b/server/wiki/import.js
@@@ -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 };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 38021cf..e3392ee 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -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 };
});
},
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..9ba9b80
---- /dev/null
++++ b/test/import.test.js
@@@ -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); });