@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,26 @@
1
+ // A cookie header parser, so the core needs no cookie-parser dependency.
2
+ // Express already provides res.cookie(); only reading is missing.
3
+
4
+ function parseCookies(header) {
5
+ const out = {};
6
+ if (typeof header !== 'string' || header.length === 0) return out;
7
+ for (const part of header.split(';')) {
8
+ const eq = part.indexOf('=');
9
+ if (eq === -1) continue;
10
+ const name = part.slice(0, eq).trim();
11
+ if (!name || Object.hasOwn(out, name)) continue;
12
+ let value = part.slice(eq + 1).trim();
13
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
14
+ try {
15
+ out[name] = decodeURIComponent(value);
16
+ } catch {
17
+ out[name] = value;
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ export function cookieMiddleware(req, res, next) {
24
+ req.cookies = parseCookies(req.headers.cookie);
25
+ next();
26
+ }
@@ -0,0 +1,8 @@
1
+ // An async route handler that rejects must reach Express's error handler, and
2
+ // Express 4 does not await anything, so an unwrapped `async (req, res)` turns a
3
+ // thrown HostError into an unhandled rejection and a request that hangs until the
4
+ // client gives up.
5
+ //
6
+ // This lived in three files as three identical definitions. One is enough, and a
7
+ // shared one is what makes "every route is wrapped" a thing you can check.
8
+ export const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'upath';
3
+
4
+ // Every disk call in the core goes through here. The one rule that separates
5
+ // this module from hyperclay's dx.js is that these THROW: in a files-are-truth
6
+ // system a swallowed write error is silent data loss.
7
+
8
+ let tmpCounter = 0;
9
+
10
+ export async function readFileText(filePath) {
11
+ return await fs.readFile(filePath, 'utf8');
12
+ }
13
+
14
+ // Returns null only for a definitively absent file. Every other error throws,
15
+ // so an EACCES can never be mistaken for "not there" (identity rule 4 rebinds
16
+ // history on ENOENT alone).
17
+ export async function readFileTextIfExists(filePath) {
18
+ try {
19
+ return await fs.readFile(filePath, 'utf8');
20
+ } catch (error) {
21
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export async function statIfExists(filePath) {
27
+ try {
28
+ return await fs.stat(filePath);
29
+ } catch (error) {
30
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ // A normalized stat for identity work. `bigint: true` is required because a
36
+ // Windows NTFS file index is 64 bit and two distinct ones collapse into one
37
+ // value above 2^53 as a JS number. It also turns EVERY numeric field into a
38
+ // BigInt, so size and mtime are converted back here rather than at each call
39
+ // site, where `Math.round(bigint)` throws and `bigint === number` is silently
40
+ // false.
41
+ export async function statInfo(filePath) {
42
+ let stat;
43
+ try {
44
+ stat = await fs.stat(filePath, { bigint: true });
45
+ } catch (error) {
46
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
47
+ throw error;
48
+ }
49
+ return {
50
+ size: Number(stat.size),
51
+ // Sub-millisecond on purpose. Under `bigint: true` mtimeMs is whole
52
+ // milliseconds, and the scanner compares this against the stored value to
53
+ // decide a file is unchanged: at millisecond resolution a rewrite landing in
54
+ // the same millisecond as the host's own last write, with an unchanged byte
55
+ // count, is invisible to the boot scan. Measured at 480 collisions in 500
56
+ // back-to-back rewrites. mtimeNs is already in this same stat, so dividing it
57
+ // keeps the field's name and units and buys about 400ns of resolution.
58
+ mtimeMs: Number(stat.mtimeNs) / 1e6,
59
+ birthtimeMs: Number(stat.birthtimeMs),
60
+ dev: Number(stat.dev),
61
+ // Null, not 0: FAT32, exFAT and some network mounts report no usable
62
+ // identity, and null makes every consumer fall back to path matching.
63
+ ino: stat.ino === 0n ? null : String(stat.ino),
64
+ isFile: stat.isFile(),
65
+ isDirectory: stat.isDirectory(),
66
+ };
67
+ }
68
+
69
+ export async function exists(filePath) {
70
+ return (await statIfExists(filePath)) !== null;
71
+ }
72
+
73
+ export async function mkdirp(dir) {
74
+ await fs.mkdir(dir, { recursive: true });
75
+ }
76
+
77
+ export async function readdir(dir, options) {
78
+ return await fs.readdir(dir, options);
79
+ }
80
+
81
+ export async function readdirIfExists(dir, options) {
82
+ try {
83
+ return await fs.readdir(dir, options);
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
86
+ throw error;
87
+ }
88
+ }
89
+
90
+ // Durability, and the one switch that turns it off. Only a test suite should
91
+ // ever set MAKERCLAY_FSYNC=0.
92
+ //
93
+ // fsync is what survives a power cut. It is NOT what makes a write atomic:
94
+ // atomicity here comes from writing a temp file and then renaming or linking it
95
+ // into place, and that rename is visible to every other process the instant the
96
+ // kernel records it, flushed or not. So killing a process, which is the only
97
+ // crash a test can stage, observes exactly the same states either way, while the
98
+ // flushes themselves are the single largest cost the suite pays.
99
+ const DURABLE = process.env.MAKERCLAY_FSYNC !== '0';
100
+
101
+ export async function flush(handle) {
102
+ if (DURABLE) await handle.sync();
103
+ }
104
+
105
+ // fsync a directory so a freshly renamed entry survives a crash. Best-effort:
106
+ // some platforms refuse to open a directory for sync, and that must never fail
107
+ // a save that has already landed.
108
+ export async function fsyncDir(dir) {
109
+ if (!DURABLE) return;
110
+ let handle;
111
+ try {
112
+ handle = await fs.open(dir, 'r');
113
+ await handle.sync();
114
+ } catch {
115
+ return;
116
+ } finally {
117
+ if (handle) await handle.close().catch(() => {});
118
+ }
119
+ }
120
+
121
+ // Write via a same-directory temp + rename, so a crash, a full disk, or a killed
122
+ // process can never leave partial bytes at `filePath`. The rename replaces the
123
+ // target rather than following it, which is why callers pass an
124
+ // already-canonicalized path. This rename is the durable commit point of the
125
+ // mutation kernel (§9.1 step 11).
126
+ export async function atomicWrite(filePath, content, encoding = 'utf8') {
127
+ const dir = path.dirname(filePath);
128
+ await mkdirp(dir);
129
+
130
+ tmpCounter = (tmpCounter + 1) % 1e6;
131
+ const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${tmpCounter}.tmp`);
132
+
133
+ let mode = 0o644;
134
+ const current = await statIfExists(filePath);
135
+ if (current) mode = current.mode & 0o777;
136
+
137
+ let handle = null;
138
+ try {
139
+ handle = await fs.open(tmpPath, 'wx', 0o600);
140
+ await handle.writeFile(content, encoding === null ? undefined : encoding);
141
+ await flush(handle);
142
+ await handle.chmod(mode);
143
+ await handle.close();
144
+ handle = null;
145
+ await fs.rename(tmpPath, filePath);
146
+ } catch (error) {
147
+ if (handle) await handle.close().catch(() => {});
148
+ await fs.unlink(tmpPath).catch(() => {});
149
+ throw error;
150
+ }
151
+ await fsyncDir(dir);
152
+ }
153
+
154
+ // Claim a path and write it in one step, or fail because someone else holds it.
155
+ // `atomicWrite` cannot do this: its rename REPLACES the target, so two writers
156
+ // racing for one name both succeed and one file is silently lost. Here the
157
+ // exclusive open IS the claim, so the loser gets EEXIST and can pick another name.
158
+ // Used by the upload lane, where the host names every file and a taken name must
159
+ // never mean an error and never mean an overwrite.
160
+ export async function createExclusive(filePath, content) {
161
+ const dir = path.dirname(filePath);
162
+ await mkdirp(dir);
163
+ const handle = await fs.open(filePath, 'wx', 0o644);
164
+ try {
165
+ await handle.writeFile(content);
166
+ await flush(handle);
167
+ } catch (error) {
168
+ await handle.close().catch(() => {});
169
+ await fs.unlink(filePath).catch(() => {});
170
+ throw error;
171
+ }
172
+ await handle.close();
173
+ await fsyncDir(dir);
174
+ }
175
+
176
+ export async function rename(from, to) {
177
+ await fs.rename(from, to);
178
+ }
179
+
180
+ export async function unlink(filePath) {
181
+ await fs.unlink(filePath);
182
+ }
183
+
184
+ export async function unlinkIfExists(filePath) {
185
+ try {
186
+ await fs.unlink(filePath);
187
+ return true;
188
+ } catch (error) {
189
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ export async function rmrf(target) {
195
+ await fs.rm(target, { recursive: true, force: true });
196
+ }
197
+
198
+ export async function copyFile(from, to) {
199
+ await mkdirp(path.dirname(to));
200
+ await fs.copyFile(from, to);
201
+ }
202
+
203
+ export async function realpath(target) {
204
+ return path.resolve(await fs.realpath(target));
205
+ }
package/src/util/id.js ADDED
@@ -0,0 +1,37 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
4
+ const TIME_LEN = 10;
5
+ const RANDOM_LEN = 16;
6
+
7
+ function encodeTime(now) {
8
+ let out = '';
9
+ let value = now;
10
+ for (let i = TIME_LEN - 1; i >= 0; i--) {
11
+ out = ENCODING[value % 32] + out;
12
+ value = Math.floor(value / 32);
13
+ }
14
+ return out;
15
+ }
16
+
17
+ function encodeRandom() {
18
+ const bytes = randomBytes(RANDOM_LEN);
19
+ let out = '';
20
+ for (let i = 0; i < RANDOM_LEN; i++) out += ENCODING[bytes[i] % 32];
21
+ return out;
22
+ }
23
+
24
+ // ULID: 48 bits of millisecond time in Crockford base32, then 80 bits of
25
+ // randomness. Lexically sortable by mint time, never reused, and safe in a URL,
26
+ // a filename, and an HTML attribute without escaping.
27
+ export function ulid(now = Date.now()) {
28
+ return encodeTime(now) + encodeRandom();
29
+ }
30
+
31
+
32
+ // Opaque high-entropy string for share links, save tokens, session cookies and
33
+ // setup keys. base64url of 24 random bytes: 32 chars, URL- and path-safe.
34
+ export function token(bytes = 24) {
35
+ return randomBytes(bytes).toString('base64url');
36
+ }
37
+
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs';
2
+
3
+ // Invariant 10: single writer. makerclay refuses to start if <data>/makerclay.lock
4
+ // is held by a live pid. Two processes on one data directory would race the
5
+ // SQLite writer, which has no story for that.
6
+
7
+ function pidIsAlive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ } catch (error) {
12
+ return error.code === 'EPERM';
13
+ }
14
+ }
15
+
16
+ export class LockHeldError extends Error {
17
+ constructor(file, pid) {
18
+ super(`makerclay is already running on this data directory (pid ${pid}, lock ${file})`);
19
+ this.name = 'LockHeldError';
20
+ this.pid = pid;
21
+ }
22
+ }
23
+
24
+ export function acquireLock(file) {
25
+ const claim = () => fs.writeFileSync(file, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
26
+ try {
27
+ claim();
28
+ } catch (error) {
29
+ if (error.code !== 'EEXIST') throw error;
30
+ const held = Number(String(fs.readFileSync(file, 'utf8')).trim());
31
+ if (Number.isInteger(held) && held > 0 && pidIsAlive(held)) {
32
+ throw new LockHeldError(file, held);
33
+ }
34
+ fs.unlinkSync(file);
35
+ claim();
36
+ }
37
+
38
+ let released = false;
39
+ return {
40
+ file,
41
+ release() {
42
+ if (released) return;
43
+ released = true;
44
+ try {
45
+ const held = Number(String(fs.readFileSync(file, 'utf8')).trim());
46
+ if (held === process.pid) fs.unlinkSync(file);
47
+ } catch {
48
+ // Already gone: nothing to release.
49
+ }
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,35 @@
1
+ // Per-key promise chains. Lifted from hyperclay-local's write-queue.js.
2
+ //
3
+ // The key is ALWAYS a canonical resolved absolute path. Any other key (a raw
4
+ // request path, a pre-realpath join) hands two names for one file two different
5
+ // slots and makes the queue a silent no-op.
6
+ //
7
+ // Callers wrap the ENTIRE read-modify-write region, not just the write:
8
+ // serializing only the write still lets two requests read the same stale base.
9
+ //
10
+ // withFileLock is NOT reentrant. The versions directory is a separate namespace
11
+ // from the live file, deliberately, so publishing a version inside a save's own
12
+ // critical section cannot deadlock.
13
+
14
+ export function createLockPool() {
15
+ const chains = new Map();
16
+
17
+ function withLock(key, fn) {
18
+ if (typeof key !== 'string' || key.length === 0) {
19
+ throw new Error('withLock requires a non-empty key');
20
+ }
21
+ const prev = chains.get(key) || Promise.resolve();
22
+ const run = prev.then(() => fn());
23
+ const tail = run.then(() => {}, () => {});
24
+ chains.set(key, tail);
25
+ tail.then(() => {
26
+ if (chains.get(key) === tail) chains.delete(key);
27
+ });
28
+ return run;
29
+ }
30
+
31
+ return {
32
+ withLock,
33
+ pendingKeys: () => [...chains.keys()],
34
+ };
35
+ }
@@ -0,0 +1,33 @@
1
+ import busboy from 'busboy';
2
+ import { HostError } from '../spec/codes.js';
3
+
4
+ // One multipart reader for both upload lanes. The dashboard's lane takes several
5
+ // files plus fields; the document lane takes one part named `file` (spec §9). They
6
+ // differ in what they accept afterwards, never in how the body is read.
7
+
8
+ export function readMultipart(req, { limit, files = 20 } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ const fields = {};
11
+ const parts = [];
12
+ const parser = busboy({ headers: req.headers, limits: { fileSize: limit, files } });
13
+ let failed = null;
14
+
15
+ parser.on('field', (name, value) => { fields[name] = value; });
16
+ parser.on('file', (name, stream, info) => {
17
+ const chunks = [];
18
+ stream.on('data', (chunk) => chunks.push(chunk));
19
+ // busboy truncates at the limit rather than erroring, so a file that trips it
20
+ // would otherwise be stored silently short.
21
+ stream.on('limit', () => {
22
+ failed = new HostError('too-large', `Files are limited to ${limit} bytes.`);
23
+ stream.resume();
24
+ });
25
+ stream.on('end', () => {
26
+ if (!failed) parts.push({ field: name, filename: info.filename, mimeType: info.mimeType, content: Buffer.concat(chunks) });
27
+ });
28
+ });
29
+ parser.on('error', reject);
30
+ parser.on('close', () => (failed ? reject(failed) : resolve({ fields, files: parts })));
31
+ req.pipe(parser);
32
+ });
33
+ }