@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,613 @@
1
+ import path from 'upath';
2
+ import crypto from 'node:crypto';
3
+ import { can } from '../auth/can.js';
4
+ import { HostError } from '../spec/codes.js';
5
+ import { isDocumentPath } from '../paths.js';
6
+ import { nameError, uniqueName, splitName, assetsDirFor } from './names.js';
7
+ import fs from 'node:fs/promises';
8
+ import {
9
+ statIfExists, rename, mkdirp, rmrf, readdirIfExists,
10
+ atomicWrite, fsyncDir, copyFile, createExclusive,
11
+ } from '../util/fsx.js';
12
+
13
+ // Tree operations, every one of them write-ahead journaled (§9.2).
14
+ //
15
+ // The filesystem and SQLite cannot share a transaction, and a best-effort
16
+ // rollback list does not survive `kill -9`. So each op is three phases with a
17
+ // durable record between them:
18
+ //
19
+ // 1. insert an `operations` row describing the intent (phase 'planned')
20
+ // 2. do the FS work, which is a single atomic rename (phase 'fs-done')
21
+ // 3. one store transaction: rewrite rows, bump seq, DELETE the operation row
22
+ //
23
+ // Startup then reads whatever rows survived and finishes or unwinds them
24
+ // (recovery.js). The rename in phase 2 is the pivot: before it nothing has
25
+ // happened, after it the metadata is merely behind.
26
+ //
27
+ // Invariant 2 still holds here. No function in this file writes a DOCUMENT;
28
+ // documents move as opaque bytes (rename/copy) or go through the mutation
29
+ // kernel. `putFile` is the one sanctioned direct write and it refuses .html.
30
+
31
+ const TRASH_SEPARATOR = '__';
32
+
33
+ function isInside(parent, child) {
34
+ return child === parent || child.startsWith(`${parent}/`);
35
+ }
36
+
37
+ export function createOps({
38
+ paths, nodes, store, versions, locks, events, clock, ids,
39
+ logger = console, replace = null,
40
+ }) {
41
+ function assert(condition, code, message) {
42
+ if (!condition) throw new HostError(code, message);
43
+ }
44
+
45
+ function authorize(actor, action, node, ctx) {
46
+ assert(can(actor, action, node, ctx), 'forbidden', 'You do not have permission to do that.');
47
+ }
48
+
49
+ function checkName(name) {
50
+ const problem = nameError(name);
51
+ assert(!problem, 'bad-request', problem || '');
52
+ }
53
+
54
+ // The op wrapper. `fsWork` must be idempotent-or-atomic; `commit` runs inside
55
+ // the store transaction and therefore must be synchronous.
56
+ async function runOperation({ kind, nodeId = null, payload }, fsWork, commit) {
57
+ const opId = ids.ulid();
58
+ let landed = false;
59
+ if (store) {
60
+ store.insertOperation({ id: opId, kind, nodeId, phase: 'planned', payload, startedAt: clock.now() });
61
+ }
62
+ try {
63
+ const fsResult = await fsWork();
64
+ landed = true;
65
+ if (store) store.setOperationPhase(opId, 'fs-done');
66
+ if (!store) return commit(fsResult);
67
+ return store.tx(() => {
68
+ const value = commit(fsResult);
69
+ store.deleteOperation(opId);
70
+ return value;
71
+ });
72
+ } catch (error) {
73
+ // The FS half is a single rename, so a throw before it landed means
74
+ // nothing happened and the plan is only noise. A throw after it landed
75
+ // leaves the row behind on purpose, for startup recovery to finish.
76
+ if (!landed && store) store.deleteOperation(opId);
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ function listDescendants(owner, prefix) {
82
+ if (!store) return [];
83
+ return store.listNodes({ owner, prefix: `${prefix}/`, includeDeleted: true });
84
+ }
85
+
86
+ function rewriteDescendants(owner, fromPath, toPath) {
87
+ return listDescendants(owner, fromPath).map((row) => store.updateNode(row.id, {
88
+ path: `${toPath}${row.path.slice(fromPath.length)}`,
89
+ updatedAt: clock.now(),
90
+ seq: store.nextSeq(),
91
+ }));
92
+ }
93
+
94
+ // Display-only bookkeeping: the versions directory is found by the identity
95
+ // half of its name, so a failure here costs a nice folder name and nothing
96
+ // else. It must never fail a move that has already landed on disk.
97
+ async function moveVersionSlug(node, toPath) {
98
+ if (node.kind !== 'doc') return;
99
+ try {
100
+ await versions.renameSlug(node, toPath);
101
+ } catch (error) {
102
+ logger.warn?.('[makerclay] could not rename versions folder (cosmetic):', error?.message || error);
103
+ }
104
+ }
105
+
106
+ // Written after the bytes have already moved, so a failure here costs a stale
107
+ // record and never the operation. The boot reconcile and the next publish both
108
+ // repair it.
109
+ async function patchMetaQuietly(node, patch) {
110
+ try {
111
+ await versions.patchMeta(node, patch);
112
+ } catch (error) {
113
+ logger.warn?.('[makerclay] could not update the durable record:', error?.message || error);
114
+ }
115
+ }
116
+
117
+ // A document's uploads live in `assets-<stem>/` beside it, and the URLs the
118
+ // upload lane hands back are relative to the document, so the pair has to
119
+ // travel together or every image 404s at the new path. Done after the
120
+ // document's own operation rather than inside it: the journal reads which
121
+ // side of ONE rename a crash landed on, and a second rename inside the same
122
+ // step would make that unreadable. A crash between the two leaves the folder
123
+ // behind, which is exactly what happened every time before this existed.
124
+ async function carryAssets(actor, owner, from, to, ctx, run) {
125
+ if (!isDocumentPath(from)) return;
126
+ const fromAssets = assetsDirFor(from);
127
+ const toAssets = assetsDirFor(to);
128
+ if (fromAssets === toAssets) return;
129
+ if (!(await statIfExists(await paths.resolveWrite(owner, fromAssets)))) return;
130
+ // Occupied means the destination document already has assets of its own.
131
+ // Merging two asset folders is a guess about which file wins, so the folder
132
+ // stays where it is and the operation still succeeds.
133
+ if (await statIfExists(await paths.resolveWrite(owner, toAssets))) {
134
+ logger.warn?.(
135
+ `[makerclay] ${fromAssets} stayed put: ${toAssets} already exists.`,
136
+ );
137
+ return;
138
+ }
139
+ await run({ actor, owner, from: fromAssets, to: toAssets, ctx });
140
+ }
141
+
142
+ async function resolveExisting(owner, relPath) {
143
+ const real = await paths.resolveWrite(owner, relPath);
144
+ const stat = await statIfExists(real);
145
+ assert(stat, 'not-found', 'Not found');
146
+ return { real, stat };
147
+ }
148
+
149
+ function rowFor(owner, relPath, kind) {
150
+ return nodes.materialize({ ...nodes.resolve(owner, relPath, { kind }), kind });
151
+ }
152
+
153
+ // ---- create ------------------------------------------------------------
154
+
155
+ async function mkdir({ actor, owner = '', dir = '', name, ctx = {} }) {
156
+ checkName(name);
157
+ const relPath = dir ? `${dir}/${name}` : name;
158
+ const node = nodes.resolve(owner, relPath, { kind: 'dir' });
159
+ authorize(actor, 'write', node, ctx);
160
+
161
+ const real = await paths.resolveWrite(owner, relPath);
162
+ assert(!(await statIfExists(real)), 'conflict', 'Something with that name is already there.');
163
+
164
+ const created = await runOperation(
165
+ { kind: 'mkdir', payload: { owner, path: relPath } },
166
+ async () => { await mkdirp(real); await fsyncDir(path.dirname(real)); },
167
+ () => rowFor(owner, relPath, 'dir'),
168
+ );
169
+ events.emit('node-created', { node: created });
170
+ return created;
171
+ }
172
+
173
+ // A new document is a mutation of a document, so it goes through the kernel
174
+ // (invariant 2) rather than getting its own write path here. What ops adds is
175
+ // the parent directory and a free name.
176
+ async function createDocument({ actor, owner = '', dir = '', name, html, ctx = {} }) {
177
+ assert(replace, 'server-error', 'This host has no mutation kernel.');
178
+ checkName(name);
179
+ assert(isDocumentPath(name), 'bad-request', 'A document name must end in .html.');
180
+
181
+ const parentReal = dir ? await paths.resolveWrite(owner, dir) : paths.realmRoot(owner);
182
+ await mkdirp(parentReal);
183
+ const free = await uniqueName(parentReal, name);
184
+ const relPath = dir ? `${dir}/${free}` : free;
185
+
186
+ const result = await replace({
187
+ actor, owner, relPath, bytes: html, trigger: 'user', source: 'create', ctx,
188
+ });
189
+ events.emit('node-created', { node: result.node });
190
+ return { node: result.node, path: relPath, etag: result.etag, derived: result.derived };
191
+ }
192
+
193
+ // The one sanctioned write that does not go through the mutation kernel, and
194
+ // it refuses documents so that stays true. Uploads are files: same tree, same
195
+ // rename, same delete, same trash as everything else.
196
+ async function putFile({ actor, owner = '', dir = '', name, content, encoding = null, ctx = {} }) {
197
+ checkName(name);
198
+ assert(!isDocumentPath(name), 'bad-request', 'HTML files are documents: save them through the save lane.');
199
+
200
+ const parentReal = dir ? await paths.resolveWrite(owner, dir) : paths.realmRoot(owner);
201
+ await mkdirp(parentReal);
202
+ const free = await uniqueName(parentReal, name);
203
+ const relPath = dir ? `${dir}/${free}` : free;
204
+
205
+ const node = nodes.resolve(owner, relPath, { kind: 'file' });
206
+ authorize(actor, 'write', node, ctx);
207
+ const real = await paths.resolveWrite(owner, relPath);
208
+
209
+ const created = await runOperation(
210
+ { kind: 'create', payload: { owner, path: relPath } },
211
+ async () => {
212
+ await atomicWrite(real, content, encoding);
213
+ return await statIfExists(real);
214
+ },
215
+ (stat) => {
216
+ const row = rowFor(owner, relPath, 'file');
217
+ if (!store) return row;
218
+ return store.updateNode(row.id, {
219
+ bytes: stat ? stat.size : null,
220
+ mtimeMs: stat ? stat.mtimeMs : null,
221
+ });
222
+ },
223
+ );
224
+ events.emit('node-created', { node: created });
225
+ return { node: created, path: relPath };
226
+ }
227
+
228
+ // The upload lane's write, and the only place the HOST names the file rather
229
+ // than the person. Two things follow from that, and both are the point.
230
+ //
231
+ // The tail is the content hash, so uploading the same bytes twice converges on
232
+ // the file already stored instead of leaving a second copy of it. And the name
233
+ // is claimed with an exclusive create, never by looking for a free one first:
234
+ // check-then-write loses the race between two uploads of one filename, and the
235
+ // loser's bytes land on top of the winner's with both callers told they
236
+ // succeeded. `uniqueName` above has exactly that shape, which is why this does
237
+ // not use it.
238
+ //
239
+ // Authorization is the CALLER's job here, and it is deliberately not repeated.
240
+ // The permission being exercised is "may this actor write to this document",
241
+ // held against the document, and a save token grants precisely that and nothing
242
+ // else. Re-checking against the stored file's own node would fail for every
243
+ // token-bearing document, which is most of them.
244
+ async function putUpload({ owner = '', dir, name, content }) {
245
+ const base = path.basename(String(name || ''));
246
+ const { stem, ext } = splitName(base);
247
+ const cleanExt = nameError(`x${ext}`) ? '' : ext;
248
+ const cleanStem = nameError(`${stem}${cleanExt}`) || !stem ? 'file' : stem;
249
+
250
+ const parentReal = await paths.resolveWrite(owner, dir);
251
+ await mkdirp(parentReal);
252
+ const digest = crypto.createHash('sha256').update(content).digest('hex');
253
+
254
+ let chosen = null;
255
+ let wrote = false;
256
+ for (let length = 6; length <= 32 && !chosen; length += 2) {
257
+ const candidate = `${cleanStem}-${digest.slice(0, length)}${cleanExt}`;
258
+ const real = path.join(parentReal, candidate);
259
+ try {
260
+ await createExclusive(real, content);
261
+ chosen = candidate;
262
+ wrote = true;
263
+ } catch (error) {
264
+ if (error.code !== 'EEXIST') throw error;
265
+ // Same name, so same content hash prefix: either the identical file (the
266
+ // common case, and the answer is the file already there) or a prefix
267
+ // collision between different bytes, which lengthens the tail.
268
+ const existing = await fs.readFile(real).catch(() => null);
269
+ if (existing && existing.equals(content)) chosen = candidate;
270
+ }
271
+ }
272
+ assert(chosen, 'server-error', 'Could not find a free name for that file.');
273
+
274
+ const relPath = `${dir}/${chosen}`;
275
+ const real = path.join(parentReal, chosen);
276
+
277
+ // The claim above is already atomic and self-healing, so it needs no journal
278
+ // to unwind: a crash between it and the row below leaves a real file that the
279
+ // scanner registers on the next walk, never a half-written one.
280
+ const node = nodes.resolve(owner, relPath, { kind: 'file' });
281
+ const stat = await statIfExists(real);
282
+ const row = store
283
+ ? store.tx(() => store.updateNode(nodes.materialize(node).id, {
284
+ bytes: stat ? stat.size : null,
285
+ mtimeMs: stat ? stat.mtimeMs : null,
286
+ }))
287
+ : node;
288
+ if (wrote) events.emit('node-created', { node: row });
289
+ return { node: row, path: relPath, name: chosen, bytes: content.length, created: wrote };
290
+ }
291
+
292
+ // ---- move --------------------------------------------------------------
293
+
294
+ async function move({ actor, owner = '', from, to, ctx = {} }) {
295
+ checkName(path.basename(to));
296
+ assert(!isInside(from, to), 'bad-request', 'A folder cannot be moved inside itself.');
297
+
298
+ const source = await resolveExisting(owner, from);
299
+ const node = nodes.materialize(nodes.resolve(owner, from, {
300
+ kind: source.stat.isDirectory() ? 'dir' : undefined,
301
+ }));
302
+ authorize(actor, 'write', node, ctx);
303
+
304
+ const targetReal = await paths.resolveWrite(owner, to);
305
+ assert(!(await statIfExists(targetReal)), 'conflict', 'Something with that name is already there.');
306
+
307
+ // A rename can change what the file IS. page.txt -> page.html must stop
308
+ // being served raw, and kind is what decides both the serve lane and the
309
+ // sandbox, so it is recomputed rather than carried over.
310
+ //
311
+ // Hoisted out of the commit callback because the durable record needs the
312
+ // same value and is written FIRST, in the FS half. Two copies of this
313
+ // expression drift, and the copy that drifts is the record, which is what a
314
+ // rebuild trusts. A directory keeps its kind whatever it is renamed to: a
315
+ // folder called `x.html` is still a folder.
316
+ const kind = nodes.kindFor(to, source.stat.isDirectory());
317
+
318
+ // Both ends take the lock, in a fixed order, so a save racing a rename
319
+ // cannot land bytes at a path this op is about to empty.
320
+ const [first, second] = [source.real, targetReal].sort();
321
+ let movedChildren = [];
322
+ const moved = await locks.withLock(first, () => locks.withLock(second, () => runOperation(
323
+ { kind: 'move', nodeId: node.id, payload: { owner, from, to, fromReal: source.real, toReal: targetReal } },
324
+ async () => {
325
+ await mkdirp(path.dirname(targetReal));
326
+ await rename(source.real, targetReal);
327
+ await fsyncDir(path.dirname(targetReal));
328
+ await moveVersionSlug(node, to);
329
+ await patchMetaQuietly(node, { path: to, kind });
330
+ },
331
+ () => {
332
+ const row = store
333
+ ? store.updateNode(node.id, { path: to, kind, updatedAt: clock.now(), seq: store.nextSeq() })
334
+ : { ...node, path: to, kind };
335
+ if (source.stat.isDirectory()) movedChildren = rewriteDescendants(owner, from, to);
336
+ return row;
337
+ },
338
+ )));
339
+
340
+ for (const child of movedChildren) await patchMetaQuietly(child, { path: child.path });
341
+
342
+ await carryAssets(actor, owner, from, to, ctx, move);
343
+
344
+ events.emit('node-moved', { node: moved, from });
345
+ return moved;
346
+ }
347
+
348
+ // ---- copy --------------------------------------------------------------
349
+
350
+ async function copyTree(fromReal, toReal) {
351
+ const stat = await statIfExists(fromReal);
352
+ if (!stat) return;
353
+ if (!stat.isDirectory()) {
354
+ await copyFile(fromReal, toReal);
355
+ return;
356
+ }
357
+ await mkdirp(toReal);
358
+ for (const entry of await readdirIfExists(fromReal, { withFileTypes: true })) {
359
+ if (entry.name.startsWith('.')) continue;
360
+ await copyTree(path.join(fromReal, entry.name), path.join(toReal, entry.name));
361
+ }
362
+ }
363
+
364
+ async function copy({ actor, owner = '', from, to = null, ctx = {} }) {
365
+ const source = await resolveExisting(owner, from);
366
+ const node = nodes.resolve(owner, from);
367
+ authorize(actor, 'read', node, ctx);
368
+
369
+ const dir = path.dirname(to || from);
370
+ const parentReal = dir === '.' ? paths.realmRoot(owner) : await paths.resolveWrite(owner, dir);
371
+ await mkdirp(parentReal);
372
+ const free = await uniqueName(parentReal, path.basename(to || from));
373
+ const relPath = dir === '.' ? free : `${dir}/${free}`;
374
+
375
+ authorize(actor, 'write', nodes.resolve(owner, relPath), ctx);
376
+ const targetReal = await paths.resolveWrite(owner, relPath);
377
+
378
+ // Built under a hidden sibling name and renamed into place, so a crash
379
+ // leaves either nothing or the whole tree, never a partial one that
380
+ // `settle()` would read as finished because all it can see is that the
381
+ // destination exists. Same shape as publishInto's temp-then-link.
382
+ const staging = path.join(parentReal, `.makerclay-copy-${crypto.randomBytes(8).toString('hex')}`);
383
+
384
+ const created = await runOperation(
385
+ { kind: 'copy', nodeId: node.id, payload: { owner, from, to: relPath, staging } },
386
+ async () => {
387
+ try {
388
+ await copyTree(source.real, staging);
389
+ await rename(staging, targetReal);
390
+ await fsyncDir(path.dirname(targetReal));
391
+ } catch (error) {
392
+ await rmrf(staging);
393
+ throw error;
394
+ }
395
+ return await statIfExists(targetReal);
396
+ },
397
+ (stat) => {
398
+ const kind = stat?.isDirectory() ? 'dir' : nodes.kindFor(relPath);
399
+ return rowFor(owner, relPath, kind);
400
+ },
401
+ );
402
+
403
+ await carryAssets(actor, owner, from, relPath, ctx, copy);
404
+
405
+ events.emit('node-created', { node: created, copiedFrom: from });
406
+ return created;
407
+ }
408
+
409
+ // ---- trash / restore / purge -------------------------------------------
410
+
411
+ async function trash({ actor, owner = '', relPath, ctx = {} }) {
412
+ const source = await resolveExisting(owner, relPath);
413
+ const node = nodes.materialize(nodes.resolve(owner, relPath, {
414
+ kind: source.stat.isDirectory() ? 'dir' : undefined,
415
+ }));
416
+ authorize(actor, 'write', node, ctx);
417
+
418
+ const target = paths.trashPath(node.id, path.basename(relPath));
419
+ const at = clock.now();
420
+
421
+ let trashedChildren = [];
422
+ const gone = await locks.withLock(source.real, () => runOperation(
423
+ { kind: 'trash', nodeId: node.id, payload: { owner, from: relPath, to: target, fromReal: source.real } },
424
+ async () => {
425
+ await mkdirp(paths.dirs.trash);
426
+ await rename(source.real, target);
427
+ await fsyncDir(paths.dirs.trash);
428
+ await patchMetaQuietly(node, { trashed: true });
429
+ },
430
+ () => {
431
+ const row = nodes.tombstone(node, at);
432
+ if (store) {
433
+ trashedChildren = listDescendants(owner, relPath);
434
+ store.deleteSaveTokensForNode(node.id);
435
+ // The platform hard-deletes the record's auth row here, so a restored
436
+ // keyed record comes back public with no code. Soft-delete it WITH the
437
+ // node and restore it WITH the node (§15).
438
+ if (store.getRecordAuth(node.id)) store.updateRecordAuth(node.id, { deletedAt: at });
439
+ for (const child of trashedChildren) {
440
+ store.tombstone(child.id, at);
441
+ store.deleteSaveTokensForNode(child.id);
442
+ if (store.getRecordAuth(child.id)) store.updateRecordAuth(child.id, { deletedAt: at });
443
+ }
444
+ }
445
+ return row;
446
+ },
447
+ ));
448
+
449
+ // Every descendant's record, not just the operation's own node. The rows are
450
+ // tombstoned inside the commit above, but a rebuild trusts the RECORD, so a
451
+ // child whose record still says `trashed: false` comes back as a live row
452
+ // pointing at bytes that are in trash/. Same shape as move's descendant
453
+ // patch, and for the same reason.
454
+ for (const child of trashedChildren) await patchMetaQuietly(child, { trashed: true });
455
+
456
+ events.emit('node-trashed', { node: gone });
457
+ return gone;
458
+ }
459
+
460
+ async function restoreFromTrash({ actor, nodeId, ctx = {} }) {
461
+ assert(store, 'not-found', 'This host keeps no trash.');
462
+ const node = store.getNodeById(nodeId);
463
+ assert(node && node.deletedAt, 'not-found', 'Not in the trash.');
464
+ authorize(actor, 'write', { ...node, deletedAt: null }, ctx);
465
+
466
+ const source = paths.trashPath(node.id, path.basename(node.path));
467
+ assert(await statIfExists(source), 'not-found', 'The trashed bytes are gone.');
468
+
469
+ const dir = path.dirname(node.path);
470
+ const parentReal = dir === '.' ? paths.realmRoot(node.owner) : await paths.resolveWrite(node.owner, dir);
471
+ await mkdirp(parentReal);
472
+ const free = await uniqueName(parentReal, path.basename(node.path));
473
+ const relPath = dir === '.' ? free : `${dir}/${free}`;
474
+ const targetReal = await paths.resolveWrite(node.owner, relPath);
475
+
476
+ let restoredChildren = [];
477
+ const back = await runOperation(
478
+ { kind: 'restore', nodeId: node.id, payload: { owner: node.owner, from: source, to: relPath, toReal: targetReal } },
479
+ async () => {
480
+ await rename(source, targetReal);
481
+ await fsyncDir(path.dirname(targetReal));
482
+ await moveVersionSlug(node, relPath);
483
+ await patchMetaQuietly(node, { trashed: false, path: relPath });
484
+ },
485
+ () => {
486
+ const row = store.updateNode(node.id, {
487
+ path: relPath, deletedAt: null, updatedAt: clock.now(), seq: store.nextSeq(),
488
+ });
489
+ const auth = store.getRecordAuth(node.id);
490
+ if (auth?.deletedAt) store.updateRecordAuth(node.id, { deletedAt: null });
491
+ restoredChildren = listDescendants(node.owner, node.path).map((child) => {
492
+ const childAuth = store.getRecordAuth(child.id);
493
+ if (childAuth?.deletedAt) store.updateRecordAuth(child.id, { deletedAt: null });
494
+ return store.updateNode(child.id, {
495
+ path: `${relPath}${child.path.slice(node.path.length)}`,
496
+ deletedAt: null,
497
+ updatedAt: clock.now(),
498
+ seq: store.nextSeq(),
499
+ });
500
+ });
501
+ return row;
502
+ },
503
+ );
504
+
505
+ // The mirror of trash's cascade: the row half already rewrote every
506
+ // descendant's path, and the record has to learn both facts or the next
507
+ // rebuild puts them back in the trash they just came out of.
508
+ for (const child of restoredChildren) {
509
+ await patchMetaQuietly(child, { trashed: false, path: child.path });
510
+ }
511
+
512
+ events.emit('node-restored', { node: back });
513
+ return back;
514
+ }
515
+
516
+ async function purge({ actor, nodeId, ctx = {} }) {
517
+ assert(store, 'not-found', 'This host keeps no trash.');
518
+ const node = store.getNodeById(nodeId);
519
+ assert(node && node.deletedAt, 'not-found', 'Not in the trash.');
520
+ // The trash is the box owner's floor. `can` refuses the owner a WRITE on a
521
+ // tenant's document on purpose (someone else's instance is their document),
522
+ // but the bytes in here are no longer anyone's instance: they are storage
523
+ // the owner is being asked to keep. Without the admin arm a departed
524
+ // tenant's documents are unpurgeable forever, because the gate answers for
525
+ // an owner who no longer exists.
526
+ assert(
527
+ can(actor, 'admin', node, ctx) || can(actor, 'write', { ...node, deletedAt: null }, ctx),
528
+ 'forbidden', 'You do not have permission to do that.',
529
+ );
530
+
531
+ const target = paths.trashPath(node.id, path.basename(node.path));
532
+ const children = listDescendants(node.owner, node.path);
533
+
534
+ await runOperation(
535
+ { kind: 'purge', nodeId: node.id, payload: { owner: node.owner, path: node.path, trash: target } },
536
+ async () => {
537
+ await rmrf(target);
538
+ for (const child of children) await versions.purge(child);
539
+ await versions.purge(node);
540
+ },
541
+ () => {
542
+ // The schema cascades shares, save tokens, collections, record_auth and
543
+ // the data-loss baseline off this one delete.
544
+ for (const child of children) store.deleteNode(child.id);
545
+ return store.deleteNode(node.id);
546
+ },
547
+ );
548
+
549
+ events.emit('node-purged', { node });
550
+ return { id: node.id, path: node.path };
551
+ }
552
+
553
+ // Reports what it actually removed. A swallowed refusal here told the owner
554
+ // their trash was empty while the bytes were still on disk, which is the one
555
+ // answer this lane must never give.
556
+ async function emptyTrash({ actor, ctx = {} }) {
557
+ if (!store) return { purged: 0, failed: [] };
558
+ const rows = store.listTrash().filter((row) => !row.path.includes('/'));
559
+ const failed = [];
560
+ let purged = 0;
561
+ for (const row of rows) {
562
+ try {
563
+ await purge({ actor, nodeId: row.id, ctx });
564
+ purged += 1;
565
+ } catch (error) {
566
+ logger.warn?.('[makerclay] could not purge', row.path, error?.message || error);
567
+ failed.push({ id: row.id, path: row.path, reason: error?.message || String(error) });
568
+ }
569
+ }
570
+ return { purged, failed };
571
+ }
572
+
573
+ // ---- flags -------------------------------------------------------------
574
+
575
+ const FLAG_OPS = { private: 'private', signups: 'signups', isolated: 'isolate' };
576
+
577
+ async function setFlag({ actor, owner = '', relPath, flag, value, ctx = {} }) {
578
+ assert(FLAG_OPS[flag], 'bad-request', `Unknown flag: ${flag}`);
579
+ assert(store, 'not-found', 'This host keeps no metadata.');
580
+ const node = nodes.materialize(nodes.resolve(owner, relPath));
581
+ authorize(actor, 'admin', node, ctx);
582
+
583
+ // Durable BEFORE the row, and allowed to throw. This is the direction that
584
+ // must never be lost: a flag that says private in the database and nothing
585
+ // on disk is a flag that a database loss turns back into public.
586
+ const flagValue = flag === 'isolated' && value === null ? null : !!value;
587
+ await versions.patchMeta(node, { [flag]: flagValue }, { create: true });
588
+
589
+ const updated = store.updateNode(node.id, {
590
+ [flag]: flagValue,
591
+ updatedAt: clock.now(),
592
+ seq: store.nextSeq(),
593
+ });
594
+ events.emit('node-updated', { node: updated, flag });
595
+ return updated;
596
+ }
597
+
598
+ return {
599
+ mkdir,
600
+ createDocument,
601
+ putFile,
602
+ putUpload,
603
+ move,
604
+ copy,
605
+ trash,
606
+ restoreFromTrash,
607
+ purge,
608
+ emptyTrash,
609
+ setFlag,
610
+ uniqueName,
611
+ TRASH_SEPARATOR,
612
+ };
613
+ }