@bongos/core 1.19.1061 → 1.19.1062

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,8 +21,9 @@
21
21
  // which no renderer reads — and the wording reaches a person only after somebody
22
22
  // claims that task and ships the diff. So the four things pinned here are:
23
23
  //
24
- // * the write surface is EXACTLY the verbs the module defines (seven since
25
- // TW08: the page draft and submit write the round TASK, never a row),
24
+ // * the write surface is EXACTLY the verbs the module defines (eight since
25
+ // TW09: the page draft, its Word upload and the submit write the round
26
+ // TASK, never a row),
26
27
  // * no column on copy_desk_flags could hold replacement text,
27
28
  // * nothing in this module writes a file, and
28
29
  // * the ONLY thing that writes a surface file is the applier, it lives outside
@@ -64,7 +65,8 @@ const stripComments = (src) => src
64
65
  // reads: pages.js (the page-tweak block format), page-status.js (the derived
65
66
  // status, drift and tally) and page-data.js (the read side of the page
66
67
  // inventory and readings). They are held to the same two rules as the rest.
67
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'routes/copy-desk.js'];
68
+ // TW09 (task 1004320) added docx.js, the Word round trip over Buffers.
69
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'routes/copy-desk.js'];
68
70
 
69
71
  console.log('copy-desk: NO LIVE CMS');
70
72
 
