@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.
- package/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- 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
|
+
}
|