@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
package/src/paths.js ADDED
@@ -0,0 +1,302 @@
1
+ import path from 'upath';
2
+ import fs from 'node:fs';
3
+ import { realpath, statIfExists } from './util/fsx.js';
4
+ import { HostError } from './spec/codes.js';
5
+
6
+ // Every disk path in the core resolves through this module and nowhere else.
7
+ // It is the entire difference between the `clean` layout (makerclay: state in
8
+ // its own data dir) and the future `inline` layout (hyperclay-local: a user
9
+ // folder with state nested inside it), so v2 convergence is a paths change
10
+ // rather than a migration.
11
+
12
+ // One error vocabulary. This used to be its own class with its own constructor,
13
+ // `PathError(status, message, code)`, sitting beside `HostError(code, message,
14
+ // status)` — the same three values in the opposite order, so passing a status
15
+ // where a code belonged was a silent mistake with a plausible-looking result.
16
+ //
17
+ // It stays a distinct subclass rather than disappearing into HostError because
18
+ // two callers genuinely need to know that path resolution is what refused,
19
+ // rather than something deeper in the same call: `serve.locate` continues to the
20
+ // next candidate on a miss, and `requests.resolveTarget` turns a miss into null
21
+ // while a HostError from the gate must still reach the client.
22
+ export class PathError extends HostError {
23
+ constructor(code, message) {
24
+ super(code, message);
25
+ this.name = 'PathError';
26
+ }
27
+ }
28
+
29
+ const notFound = (message = 'File not found') => new PathError('not-found', message);
30
+ const invalidPath = (message = 'Invalid path') => new PathError('bad-request', message);
31
+
32
+ const OWNER_NAME = /^[a-z0-9_-]{2,32}$/;
33
+
34
+ // Decode exactly once. Express never decodes req.path, so without this a file
35
+ // with a space or any non-ASCII name is unreachable; decoding twice would let
36
+ // `%252e%252e` walk out of the tree.
37
+ export function decodeOnce(urlPath) {
38
+ try {
39
+ return decodeURIComponent(urlPath);
40
+ } catch {
41
+ throw invalidPath('Malformed URL encoding');
42
+ }
43
+ }
44
+
45
+ // Reject traversal, NUL and backslash outright; hide dotfiles behind a 404 so
46
+ // their existence never leaks. A leading-dot refusal is also what makes an
47
+ // `inline` state directory structurally unservable later.
48
+ export function validateSegments(relPath, { maxDepth = 5 } = {}) {
49
+ if (typeof relPath !== 'string' || relPath.length === 0) {
50
+ throw invalidPath();
51
+ }
52
+ if (relPath.includes('\0') || relPath.includes('\\')) {
53
+ throw invalidPath();
54
+ }
55
+ if (path.isAbsolute(relPath)) {
56
+ throw invalidPath();
57
+ }
58
+
59
+ const segments = relPath.split('/').filter((segment) => segment.length > 0);
60
+ if (segments.length === 0) throw invalidPath();
61
+ if (segments.length > maxDepth + 1) throw invalidPath('Path too deep');
62
+
63
+ for (const segment of segments) {
64
+ if (segment === '.' || segment === '..') throw invalidPath();
65
+ if (segment.startsWith('.')) throw notFound();
66
+ if (segment.length > 255) throw invalidPath();
67
+ }
68
+ return segments;
69
+ }
70
+
71
+ export function isDocumentPath(relPath) {
72
+ return /\.(html|htmlclay)$/i.test(relPath);
73
+ }
74
+
75
+ // A node row whose path begins with a dot segment is a metadata anchor, not a
76
+ // file in the served tree (`.records/<slug>/<key>.json` is the only one in v1).
77
+ // validateSegments 404s the path, the scanner's walk skips it, and no listing
78
+ // shows it, so this is the one predicate that keeps those three honest.
79
+ export function isReservedNodePath(relPath) {
80
+ return String(relPath || '').startsWith('.');
81
+ }
82
+
83
+ function isContained(base, target) {
84
+ if (target === base) return true;
85
+ return target.startsWith(base.endsWith('/') ? base : `${base}/`);
86
+ }
87
+
88
+ // Walk up until realpath succeeds, then re-append the not-yet-existing tail.
89
+ // This is what makes write resolution work for a file about to be created.
90
+ async function realpathNearestParent(dir) {
91
+ const missing = [];
92
+ let current = dir;
93
+ for (;;) {
94
+ try {
95
+ const real = await realpath(current);
96
+ return missing.length ? path.join(real, ...missing.reverse()) : real;
97
+ } catch (error) {
98
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
99
+ throw new PathError('forbidden', 'Access denied');
100
+ }
101
+ const parent = path.dirname(current);
102
+ if (parent === current) throw new PathError('forbidden', 'Access denied');
103
+ missing.push(path.basename(current));
104
+ current = parent;
105
+ }
106
+ }
107
+ }
108
+
109
+ // Synchronous on purpose: this runs once at boot, before a single request, and
110
+ // keeping it sync is what lets createHost() stay a plain constructor.
111
+ // `create: false` is for inspection, which must not bring an installation into
112
+ // existence just by looking at it. Everything else here is pure path arithmetic;
113
+ // this one mkdir was the only write, and a caller that passes false is promising
114
+ // the directory already exists.
115
+ export function createPaths({ data, layout = 'clean', maxFolderDepth = 5, create = true }) {
116
+ if (layout !== 'clean') {
117
+ throw new Error(`Unsupported layout: ${layout}. Only 'clean' ships in v1.`);
118
+ }
119
+ if (!data || typeof data !== 'string') {
120
+ throw new Error('createHost requires a data directory');
121
+ }
122
+
123
+ const root = path.resolve(data);
124
+ if (create) fs.mkdirSync(path.join(root, 'files'), { recursive: true });
125
+ const dataReal = path.resolve(fs.realpathSync(root));
126
+
127
+ const dirs = {
128
+ data: dataReal,
129
+ files: path.join(dataReal, 'files'),
130
+ tenants: path.join(dataReal, 'tenants'),
131
+ versions: path.join(dataReal, 'versions'),
132
+ trash: path.join(dataReal, 'trash'),
133
+ derived: path.join(dataReal, 'derived'),
134
+ ui: path.join(dataReal, 'ui'),
135
+ records: path.join(dataReal, 'records'),
136
+ };
137
+
138
+ const files = {
139
+ db: path.join(dataReal, 'makerclay.db'),
140
+ lock: path.join(dataReal, 'makerclay.lock'),
141
+ config: path.join(dataReal, 'config.json'),
142
+ };
143
+
144
+ function realmRoot(owner = '') {
145
+ if (!owner) return dirs.files;
146
+ if (!OWNER_NAME.test(owner)) throw notFound();
147
+ return path.join(dirs.tenants, owner);
148
+ }
149
+
150
+ // Reject any symlink inside a managed tree. A network server must not follow
151
+ // a user symlink into versions/, the db, or the host filesystem; the desktop
152
+ // consent registry hyperclay-local uses does not come along.
153
+ function assertNoSymlinkTraversal(base, lexical, real) {
154
+ if (real !== lexical || !isContained(base, real)) {
155
+ throw notFound();
156
+ }
157
+ }
158
+
159
+ // Canonical read resolution. Returns the real absolute path of an existing
160
+ // entry, or throws a PathError when it is absent, a symlink, or outside the
161
+ // realm.
162
+ async function resolveRead(owner, relPath) {
163
+ const base = realmRoot(owner);
164
+ validateSegments(relPath, { maxDepth: maxFolderDepth });
165
+ const lexical = path.resolve(path.join(base, relPath));
166
+ if (!isContained(base, lexical)) throw notFound();
167
+
168
+ let real;
169
+ try {
170
+ real = await realpath(lexical);
171
+ } catch (error) {
172
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
173
+ throw notFound();
174
+ }
175
+ throw new PathError('forbidden', 'Access denied');
176
+ }
177
+ assertNoSymlinkTraversal(base, lexical, real);
178
+ return real;
179
+ }
180
+
181
+ // What a reader almost always wants: null instead of a throw. Every caller of
182
+ // `resolveRead` outside a writer was wrapping it in a try/catch that tested
183
+ // `instanceof PathError` and returned null, which put the decision about which
184
+ // failures are ordinary in the caller, where it has to be re-derived and can
185
+ // silently widen. It belongs here, where "the path does not resolve" is known
186
+ // precisely.
187
+ //
188
+ // Anything that is not a PathError still throws: a permissions failure or a
189
+ // full disk is not a missing file, and answering 404 to one is how a real
190
+ // fault gets mistaken for a typo.
191
+ async function tryResolveRead(owner, relPath) {
192
+ try {
193
+ return await resolveRead(owner, relPath);
194
+ } catch (error) {
195
+ if (error instanceof PathError) return null;
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ // Canonical create/write resolution. The returned path is both the file to
201
+ // write and the lock key; every writer uses exactly this.
202
+ async function resolveWrite(owner, relPath) {
203
+ const base = realmRoot(owner);
204
+ validateSegments(relPath, { maxDepth: maxFolderDepth });
205
+ const lexical = path.resolve(path.join(base, relPath));
206
+ if (!isContained(base, lexical)) throw notFound();
207
+
208
+ const existing = await statIfExists(lexical);
209
+ if (existing) {
210
+ const real = await realpath(lexical);
211
+ assertNoSymlinkTraversal(base, lexical, real);
212
+ return real;
213
+ }
214
+ const parentReal = await realpathNearestParent(path.dirname(lexical));
215
+ const real = path.join(parentReal, path.basename(lexical));
216
+ assertNoSymlinkTraversal(base, lexical, real);
217
+ return real;
218
+ }
219
+
220
+ // versions/<slug>--<id8>/ — keyed by identity, named for humans. The slug is
221
+ // the current basename (display only, renamed in place on file rename); id8
222
+ // is the real key.
223
+ //
224
+ // id8 is the LAST eight characters of the ULID, not the first. The first ten
225
+ // are the mint TIMESTAMP, so an eight-character prefix has no randomness in it
226
+ // at all and every pair of documents created in the same second collides. That
227
+ // is not a rare race: a signup forks a document milliseconds after the one it
228
+ // copied, and the two then shared a history folder, which let the owner list
229
+ // and restore a tenant's bytes. The tail is 40 bits of randomness.
230
+ function idKey(nodeId) {
231
+ return String(nodeId).slice(-8);
232
+ }
233
+
234
+ function versionsDir(nodeId, slug) {
235
+ const safeSlug = String(slug || 'document')
236
+ .replace(/\.(html|htmlclay)$/i, '')
237
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
238
+ .replace(/^[.-]+/, '')
239
+ .slice(0, 64) || 'document';
240
+ return path.join(dirs.versions, `${safeSlug}--${idKey(nodeId)}`);
241
+ }
242
+
243
+ function versionsDirPrefix(nodeId) {
244
+ return `--${idKey(nodeId)}`;
245
+ }
246
+
247
+ function trashPath(nodeId, originalName) {
248
+ const safe = String(originalName).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 96);
249
+ return path.join(dirs.trash, `${nodeId}__${safe}`);
250
+ }
251
+
252
+ // Collection records live OUTSIDE files/, and that placement is the whole
253
+ // privacy model rather than a filing preference. A record is a stranger's
254
+ // submission; inside the served tree it would be one un-set `private` flag
255
+ // away from public, and a lost database would set it back to public. Out
256
+ // here, the collection API is the only door, and it is the only thing that
257
+ // checks record codes.
258
+ //
259
+ // The record's node row (which carries its identity, its record_auth and its
260
+ // tombstone) uses the reserved `.records/` path prefix. No real file can
261
+ // collide with it: validateSegments 404s every dot-leading segment.
262
+ function recordsDir(slug) {
263
+ return path.join(dirs.records, String(slug).replace(/[^a-z0-9_-]/gi, ''));
264
+ }
265
+
266
+ function recordPath(slug, filename) {
267
+ return path.join(recordsDir(slug), `${String(filename).replace(/[^a-z0-9._@-]/gi, '')}.json`);
268
+ }
269
+
270
+ function recordNodePath(slug, filename) {
271
+ return `.records/${String(slug).replace(/[^a-z0-9_-]/gi, '')}/${String(filename).replace(/[^a-z0-9._@-]/gi, '')}.json`;
272
+ }
273
+
274
+ function derivedApiPath(owner, relPath) {
275
+ const realm = owner ? path.join('tenants', owner) : 'files';
276
+ return path.join(dirs.derived, 'api', realm, `${relPath}.json`);
277
+ }
278
+
279
+ function derivedTailwindPath(owner, name) {
280
+ const realm = owner ? path.join('tenants', owner) : 'files';
281
+ return path.join(dirs.derived, 'tailwind', realm, `${name}.css`);
282
+ }
283
+
284
+ return {
285
+ layout,
286
+ maxFolderDepth,
287
+ dirs,
288
+ files,
289
+ realmRoot,
290
+ resolveRead,
291
+ tryResolveRead,
292
+ resolveWrite,
293
+ versionsDir,
294
+ versionsDirPrefix,
295
+ trashPath,
296
+ recordsDir,
297
+ recordPath,
298
+ recordNodePath,
299
+ derivedApiPath,
300
+ derivedTailwindPath,
301
+ };
302
+ }
@@ -0,0 +1,327 @@
1
+ import path from 'upath';
2
+ import { exists, readdirIfExists, readFileTextIfExists, statIfExists } from '../util/fsx.js';
3
+ import { token as mintToken } from '../util/id.js';
4
+
5
+ // Deciding whether the store can be trusted, and rebuilding it when it cannot.
6
+ //
7
+ // The store is an OVERLAY. The durable records under versions/<slug>--<id8>/
8
+ // are the source of truth, so losing the database must not lose privacy or
9
+ // tenancy (§6.3). With the db gone and the records present, the overlay is
10
+ // rebuilt from them before the first request. With neither, but with evidence
11
+ // of prior state on disk, the host refuses to serve a freshly public tree and
12
+ // asks for recovery mode instead.
13
+ //
14
+ // Never guess. A clear degraded mode beats a half-working dashboard sitting on
15
+ // top of uncertain privacy metadata.
16
+
17
+ export function createOverlay({ paths, store, nodes, clock, logger = console }) {
18
+ async function hasAny(dir) {
19
+ const names = (await readdirIfExists(dir)).filter((name) => !name.startsWith('.'));
20
+ return names.length > 0;
21
+ }
22
+
23
+ // One pass over versions/, which is the same walk the attic will need. The
24
+ // directory name carries only the last eight characters of the id, so the
25
+ // record inside is what is authoritative, never the folder name.
26
+ async function readAllMeta() {
27
+ const found = [];
28
+ for (const name of await readdirIfExists(paths.dirs.versions)) {
29
+ const raw = await readFileTextIfExists(path.join(paths.dirs.versions, name, 'meta.json'));
30
+ if (!raw) continue;
31
+ try {
32
+ const record = JSON.parse(raw);
33
+ if (record?.id && typeof record.path === 'string') found.push(record);
34
+ } catch {
35
+ logger.warn?.(`[makerclay] unreadable durable record in versions/${name}`);
36
+ }
37
+ }
38
+ return found;
39
+ }
40
+
41
+ // Written once, at the end of the first boot that reaches a normal state. It
42
+ // is what tells "this database is ours" apart from "this database is new",
43
+ // and row COUNT cannot: a host whose only document was purged has zero rows
44
+ // and a perfectly healthy db, which would otherwise read as catastrophic loss.
45
+ const INSTALLED = 'installed';
46
+
47
+ function markInstalled() {
48
+ if (store && !store.getKv(INSTALLED)) store.setKv(INSTALLED, String(clock.now()));
49
+ }
50
+
51
+ // A database that opens and answers is not the same as a database that is
52
+ // sound: SQLite will read `kv` happily out of a file whose other pages are
53
+ // corrupt. §18.3 names a failed integrity check as a recovery trigger, and it
54
+ // has to be checked BEFORE the installed key, because the installed key is one
55
+ // of the things a corrupt file can hand back.
56
+ function integrity() {
57
+ if (!store.integrityCheck) return 'ok';
58
+ try {
59
+ const rows = store.integrityCheck();
60
+ return rows.length === 1 && rows[0] === 'ok' ? 'ok' : rows.join('; ');
61
+ } catch (error) {
62
+ return error?.message || String(error);
63
+ }
64
+ }
65
+
66
+ // `normal` | `rebuild` | `recover`
67
+ async function assess() {
68
+ if (!store) return { state: 'normal', reason: 'no-store' };
69
+
70
+ const sound = integrity();
71
+ if (sound !== 'ok') return { state: 'recover', reason: 'integrity-check-failed', error: sound };
72
+
73
+ if (store.getKv(INSTALLED)) return { state: 'normal', reason: 'installed' };
74
+
75
+ const rows = store.listAllNodes({ includeDeleted: true });
76
+ if (rows.length > 0) return { state: 'normal', reason: 'store-populated' };
77
+
78
+ // Durable records are consulted FIRST, because they are the evidence. Asking
79
+ // about versions/ or tenants/ first gets it backwards: a document the owner
80
+ // marked private but never saved has a record and no versions, so a
81
+ // versions-first check reads "fresh install" and serves it to the world.
82
+ const records = await readAllMeta();
83
+ if (records.length > 0) return { state: 'rebuild', reason: 'records-present', records: records.length };
84
+
85
+ // No rows, no records. Anything else on disk means there WAS state here and
86
+ // nothing left records who could read what; serving now would publish
87
+ // whatever used to be private.
88
+ const priorState = (await hasAny(paths.dirs.versions)) || (await hasAny(paths.dirs.tenants));
89
+ if (priorState) return { state: 'recover', reason: 'state-without-metadata' };
90
+
91
+ return { state: 'normal', reason: 'fresh' };
92
+ }
93
+
94
+ // Runs on EVERY boot, not only into an empty database. The likeliest real
95
+ // disaster is not a lost database, it is a RESTORED one: an old backup dropped
96
+ // onto today's files passes every health check, has rows, and says a document
97
+ // is public that was made private after the backup was taken. Neither the
98
+ // journal nor a rowless-file rule ever covered that. This does.
99
+ //
100
+ // Inserting a row for a record that has none is deliberate: it means the serve
101
+ // lane needs no special case at all, because by the time the first request is
102
+ // answered every previously-flagged document already has a correct row. The
103
+ // rows cost nothing new, since a record only exists for a document that was
104
+ // saved or flagged, which is exactly the set that already had rows.
105
+ //
106
+ // Where the two disagree, THE MORE RESTRICTIVE VALUE WINS. Not "the newer one":
107
+ // there is no reliable way to tell which is newer (a restored versions/ can be
108
+ // older than the database just as easily as the reverse), and guessing wrong in
109
+ // the permissive direction publishes something. Guessing wrong in the
110
+ // restrictive direction costs the owner one click.
111
+ async function reconcileMeta() {
112
+ if (!store) return { records: 0, inserted: 0, tightened: 0, skipped: 0 };
113
+ const records = await readAllMeta();
114
+ let inserted = 0;
115
+ let tightened = 0;
116
+ let skipped = 0;
117
+
118
+ for (const record of records) {
119
+ const row = store.getNodeById(record.id);
120
+ // Read before the transaction, because a transaction body has to be
121
+ // synchronous and this is the one question only the disk can answer. Asked
122
+ // only when a trash marker is actually in play, so an ordinary boot still
123
+ // costs nothing per record.
124
+ const fileThere = record.trashed && row && !row.deletedAt
125
+ ? await exists(path.join(paths.realmRoot(row.owner || ''), row.path))
126
+ : false;
127
+
128
+ if (!row) {
129
+ // Two records can name the same path: rename a document, create a new
130
+ // one at the old name, then restore a database from before the rename.
131
+ // The live row is the one whose file is actually there, so the loser is
132
+ // not inserted at all. It keeps its history and becomes an attic entry,
133
+ // which is exactly what the attic is for. Inserting it anyway is the one
134
+ // option that is neither open nor closed: SQLite refuses on the live
135
+ // path index, the exception escapes boot(), and the host never serves
136
+ // again.
137
+ const holder = store.getNode(record.owner || '', record.path);
138
+ if (holder && holder.id !== record.id) {
139
+ skipped += 1;
140
+ logger.warn?.(
141
+ `[makerclay] ${record.path} is already held by another document, so the ` +
142
+ 'older record was left for the attic rather than restored.',
143
+ );
144
+ continue;
145
+ }
146
+ }
147
+
148
+ // One transaction per record, never one around the whole loop: a single
149
+ // unplaceable record must not discard the privacy tightening every other
150
+ // record just earned.
151
+ try {
152
+ const verdict = store.tx(() => {
153
+ if (!row) {
154
+ const now = clock.now();
155
+ store.insertNode({
156
+ id: record.id,
157
+ owner: record.owner || '',
158
+ path: record.path,
159
+ kind: record.kind || nodes.kindFor(record.path),
160
+ private: !!record.private,
161
+ isolated: record.isolated === undefined ? null : record.isolated,
162
+ signups: !!record.signups,
163
+ // Restoring the digest is also what restores the TRUST marker:
164
+ // `isolatedFor` reads `etag == null` to mean "the owner has never
165
+ // saved these bytes through this host, so sandbox it". A record only
166
+ // exists for a document that was saved or flagged, so a record with
167
+ // a digest is proof of a save and the row should say so. A document
168
+ // that was only ever flagged has no digest in its record either, and
169
+ // correctly stays untrusted.
170
+ etag: record.etag ?? null,
171
+ bytes: record.bytes ?? null,
172
+ deletedAt: record.trashed ? now : null,
173
+ seq: store.nextSeq(),
174
+ createdAt: now,
175
+ updatedAt: now,
176
+ });
177
+ return 'inserted';
178
+ }
179
+ const patch = {};
180
+ // private: true is the restrictive side.
181
+ if (record.private && !row.private) patch.private = true;
182
+ // signups: FALSE is the restrictive side. Losing "signups on" costs the
183
+ // owner a click; losing "signups off" lets strangers open accounts.
184
+ if (record.signups === false && row.signups) patch.signups = false;
185
+ // isolated is tri-state, most restrictive first: true, then null
186
+ // (inherit the host default), then an explicit false.
187
+ const rank = (value) => (value === true ? 2 : value === false ? 0 : 1);
188
+ if (rank(record.isolated) > rank(row.isolated)) patch.isolated = record.isolated;
189
+ // Only when nothing is at the row's path. A trashed document's bytes
190
+ // live under trash/, so tombstoning a row whose file is sitting right
191
+ // where it says it is would leave the row saying trashed with the file
192
+ // outside trash/, and restore would then go looking for bytes that are
193
+ // not there.
194
+ if (record.trashed && !row.deletedAt && !fileThere) patch.deletedAt = clock.now();
195
+ if (!Object.keys(patch).length) return 'unchanged';
196
+ store.updateNode(row.id, { ...patch, updatedAt: clock.now(), seq: store.nextSeq() });
197
+ return 'tightened';
198
+ });
199
+ if (verdict === 'inserted') inserted += 1;
200
+ else if (verdict === 'tightened') tightened += 1;
201
+ } catch (error) {
202
+ skipped += 1;
203
+ logger.warn?.(
204
+ `[makerclay] could not reconcile the record for ${record.path}: ${error?.message || error}`,
205
+ );
206
+ }
207
+ }
208
+
209
+ if (inserted || tightened || skipped) {
210
+ logger.warn?.(
211
+ `[makerclay] reconciled the overlay against ${records.length} durable record(s): ` +
212
+ `${inserted} row(s) restored, ${tightened} tightened, ${skipped} skipped.`,
213
+ );
214
+ }
215
+ return { records: records.length, inserted, tightened, skipped };
216
+ }
217
+
218
+ // Deliberately partial, and honest about it. Sessions, share links, tenant
219
+ // passwords, save tokens and record codes are NOT recoverable and are not
220
+ // faked. What comes back is the state whose loss would fail open, plus enough
221
+ // to reach the bytes again.
222
+ async function rebuild() {
223
+ if (!store) return { nodes: 0, tenants: 0, collections: 0, trashed: 0 };
224
+ const reconciled = await reconcileMeta();
225
+
226
+ // Trashed bytes live at trash/<nodeId>__<name> and the name is
227
+ // self-describing, so a document trashed before its record was written is
228
+ // still reachable. Without this the bytes sit there with no row, the trash
229
+ // view is empty, and restore cannot be reached.
230
+ let trashed = 0;
231
+ const byId = new Map((await readAllMeta()).map((record) => [record.id, record]));
232
+ for (const name of await readdirIfExists(paths.dirs.trash)) {
233
+ const split = name.indexOf('__');
234
+ if (split <= 0) continue;
235
+ const id = name.slice(0, split);
236
+ if (store.getNodeById(id)) continue;
237
+ // trash/ is one flat directory carrying a basename and no realm, so the
238
+ // name alone puts every tenant's document back in the OWNER's trash,
239
+ // restorable into the owner's realm. That is the crossing attic.putBack
240
+ // refuses by hand. The record is the only thing that knows which realm
241
+ // these bytes came out of.
242
+ //
243
+ // Reaching it with a record in hand is rare, and deliberately so:
244
+ // reconcileMeta ran first and inserted a row for every record it could
245
+ // place, so an entry that still has none is one whose record it skipped on
246
+ // a path collision. Rare is not never, and the alternative is a realm
247
+ // crossing, so the lookup stays.
248
+ const record = byId.get(id) || null;
249
+ const owner = (record?.owner ?? '') || '';
250
+ const relPath = record?.path || name.slice(split + 2);
251
+ if (!record) {
252
+ // No record, so the realm is genuinely unknowable. Today's answer is the
253
+ // owner's trash under a bare name, and saying so beats pretending.
254
+ logger.warn?.(
255
+ `[makerclay] trash entry ${name} has no durable record, so it comes back in ` +
256
+ "the owner's trash under its own name.",
257
+ );
258
+ }
259
+ const now = clock.now();
260
+ // Ask the filesystem, not the filename. A directory has no history folder,
261
+ // so trashing one writes no record at all, and typing it by name brings a
262
+ // folder called `notes` back as a file. `move` then skips rewriting its
263
+ // descendants, their rows keep the old path, and the bytes at the new path
264
+ // resolve to a provisional node that reads as public.
265
+ const entry = await statIfExists(path.join(paths.dirs.trash, name));
266
+ store.insertNode({
267
+ id,
268
+ owner,
269
+ path: relPath,
270
+ kind: record?.kind || nodes.kindFor(relPath, !!entry?.isDirectory()),
271
+ private: true,
272
+ deletedAt: now,
273
+ seq: store.nextSeq(),
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ });
277
+ trashed += 1;
278
+ }
279
+
280
+ // A collection's submissions are a folder of JSON on disk and the binding
281
+ // from slug to document exists nowhere else, so it comes from the record.
282
+ // The submit token does NOT: it is a credential, so a fresh one is minted
283
+ // and every form pointing here needs the new one, the same deal share links
284
+ // get.
285
+ let collections = 0;
286
+ for (const record of await readAllMeta()) {
287
+ if (!record.slug || !store.getNodeById(record.id)) continue;
288
+ if (store.getCollection(record.id) || store.getCollectionBySlug(record.slug)) continue;
289
+ store.insertCollection({
290
+ nodeId: record.id,
291
+ slug: record.slug,
292
+ submitToken: mintToken(18),
293
+ accepting: true,
294
+ createdAt: clock.now(),
295
+ });
296
+ collections += 1;
297
+ }
298
+
299
+ // A realm directory is proof a tenant existed. The password hash is gone
300
+ // with the database and is not invented: the tenant comes back DISABLED so
301
+ // their tree keeps its owner and stays unreadable until the box owner resets
302
+ // it by hand.
303
+ let tenants = 0;
304
+ for (const name of await readdirIfExists(paths.dirs.tenants)) {
305
+ if (name.startsWith('.') || store.getTenant(name)) continue;
306
+ const now = clock.now();
307
+ store.insertTenant({
308
+ username: name,
309
+ passwordHash: 'disabled:rebuilt-from-disk',
310
+ createdAt: now,
311
+ approvedAt: now,
312
+ });
313
+ store.updateTenant(name, { disabledAt: now });
314
+ tenants += 1;
315
+ }
316
+
317
+ const count = store.listAllNodes({ includeDeleted: true }).length;
318
+ logger.warn?.(
319
+ `[makerclay] rebuilt the overlay from disk: ${count} node(s), ${trashed} from trash, ` +
320
+ `${tenants} tenant(s) restored disabled, ${collections} collection(s) with new submit tokens. ` +
321
+ 'Share links, sessions, tenant passwords and record codes were not recoverable.',
322
+ );
323
+ return { nodes: count, tenants, collections, trashed, ...reconciled };
324
+ }
325
+
326
+ return { assess, rebuild, reconcileMeta, readAllMeta, markInstalled };
327
+ }