@@ -81,6 +83,10 @@ await test('the write surface is exactly the verbs the module defines', () => {
81
83
  // flag row whose target is a page id. Neither stores wording.
82
84
  'POST /copy-desk/pages/:pageId/asks',
83
85
  'POST /copy-desk/pages/:pageId/claim',
86
+ // TW09 (task 1004320, ADR 0341 D11, D12): the Word upload. It lands in the
87
+ // round's DRAFT through the autosave's own port call and resolver, never a
88
+ // row here, a file, or the site.
89
+ 'POST /copy-desk/pages/:pageId/draft.docx',
84
90
  // TW08 (task 1004319, ADR 0341 D4, D11): the draft autosave and the
85
91
  // submit. Both write the page round's TASK DESCRIPTION through the
86
92
  // lifecycle port, the place ADR 0233 lets wording rest; neither writes a
@@ -220,6 +226,29 @@ await test('the draft and the submit write only the round task, through the port
220
226
  assert.equal(/\b(before|section|placement|file|string_id)\s*:/.test(schemas), false, 'the draft and submit schemas name no target field');
221
227
  });
222
228
 
229
+ await test('the Word round trip is pure over Buffers, and its upload writes only the draft, through the port', () => {
230
+ // docx.js handles an artist's wording like pages.js does, so it is held to
231
+ // the same shape: its one require is node:zlib (a zip is deflate), and it
232
+ // reaches no fs, pool or network.
233
+ const lib = stripComments(read('docx.js'));
234
+ assert.deepEqual([...lib.matchAll(/require\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]), ['node:zlib'],
235
+ 'docx.js may require node:zlib and nothing else');
236
+ for (const forbidden of ['fs.', 'pool', 'fetch(']) {
237
+ assert.equal(lib.includes(forbidden), false, `docx.js must stay pure — it must not reach for ${forbidden}`);
238
+ }
239
+ const src = stripComments(read('routes/copy-desk.js'));
240
+ const start = src.indexOf("router.post('/copy-desk/pages/:pageId/draft.docx'");
241
+ assert.ok(start >= 0, 'the upload is mounted');
242
+ const next = src.indexOf('router.', start + 1);
243
+ const h = src.slice(start, next === -1 ? undefined : next);
244
+ assert.ok(/lifecycle\.updateHeldTaskDescription\(/.test(h), 'the upload lands on the task via the autosave\'s port call (holder only)');
245
+ assert.ok(/pages\.resolveDraft\(\{ readingPage,/.test(h), 'the upload is resolved from the reading by the autosave\'s resolver');
246
+ assert.ok(/pages\.saveDraftInto\(/.test(h), 'the upload writes the draft block through the autosave\'s writer');
247
+ assert.ok(/readingPageOrFail\(res, pageId\)/.test(h), 'the upload resolves against the page reading');
248
+ assert.equal(/insertFlag|closeFlag|INSERT INTO|UPDATE |composeBatchBlock|submitPageTweak/i.test(h), false,
249
+ 'the upload writes the draft and nothing else: no row, and never the frozen batch');
250
+ });
251
+
223
252
  await test('the module manifest declares no seam another module could write copy through', () => {
224
253
  const manifest = JSON.parse(read('module.json'));
225
254
  assert.deepEqual(manifest.provides, [], 'a provided port here would be a copy write path for every other module');
@@ -0,0 +1,131 @@
1
+ // modules/copy-desk/tests/fixtures/word-docx.mjs — edit a downloaded page .docx
2
+ // the way Word stores an edit (task 1004320 / BV2.TW09, ADR 0341 D12).
3
+ //
4
+ // The Word round-trip tests must not prove the reader against its own writer
5
+ // only. So this fixture re-packs a file the way Word does, with its OWN zip
6
+ // writer (data descriptors after each entry, zero sizes in the local headers,
7
+ // entries in another order), and rewrites a line's paragraph the way Word
8
+ // leaves one after typing: the text split across several runs with their own
9
+ // properties and revision ids, proofing marks between them, smart quotes, and
10
+ // tracked insertions and deletions.
11
+ //
12
+ // Shared by modules/copy-desk/tests/copy_docx.mjs and
13
+ // tests/copy_desk_page_docx.mjs (a fixture, not a test: the runner discovers
14
+ // tests/*.mjs only, not this folder).
15
+
16
+ import zlib from 'node:zlib';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const docx = require('../../docx.js');
21
+
22
+ // Every entry of a zip, by the module's own reader (reading is not what these
23
+ // tests doubt; the re-pack below is independent of the module's writer).
24
+ export function unzipAll(buf) {
25
+ const z = docx.readZip(buf);
26
+ if (!z.ok) throw new Error(`unzip: ${z.code} ${JSON.stringify(z.detail)}`);
27
+ return new Map([...z.parts].map(([k, v]) => [k, v.toString('utf8')]));
28
+ }
29
+
30
+ // Pack entries the way Word writes a .docx: general-purpose flag bit 3 (sizes
31
+ // and CRC in a data descriptor after the data, zeros in the local header),
32
+ // deflated, in the order given.
33
+ export function wordZip(entries) {
34
+ const out = [];
35
+ const central = [];
36
+ let offset = 0;
37
+ for (const [name, text] of entries) {
38
+ const data = Buffer.from(text, 'utf8');
39
+ const packed = zlib.deflateRawSync(data, { level: 6 });
40
+ const crc = docx.crc32(data);
41
+ const nameBuf = Buffer.from(name, 'utf8');
42
+ const local = Buffer.alloc(30);
43
+ local.writeUInt32LE(0x04034b50, 0);
44
+ local.writeUInt16LE(20, 4);
45
+ local.writeUInt16LE(0x0008 | 0x0800, 6);
46
+ local.writeUInt16LE(8, 8);
47
+ local.writeUInt16LE(0x6000, 10);
48
+ local.writeUInt16LE(0x5b3c, 12);
49
+ local.writeUInt16LE(nameBuf.length, 26);
50
+ const desc = Buffer.alloc(16);
51
+ desc.writeUInt32LE(0x08074b50, 0);
52
+ desc.writeUInt32LE(crc, 4);
53
+ desc.writeUInt32LE(packed.length, 8);
54
+ desc.writeUInt32LE(data.length, 12);
55
+ const c = Buffer.alloc(46);
56
+ c.writeUInt32LE(0x02014b50, 0);
57
+ c.writeUInt16LE(45, 4);
58
+ c.writeUInt16LE(20, 6);
59
+ c.writeUInt16LE(0x0008 | 0x0800, 8);
60
+ c.writeUInt16LE(8, 10);
61
+ c.writeUInt16LE(0x6000, 12);
62
+ c.writeUInt16LE(0x5b3c, 14);
63
+ c.writeUInt32LE(crc, 16);
64
+ c.writeUInt32LE(packed.length, 20);
65
+ c.writeUInt32LE(data.length, 24);
66
+ c.writeUInt16LE(nameBuf.length, 28);
67
+ c.writeUInt32LE(offset, 42);
68
+ out.push(local, nameBuf, packed, desc);
69
+ central.push(c, nameBuf);
70
+ offset += 30 + nameBuf.length + packed.length + 16;
71
+ }
72
+ const cd = Buffer.concat(central);
73
+ const end = Buffer.alloc(22);
74
+ end.writeUInt32LE(0x06054b50, 0);
75
+ end.writeUInt16LE(entries.length, 8);
76
+ end.writeUInt16LE(entries.length, 10);
77
+ end.writeUInt32LE(cd.length, 12);
78
+ end.writeUInt32LE(offset, 16);
79
+ return Buffer.concat([...out, cd, end]);
80
+ }
81
+
82
+ // Re-pack a downloaded file after changing its document part (and optionally
83
+ // other parts), in reverse entry order, the way a save by another program
84
+ // reorders them.
85
+ export function resave(buf, { document, drop = [], parts = {} } = {}) {
86
+ const all = unzipAll(buf);
87
+ if (document) all.set('word/document.xml', document(all.get('word/document.xml')));
88
+ for (const [k, v] of Object.entries(parts)) all.set(k, v);
89
+ for (const k of drop) all.delete(k);
90
+ return wordZip([...all].reverse());
91
+ }
92
+
93
+ // The paragraph that holds a line's content control, as it stands.
94
+ const LINE_PARA = (key) => new RegExp(`<w:p>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
95
+ const LINE_PARA_STYLED = (key) => new RegExp(`<w:p><w:pPr>(?:(?!<w:p>).)*?<w:tag w:val="${key}"/>.*?</w:p>`, 's');
96
+ export function lineParagraph(xml, key) {
97
+ const m = LINE_PARA_STYLED(key).exec(xml) || LINE_PARA(key).exec(xml);
98
+ if (!m) throw new Error(`no paragraph for ${key}`);
99
+ return m[0];
100
+ }
101
+
102
+ // Replace the runs inside a line's content control with Word-shaped runs.
103
+ export function typeInto(xml, key, runsXml) {
104
+ const para = lineParagraph(xml, key);
105
+ const edited = para.replace(/<w:sdtContent>[\s\S]*?<\/w:sdtContent>/, () => `<w:sdtContent>${runsXml}</w:sdtContent>`);
106
+ return xml.replace(para, () => edited);
107
+ }
108
+
109
+ // Word's runs for a typed sentence: each part its own run, some with
110
+ // properties and revision ids, proofing marks between them.
111
+ export function wordRuns(parts) {
112
+ return parts.map((p, i) => {
113
+ const rPr = i % 2 ? '<w:rPr><w:rFonts w:ascii="Calibri"/><w:lang w:val="en-GB"/></w:rPr>' : '';
114
+ const proof = i === 1 ? '<w:proofErr w:type="spellStart"/>' : i === 2 ? '<w:proofErr w:type="spellEnd"/>' : '';
115
+ return `${proof}<w:r w:rsidR="00A1B2C${i}" w:rsidRPr="00D4E5F${i}">${rPr}<w:t xml:space="preserve">${p}</w:t></w:r>`;
116
+ }).join('');
117
+ }
118
+
119
+ // A tracked change: the insertion is text now, the deletion is not.
120
+ export function trackedInsert(text) {
121
+ return `<w:ins w:id="91" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:ins>`;
122
+ }
123
+ export function trackedDelete(text) {
124
+ return `<w:del w:id="92" w:author="Artist" w:date="2026-09-28T10:00:00Z"><w:r><w:delText xml:space="preserve">${text}</w:delText></w:r></w:del>`;
125
+ }
126
+
127
+ // Remove every content control, keeping its runs: what an editor that does
128
+ // not keep controls leaves behind (the upload then maps by position).
129
+ export function stripControls(xml) {
130
+ return xml.replace(/<w:sdt><w:sdtPr>[\s\S]*?<\/w:sdtPr><w:sdtContent>([\s\S]*?)<\/w:sdtContent><\/w:sdt>/g, (m, inner) => inner);
131
+ }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1061",
3
+ "version": "1.19.1062",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1061",
9
+ "version": "1.19.1062",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1061",
3
+ "version": "1.19.1062",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7667,5 +7667,11 @@
7667
7667
  "id": "1004319",
7668
7668
  "text": "An artist's page rewrite is now saved as they type and can be submitted. The draft rests on the page's own tweak task, and saving the same words again changes nothing. Submitting freezes the rewrite for a /tweak session to app"
7669
7669
  }
7670
+ ],
7671
+ "1.19.1062": [
7672
+ {
7673
+ "id": "1004320",
7674
+ "text": "An artist can now download any page's words as a Word document, work on it offline, and upload it back. The upload goes into their draft, never straight to the site, and only the lines they actually changed in Word are taken,"
7675
+ }
7670
7676
  ]
7671
7677
  }
@@ -42,6 +42,10 @@ const WRITE_VALIDATION_EXEMPT = new Set([
42
42
  'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
43
43
  'PATCH /me/display-name', // { display_name } — displayName.validate
44
44
  'PATCH /me/profile-nudge', // { dismissed: <bool>|null } — profileNudgePrefs.validatePatch
45
+ // Not a JSON body at all: the raw .docx bytes (task 1004320, ADR 0341 D12), capped by
46
+ // the route's own express.raw parser and read by docx.readUpload, which refuses
47
+ // anything that is not a bounded, readable Word file (not_a_docx / upload_too_large).
48
+ 'POST /copy-desk/pages/:pageId/draft.docx',
45
49
  ]);
46
50
 
47
51
  // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1061'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1062'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,279 @@
1
+ // tests/copy_desk_page_docx.mjs — download a page as .docx, edit it the way
2
+ // Word stores an edit, and upload it back into the draft, the WHOLE path (task
3
+ // 1004320 / BV2.TW09, ADR 0341 D12).
4
+ //
5
+ // The done-when: a round trip with edits lands those edits in the draft, and a
6
+ // structure-changing upload is refused by name.
7
+ //
8
+ // Driven through the real copy-desk routes (with the real raw-body parser and
9
+ // its cap), the real lifecycle port (the page claim, updateHeldTaskDescription,
10
+ // submitPageTweak, createTask) and the real page reads, against the shared
11
+ // stateful fake ledger (tests/fixtures/page-ledger.mjs). The reading is a file
12
+ // on disk, read through the real page-data.js. The file is edited by the
13
+ // Word-shaped fixture, which re-packs it with its own zip writer.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { makeRunner } from './helpers.mjs';
21
+ import { installPageLedger } from './fixtures/page-ledger.mjs';
22
+ import { resave, typeInto, wordRuns, lineParagraph } from '../modules/copy-desk/tests/fixtures/word-docx.mjs';
23
+
24
+ process.env.NODE_ENV = 'test';
25
+ const require = createRequire(import.meta.url);
26
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
27
+ const pages = require('../modules/copy-desk/pages.js');
28
+ const docx = require('../modules/copy-desk/docx.js');
29
+ const api = require('../src/module-api.js');
30
+
31
+ const { db } = installPageLedger(api, {
32
+ builders: [
33
+ { id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
34
+ { id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
35
+ ],
36
+ });
37
+ const ALICE = '7';
38
+ const BOB = '8';
39
+
40
+ api.requireBuilder = (req, res, next) => {
41
+ req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
42
+ next();
43
+ };
44
+
45
+ const seams = require('../src/module-seams.js');
46
+ const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
47
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
48
+
49
+ const pageData = require('../modules/copy-desk/page-data.js');
50
+ const express = require('express');
51
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
52
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
53
+
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use(attachFail);
57
+ app.use(buildRouter());
58
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
59
+ const base = `http://127.0.0.1:${server.address().port}`;
60
+
61
+ async function call(method, p, builder, body = {}) {
62
+ const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
63
+ return { status: r.status, body: await r.json() };
64
+ }
65
+ async function getDocx(p, builder) {
66
+ const r = await fetch(base + p, { headers: { 'x-builder': builder } });
67
+ return { status: r.status, headers: r.headers, buf: Buffer.from(await r.arrayBuffer()) };
68
+ }
69
+ async function putDocx(p, builder, buf, type = docx.DOCX_MIME) {
70
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': type, 'x-builder': builder }, body: buf });
71
+ return { status: r.status, body: await r.json() };
72
+ }
73
+ const errCode = (b) => (b && b.error && b.error.code) || null;
74
+ const errDetail = (b) => (b && b.error && b.error.details) || {};
75
+
76
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
77
+ const STUDIO_LINES = [
78
+ L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
79
+ L('L0002', 'top bar', 'Ctrl K', 'shared'),
80
+ L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
81
+ L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
82
+ L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
83
+ L('L0006', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
84
+ ];
85
+ const INDEX_LINES = [
86
+ L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
87
+ ];
88
+ const readingsWith = (studioHash) => ({
89
+ schema: 1,
90
+ pages: [
91
+ { id: 'builders:studio', reading_hash: studioHash, lines: STUDIO_LINES },
92
+ { id: 'landing:index', reading_hash: 'index-h1', lines: INDEX_LINES },
93
+ ],
94
+ });
95
+
96
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-docx-'));
97
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
98
+ fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
99
+ function writeReadings(r) {
100
+ fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
101
+ pageData._setRoot(tmp); // drop the (mtime, size) cache
102
+ }
103
+
104
+ const { test, summary } = makeRunner();
105
+ const STUDIO = '/copy-desk/pages/builders:studio';
106
+ const INDEX = '/copy-desk/pages/landing:index';
107
+ const studioTask = () => db.tasks.find((t) => t.source_ref === 'page-tweak/builders:studio/r1');
108
+ const draftLines = () => pages.parseDraftBlock(studioTask().description).draft.lines.map((l) => [l.key, l.before, l.after]);
109
+ let downloaded = null;
110
+
111
+ try {
112
+ writeReadings(readingsWith('studio-h1'));
113
+
114
+ await test('the download works before anyone claims the page: the reading, as a Word file', async () => {
115
+ const r = await getDocx(`${INDEX}/draft.docx`, BOB);
116
+ assert.equal(r.status, 200);
117
+ assert.equal(r.headers.get('content-type'), docx.DOCX_MIME);
118
+ assert.equal(r.headers.get('content-disposition'), 'attachment; filename="tweak-landing-index.docx"');
119
+ const u = docx.readUpload(r.buf);
120
+ assert.equal(u.ok, true);
121
+ assert.equal(u.page_id, 'landing:index');
122
+ assert.deepEqual([...u.baseline], [['L0001', 'Build here']]);
123
+ });
124
+
125
+ await test('the download is built from the reading plus the artist\'s current draft', async () => {
126
+ assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
127
+ const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Welcome home' }] });
128
+ assert.equal(saved.body.outcome, 'saved');
129
+ const r = await getDocx(`${STUDIO}/draft.docx`, ALICE);
130
+ assert.equal(r.status, 200);
131
+ downloaded = r.buf;
132
+ const u = docx.readUpload(r.buf);
133
+ assert.deepEqual([u.page_id, u.reading_hash], ['builders:studio', 'studio-h1']);
134
+ assert.equal(u.baseline.get('L0003'), 'Welcome home', 'the draft rewrite, not the page text');
135
+ assert.equal(u.baseline.get('L0006'), 'Resume where you left off');
136
+ assert.deepEqual(u.paragraphs.filter((p) => p.tags.length).map((p) => p.tags[0]), STUDIO_LINES.map((l) => l.key));
137
+ });
138
+
139
+ await test('a round trip with edits (Word\'s split runs and smart quotes) lands those edits in the draft', async () => {
140
+ const writes = db.writes;
141
+ const edited = resave(downloaded, {
142
+ document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
143
+ 'L0004', wordRuns(['{…} ', 'pages rewritten'])),
144
+ });
145
+ const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
146
+ assert.equal(r.status, 200, JSON.stringify(r.body));
147
+ assert.equal(r.body.outcome, 'saved');
148
+ assert.deepEqual(r.body.merge, { mode: 'tag', baseline: true, from_file: ['L0004', 'L0006'], kept_from_draft: ['L0003'] });
149
+ assert.deepEqual(r.body.counter, { changed: 3, total: 6 });
150
+ assert.deepEqual(draftLines(), [
151
+ ['L0003', 'Welcome back', 'Welcome home'],
152
+ ['L0004', '{…} pages tweaked', '{…} pages rewritten'],
153
+ ['L0006', 'Resume where you left off', 'Pick up where you\'re at'],
154
+ ]);
155
+ // Into the DRAFT and nowhere else: one write, the round still writing,
156
+ // no frozen batch, one draft block, and the reading on disk untouched.
157
+ assert.equal(db.writes, writes + 1);
158
+ assert.equal(studioTask().status, 'active');
159
+ assert.equal(pages.blockPresence(studioTask().description).batch, false);
160
+ assert.equal((studioTask().description.match(/```page-tweak-draft/g) || []).length, 1);
161
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(tmp, 'docs/page-readings.json'), 'utf8')), readingsWith('studio-h1'));
162
+ });
163
+
164
+ await test('the same upload again writes nothing', async () => {
165
+ const writes = db.writes;
166
+ const stamp = studioTask().updated_at;
167
+ const edited = resave(downloaded, {
168
+ document: (xml) => typeInto(typeInto(xml, 'L0006', wordRuns(['Pick ', 'up where ', 'you’re ', 'at'])),
169
+ 'L0004', wordRuns(['{…} ', 'pages rewritten'])),
170
+ });
171
+ const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
172
+ assert.equal(r.body.outcome, 'unchanged');
173
+ assert.equal(db.writes, writes);
174
+ assert.equal(studioTask().updated_at, stamp);
175
+ });
176
+
177
+ await test('a rewrite made in the studio after the download survives an upload of the older file', async () => {
178
+ // The artist keeps working in the studio (a whole-draft save), then
179
+ // uploads the file they downloaded earlier with one more Word edit.
180
+ const saved = await call('PUT', `${STUDIO}/draft`, ALICE, { reading_hash: 'studio-h1', lines: [{ key: 'L0003', after: 'Hello again' }, { key: 'L0005', after: 'Nothing is waiting' }] });
181
+ assert.equal(saved.body.outcome, 'saved');
182
+ const edited = resave(downloaded, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Your ', 'studio'])) });
183
+ const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, edited);
184
+ assert.equal(r.status, 200, JSON.stringify(r.body));
185
+ assert.deepEqual(r.body.merge.from_file, ['L0001']);
186
+ assert.deepEqual(draftLines().map(([k, , a]) => [k, a]), [
187
+ ['L0001', 'Your studio'],
188
+ ['L0003', 'Hello again'],
189
+ ['L0005', 'Nothing is waiting'],
190
+ ]);
191
+ });
192
+
193
+ await test('a structure-changing upload is refused by name, and nothing is saved', async () => {
194
+ const writes = db.writes;
195
+ const buf = resave(downloaded, {
196
+ document: (xml) => {
197
+ const p = lineParagraph(xml, 'L0003');
198
+ return xml.replace(lineParagraph(xml, 'L0005'), '').replace(p, () => `${p}<w:p><w:r><w:t>An extra thought</w:t></w:r></w:p>`);
199
+ },
200
+ });
201
+ const r = await putDocx(`${STUDIO}/draft.docx`, ALICE, buf);
202
+ assert.equal(r.status, 409);
203
+ assert.equal(errCode(r.body), 'structure_changed');
204
+ assert.deepEqual(errDetail(r.body).moved, [
205
+ { change: 'added', key: null, section: 'greeting', first_words: 'An extra thought' },
206
+ { change: 'dropped', key: 'L0005', section: 'greeting', first_words: 'Your desk is quiet today' },
207
+ ]);
208
+ assert.match(errDetail(r.body).what_this_means, /added, removed or moved/);
209
+ assert.equal(db.writes, writes);
210
+ });
211
+
212
+ await test('another page\'s file is wrong_page; a file from an older reading is reading_moved', async () => {
213
+ assert.equal((await call('POST', `${INDEX}/claim`, BOB)).status, 201);
214
+ const wrong = await putDocx(`${INDEX}/draft.docx`, BOB, downloaded);
215
+ assert.equal(wrong.status, 409);
216
+ assert.equal(errCode(wrong.body), 'wrong_page');
217
+ assert.equal(errDetail(wrong.body).file_page_id, 'builders:studio');
218
+
219
+ writeReadings(readingsWith('studio-h2'));
220
+ const moved = await putDocx(`${STUDIO}/draft.docx`, ALICE, downloaded);
221
+ assert.equal(moved.status, 409);
222
+ assert.equal(errCode(moved.body), 'reading_moved');
223
+ assert.deepEqual([errDetail(moved.body).reading_hash, errDetail(moved.body).file_reading_hash], ['studio-h2', 'studio-h1']);
224
+ writeReadings(readingsWith('studio-h1'));
225
+ });
226
+
227
+ await test('only the holder may upload: page_not_held names the holder', async () => {
228
+ const r = await putDocx(`${STUDIO}/draft.docx`, BOB, downloaded);
229
+ assert.equal(r.status, 409);
230
+ assert.equal(errCode(r.body), 'page_not_held');
231
+ assert.deepEqual(errDetail(r.body).holder, { id: ALICE, login: 'alice', name: 'Alice' });
232
+ });
233
+
234
+ await test('the upload is user input: anything not a .docx is refused, and the size is capped', async () => {
235
+ const writes = db.writes;
236
+ const json = await call('POST', `${STUDIO}/draft.docx`, ALICE, { lines: [] });
237
+ assert.equal(json.status, 415);
238
+ assert.equal(errCode(json.body), 'not_a_docx');
239
+ const text = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('hello'), 'text/plain');
240
+ assert.equal(errCode(text.body), 'not_a_docx');
241
+ const junk = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.from('not a zip at all'));
242
+ assert.equal(junk.status, 415);
243
+ assert.equal(errCode(junk.body), 'not_a_docx');
244
+ const zip = await putDocx(`${STUDIO}/draft.docx`, ALICE, docx.writeZip([{ name: 'notes.txt', data: 'hi' }]), 'application/zip');
245
+ assert.equal(errCode(zip.body), 'not_a_docx');
246
+ const big = await putDocx(`${STUDIO}/draft.docx`, ALICE, Buffer.alloc(docx.MAX_UPLOAD_BYTES + 1024, 7));
247
+ assert.equal(big.status, 413);
248
+ assert.equal(errCode(big.body), 'upload_too_large');
249
+ assert.equal(db.writes, writes);
250
+ });
251
+
252
+ await test('no round is no_open_round; after submit the draft is frozen, so an upload is page_in_flight', async () => {
253
+ const indexFile = (await getDocx(`${INDEX}/draft.docx`, BOB)).buf;
254
+ // Bob holds index; a page nobody opened a round on has nowhere to land.
255
+ const inv = F.INVENTORY.pages.find((p) => p.id !== 'builders:studio' && p.id !== 'landing:index');
256
+ assert.ok(inv, 'the fixture inventory has a page with no round');
257
+ const readings = readingsWith('studio-h1');
258
+ readings.pages.push({ id: inv.id, reading_hash: 'x-h1', lines: [L('L0001', 'hero', 'Hi', 'placed', { file: 'f', line: 1, string_id: 's' })] });
259
+ writeReadings(readings);
260
+ const other = (await getDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB)).buf;
261
+ const none = await putDocx(`/copy-desk/pages/${inv.id}/draft.docx`, BOB, other);
262
+ assert.equal(errCode(none.body), 'no_open_round');
263
+ writeReadings(readingsWith('studio-h1'));
264
+ const edited = resave(indexFile, { document: (xml) => typeInto(xml, 'L0001', wordRuns(['Build ', 'it here'])) });
265
+ assert.equal((await putDocx(`${INDEX}/draft.docx`, BOB, edited)).body.outcome, 'saved');
266
+ assert.equal((await call('POST', `${INDEX}/submit`, BOB)).body.outcome, 'submitted');
267
+ const after = await putDocx(`${INDEX}/draft.docx`, BOB, edited);
268
+ assert.equal(after.status, 409);
269
+ assert.equal(errCode(after.body), 'page_in_flight');
270
+ // The submitted batch carries the Word edit, with its target from the reading.
271
+ const t = db.tasks.find((x) => x.source_ref === 'page-tweak/landing:index/r1');
272
+ assert.deepEqual(pages.parseBatchBlock(t.description).batch.lines.map((l) => [l.key, l.file, l.before, l.after]),
273
+ [['L0001', 'modules/public-landing/public/index.html', 'Build here', 'Build it here']]);
274
+ });
275
+ } finally {
276
+ server.close();
277
+ pageData._setRoot(null);
278
+ }
279
+ summary();
package/tests/fitness.mjs CHANGED
@@ -922,9 +922,14 @@ test('write-route validation check actually CATCHES an unvalidated write route (
922
922
  assert.ok(/POST \/widgets/.test(flagged[0]), 'the offending route is named');
923
923
  });
924
924
 
925
- test('write-route validation exemptions are the known dynamic-key prefs routes only', () => {
925
+ test('write-route validation exemptions are the known dynamic-key prefs routes, plus the one raw-file upload', () => {
926
+ // The .docx upload (task 1004320) has no JSON body to hold a field schema: its
927
+ // body is the file's bytes, capped by the route's parser and read by
928
+ // docx.readUpload. Named here so a second raw-body exemption is a reviewed diff.
929
+ const RAW_FILE_UPLOADS = ['POST /copy-desk/pages/:pageId/draft.docx'];
926
930
  const exempt = [...fitness.WRITE_VALIDATION_EXEMPT];
927
- assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/')), 'exemptions are all /me/* prefs patches: ' + exempt.join(', '));
931
+ assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/') || RAW_FILE_UPLOADS.includes(e)),
932
+ 'exemptions are all /me/* prefs patches or a named raw-file upload: ' + exempt.join(', '));
928
933
  });
929
934
 
930
935
  test('identity gate strips comments on CRLF checkouts too (task 1002428)', () => {