@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,362 @@
1
+ import express from 'express';
2
+ import path from 'upath';
3
+ import { can } from '../auth/can.js';
4
+ import { HostError } from '../spec/codes.js';
5
+ import { readMultipart } from '../util/multipart.js';
6
+ import { jsonError } from '../json-errors.js';
7
+ import { rawDocumentPath, documentUrlHeader, resolveDocumentHref } from '../spec/wire.js';
8
+ import { readFileTextIfExists } from '../util/fsx.js';
9
+
10
+ // The admin surface: the JSON API the dashboard is written against, and the one
11
+ // the `hackable-dashboard.html` template calls with the owner's own session. It
12
+ // is deliberately a plain API rather than a template's private plumbing, so the
13
+ // UI has a second, user-authored consumer from day one.
14
+ //
15
+ // Everything here is owner-only. The individual ops re-check `can()` for the
16
+ // exact node anyway; this guard is the coarse one that keeps a tenant out of the
17
+ // file manager entirely.
18
+
19
+ const asArray = (value) => (Array.isArray(value) ? value : value == null ? [] : [value]);
20
+
21
+ // v1 has one file manager, the owner's. Tenant trees are moderated read-only
22
+ // through the Tenants view, never through this surface. It was a realmOf(req)
23
+ // function that ignored its argument and returned this, which read as though the
24
+ // realm were derived per request.
25
+ const OWNER_REALM = '';
26
+
27
+ export function mountAdminRoutes(ctx) {
28
+ const {
29
+ router, config, paths, ops, listing, attic, templates, events,
30
+ gateCtx, wrap, track, clock, logger = console,
31
+ } = ctx;
32
+
33
+ const json = express.json({ limit: '2mb' });
34
+
35
+ function requireOwner(req, res, next) {
36
+ if (!can(req.actor, 'admin', null, gateCtx)) {
37
+ return jsonError(res, 'not-found', 'Not found');
38
+ }
39
+ return next();
40
+ }
41
+
42
+ function relPathFrom(value, field = 'path') {
43
+ const raw = String(value ?? '').replace(/^\/+/, '');
44
+ if (!raw) throw new HostError('bad-request', `${field} is required.`);
45
+ return raw;
46
+ }
47
+
48
+ // ---- listing -----------------------------------------------------------
49
+
50
+ router.get('/nodes', requireOwner, wrap(async (req, res) => {
51
+ const found = await listing.list({
52
+ owner: OWNER_REALM,
53
+ dir: String(req.query.dir || req.query.path || '').replace(/^\/+|\/+$/g, ''),
54
+ q: req.query.q || '',
55
+ sort: req.query.sort || 'name',
56
+ direction: req.query.direction === 'desc' ? 'desc' : 'asc',
57
+ });
58
+ if (!found) return jsonError(res, 'not-found', 'No such folder.');
59
+ return res.json({ ok: true, ...found });
60
+ }));
61
+
62
+ router.get('/trash', requireOwner, wrap(async (req, res) => {
63
+ res.json({ ok: true, ...(await listing.trash()) });
64
+ }));
65
+
66
+ router.get('/templates', requireOwner, wrap(async (req, res) => {
67
+ res.json({ ok: true, templates: await templates.list() });
68
+ }));
69
+
70
+ // ---- create ------------------------------------------------------------
71
+
72
+ router.post('/new', requireOwner, json, wrap(async (req, res) => {
73
+ const { dir = '', name, template = 'blank' } = req.body || {};
74
+ const html = await templates.read(template);
75
+ const created = await ops.createDocument({
76
+ actor: req.actor, owner: OWNER_REALM, dir: String(dir).replace(/^\/+|\/+$/g, ''),
77
+ name: String(name || 'untitled.html'), html, ctx: gateCtx,
78
+ });
79
+ if (created.derived) track(created.derived());
80
+ return res.json({ ok: true, node: created.node, path: created.path, url: `/${created.path}` });
81
+ }));
82
+
83
+ router.post('/mkdir', requireOwner, json, wrap(async (req, res) => {
84
+ const { dir = '', name } = req.body || {};
85
+ const node = await ops.mkdir({
86
+ actor: req.actor, owner: OWNER_REALM,
87
+ dir: String(dir).replace(/^\/+|\/+$/g, ''), name: String(name || ''), ctx: gateCtx,
88
+ });
89
+ return res.json({ ok: true, node });
90
+ }));
91
+
92
+ // ---- move / copy -------------------------------------------------------
93
+
94
+ router.post('/rename', requireOwner, json, wrap(async (req, res) => {
95
+ const from = relPathFrom(req.body?.path);
96
+ const name = String(req.body?.name || '');
97
+ const dir = path.dirname(from);
98
+ const to = dir === '.' ? name : `${dir}/${name}`;
99
+ const node = await ops.move({ actor: req.actor, owner: OWNER_REALM, from, to, ctx: gateCtx });
100
+ return res.json({ ok: true, node });
101
+ }));
102
+
103
+ router.post('/move', requireOwner, json, wrap(async (req, res) => {
104
+ const targets = asArray(req.body?.paths ?? req.body?.path).map((value) => relPathFrom(value, 'paths'));
105
+ const toDir = String(req.body?.to ?? '').replace(/^\/+|\/+$/g, '');
106
+ const moved = [];
107
+ for (const from of targets) {
108
+ const to = toDir ? `${toDir}/${path.basename(from)}` : path.basename(from);
109
+ moved.push(await ops.move({ actor: req.actor, owner: OWNER_REALM, from, to, ctx: gateCtx }));
110
+ }
111
+ return res.json({ ok: true, moved });
112
+ }));
113
+
114
+ router.post('/copy', requireOwner, json, wrap(async (req, res) => {
115
+ const from = relPathFrom(req.body?.path);
116
+ const node = await ops.copy({
117
+ actor: req.actor, owner: OWNER_REALM, from, to: req.body?.to || null, ctx: gateCtx,
118
+ });
119
+ return res.json({ ok: true, node, path: node.path, url: `/${node.path}` });
120
+ }));
121
+
122
+ // ---- trash -------------------------------------------------------------
123
+
124
+ router.post('/delete', requireOwner, json, wrap(async (req, res) => {
125
+ const targets = asArray(req.body?.paths ?? req.body?.path).map((value) => relPathFrom(value, 'paths'));
126
+ const trashed = [];
127
+ for (const relPath of targets) {
128
+ trashed.push(await ops.trash({ actor: req.actor, owner: OWNER_REALM, relPath, ctx: gateCtx }));
129
+ }
130
+ return res.json({ ok: true, trashed });
131
+ }));
132
+
133
+ router.post('/trash/restore', requireOwner, json, wrap(async (req, res) => {
134
+ const ids = asArray(req.body?.ids ?? req.body?.id);
135
+ const restored = [];
136
+ for (const id of ids) {
137
+ restored.push(await ops.restoreFromTrash({ actor: req.actor, nodeId: String(id), ctx: gateCtx }));
138
+ }
139
+ return res.json({ ok: true, restored });
140
+ }));
141
+
142
+ router.post('/trash/purge', requireOwner, json, wrap(async (req, res) => {
143
+ const ids = asArray(req.body?.ids ?? req.body?.id);
144
+ const purged = [];
145
+ for (const id of ids) {
146
+ purged.push(await ops.purge({ actor: req.actor, nodeId: String(id), ctx: gateCtx }));
147
+ }
148
+ return res.json({ ok: true, purged });
149
+ }));
150
+
151
+ router.post('/trash/empty', requireOwner, json, wrap(async (req, res) => {
152
+ const result = await ops.emptyTrash({ actor: req.actor, ctx: gateCtx });
153
+ res.json({ ok: result.failed.length === 0, ...result });
154
+ }));
155
+
156
+ // ---- the attic ---------------------------------------------------------
157
+
158
+ router.get('/attic', requireOwner, wrap(async (req, res) => {
159
+ res.json({ ok: true, entries: await attic.list() });
160
+ }));
161
+
162
+ // One row's versions, fetched when the row is opened. Deliberately not folded
163
+ // into `GET /attic`, so that listing stays one directory walk however many
164
+ // orphans a host has accumulated.
165
+ router.get('/attic/:id', requireOwner, wrap(async (req, res) => {
166
+ const found = await attic.history(String(req.params.id));
167
+ if (!found) return jsonError(res, 'not-found', 'There is no history here with that id.');
168
+ return res.json({ ok: true, versions: found });
169
+ }));
170
+
171
+ // A tenant's orphaned version full of script, opened at the real origin, is a
172
+ // full escape, so this lane never renders: octet-stream, an attachment, and a
173
+ // CSP that permits nothing. Same posture as the recovery download lane.
174
+ router.get('/attic/:id/:name', requireOwner, wrap(async (req, res) => {
175
+ const file = await attic.versionPath({ id: req.params.id, name: req.params.name });
176
+ if (!file) return jsonError(res, 'not-found', 'Not found');
177
+ res.setHeader('Content-Type', 'application/octet-stream');
178
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
179
+ // A filename is whatever the filesystem allowed, including a newline, and a
180
+ // newline in a header value throws rather than being escaped.
181
+ res.setHeader('Content-Disposition',
182
+ `attachment; filename="${path.basename(file).replace(/[^\w.-]+/g, '_')}"`);
183
+ res.sendFile(file);
184
+ return undefined;
185
+ }));
186
+
187
+ router.post('/attic/put-back', requireOwner, json, wrap(async (req, res) => {
188
+ const result = await attic.putBack({
189
+ id: String(req.body?.id ?? ''),
190
+ toPath: req.body?.path,
191
+ actor: req.actor,
192
+ ctx: gateCtx,
193
+ });
194
+ return res.json({ ok: true, ...result });
195
+ }));
196
+
197
+ router.post('/attic/forget', requireOwner, json, wrap(async (req, res) => {
198
+ const forgotten = await attic.forget({ id: String(req.body?.id ?? '') });
199
+ if (!forgotten) return jsonError(res, 'not-found', 'There is no history here with that id.');
200
+ return res.json({ ok: true });
201
+ }));
202
+
203
+ // ---- flags -------------------------------------------------------------
204
+
205
+ const STORAGE_APIS = /\b(localStorage|sessionStorage|indexedDB|document\.cookie|navigator\.serviceWorker)\b/;
206
+
207
+ async function storageWarning(owner, relPath) {
208
+ try {
209
+ const real = await paths.resolveRead(owner, relPath);
210
+ const source = await readFileTextIfExists(real);
211
+ if (!source) return null;
212
+ const found = source.match(STORAGE_APIS);
213
+ if (!found) return null;
214
+ return `This file uses ${found[1]}, which a sandboxed document cannot reach. `
215
+ + 'Keep its state in the DOM instead, or leave the sandbox off.';
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ for (const [route, flag] of [['private', 'private'], ['isolate', 'isolated'], ['signups', 'signups']]) {
222
+ router.post(`/${route}`, requireOwner, json, wrap(async (req, res) => {
223
+ const relPath = relPathFrom(req.body?.path);
224
+ const raw = req.body?.value;
225
+ const value = raw === null || raw === 'inherit' ? null : !!raw;
226
+ const node = await ops.setFlag({
227
+ actor: req.actor, owner: OWNER_REALM, relPath, flag, value, ctx: gateCtx,
228
+ });
229
+ // §16.4. A sandbox costs the document localStorage, sessionStorage,
230
+ // IndexedDB and cookies. A conforming malleable document keeps its state
231
+ // in the DOM and loses nothing, so the warning is only worth raising for
232
+ // a document that is actually reaching for browser storage.
233
+ const warning = flag === 'isolated' && value === true
234
+ ? await storageWarning(OWNER_REALM, relPath)
235
+ : null;
236
+ return res.json({ ok: true, node, warning });
237
+ }));
238
+ }
239
+
240
+ // ---- upload ------------------------------------------------------------
241
+ //
242
+ // Uploads are just files: one tree, one rename, one delete, one trash. The
243
+ // platform's parallel uploads/ universe existed for nginx routing and quotas,
244
+ // and both reasons are gone.
245
+
246
+ function defaultDir(req) {
247
+ const explicit = req.query.dir;
248
+ if (typeof explicit === 'string') return explicit.replace(/^\/+|\/+$/g, '');
249
+ const href = documentUrlHeader(req);
250
+ if (!href) return '';
251
+ const dir = path.dirname(resolveDocumentHref(rawDocumentPath(href)));
252
+ return dir === '.' ? '' : dir;
253
+ }
254
+
255
+ // `/add-files`, not `/upload`: the spec claims `/_/upload` for a document's own
256
+ // uploads, and that lane has stricter rules than this one (it refuses anything
257
+ // that can run, and never lets one file replace another). Sharing one address
258
+ // would make those rules something a caller opts into by shaping the request
259
+ // rather than a property of the address, so the two lanes stay two addresses.
260
+ // This one is the file manager: the owner's own tool, on the owner's own tree,
261
+ // where uploading the .js and .css a site loads is the point.
262
+ router.post('/add-files', requireOwner, wrap(async (req, res) => {
263
+ if (config.features?.uploads === false) return jsonError(res, 'not-found', 'Not found');
264
+ const limit = config.limits.uploadBytes;
265
+ const contentType = String(req.headers['content-type'] || '');
266
+ let incoming = [];
267
+ let dir = defaultDir(req);
268
+
269
+ if (contentType.startsWith('multipart/form-data')) {
270
+ const { fields, files } = await readMultipart(req, { limit, files: 20 });
271
+ if (typeof fields.dir === 'string' && req.query.dir === undefined) {
272
+ dir = fields.dir.replace(/^\/+|\/+$/g, '');
273
+ }
274
+ incoming = files.map((file) => ({ name: file.filename, content: file.content }));
275
+ } else {
276
+ await new Promise((resolve, reject) => {
277
+ express.json({ limit: limit * 1.4 })(req, res, (error) => (error ? reject(error) : resolve()));
278
+ });
279
+ const body = req.body || {};
280
+ if (typeof body.dir === 'string' && req.query.dir === undefined) {
281
+ dir = body.dir.replace(/^\/+|\/+$/g, '');
282
+ }
283
+ const items = Array.isArray(body.files) ? body.files : [body];
284
+ incoming = items
285
+ .filter((item) => item && typeof item.name === 'string' && typeof item.data === 'string')
286
+ .map((item) => ({
287
+ name: item.name,
288
+ content: Buffer.from(item.data.replace(/^data:[^;,]*;base64,/, ''), 'base64'),
289
+ }));
290
+ }
291
+
292
+ if (!incoming.length) return jsonError(res, 'bad-request', 'No files in this upload.');
293
+
294
+ const written = [];
295
+ for (const file of incoming) {
296
+ if (file.content.length > limit) {
297
+ return jsonError(res, 'too-large', `${file.name} is larger than the ${limit}-byte limit.`);
298
+ }
299
+ const saved = await ops.putFile({
300
+ actor: req.actor, owner: OWNER_REALM, dir,
301
+ name: path.basename(file.name), content: file.content, ctx: gateCtx,
302
+ });
303
+ written.push({ name: path.basename(saved.path), path: saved.path, url: `/${saved.path}`, bytes: file.content.length });
304
+ }
305
+ return res.json({ ok: true, files: written, uploads: written });
306
+ }));
307
+
308
+ // ---- the events feed ---------------------------------------------------
309
+ //
310
+ // The dashboard morphs off this rather than polling, which is what makes a
311
+ // rename in a terminal show up in an open browser tab.
312
+
313
+ const FEED = [
314
+ 'node-saved', 'node-created', 'node-moved', 'node-trashed', 'node-restored',
315
+ 'node-purged', 'node-updated', 'node-changed', 'node-renamed', 'node-missing', 'node-appeared',
316
+ // A signup arrives while the owner is looking at something else, which is
317
+ // the whole reason this feed exists.
318
+ 'tenant-created', 'tenant-approved', 'tenant-updated', 'tenant-removed',
319
+ ];
320
+
321
+ router.get('/events', requireOwner, (req, res) => {
322
+ res.writeHead(200, {
323
+ 'Content-Type': 'text/event-stream',
324
+ 'Cache-Control': 'no-cache, no-transform',
325
+ Connection: 'keep-alive',
326
+ 'X-Accel-Buffering': 'no',
327
+ });
328
+ res.write(`retry: 3000\n\n`);
329
+
330
+ const send = (name) => (payload) => {
331
+ try {
332
+ const node = payload?.node || null;
333
+ res.write(`event: ${name}\ndata: ${JSON.stringify({
334
+ name,
335
+ at: clock.now(),
336
+ path: node?.path ?? payload?.path ?? null,
337
+ owner: node?.owner ?? payload?.owner ?? '',
338
+ id: node?.id ?? null,
339
+ from: payload?.from ?? null,
340
+ })}\n\n`);
341
+ } catch (error) {
342
+ logger.error?.('[makerclay] events feed write failed:', error?.message || error);
343
+ }
344
+ };
345
+
346
+ const bound = FEED.map((name) => {
347
+ const handler = send(name);
348
+ events.on(name, handler);
349
+ return [name, handler];
350
+ });
351
+
352
+ const keepalive = setInterval(() => res.write(': keepalive\n\n'), config.limits.sseKeepaliveMs);
353
+ keepalive.unref?.();
354
+
355
+ const stop = () => {
356
+ clearInterval(keepalive);
357
+ for (const [name, handler] of bound) events.off(name, handler);
358
+ };
359
+ req.on('close', stop);
360
+ res.on('close', stop);
361
+ });
362
+ }
package/src/attic.js ADDED
@@ -0,0 +1,168 @@
1
+ import path from 'upath';
2
+ import { HostError } from './spec/codes.js';
3
+ import { statIfExists, rmrf } from './util/fsx.js';
4
+ import { VERSION_NAME } from './versions/naming.js';
5
+
6
+ // The attic: history whose document is gone.
7
+ //
8
+ // Identity follows a file by inode and, when that fails, by content digest.
9
+ // Both can miss at once (a move across a filesystem boundary, in one action,
10
+ // while the host was down), and the file is then a new document with no history
11
+ // while the old history sits under versions/ with nothing pointing at it. That
12
+ // is the honest failure mode of matching files without writing anything into
13
+ // them, and what makes it acceptable is that the history never disappears. This
14
+ // is the room you walk into to find it.
15
+
16
+ export function createAttic({
17
+ paths, store, nodes, versions, recovery, replace, locks, logger = console,
18
+ }) {
19
+ const rowFor = (id) => (store ? store.getNodeById(id) : null);
20
+
21
+ async function find(id) {
22
+ const wanted = String(id ?? '');
23
+ if (!wanted) return null;
24
+ return (await recovery.readAllMeta()).find((record) => record.id === wanted) || null;
25
+ }
26
+
27
+ // Membership is "no live row whose path currently resolves to a file", NOT the
28
+ // simpler "no live row". A row whose file the scanner cannot find is
29
+ // deliberately left alone rather than tombstoned, because the file may be
30
+ // coming back. That is exactly the cross-filesystem move this room exists for,
31
+ // so filtering on the row alone would hide the main case.
32
+ async function list() {
33
+ const entries = [];
34
+ for (const record of await recovery.readAllMeta()) {
35
+ const row = rowFor(record.id);
36
+ // A trashed document has its own room and its bytes are in trash/.
37
+ if (row?.deletedAt) continue;
38
+
39
+ const relPath = row?.path ?? record.path;
40
+ const owner = (row?.owner ?? record.owner) || '';
41
+ if (row) {
42
+ const real = path.join(paths.realmRoot(owner), relPath);
43
+ const stat = await statIfExists(real);
44
+ if (stat?.isFile()) continue;
45
+ }
46
+
47
+ const history = await versions.list({ id: record.id, path: relPath });
48
+ if (!history.length) continue;
49
+
50
+ entries.push({
51
+ id: record.id,
52
+ path: relPath,
53
+ owner,
54
+ kind: record.kind ?? null,
55
+ private: !!record.private,
56
+ versions: history.length,
57
+ newest: history[0].name,
58
+ lastSeen: record.updatedAt ?? null,
59
+ stranded: !!row,
60
+ });
61
+ }
62
+ return entries;
63
+ }
64
+
65
+ // One history's versions, read when its row is opened rather than folded into
66
+ // list(). The room is a walk over every orphan on the host, and reading every
67
+ // version of every one of them to render one table is the cost that walk
68
+ // cannot afford.
69
+ async function history(id) {
70
+ const record = await find(id);
71
+ if (!record) return null;
72
+ const row = rowFor(record.id);
73
+ return await versions.list({ id: record.id, path: row?.path ?? record.path });
74
+ }
75
+
76
+ // One action covers both cases, because from the owner's point of view they
77
+ // are one thing: put this history back on a file.
78
+ //
79
+ // Adopt FIRST, then write. Creating the document first would give it a history
80
+ // of its own and leave adopt with two directories and no honest way to merge
81
+ // them. This order also tells the truth about what happened: the restore is a
82
+ // save, so it publishes its own version on top of the recovered ones.
83
+ async function putBack({ id, toPath, actor, ctx = {} }) {
84
+ const record = await find(id);
85
+ if (!record) throw new HostError('not-found', 'There is no history here with that id.');
86
+ // versions/ is one flat directory shared by every realm and only
87
+ // meta.json.owner separates them, so binding a tenant's orphaned history to
88
+ // an owner document is a realm crossing.
89
+ if ((record.owner || '') !== '') {
90
+ throw new HostError(
91
+ 'forbidden',
92
+ 'This history belongs to a tenant, so it can only go back onto one of their files.',
93
+ );
94
+ }
95
+
96
+ const relPath = String(toPath ?? '').replace(/^\/+/, '');
97
+ if (!relPath) throw new HostError('bad-request', 'Say where this history should go.');
98
+
99
+ return await locks.withLock(`attic:${record.id}`, async () => {
100
+ // Re-stat inside the lock: the target can appear, move or be replaced
101
+ // between the page rendering and the click.
102
+ const real = await paths.resolveWrite('', relPath);
103
+ const already = await statIfExists(real);
104
+
105
+ const existing = nodes.get('', relPath);
106
+ if (already && existing && (await versions.list(existing)).length) {
107
+ throw new HostError('conflict', 'That file already has its own history.');
108
+ }
109
+
110
+ let node = nodes.materialize(nodes.resolve('', relPath));
111
+ if (!(await versions.adopt(record.id, node))) {
112
+ throw new HostError('conflict', 'That file already has its own history.');
113
+ }
114
+
115
+ // After adopt, never before. A refusal has to leave the target exactly as
116
+ // it was, and adopt is the only thing that knows whether it will refuse:
117
+ // setting the flags first means a conflict hands an unrelated document
118
+ // someone else's signups flag. The record adopt just wrote describes the
119
+ // target's own defaults, which are public, so the row and the record are
120
+ // corrected together right here, still before any bytes are written.
121
+ node = nodes.update(node, {
122
+ private: !!record.private,
123
+ isolated: record.isolated === undefined ? null : record.isolated,
124
+ signups: !!record.signups,
125
+ });
126
+ await versions.patchMeta(node, {});
127
+
128
+ if (already) return { id: record.id, path: relPath, restored: null };
129
+
130
+ const history = await versions.list(node);
131
+ const newest = history[0];
132
+ if (!newest) throw new HostError('not-found', 'That history has no versions left.');
133
+ const bytes = await versions.read(node, newest.name);
134
+ if (bytes == null) throw new HostError('not-found', 'That version could not be read.');
135
+
136
+ // Through the kernel, never straight to disk: it is the only writer of
137
+ // managed documents.
138
+ const result = await replace({
139
+ actor, owner: '', relPath, bytes, trigger: 'user', source: 'restore', ctx,
140
+ });
141
+ return { id: record.id, path: relPath, restored: newest.name, etag: result.etag };
142
+ });
143
+ }
144
+
145
+ // Owner-only and irreversible, so the confirmation lives in the UI.
146
+ async function forget({ id }) {
147
+ const record = await find(id);
148
+ if (!record) return false;
149
+ const dir = await versions.findDirById(record.id);
150
+ if (!dir) return false;
151
+ await rmrf(dir);
152
+ logger.warn?.(`[makerclay] forgot the orphaned history of ${record.path}`);
153
+ return true;
154
+ }
155
+
156
+ // The absolute path of one version file, or null. No reading here: the route
157
+ // streams it.
158
+ async function versionPath({ id, name }) {
159
+ if (!VERSION_NAME.test(String(name ?? ''))) return null;
160
+ const dir = await versions.findDirById(id);
161
+ if (!dir) return null;
162
+ const full = path.join(dir, String(name));
163
+ const stat = await statIfExists(full);
164
+ return stat?.isFile() ? full : null;
165
+ }
166
+
167
+ return { list, history, putBack, forget, versionPath };
168
+ }
@@ -0,0 +1,79 @@
1
+ // The capability gate. One pure function, no I/O, called by every route
2
+ // including the static document handler.
3
+ //
4
+ // can(actor, action, node, ctx) -> boolean
5
+ // actor: {kind:'owner'|'tenant'|'guest', username?, grants:{share?, saveToken?}}
6
+ // action: 'read'|'write'|'edit-source'|'admin'|'version-read'|'version-write'
7
+ //
8
+ // A share link grants a capability set for one node; it never promotes an
9
+ // identity. That is the whole difference from the platform's no_auth -> app_user
10
+ // elevation, which set isOwner unconditionally.
11
+
12
+ export const ACTIONS = ['read', 'write', 'edit-source', 'admin', 'version-read', 'version-write'];
13
+
14
+ export const GUEST = { kind: 'guest', grants: {} };
15
+
16
+ // A visitor can hold several share links at once (one cookie per node), so the
17
+ // grants are a list and the matching one is picked per node. `share` stays
18
+ // accepted as the single-grant spelling.
19
+ function heldShares(actor) {
20
+ const grants = actor?.grants;
21
+ if (!grants) return [];
22
+ const held = Array.isArray(grants.shares) ? [...grants.shares] : [];
23
+ if (grants.share) held.push(grants.share);
24
+ return held;
25
+ }
26
+
27
+ export function shareFor(actor, node, kinds = ['view', 'edit']) {
28
+ if (!node) return null;
29
+ return heldShares(actor).find(
30
+ (grant) => grant?.nodeId === node.id && kinds.includes(grant.grantKind),
31
+ ) || null;
32
+ }
33
+
34
+ function grantFor(actor, node, kinds) {
35
+ if (!node || !actor?.grants) return false;
36
+ if (shareFor(actor, node, kinds)) return true;
37
+ const { saveToken } = actor.grants;
38
+ if (saveToken && saveToken.nodeId === node.id && kinds.includes(saveToken.grantKind)) return true;
39
+ return false;
40
+ }
41
+
42
+ export function can(actor, action, node = null, ctx = {}) {
43
+ const kind = actor?.kind || 'guest';
44
+ const owner = node?.owner ?? '';
45
+ const isTenantRealm = owner !== '';
46
+
47
+ if (action === 'admin') return kind === 'owner';
48
+
49
+ // The source editor is the owner's tool on the owner's own realm. A tenant
50
+ // never gets it, and neither does a share link: an edit grant is permission to
51
+ // change one document through the save lane, not to open its source.
52
+ if (action === 'edit-source') return kind === 'owner' && !isTenantRealm;
53
+
54
+ if (action === 'read') {
55
+ if (kind === 'owner') return true;
56
+ if (grantFor(actor, node, ['view', 'edit'])) return true;
57
+ if (kind === 'tenant' && owner === actor.username) return true;
58
+ if (ctx.requireAuthToRead) return false;
59
+ if (isTenantRealm) return false;
60
+ return !node?.private;
61
+ }
62
+
63
+ if (action === 'write') {
64
+ if (grantFor(actor, node, ['edit'])) return true;
65
+ // The owner moderates tenant trees read-only. There is deliberately no
66
+ // owner-edits-instance lane: someone else's instance is their document.
67
+ if (kind === 'owner') return !isTenantRealm;
68
+ if (kind === 'tenant') return owner === actor.username;
69
+ return false;
70
+ }
71
+
72
+ // History is the edit surface's undo, so it follows write, not read: a
73
+ // view-grant holder can see the current document and no earlier state of it.
74
+ if (action === 'version-read' || action === 'version-write') {
75
+ return can(actor, 'write', node, ctx);
76
+ }
77
+
78
+ return false;
79
+ }
@@ -0,0 +1,74 @@
1
+ import { jsonError } from '../json-errors.js';
2
+
3
+ // Spec §8: validate the origin of every save. A cross-origin POST with a
4
+ // text/plain body is a CORS "simple request", so without this any page a person
5
+ // visits could silently save garbage into a document on their host.
6
+ //
7
+ // The rules, in order:
8
+ //
9
+ // - Safe methods and exempt lanes pass untouched.
10
+ // - No Origin header at all passes: curl, a webhook and a shell script send
11
+ // none, and none of them carry ambient browser authority. This is the same
12
+ // reasoning that makes CSRF a cookie-auth problem in the first place.
13
+ // - `Origin: null` (a sandboxed document, a data: URL, a redirect chain) is
14
+ // forgeable and therefore carries no authority, so it passes only when the
15
+ // request's credential is a per-document token. The token is the whole
16
+ // credential there, which is exactly why isolated documents use one.
17
+ // Most lanes carry it in the path; the sync lane carries it in `?token=`,
18
+ // which is the same credential in a different place, so the lanes that do
19
+ // that say so explicitly rather than the rule going loose for everyone.
20
+ // - Any other Origin must name this host.
21
+
22
+ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
23
+
24
+ export function createCsrfGuard({
25
+ exempt = [], publicUrl = null, tokenLanes = null, queryTokenLanes = null,
26
+ } = {}) {
27
+ const exemptPatterns = exempt.map((entry) => (entry instanceof RegExp ? entry : new RegExp(entry)));
28
+
29
+ function expectedHosts(req) {
30
+ const hosts = new Set();
31
+ const header = req.get('host');
32
+ if (header) hosts.add(header.toLowerCase());
33
+ if (publicUrl) {
34
+ try {
35
+ hosts.add(new URL(publicUrl).host.toLowerCase());
36
+ } catch {
37
+ // A malformed publicUrl simply contributes no host.
38
+ }
39
+ }
40
+ return hosts;
41
+ }
42
+
43
+ return function csrfGuard(req, res, next) {
44
+ if (!UNSAFE.has(req.method)) return next();
45
+ if (exemptPatterns.some((pattern) => pattern.test(req.path))) return next();
46
+
47
+ const origin = req.get('origin');
48
+ if (!origin) return next();
49
+
50
+ // The caller passes the same list the CORS headers are built from, so a new
51
+ // token lane cannot arrive here unrecognised.
52
+ const hasPathToken = !!req.params?.token
53
+ || (tokenLanes ? tokenLanes.test(req.path) : /^\/(?:save|restore|versions|meta)\/[^/]+/.test(req.path));
54
+ // A lane that takes its token as a query parameter has to say so, and the
55
+ // token has to actually be there: the path alone proves nothing.
56
+ const hasQueryToken = !!queryTokenLanes
57
+ && queryTokenLanes.test(req.path)
58
+ && typeof req.query?.token === 'string'
59
+ && req.query.token.length > 0;
60
+ if (origin === 'null') {
61
+ if (hasPathToken || hasQueryToken) return next();
62
+ return jsonError(res, 'forbidden', 'Cross-origin save refused.');
63
+ }
64
+
65
+ let host = null;
66
+ try {
67
+ host = new URL(origin).host.toLowerCase();
68
+ } catch {
69
+ host = null;
70
+ }
71
+ if (host && expectedHosts(req).has(host)) return next();
72
+ return jsonError(res, 'forbidden', 'Cross-origin save refused.');
73
+ };
74
+ }