@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,307 @@
1
+ import path from 'upath';
2
+ import {
3
+ mkdirp,
4
+ readdirIfExists,
5
+ statIfExists,
6
+ readFileText,
7
+ unlinkIfExists,
8
+ rename,
9
+ rmrf,
10
+ atomicWrite,
11
+ readFileTextIfExists,
12
+ } from '../util/fsx.js';
13
+ import { documentEtag } from '../spec/wire.js';
14
+ import {
15
+ VERSION_NAME, compareNewestFirst, publishInto, sortKey,
16
+ } from './naming.js';
17
+
18
+ // Version history on disk, lifted from hyperclay-local's backup.js and
19
+ // prune-versions.js, re-keyed by node identity instead of path.
20
+ //
21
+ // The driver half: which directory a node's history lives in, listing and
22
+ // reading it back, pruning it, and holding the lock while any of that happens.
23
+ // What a version is CALLED and how it is committed under that name is
24
+ // naming.js, which knows nothing about nodes, locks or config.
25
+ //
26
+ // One behavioral change from the lift: publish() THROWS. createBackup's
27
+ // swallow-and-return-null does not come along, because a save that cannot
28
+ // publish its version must fail closed rather than silently discard the undo.
29
+
30
+ export function createFilesVersionDriver({ paths, locks, config = {}, clock = Date, logger = console }) {
31
+ const maxAgeMs = (config.maxAgeDays ?? 60) * 24 * 60 * 60 * 1000;
32
+ const keepNewest = config.keepNewest ?? 20;
33
+ const pruneIntervalMs = 60 * 60 * 1000;
34
+ const lastPruneAt = new Map();
35
+
36
+ function dirFor(node) {
37
+ return paths.versionsDir(node.id, path.basename(node.path));
38
+ }
39
+
40
+ // The versions directory for a node whose slug has drifted (a rename that
41
+ // never got to move the directory). Identity is the key, so find by suffix.
42
+ async function findDirById(nodeId) {
43
+ const suffix = paths.versionsDirPrefix(nodeId);
44
+ const names = (await readdirIfExists(paths.dirs.versions)).filter((name) => name.endsWith(suffix));
45
+ if (names.length <= 1) return names[0] ? path.join(paths.dirs.versions, names[0]) : null;
46
+ // Eight characters of a ULID, so two documents can share a suffix. One hit is
47
+ // the normal case and is free to trust; more than one has to be settled by the
48
+ // record inside, which is the authoritative copy of the id.
49
+ for (const name of names) {
50
+ const dir = path.join(paths.dirs.versions, name);
51
+ if ((await readMetaIn(dir))?.id === nodeId) return dir;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ async function resolveDir(node, { create = false } = {}) {
57
+ const preferred = dirFor(node);
58
+ if (await statIfExists(preferred)) return preferred;
59
+ const existing = await findDirById(node.id);
60
+ if (existing) return existing;
61
+ if (create) {
62
+ await mkdirp(preferred);
63
+ return preferred;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ async function readMetaIn(dir) {
69
+ const raw = await readFileTextIfExists(path.join(dir, 'meta.json'));
70
+ if (!raw) return null;
71
+ try {
72
+ return JSON.parse(raw);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Deliberately derived from the content rather than from `node.etag`: the
79
+ // version is published BEFORE the live file is replaced, so the node still
80
+ // carries the previous save's digest at this point and a record built from it
81
+ // would lag one save behind forever.
82
+ function signatureOfContent(content, encoding) {
83
+ if (encoding !== 'utf8' || typeof content !== 'string') return null;
84
+ return { etag: documentEtag(content), bytes: Buffer.byteLength(content, 'utf8') };
85
+ }
86
+
87
+ // The durable half of the overlay. Everything in here is either impossible to
88
+ // recover from the filesystem (the three flags, the collection slug) or
89
+ // expensive to work out again (which id owns this history). The directory
90
+ // name carries only the LAST EIGHT characters of the id, which is 40 bits, so
91
+ // `id` here is what is authoritative after a database loss, not the folder.
92
+ //
93
+ // Spread over `current` rather than replacing it: a publish must not drop a
94
+ // slug it knows nothing about.
95
+ function recordFor(node, current, signature = null) {
96
+ return {
97
+ ...(current || {}),
98
+ id: node.id,
99
+ path: node.path,
100
+ owner: node.owner ?? '',
101
+ kind: node.kind,
102
+ private: !!node.private,
103
+ isolated: node.isolated === undefined ? (current?.isolated ?? null) : node.isolated,
104
+ signups: !!node.signups,
105
+ trashed: !!node.deletedAt,
106
+ // The only fields here that describe the BYTES rather than the overlay,
107
+ // and the reason is narrow: a row rebuilt from this record has to be able
108
+ // to find its file again if the file moved while the host was down.
109
+ // `byDigest` (nodes/scanner.js) needs both halves, the size to filter
110
+ // candidates for free out of the stats the walk already has, and the etag
111
+ // to confirm one. Without them a database loss plus a move strands the
112
+ // history, which is the one thing the journal this replaced did that these
113
+ // records otherwise would not.
114
+ //
115
+ // They go stale on an external edit, because only a publish refreshes
116
+ // them. That is the same coverage the journal this replaced had, since its
117
+ // save lines also only ever recorded host writes.
118
+ etag: signature?.etag ?? current?.etag ?? null,
119
+ bytes: signature?.bytes ?? current?.bytes ?? null,
120
+ rev: (current?.rev ?? 0) + 1,
121
+ updatedAt: clock.now(),
122
+ };
123
+ }
124
+
125
+ // Lock-free on purpose: its only caller is `publish`, which already holds the
126
+ // directory lock. Taking it again here would deadlock.
127
+ async function writeMeta(dir, node, signature = null) {
128
+ const current = await readMetaIn(dir);
129
+ await atomicWrite(path.join(dir, 'meta.json'), `${JSON.stringify(recordFor(node, current, signature), null, 2)}\n`);
130
+ }
131
+
132
+ async function entries(dir) {
133
+ const names = await readdirIfExists(dir);
134
+ const found = [];
135
+ for (const name of names) {
136
+ if (!VERSION_NAME.test(name)) continue;
137
+ const stat = await statIfExists(path.join(dir, name));
138
+ if (!stat || !stat.isFile()) continue;
139
+ found.push({ name, full: path.join(dir, name), mtimeMs: stat.mtimeMs, size: stat.size });
140
+ }
141
+ found.sort(compareNewestFirst);
142
+ return found;
143
+ }
144
+
145
+ async function pruneDir(dir, now = clock.now()) {
146
+ const found = await entries(dir);
147
+ if (!found.length) return { kept: 0, deleted: [] };
148
+
149
+ const keep = new Set();
150
+ found.slice(0, keepNewest).forEach((entry) => keep.add(entry.name));
151
+ for (const entry of found) {
152
+ if (now - sortKey(entry) <= maxAgeMs) keep.add(entry.name);
153
+ }
154
+
155
+ const deleted = [];
156
+ for (const entry of found) {
157
+ if (keep.has(entry.name)) continue;
158
+ if (await unlinkIfExists(entry.full)) deleted.push(entry.name);
159
+ }
160
+ return { kept: keep.size, deleted };
161
+ }
162
+
163
+ function maybePrune(dir) {
164
+ const now = clock.now();
165
+ if (now - (lastPruneAt.get(dir) || 0) < pruneIntervalMs) return;
166
+ lastPruneAt.set(dir, now);
167
+ pruneDir(dir, now).catch((error) => {
168
+ logger.error?.('[versions] prune failed (non-fatal):', error?.message || error);
169
+ });
170
+ }
171
+
172
+ // The ONE place durable overlay state is written from outside a publish. It
173
+ // takes the SAME lock as publish, which is already a node-id lock: resolveDir
174
+ // finds the directory by the identity suffix in its name rather than by path,
175
+ // so it survives renames. Without sharing that lock, a save racing a flag
176
+ // change overwrites the flag with a stale snapshot, and the host reports a
177
+ // durability it did not get.
178
+ //
179
+ // `create` is off by default so a best-effort path or trash update never
180
+ // conjures a history folder for a document that has none.
181
+ async function patchMeta(node, patch = {}, { create = false } = {}) {
182
+ const dir = await resolveDir(node, { create });
183
+ if (!dir) return null;
184
+ return await locks.withLock(`versions:${dir}`, async () => {
185
+ const current = await readMetaIn(dir);
186
+ const next = { ...recordFor(node, current), ...patch };
187
+ await atomicWrite(path.join(dir, 'meta.json'), `${JSON.stringify(next, null, 2)}\n`);
188
+ return next;
189
+ });
190
+ }
191
+
192
+ return {
193
+ name: 'files',
194
+
195
+ findDirById,
196
+
197
+ // Throws on any failure. A save that cannot publish its version fails closed.
198
+ async publish(node, content, { encoding = 'utf8', ext = '.html' } = {}) {
199
+ const dir = (await resolveDir(node, { create: true }));
200
+ return await locks.withLock(`versions:${dir}`, async () => {
201
+ const published = await publishInto(dir, ext, content, encoding, new Date(clock.now()));
202
+ await writeMeta(dir, node, signatureOfContent(content, encoding));
203
+ maybePrune(dir);
204
+ return published;
205
+ });
206
+ },
207
+
208
+ async list(node) {
209
+ const dir = await resolveDir(node);
210
+ if (!dir) return [];
211
+ const found = await entries(dir);
212
+ return found.map((entry, index) => ({
213
+ name: entry.name,
214
+ time: sortKey(entry),
215
+ seq: index,
216
+ size: entry.size,
217
+ }));
218
+ },
219
+
220
+ async read(node, name) {
221
+ if (!VERSION_NAME.test(String(name || ''))) return null;
222
+ const dir = await resolveDir(node);
223
+ if (!dir) return null;
224
+ const full = path.join(dir, name);
225
+ if (!(await statIfExists(full))) return null;
226
+ return await readFileText(full);
227
+ },
228
+
229
+ async meta(node) {
230
+ const dir = await resolveDir(node);
231
+ if (!dir) return null;
232
+ return await readMetaIn(dir);
233
+ },
234
+
235
+ patchMeta,
236
+
237
+ // Move an orphaned history onto a live document. The DIRECTORY is renamed
238
+ // rather than the node re-identified, and that direction is not arbitrary:
239
+ // node ids are referenced by shares, save tokens and collections, so
240
+ // changing a live node's id would silently break all three, while a history
241
+ // directory is referenced by nothing except its own name.
242
+ async adopt(fromId, node) {
243
+ const from = await findDirById(fromId);
244
+ if (!from) return false;
245
+ const to = dirFor(node);
246
+ if (from === to) return true;
247
+ // findDirById, not statIfExists(dirFor(node)): the question is "does this
248
+ // node already have a history", and dirFor only answers "is there one filed
249
+ // under the name its CURRENT path implies". A slug that has drifted makes
250
+ // those different questions, and the wrong one lets a second directory land
251
+ // with the same id suffix.
252
+ if (await findDirById(node.id)) return false;
253
+ await mkdirp(path.dirname(to));
254
+ await rename(from, to);
255
+ // The record's digest describes the file this history used to follow, and
256
+ // that file may still exist somewhere. Carrying it onto a different
257
+ // document would let a later scan rebind on it. Clear it and let the next
258
+ // save write the truth.
259
+ //
260
+ // `slug` binds a document to a folder of submissions under records/, and it
261
+ // belongs to the document this history came FROM, not the one it is landing
262
+ // on. Carrying it over rebinds a stranger's submissions on the next rebuild.
263
+ await patchMeta(node, { etag: null, bytes: null, slug: null }, { create: true });
264
+ return true;
265
+ },
266
+
267
+ // The slug is display only, so a rename moves the directory in place and
268
+ // never touches the identity half of its name.
269
+ async renameSlug(node, nextPath) {
270
+ const current = await resolveDir(node);
271
+ if (!current) return null;
272
+ const next = paths.versionsDir(node.id, path.basename(nextPath));
273
+ if (current === next) return current;
274
+ if (await statIfExists(next)) return current;
275
+ await rename(current, next);
276
+ return next;
277
+ },
278
+
279
+ async prune(node) {
280
+ const dir = await resolveDir(node);
281
+ if (!dir) return { kept: 0, deleted: [] };
282
+ return await locks.withLock(`versions:${dir}`, () => pruneDir(dir));
283
+ },
284
+
285
+ async pruneAll() {
286
+ const names = await readdirIfExists(paths.dirs.versions);
287
+ let sites = 0;
288
+ let deleted = 0;
289
+ for (const name of names) {
290
+ const dir = path.join(paths.dirs.versions, name);
291
+ const stat = await statIfExists(dir);
292
+ if (!stat?.isDirectory()) continue;
293
+ const result = await locks.withLock(`versions:${dir}`, () => pruneDir(dir));
294
+ sites += 1;
295
+ deleted += result.deleted.length;
296
+ }
297
+ return { sites, deleted };
298
+ },
299
+
300
+ async purge(node) {
301
+ const dir = await resolveDir(node);
302
+ if (!dir) return false;
303
+ await rmrf(dir);
304
+ return true;
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,16 @@
1
+ import { createFilesVersionDriver } from './files.js';
2
+
3
+ // The versions driver interface: publish / list / read / meta / patchMeta /
4
+ // adopt / renameSlug / findDirById / prune / pruneAll / purge. `files` is the
5
+ // only driver, and the call is direct: a one-entry lookup table read as though
6
+ // a second driver existed somewhere. The git driver, if it ever arrives, adds
7
+ // the branch back here and touches no caller, which was the only thing the
8
+ // table was buying.
9
+
10
+ export function createVersions(options) {
11
+ const name = options?.config?.driver || 'files';
12
+ if (name !== 'files') throw new Error(`Unknown versions driver: ${name}`);
13
+ return createFilesVersionDriver(options);
14
+ }
15
+
16
+ export { createFilesVersionDriver };
@@ -0,0 +1,172 @@
1
+ import path from 'upath';
2
+ import fsp from 'node:fs/promises';
3
+ import crypto from 'node:crypto';
4
+ import {
5
+ mkdirp,
6
+ readdirIfExists,
7
+ statIfExists,
8
+ unlinkIfExists,
9
+ fsyncDir,
10
+ flush,
11
+ rename,
12
+ } from '../util/fsx.js';
13
+
14
+ // What a version is CALLED, and how one is committed under that name.
15
+ //
16
+ // Split out of files.js because it is the pure half: given a directory and an
17
+ // instant, it decides a filename that sorts strictly after every version already
18
+ // there, and it puts the bytes under that name atomically. It knows nothing
19
+ // about nodes, locks, pruning or config, and it never reads a version back.
20
+ //
21
+ // The two halves are worth keeping apart because the rules here are subtle in a
22
+ // way the driver's are not, and every one of them exists because of a specific
23
+ // way version history can be silently corrupted: a DST fall-back that makes two
24
+ // versions claim the same instant, a clock that rolls backwards, a crash between
25
+ // writing bytes and naming them.
26
+
27
+ // `YYYY-MM-DD-HH-MM-SS-mmm`, an optional zone (a signed four-digit UTC offset,
28
+ // or the older bare `Z`), and an optional zero-padded collision suffix. The
29
+ // suffix MUST be matched: an unrecognised name is one the pruner refuses to
30
+ // touch, so without it every collision-suffixed version would accumulate.
31
+ export const VERSION_NAME =
32
+ /^(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{3})(Z|[+-]\d{4})?(?:-(\d{3}))?\.[A-Za-z0-9]+$/;
33
+
34
+ const MAX_COLLISION_ATTEMPTS = 1000;
35
+ const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'ENOSYS']);
36
+ const pad3 = (n) => String(n).padStart(3, '0');
37
+
38
+ // Local wall time plus the signed UTC offset in force at that moment, e.g.
39
+ // `2026-11-01-01-30-00-431-0400`. Local time is what the user reads in a file
40
+ // browser; the offset is what makes it orderable, because local wall time
41
+ // repeats for one hour on every DST fall-back and the pruner DELETES.
42
+ //
43
+ // Note the sign: getTimezoneOffset() returns POSITIVE minutes for zones BEHIND
44
+ // UTC, so it is negated here. Getting that backwards inverts every ordering.
45
+ export function generateTimestamp(now = new Date()) {
46
+ const pad = (value, width = 2) => String(value).padStart(width, '0');
47
+ const offsetMinutes = -now.getTimezoneOffset();
48
+ const sign = offsetMinutes < 0 ? '-' : '+';
49
+ const absMinutes = Math.abs(offsetMinutes);
50
+ const offset = `${sign}${pad(Math.floor(absMinutes / 60))}${pad(absMinutes % 60)}`;
51
+
52
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-` +
53
+ `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}-` +
54
+ `${pad(now.getMilliseconds(), 3)}${offset}`;
55
+ }
56
+
57
+ export function parseVersionTimestamp(filename) {
58
+ const match = VERSION_NAME.exec(filename);
59
+ if (!match) return null;
60
+ const [, year, month, day, hours, minutes, seconds, ms, zone] = match;
61
+ if (!zone) return null;
62
+
63
+ const wallClock = Date.UTC(+year, +month - 1, +day, +hours, +minutes, +seconds, +ms);
64
+ if (zone === 'Z') return wallClock;
65
+ const offsetMinutes = (+zone.slice(1, 3) * 60 + +zone.slice(3, 5)) * (zone[0] === '-' ? -1 : 1);
66
+ return wallClock - offsetMinutes * 60 * 1000;
67
+ }
68
+
69
+ export function sortKey(entry) {
70
+ const parsed = parseVersionTimestamp(entry.name);
71
+ return parsed === null ? entry.mtimeMs : parsed;
72
+ }
73
+
74
+ function collisionSuffix(filename) {
75
+ const match = VERSION_NAME.exec(filename);
76
+ return match && match[9] ? Number(match[9]) : 0;
77
+ }
78
+
79
+ function versionStamp(filename) {
80
+ const m = VERSION_NAME.exec(filename);
81
+ if (!m) return null;
82
+ return `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}-${m[6]}-${m[7]}${m[8] || ''}`;
83
+ }
84
+
85
+ export function compareNewestFirst(a, b) {
86
+ return (sortKey(b) - sortKey(a)) || (collisionSuffix(b.name) - collisionSuffix(a.name));
87
+ }
88
+
89
+ // Choose a name that sorts strictly AFTER every committed version. Normally the
90
+ // fresh clock instant already does; if the wall clock has rolled backwards to
91
+ // at-or-before the newest committed instant, reuse that instant's stamp and take
92
+ // the next collision suffix so the new file still ranks first.
93
+ async function planVersionName(dir, candidateDate) {
94
+ let stamp = generateTimestamp(candidateDate);
95
+ let suffix = 0;
96
+ let reuseInstant = null;
97
+
98
+ const names = await readdirIfExists(dir);
99
+ const committed = [];
100
+ for (const name of names) {
101
+ if (!VERSION_NAME.test(name)) continue;
102
+ const stat = await statIfExists(path.join(dir, name));
103
+ if (stat) committed.push({ name, mtimeMs: stat.mtimeMs });
104
+ }
105
+
106
+ if (committed.length) {
107
+ committed.sort(compareNewestFirst);
108
+ const newest = committed[0];
109
+ const newestInstant = sortKey(newest);
110
+ if (candidateDate.getTime() <= newestInstant) {
111
+ stamp = versionStamp(newest.name);
112
+ suffix = collisionSuffix(newest.name) + 1;
113
+ reuseInstant = newestInstant;
114
+ }
115
+ }
116
+
117
+ return { stamp, suffix, reuseInstant };
118
+ }
119
+
120
+ // Publish one version atomically and monotonically.
121
+ //
122
+ // Never a partial file: the content is written to a dot-prefixed temp in the
123
+ // same directory, fsynced, chmod'd and closed FIRST. The dot prefix can never
124
+ // match VERSION_NAME, so the pruner and the listing ignore whatever a crash
125
+ // leaves behind. The final name is then produced by fs.link (atomic, no-replace),
126
+ // or by rename on a filesystem without hard links.
127
+ export async function publishInto(dir, ext, content, encoding, now) {
128
+ await mkdirp(dir);
129
+ const tempPath = path.join(dir, `.makerclay-ver-${crypto.randomBytes(8).toString('hex')}.tmp`);
130
+ try {
131
+ const handle = await fsp.open(tempPath, 'wx', 0o644);
132
+ try {
133
+ await handle.writeFile(content, encoding === null ? undefined : encoding);
134
+ await flush(handle);
135
+ await handle.chmod(0o644);
136
+ } finally {
137
+ await handle.close();
138
+ }
139
+
140
+ const plan = await planVersionName(dir, now);
141
+ let { stamp, reuseInstant } = plan;
142
+ let suffix = plan.suffix;
143
+
144
+ for (;;) {
145
+ if (suffix >= MAX_COLLISION_ATTEMPTS) {
146
+ if (reuseInstant == null) {
147
+ throw new Error(`No free version name for ${stamp}${ext} after ${MAX_COLLISION_ATTEMPTS} attempts`);
148
+ }
149
+ reuseInstant += 1;
150
+ stamp = generateTimestamp(new Date(reuseInstant));
151
+ suffix = 0;
152
+ }
153
+
154
+ const filename = suffix === 0 ? `${stamp}${ext}` : `${stamp}-${pad3(suffix)}${ext}`;
155
+ const full = path.join(dir, filename);
156
+ try {
157
+ await fsp.link(tempPath, full);
158
+ return { name: filename, full };
159
+ } catch (error) {
160
+ if (error.code === 'EEXIST') { suffix += 1; continue; }
161
+ if (LINK_UNSUPPORTED.has(error.code)) {
162
+ await rename(tempPath, full);
163
+ return { name: filename, full };
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+ } finally {
169
+ await unlinkIfExists(tempPath).catch(() => {});
170
+ await fsyncDir(dir);
171
+ }
172
+ }
@@ -0,0 +1,91 @@
1
+ import path from 'upath';
2
+ import { can } from '../auth/can.js';
3
+ import { jsonError } from '../json-errors.js';
4
+ import { documentUrlHeader } from '../spec/wire.js';
5
+ import { VERSION_NAME } from './naming.js';
6
+ import { applySecurityHeaders } from '../tenants/isolation.js';
7
+ import { wrap } from '../util/express.js';
8
+
9
+ // The versions HTTP surface, in htmlclay's shapes. A document is addressed
10
+ // either by the Document-URL header (cookie-authenticated) or by a save token in
11
+ // the path (isolated documents and share links), which is the same split the
12
+ // save lane uses.
13
+
14
+ export function mountVersionRoutes({
15
+ router, versions, replace,
16
+ resolveTarget, resolveSaveToken, tryResolveRead, gateCtx, track, isolationFor,
17
+ }) {
18
+ async function targetFromRequest(req, action) {
19
+ if (req.params.token) {
20
+ const grant = await resolveSaveToken(req.params.token);
21
+ if (!grant) return null;
22
+ const actor = { ...req.actor, grants: { ...req.actor.grants, saveToken: grant } };
23
+ if (!can(actor, action, grant.node, gateCtx)) return null;
24
+ const real = await tryResolveRead(grant.node.owner, grant.node.path);
25
+ if (!real) return null;
26
+ return { actor, node: grant.node, owner: grant.node.owner, real };
27
+ }
28
+ const href = documentUrlHeader(req);
29
+ if (!href) return null;
30
+ return await resolveTarget(req.actor, href, action);
31
+ }
32
+
33
+ async function listHandler(req, res) {
34
+ const found = await targetFromRequest(req, 'version-read');
35
+ if (!found) return jsonError(res, 'not-found', 'Not found');
36
+ const list = await versions.list(found.node);
37
+ return res.json({ ok: true, name: path.basename(found.node.path), versions: list });
38
+ }
39
+
40
+ async function readHandler(req, res) {
41
+ const name = req.params.name;
42
+ if (!VERSION_NAME.test(String(name || ''))) return jsonError(res, 'not-found', 'Not found');
43
+ const found = await targetFromRequest(req, 'version-read');
44
+ if (!found) return jsonError(res, 'not-found', 'Not found');
45
+ const body = await versions.read(found.node, name);
46
+ if (body == null) return jsonError(res, 'not-found', 'Not found');
47
+ // A version is the same document at an earlier moment, so it gets the same
48
+ // posture. Without this a tenant saves a version full of script and opens it
49
+ // through their own save token at the host's real origin.
50
+ applySecurityHeaders(res, isolationFor ? isolationFor(found.node) : false);
51
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
52
+ res.setHeader('Cache-Control', 'no-store');
53
+ return res.send(body);
54
+ }
55
+
56
+ // Backs the current document up FIRST and hard-fails if it cannot: a restore
57
+ // that loses the state it replaced is worse than a restore that refuses.
58
+ // The restored bytes have their identity stripped rather than adopted, so a
59
+ // restore never re-anchors the document onto an older identity.
60
+ async function restoreHandler(req, res) {
61
+ const name = req.params.name;
62
+ if (!VERSION_NAME.test(String(name || ''))) return jsonError(res, 'not-found', 'Not found');
63
+ const found = await targetFromRequest(req, 'version-write');
64
+ if (!found) return jsonError(res, 'not-found', 'Not found');
65
+
66
+ const body = await versions.read(found.node, name);
67
+ if (body == null) return jsonError(res, 'not-found', 'Not found');
68
+
69
+ const result = await replace({
70
+ actor: found.actor || req.actor,
71
+ owner: found.node.owner,
72
+ relPath: found.node.path,
73
+ bytes: body,
74
+ trigger: 'user',
75
+ source: 'restore',
76
+ ctx: gateCtx,
77
+ backupCurrent: true,
78
+ });
79
+ res.json({ ok: true, msg: `Restored ${name}`, msgType: 'success', etag: result.etag });
80
+ track(result.derived());
81
+ return undefined;
82
+ }
83
+
84
+
85
+ router.get('/versions', wrap(listHandler));
86
+ router.get('/versions/:token', wrap(listHandler));
87
+ router.get('/version/:name', wrap(readHandler));
88
+ router.get('/version/:token/:name', wrap(readHandler));
89
+ router.post('/restore/:name', wrap(restoreHandler));
90
+ router.post('/restore/:token/:name', wrap(restoreHandler));
91
+ }
@@ -0,0 +1,61 @@
1
+ // Every legacy shim in one file, each line naming the deployed client that needs
2
+ // it.
3
+ //
4
+ // This file is documentation, not a module: the leniency it describes lives in
5
+ // spec/wire.js, documents/replace.js and the route table, and it is PERMANENTLY
6
+ // on. There is no shims-off mode and, for the live-sync aliases below, there
7
+ // must never be one.
8
+ //
9
+ // The plan's §11.1 did ask for two CI runs, shims on and shims off, and CI never
10
+ // did that: the flag meant to select it (`wire.legacyRoutes`) was read by
11
+ // nothing, so both "runs" were the same default configuration. That requirement
12
+ // is now obsolete rather than unmet. It was written when this file was a module
13
+ // with a real switch; the shims it describes were folded into the code they
14
+ // belong to, and the live-sync aliases below are permanent by decision, so there
15
+ // is no second configuration left to run.
16
+ //
17
+ // What holds instead, and is enforced by conformance.test.js: none of this is
18
+ // allowed to cost conformance while it is on, which is the only state it has.
19
+
20
+ // clayjs 0.2.0 sends `Page-URL` and never `Document-URL`. clayjs 0.3.0 sends
21
+ // both, and htmlclay reads `Page-URL` on its live-sync lane.
22
+ // Handled in spec/wire.js documentUrlHeader(): both spellings are read and
23
+ // `Document-URL` wins.
24
+
25
+ // clayjs 0.2.0 read the save token only from `htmlclaytoken`; 0.3.0 prefers the
26
+ // spec's `savetoken`, which is the one this host injects. `htmlclaytoken` is
27
+ // still STRIPPED on the way in (documents/replace.js), because htmlclay injects
28
+ // that spelling on its own host and a copy of one of its documents must not
29
+ // arrive here carrying a stale credential that clayjs would still read.
30
+
31
+ // clayjs 0.2.0 reads only `msg` and `msgType` off a save response; the spec adds
32
+ // two OPTIONAL fields, `etag` (the version stamp of what was stored) and `code`
33
+ // (a machine-readable *failure* reason). So a success answers `msg`, `msgType`
34
+ // and `etag`, and `code` appears only on errors, which is what json-errors.js
35
+ // does. htmlclay's own save lane answers with `msg` and `msgType` only.
36
+ //
37
+ // There was a `SAVE_RESPONSE_KEYS` constant here listing all four as though a
38
+ // success carried `code`. Nothing imported it and no response was ever built
39
+ // from it, so it recorded a shape the host does not emit and the spec does not
40
+ // ask for.
41
+
42
+ // clayjs 0.2.0's live-sync plugin posted to `/_/live-sync/save` and subscribed
43
+ // at `/_/live-sync/stream`, both of which predate the spec's `/_/sync`. Those
44
+ // aliases were removed on 2026-08-21 and RESTORED the same day, permanently.
45
+ // The premise for removing them was that nothing outside our own clients speaks
46
+ // them. That is false in two ways: a saved document is a frozen client, and the
47
+ // Collection dashboard opens `/_/live-sync/stream` from inline page script, so
48
+ // every dashboard ever minted names that path and no library update can reach
49
+ // it; and hyperclayjs, a second shipped client, hardcodes both. This is the one
50
+ // place a deleted route cannot be fixed by shipping a new client, so it is the
51
+ // documented exception to the no-dual-accept rule. The host serves POST and GET
52
+ // on `/_/sync` and on both legacy paths. Leniency also covers the query
53
+ // parameter: the stream reads `document-url` first and still accepts the old
54
+ // `page-url` spelling.
55
+
56
+ // clayjs 0.2.0 sent a JSON envelope `{content, snapshotHtml, userDriven}` to
57
+ // `/_/save`, and 0.3.0 sent it to any host that declared
58
+ // `clay-save-transport="desktop-json-v1"`. Nothing sends it now: the document is
59
+ // the body, the provenance bit is `Save-Trigger`, and the unstripped snapshot
60
+ // goes to the §10 relay, which is the only route allowed to carry one. Refusing
61
+ // a JSON save with 415 is spec/wire.js readSaveBody(), a rule rather than a shim.