@makerclay/core 1.0.0

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.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
@@ -0,0 +1,313 @@
1
+ // Lifted from hyperclay-local (MIT): src/main/format-html.js.
2
+ // Spec §4 opt-in formatting, with the abuse bounds that keep js-beautify from
3
+ // turning a hostile document into a memory bomb.
4
+
5
+ import beautify from 'js-beautify';
6
+
7
+ const beautifyOptions = {
8
+ indent_size: 2,
9
+ indent_char: ' ',
10
+ wrap_attributes: 'force-expand-multiline',
11
+ unformatted: ['svg', 'path', 'rect', 'circle', 'script', 'style', 'link', 'meta']
12
+ };
13
+
14
+ // Beautify is superlinear in nesting depth and amplifies output through indentation
15
+ // (force-expand-multiline puts each attribute on its own line), so its cost is bounded in
16
+ // three places, each failing safe to storing the bytes as sent: deepest nesting and
17
+ // projected output size are checked BEFORE formatting (the CPU and the allocation are both
18
+ // spent during beautify and cannot be measured afterwards), actual growth is checked after,
19
+ // and beautify itself is wrapped so even a throw stores the input unchanged. A heuristic
20
+ // miss only ever means "not reformatted", never a wrong document.
21
+ const MAX_FORMAT_DEPTH = 256;
22
+ const MAX_PROJECTED_BYTES = 32 * 1024 * 1024;
23
+ const MAX_GROWTH_RATIO = 3;
24
+
25
+ const VOID_ELEMENTS = new Set([
26
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
27
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
28
+ ]);
29
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style']);
30
+
31
+ // Formatting is opt-in per document (spec §4): reformat only when the ROOT <html> element
32
+ // carries formathtml="true", read by value. This is a small linear scan of the document
33
+ // prefix and the root start-tag (never a whole-document regex) so it stays anchored to the
34
+ // real root, matches how a browser parses the tag (quotes only delimit in value position,
35
+ // only ASCII whitespace separates attributes, comments end at --> or --!>), and cannot
36
+ // backtrack on hostile input. Anything but the exact literal value "true" — any other value,
37
+ // no attribute, the attribute on a non-root element, an entity-encoded value, or a root tag
38
+ // that never closes — stores the bytes exactly as sent.
39
+ function isWs(c) {
40
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
41
+ }
42
+
43
+ function commentEnd(str, from) {
44
+ if (str[from] === '>') return from + 1; // <!-->
45
+ if (str[from] === '-' && str[from + 1] === '>') return from + 2; // <!--->
46
+ const len = str.length;
47
+ for (let k = from; k < len; k++) {
48
+ if (str[k] === '-' && str[k + 1] === '-') {
49
+ let e = k + 2;
50
+ if (str[e] === '!') e++; // comment-end-bang: --!>
51
+ if (str[e] === '>') return e + 1;
52
+ }
53
+ }
54
+ return -1;
55
+ }
56
+
57
+ // Scan the document prologue and the root start-tag once, and report what the root is. Returns
58
+ // null when the bytes carry no complete root <html> start-tag: junk before the root, a root that
59
+ // is not <html>, or a start-tag that never closes with '>'. That last case is a truncated
60
+ // document — a real parser would synthesize an implied <html> root, but the bytes on the wire are
61
+ // half a tag, so neither caller should trust it. Two callers ask two questions of this one scan,
62
+ // so they can never disagree about what the document's root is.
63
+ function scanRootHtmlTag(str) {
64
+ const len = str.length;
65
+ let i = str.charCodeAt(0) === 0xFEFF ? 1 : 0;
66
+
67
+ // Skip whitespace, comments, doctype, and processing instructions before the root.
68
+ while (i < len) {
69
+ const c = str[i];
70
+ if (isWs(c)) { i++; continue; }
71
+ if (c !== '<') return null;
72
+ if (str[i + 1] === '!' && str[i + 2] === '-' && str[i + 3] === '-') {
73
+ const end = commentEnd(str, i + 4);
74
+ if (end === -1) return null;
75
+ i = end;
76
+ continue;
77
+ }
78
+ if (str[i + 1] === '!' || str[i + 1] === '?') {
79
+ const end = str.indexOf('>', i);
80
+ if (end === -1) return null;
81
+ i = end + 1;
82
+ continue;
83
+ }
84
+ break;
85
+ }
86
+
87
+ // Require the root <html> start-tag.
88
+ if (str.substr(i, 5).toLowerCase() !== '<html') return null;
89
+ const boundary = str[i + 5];
90
+ if (boundary === undefined || !(isWs(boundary) || boundary === '>' || boundary === '/')) return null;
91
+ i += 5;
92
+
93
+ // Parse attributes, but only trust the result once the tag actually closes with '>'.
94
+ // An unterminated tag (EOF, or a quoted value with no closing quote) is dropped whole by
95
+ // an HTML parser, so it carries no root attribute: fail safe to "not opt-in".
96
+ let optIn = false;
97
+ let seen = false;
98
+ while (i < len) {
99
+ let c = str[i];
100
+ if (c === '>') return { optIn };
101
+ if (isWs(c) || c === '/') { i++; continue; }
102
+
103
+ const nameStart = i;
104
+ while (i < len) {
105
+ c = str[i];
106
+ if (isWs(c) || c === '=' || c === '>' || c === '/') break;
107
+ i++;
108
+ }
109
+ if (i === nameStart) { // sitting on '=' with no name: it begins the name
110
+ i++;
111
+ while (i < len) {
112
+ c = str[i];
113
+ if (isWs(c) || c === '=' || c === '>' || c === '/') break;
114
+ i++;
115
+ }
116
+ }
117
+ const name = str.slice(nameStart, i).toLowerCase();
118
+
119
+ while (i < len && isWs(str[i])) i++;
120
+ let value = '';
121
+ if (str[i] === '=') {
122
+ i++;
123
+ while (i < len && isWs(str[i])) i++;
124
+ c = str[i];
125
+ if (c === '"' || c === "'") {
126
+ const close = str.indexOf(c, i + 1);
127
+ if (close === -1) return null;
128
+ value = str.slice(i + 1, close);
129
+ i = close + 1;
130
+ } else {
131
+ const valueStart = i;
132
+ while (i < len) {
133
+ c = str[i];
134
+ if (isWs(c) || c === '>') break;
135
+ i++;
136
+ }
137
+ value = str.slice(valueStart, i);
138
+ }
139
+ }
140
+
141
+ if (name === 'formathtml' && !seen) { // duplicate attributes: first occurrence wins
142
+ seen = true;
143
+ optIn = value === 'true';
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function formatOptIn(str) {
150
+ const root = scanRootHtmlTag(str);
151
+ return root !== null && root.optIn;
152
+ }
153
+
154
+ // Spec §4 obliges a host to refuse a save body that is not a complete HTML document with a
155
+ // top-level <html> element. It reuses the scan above rather than adding a second tokenizer: that
156
+ // one is fuzz-verified against a real parser, and a second one would drift from it.
157
+ function hasHtmlRoot(str) {
158
+ return typeof str === 'string' && scanRootHtmlTag(str) !== null;
159
+ }
160
+
161
+ // Find the '>' that ends a start tag, skipping quoted attribute values, and count the
162
+ // attributes on the way. force-expand-multiline puts each attribute on its own indented
163
+ // line once a tag carries two or more, so attribute count drives the formatted size as much
164
+ // as nesting depth does: a shallow document packed with many-attribute tags can still make
165
+ // beautify allocate hundreds of MB. Returns { end, attrs }; end is -1 for an unterminated tag.
166
+ function scanStartTag(str, from) {
167
+ const len = str.length;
168
+ let attrs = 0;
169
+ let j = from;
170
+ while (j < len) {
171
+ while (j < len && (isWs(str[j]) || str[j] === '/')) j++;
172
+ if (j >= len) return { end: -1, attrs };
173
+ if (str[j] === '>') return { end: j, attrs };
174
+ attrs++;
175
+ while (j < len && !isWs(str[j]) && str[j] !== '=' && str[j] !== '>' && str[j] !== '/') j++;
176
+ while (j < len && isWs(str[j])) j++;
177
+ if (str[j] === '=') {
178
+ j++;
179
+ while (j < len && isWs(str[j])) j++;
180
+ const c = str[j];
181
+ if (c === '"' || c === "'") {
182
+ const close = str.indexOf(c, j + 1);
183
+ if (close === -1) return { end: -1, attrs };
184
+ j = close + 1;
185
+ } else {
186
+ while (j < len && !isWs(str[j]) && str[j] !== '>') j++;
187
+ }
188
+ }
189
+ }
190
+ return { end: -1, attrs };
191
+ }
192
+
193
+ function rawTextEnd(str, from, name) {
194
+ const len = str.length;
195
+ for (let k = from; k < len; k++) {
196
+ if (str[k] !== '<' || str[k + 1] !== '/') continue;
197
+ let m = 0;
198
+ while (m < name.length && str[k + 2 + m] !== undefined &&
199
+ str[k + 2 + m].toLowerCase() === name[m]) m++;
200
+ if (m === name.length) return k;
201
+ }
202
+ return -1;
203
+ }
204
+
205
+ // One linear pass returning how expensive beautifying this document would be: the deepest
206
+ // element nesting, and an over-estimate of the formatted output size (input plus the
207
+ // indentation and newlines beautify inserts: one line per tag, plus one indented line per
208
+ // attribute once a tag carries two or more). Bails out early the moment the projected size
209
+ // alone is already decisive, so a size bomb is refused in a few ms without finishing the scan.
210
+ function measureFormatCost(str) {
211
+ const len = str.length;
212
+ const indent = beautifyOptions.indent_size;
213
+ let depth = 0;
214
+ let maxDepth = 0;
215
+ let added = 0;
216
+ let i = 0;
217
+ while (i < len) {
218
+ if (str[i] !== '<') {
219
+ // beautify re-indents each line of a formatted text node by depth*indent spaces, so a
220
+ // newline that costs 1 input byte costs depth*indent output bytes. Charge it, or a deeply
221
+ // nested newline-heavy text node slips the size gate and makes beautify allocate/crash.
222
+ if (str[i] === '\n' || (str[i] === '\r' && str[i + 1] !== '\n')) {
223
+ added += depth * indent;
224
+ if (len + added > MAX_PROJECTED_BYTES) return { maxDepth, projectedBytes: len + added };
225
+ }
226
+ i++;
227
+ continue;
228
+ }
229
+ if (str[i + 1] === '!' && str[i + 2] === '-' && str[i + 3] === '-') {
230
+ const end = commentEnd(str, i + 4);
231
+ if (end === -1) break;
232
+ i = end;
233
+ continue;
234
+ }
235
+ if (str[i + 1] === '!' || str[i + 1] === '?') {
236
+ const end = str.indexOf('>', i);
237
+ if (end === -1) break;
238
+ i = end + 1;
239
+ continue;
240
+ }
241
+ const closing = str[i + 1] === '/';
242
+ let j = i + (closing ? 2 : 1);
243
+ const nameStart = j;
244
+ while (j < len && !isWs(str[j]) && str[j] !== '>' && str[j] !== '/') j++;
245
+ const name = str.slice(nameStart, j).toLowerCase();
246
+ if (name === '') { i++; continue; }
247
+
248
+ if (closing) {
249
+ if (depth > 0) depth--;
250
+ added += depth * indent + 1;
251
+ i = str.indexOf('>', j);
252
+ if (i === -1) break;
253
+ i++;
254
+ continue;
255
+ }
256
+
257
+ const tag = scanStartTag(str, j);
258
+ if (tag.end === -1) break;
259
+ const end = tag.end;
260
+
261
+ // the tag's own line, plus force-expand's one indented line per attribute once >= 2
262
+ added += depth * indent + 1;
263
+ if (tag.attrs >= 2) {
264
+ added += tag.attrs * ((depth + 1) * indent + 6) + (depth * indent + 1);
265
+ }
266
+ if (len + added > MAX_PROJECTED_BYTES) {
267
+ return { maxDepth: Math.max(maxDepth, depth), projectedBytes: len + added };
268
+ }
269
+
270
+ const selfClosed = str[end - 1] === '/';
271
+ if (!VOID_ELEMENTS.has(name) && !selfClosed) {
272
+ depth++;
273
+ if (depth > maxDepth) {
274
+ maxDepth = depth;
275
+ if (maxDepth > MAX_FORMAT_DEPTH) return { maxDepth, projectedBytes: len + added };
276
+ }
277
+ }
278
+ if (RAW_TEXT_ELEMENTS.has(name) && !selfClosed) {
279
+ const raw = rawTextEnd(str, end + 1, name);
280
+ if (raw === -1) break;
281
+ i = raw;
282
+ continue;
283
+ }
284
+ i = end + 1;
285
+ }
286
+ return { maxDepth, projectedBytes: len + added };
287
+ }
288
+
289
+ // Returns { output, declined }. declined is null when the document was reformatted or was
290
+ // never opted in; otherwise it names why a formatted result was refused and the input bytes
291
+ // were stored unchanged: 'depth' / 'size' / 'error' are pathological (would exhaust CPU or
292
+ // memory, or crash the formatter), 'growth' merely grew past the ratio cap. The gates run
293
+ // only after formatOptIn, so a non-null declined always means "opted in but refused".
294
+ function formatHtmlDetailed(str) {
295
+ if (!formatOptIn(str)) return { output: str, declined: null };
296
+ const cost = measureFormatCost(str);
297
+ if (cost.maxDepth > MAX_FORMAT_DEPTH) return { output: str, declined: 'depth' };
298
+ if (cost.projectedBytes > MAX_PROJECTED_BYTES) return { output: str, declined: 'size' };
299
+ let formatted;
300
+ try {
301
+ formatted = beautify.html(str, beautifyOptions).replace(/(\r\n|\r|\n){3,}/g, '\n\n');
302
+ } catch {
303
+ return { output: str, declined: 'error' };
304
+ }
305
+ if (formatted.length > str.length * MAX_GROWTH_RATIO) return { output: str, declined: 'growth' };
306
+ return { output: formatted, declined: null };
307
+ }
308
+
309
+ function formatHtml(str) {
310
+ return formatHtmlDetailed(str).output;
311
+ }
312
+
313
+ export { formatHtml, formatHtmlDetailed, hasHtmlRoot, formatOptIn };
@@ -0,0 +1,257 @@
1
+ import { can } from '../auth/can.js';
2
+ import { documentEtag, ifMatchSatisfied } from '../spec/wire.js';
3
+ import { formatHtmlDetailed, hasHtmlRoot } from './format-html.js';
4
+ import { stripRootAttrs } from './root-attrs.js';
5
+ import { detectDrift, DRIFT_MESSAGE } from './stale.js';
6
+ import { HostError } from '../spec/codes.js';
7
+ import { atomicWrite, readFileTextIfExists, statInfo } from '../util/fsx.js';
8
+
9
+ // THE MUTATION KERNEL.
10
+ //
11
+ // Every write lane calls this one function: browser save, editor save, restore,
12
+ // template application, data-loss revert, tenant instance creation, future sync.
13
+ // No route, CLI command or internal feature writes a managed file directly.
14
+ //
15
+ // The step order below is load-bearing, not stylistic:
16
+ // - the etag is computed from the bytes on DISK, never from the store, so a
17
+ // file edited in a terminal cannot be silently overwritten on a stale column;
18
+ // - If-Match is answered before anything is written, and drift detection only
19
+ // runs when the client sent no If-Match, so the two protections layer rather
20
+ // than duplicate;
21
+ // - the version is published BEFORE the live file is replaced, so a crash
22
+ // between them costs the newest save and never the previous document;
23
+ // - the rename in step 11 is the durable commit point: everything after it is
24
+ // bookkeeping, and everything after step 12 is best-effort, because a false
25
+ // error reported after the file landed provokes a retry against the caller's
26
+ // own successful write.
27
+
28
+ // Serve-time machinery, never a document's own bytes. `savetoken` is the one this
29
+ // host injects; `htmlclaytoken` arrives only on a document that came from
30
+ // htmlclay, and clayjs reads both, so a stale one left in a file would hand out a
31
+ // dead credential.
32
+ export const EPHEMERAL_ATTRS = ['savetoken', 'htmlclaytoken'];
33
+
34
+ export function createReplace({
35
+ paths, nodes, versions, locks, events, config, clock, logger = console, derived = null,
36
+ }) {
37
+ const capabilities = config.capabilities || {};
38
+ const limits = config.limits || {};
39
+
40
+ async function replace({
41
+ actor,
42
+ owner = '',
43
+ relPath,
44
+ bytes,
45
+ ifMatch = null,
46
+ trigger = 'auto',
47
+ snapshot = null,
48
+ senderId = null,
49
+ source = 'save',
50
+ ctx = {},
51
+ backupCurrent = false,
52
+ }) {
53
+ // 1. Authorize write for that exact node.
54
+ const node = nodes.resolve(owner, relPath);
55
+ if (!can(actor, 'write', node, ctx)) {
56
+ throw new HostError('forbidden', 'You do not have permission to save this document.');
57
+ }
58
+
59
+ // These two run 3-then-2 on purpose, and the numbers are the sequence's, not
60
+ // this file's: the lock in step 2 is keyed on the resolved path, so step 3
61
+ // has to produce that path before step 2 can take it. Renumbering them to
62
+ // code order would hide the one thing worth knowing here.
63
+
64
+ // 3. Open the current file through paths.js. Never rebuild a raw path from
65
+ // the Document-URL header.
66
+ const filePath = await paths.resolveWrite(owner, relPath);
67
+
68
+ // 2. Acquire the node's canonical path lock, wrapping the ENTIRE
69
+ // read-modify-write region. Serializing only the write would still let two
70
+ // saves read the same stale base.
71
+ return await locks.withLock(filePath, async () => {
72
+ // Everything above ran outside this lock, so a move can have landed in
73
+ // between: the file is somewhere else now and the row went with it. Writing
74
+ // here anyway lands bytes at a path this save no longer owns, and because
75
+ // the row moved, the new file has no row at all, which means it is public.
76
+ //
77
+ // Checked against the row that was authorized, not against the path. A
78
+ // first save to a path with no row authorizes a provisional node and is the
79
+ // common case, so "a row appeared here" is a concurrent first save to the
80
+ // same new path: materialize resolves it onto that same row at this same
81
+ // path, nothing is left rowless, and last-write-wins still applies.
82
+ const holder = nodes.get(owner, relPath);
83
+ if (!node.provisional && holder?.id !== node.id) {
84
+ throw new HostError('conflict', 'That document moved while this save was in flight. Reload and try again.');
85
+ }
86
+
87
+ // 4. Read stored bytes; compute the etag from THOSE bytes.
88
+ const storedBytes = await readFileTextIfExists(filePath);
89
+ const storedEtag = storedBytes == null ? null : documentEtag(storedBytes);
90
+
91
+ // 5. Apply If-Match. Mismatch: 412, nothing written.
92
+ let driftWarning = false;
93
+ if (ifMatch != null && capabilities.conditional !== false) {
94
+ if (!ifMatchSatisfied(ifMatch, storedBytes)) {
95
+ throw new HostError('conflict', 'This document changed since you loaded it.');
96
+ }
97
+ } else {
98
+ // 6. No If-Match: whole-file drift detection.
99
+ //
100
+ // Drift means "the bytes on disk are not bytes this host wrote", so the
101
+ // baseline is the kernel's own last write. It used to be `served_etag`,
102
+ // which is a single node-wide value overwritten by every reader, so any
103
+ // second tab merely LOADING a terminal edit erased the first tab's
104
+ // protection against clobbering it.
105
+ //
106
+ // This deliberately does not try to catch tab-versus-tab staleness. That is
107
+ // what `If-Match` is for, and losing it costs nothing permanent: whoever
108
+ // saves second publishes the first one's bytes as a version on the way
109
+ // past. An external edit clobbered with no rescue version is the only case
110
+ // where bytes exist nowhere afterwards, and it is the case this now covers
111
+ // for every tab regardless of who loaded when.
112
+ driftWarning = detectDrift({ storedBytes, lastKnownEtag: holder?.etag ?? null }) === 'drift';
113
+ }
114
+
115
+ // 7. Validate.
116
+ if (typeof bytes !== 'string') {
117
+ throw new HostError('invalid-document', 'Expected the document as text.');
118
+ }
119
+ const size = Buffer.byteLength(bytes, 'utf8');
120
+ if (limits.saveBytes && size > limits.saveBytes) {
121
+ throw new HostError('too-large', `Document is larger than the ${limits.saveBytes}-byte limit.`);
122
+ }
123
+ if (!hasHtmlRoot(bytes)) {
124
+ throw new HostError('invalid-document', 'Not a complete HTML document with a top-level <html> element.');
125
+ }
126
+
127
+ let next = stripRootAttrs(bytes, EPHEMERAL_ATTRS);
128
+
129
+ // 8. Format only when the host advertises `format` AND the root carries
130
+ // exactly formathtml="true".
131
+ if (capabilities.format !== false) {
132
+ const formatted = formatHtmlDetailed(next);
133
+ if (formatted.declined) {
134
+ logger.warn?.(`[makerclay] declined to format ${relPath}: ${formatted.declined}`);
135
+ }
136
+ next = formatted.output;
137
+ }
138
+
139
+ const versionNode = await ensureNodeRow({ owner, relPath, node });
140
+ const nextEtag = documentEtag(next);
141
+ // The size of what LANDS, not of what was sent. `size` above is the
142
+ // client's bytes, which is the right number to check a limit against and
143
+ // the wrong one to store: the ephemeral attributes, the edit-mode shim and
144
+ // formatting all change the length before the write. Storing the submitted
145
+ // number leaves the row's size disagreeing with its own file, and byDigest
146
+ // filters candidates on size before it computes a digest, so the right file
147
+ // is discarded before it is ever read.
148
+ const storedSize = Buffer.byteLength(next, 'utf8');
149
+
150
+ // 9. Dedupe: identical bytes return the current etag, no version, no event.
151
+ if (storedBytes != null && nextEtag === storedEtag) {
152
+ // Nothing was written, but this may be the row's first sight of the
153
+ // file, and a row with no signature cannot follow a later rename.
154
+ const settled = await nodes.update(versionNode, {
155
+ ...(versionNode.ino == null ? await signatureOf(filePath) : {}),
156
+ // The dedupe branch is the one place the host is looking directly at
157
+ // ground truth: these bytes and the bytes on disk are the same bytes. A
158
+ // row that walks away from here still describing an earlier kernel write
159
+ // is describing a document that exists nowhere, and byDigest is built on
160
+ // that pair.
161
+ observedEtag: nextEtag,
162
+ bytes: storedSize,
163
+ });
164
+ return {
165
+ node: settled,
166
+ etag: nextEtag,
167
+ changed: false,
168
+ msg: 'Saved',
169
+ msgType: 'success',
170
+ derived: async () => {},
171
+ };
172
+ }
173
+
174
+ // 10. Publish the version. publishVersion throws: a save that cannot
175
+ // publish its undo fails closed. Seed the pre-host state when history
176
+ // is empty, and rescue drifted bytes before they are clobbered.
177
+ if (storedBytes != null) {
178
+ const history = await versions.list(versionNode);
179
+ if (history.length === 0 || driftWarning || backupCurrent) {
180
+ await versions.publish(versionNode, storedBytes);
181
+ }
182
+ }
183
+ await versions.publish(versionNode, next);
184
+
185
+ // 11. Atomic replace of the live file + fsync of its directory. This
186
+ // rename is the durable commit point.
187
+ await atomicWrite(filePath, next);
188
+
189
+ // 12. One store transaction: refresh etag/bytes/mtime, bump seq.
190
+ const saved = await touchNode({
191
+ owner, relPath, node: versionNode, etag: nextEtag, size: storedSize, filePath,
192
+ });
193
+
194
+ const result = {
195
+ node: saved,
196
+ etag: nextEtag,
197
+ changed: true,
198
+ msg: driftWarning ? DRIFT_MESSAGE : 'Saved',
199
+ msgType: driftWarning ? 'warning' : 'success',
200
+ };
201
+
202
+ // 14. After the response: derived work, best-effort, never converted into
203
+ // an HTTP failure.
204
+ result.derived = async () => {
205
+ events.emit('node-saved', { node: saved, etag: nextEtag, trigger, source, senderId });
206
+ if (!derived) return;
207
+ try {
208
+ await derived.onSaved({
209
+ node: saved, html: next, previousHtml: storedBytes, snapshot, senderId, trigger,
210
+ sourcePath: filePath,
211
+ });
212
+ } catch (error) {
213
+ logger.error?.('[makerclay] derived work failed (non-fatal):', error?.message || error);
214
+ }
215
+ };
216
+
217
+ return result;
218
+ });
219
+ }
220
+
221
+ async function ensureNodeRow({ owner, relPath, node }) {
222
+ return nodes.materialize({ ...node, owner, path: relPath, kind: nodes.kindFor(relPath) });
223
+ }
224
+
225
+ // atomicWrite is temp-file-plus-rename, so the host changes a file's inode on
226
+ // EVERY save. A row that does not re-record it here stops matching its own
227
+ // file the next time anything moves.
228
+ async function signatureOf(filePath) {
229
+ const info = await statInfo(filePath);
230
+ if (!info) return {};
231
+ return { dev: info.dev, ino: info.ino, mtimeMs: info.mtimeMs };
232
+ }
233
+
234
+ async function touchNode({ owner, relPath, node, etag, size, filePath }) {
235
+ const patch = {
236
+ // Also the drift baseline: the bytes this host wrote are the bytes a later
237
+ // save compares the file against, so a second save from the same tab
238
+ // measures itself against its own write rather than reporting drift that
239
+ // never happened.
240
+ etag,
241
+ // A statement of fact rather than the scanner's column being borrowed:
242
+ // once this write lands, the bytes on disk ARE this digest. `bytes` has
243
+ // always had two writers and `observedEtag` only had one, so after an
244
+ // external edit the row carried the scanner's old digest beside the
245
+ // kernel's new size and byDigest looked for a pair no file has. It also
246
+ // restores the own-write dedupe in refreshRow, which never fired after a
247
+ // browser save.
248
+ observedEtag: etag,
249
+ bytes: size,
250
+ ...(await signatureOf(filePath)),
251
+ updatedAt: clock.now(),
252
+ };
253
+ return nodes.materialize({ ...node, owner, path: relPath, kind: nodes.kindFor(relPath) }, patch);
254
+ }
255
+
256
+ return { replace };
257
+ }