@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,171 @@
1
+ // Read, inject and strip attributes on a document's root <html> start tag.
2
+ //
3
+ // Spec §4 permits exactly two serve-time injections (the ephemeral save token
4
+ // and the durable document identity) and forbids serving from mutating the
5
+ // stored file, so every operation here works on a byte string and splices only
6
+ // the spans it must. The rest of the document, and the rest of the root tag,
7
+ // comes out byte-identical.
8
+ //
9
+ // The scan mirrors how a browser parses the tag: quotes only delimit in value
10
+ // position, only ASCII whitespace separates attributes, comments end at --> or
11
+ // --!>, and a start tag that never closes is not a root tag at all. It cannot
12
+ // backtrack, so hostile input costs one linear pass.
13
+
14
+ function isWs(c) {
15
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
16
+ }
17
+
18
+ function commentEnd(str, from) {
19
+ if (str[from] === '>') return from + 1;
20
+ if (str[from] === '-' && str[from + 1] === '>') return from + 2;
21
+ const len = str.length;
22
+ for (let k = from; k < len; k++) {
23
+ if (str[k] === '-' && str[k + 1] === '-') {
24
+ let e = k + 2;
25
+ if (str[e] === '!') e++;
26
+ if (str[e] === '>') return e + 1;
27
+ }
28
+ }
29
+ return -1;
30
+ }
31
+
32
+ export function scanRootTag(html) {
33
+ if (typeof html !== 'string' || html.length === 0) return null;
34
+ const len = html.length;
35
+ let i = html.charCodeAt(0) === 0xFEFF ? 1 : 0;
36
+
37
+ while (i < len) {
38
+ const c = html[i];
39
+ if (isWs(c)) { i++; continue; }
40
+ if (c !== '<') return null;
41
+ if (html[i + 1] === '!' && html[i + 2] === '-' && html[i + 3] === '-') {
42
+ const end = commentEnd(html, i + 4);
43
+ if (end === -1) return null;
44
+ i = end;
45
+ continue;
46
+ }
47
+ if (html[i + 1] === '!' || html[i + 1] === '?') {
48
+ const end = html.indexOf('>', i);
49
+ if (end === -1) return null;
50
+ i = end + 1;
51
+ continue;
52
+ }
53
+ break;
54
+ }
55
+
56
+ if (html.substr(i, 5).toLowerCase() !== '<html') return null;
57
+ const boundary = html[i + 5];
58
+ if (boundary === undefined || !(isWs(boundary) || boundary === '>' || boundary === '/')) return null;
59
+
60
+ const tagStart = i;
61
+ const nameEnd = i + 5;
62
+ i = nameEnd;
63
+
64
+ const attrs = [];
65
+ while (i < len) {
66
+ const leadStart = i;
67
+ while (i < len && (isWs(html[i]) || html[i] === '/')) i++;
68
+ if (i >= len) return null;
69
+ if (html[i] === '>') {
70
+ return { tagStart, nameEnd, tagEnd: i + 1, insertAt: nameEnd, attrs };
71
+ }
72
+
73
+ const nameStart = i;
74
+ while (i < len && !isWs(html[i]) && html[i] !== '=' && html[i] !== '>' && html[i] !== '/') i++;
75
+ if (i === nameStart) i++;
76
+ const name = html.slice(nameStart, i).toLowerCase();
77
+
78
+ let cursor = i;
79
+ while (cursor < len && isWs(html[cursor])) cursor++;
80
+ let value = '';
81
+ let attrEnd = i;
82
+ if (html[cursor] === '=') {
83
+ cursor++;
84
+ while (cursor < len && isWs(html[cursor])) cursor++;
85
+ const quote = html[cursor];
86
+ if (quote === '"' || quote === "'") {
87
+ const close = html.indexOf(quote, cursor + 1);
88
+ if (close === -1) return null;
89
+ value = html.slice(cursor + 1, close);
90
+ attrEnd = close + 1;
91
+ } else {
92
+ const valueStart = cursor;
93
+ while (cursor < len && !isWs(html[cursor]) && html[cursor] !== '>') cursor++;
94
+ value = html.slice(valueStart, cursor);
95
+ attrEnd = cursor;
96
+ }
97
+ i = attrEnd;
98
+ }
99
+
100
+ attrs.push({ name, value, leadStart, nameStart, attrEnd });
101
+ }
102
+ return null;
103
+ }
104
+
105
+ export function hasRootTag(html) {
106
+ return scanRootTag(html) !== null;
107
+ }
108
+
109
+ function decodeEntities(value) {
110
+ return String(value)
111
+ .replace(/&lt;/g, '<')
112
+ .replace(/&gt;/g, '>')
113
+ .replace(/&quot;/g, '"')
114
+ .replace(/&#39;/g, "'")
115
+ .replace(/&amp;/g, '&');
116
+ }
117
+
118
+ function escapeAttr(value) {
119
+ return String(value)
120
+ .replace(/&/g, '&amp;')
121
+ .replace(/</g, '&lt;')
122
+ .replace(/>/g, '&gt;')
123
+ .replace(/"/g, '&quot;');
124
+ }
125
+
126
+ // First occurrence wins, matching how a browser resolves duplicate attributes.
127
+ export function getRootAttr(html, name) {
128
+ const root = scanRootTag(html);
129
+ if (!root) return null;
130
+ const wanted = name.toLowerCase();
131
+ const hit = root.attrs.find((attr) => attr.name === wanted);
132
+ return hit ? decodeEntities(hit.value) : null;
133
+ }
134
+
135
+ export function stripRootAttrs(html, names) {
136
+ const root = scanRootTag(html);
137
+ if (!root) return html;
138
+ const wanted = new Set(names.map((name) => name.toLowerCase()));
139
+ const spans = root.attrs
140
+ .filter((attr) => wanted.has(attr.name))
141
+ .map((attr) => [attr.leadStart, attr.attrEnd])
142
+ .sort((a, b) => b[0] - a[0]);
143
+ if (!spans.length) return html;
144
+ let out = html;
145
+ for (const [from, to] of spans) out = out.slice(0, from) + out.slice(to);
146
+ return out;
147
+ }
148
+
149
+ // Serve-time injection. Attributes already present keep the document's own
150
+ // value. Nothing durable is injected any more, so the only caller that matters
151
+ // reaches this through setRootAttrs, which strips first.
152
+ export function injectRootAttrs(html, values) {
153
+ const root = scanRootTag(html);
154
+ if (!root) return html;
155
+ const present = new Set(root.attrs.map((attr) => attr.name));
156
+ const additions = Object.entries(values)
157
+ .filter(([name, value]) => value != null && !present.has(name.toLowerCase()))
158
+ .map(([name, value]) => ` ${name}="${escapeAttr(value)}"`)
159
+ .join('');
160
+ if (!additions) return html;
161
+ return html.slice(0, root.insertAt) + additions + html.slice(root.insertAt);
162
+ }
163
+
164
+ // Replace an attribute's value even when the document already carries one. Used
165
+ // where the host's own value must win: a document carrying a literal savetoken
166
+ // of its own is served the host's token, never its own.
167
+ export function setRootAttrs(html, values) {
168
+ let out = stripRootAttrs(html, Object.keys(values));
169
+ out = injectRootAttrs(out, values);
170
+ return out;
171
+ }
@@ -0,0 +1,137 @@
1
+ import path from 'upath';
2
+ import { can, shareFor } from '../auth/can.js';
3
+ import { PathError, decodeOnce } from '../paths.js';
4
+ import { documentEtag } from '../spec/wire.js';
5
+ import { setRootAttrs } from './root-attrs.js';
6
+ import { readFileText, statIfExists } from '../util/fsx.js';
7
+
8
+ // Document serving, realm resolution and serve-time injection.
9
+ //
10
+ // Invariant 3: the document root is never served statically. There is no
11
+ // express.static(filesRoot) anywhere in the core; every read passes the gate,
12
+ // which is what makes private-as-a-flag safe.
13
+ //
14
+ // Invariant 5: serving never mutates the stored file. Injection happens on the
15
+ // response bytes only.
16
+
17
+ const REALM_PREFIX = '~';
18
+
19
+ export function parseRealm(urlPath) {
20
+ const decoded = decodeOnce(urlPath).replace(/^\/+/, '');
21
+ if (!decoded.startsWith(REALM_PREFIX)) return { owner: '', rest: decoded };
22
+ const slash = decoded.indexOf('/');
23
+ if (slash === -1) return { owner: decoded.slice(1), rest: '' };
24
+ return { owner: decoded.slice(1, slash), rest: decoded.slice(slash + 1) };
25
+ }
26
+
27
+ // Within a realm, an unmatched path whose longest .html-suffixed prefix resolves
28
+ // to a real document serves that document: /blog/app.html/dashboard is the app
29
+ // with an SPA route, not a missing file.
30
+ function collapseSpaTail(relPath) {
31
+ const match = relPath.match(/^(.*?\.(?:html|htmlclay))(?:\/.*)?$/i);
32
+ return match ? match[1] : relPath;
33
+ }
34
+
35
+ export function createServe({
36
+ paths, nodes, config, isolation = null, saveTokens = null,
37
+ }) {
38
+ const ctx = { requireAuthToRead: !!config.requireAuthToRead };
39
+
40
+ async function locate(owner, rest) {
41
+ const relPath = rest === '' ? 'index.html' : rest;
42
+ const candidates = [relPath];
43
+ const collapsed = collapseSpaTail(relPath);
44
+ if (collapsed !== relPath) candidates.unshift(collapsed);
45
+
46
+ for (const candidate of candidates) {
47
+ let real;
48
+ try {
49
+ real = await paths.resolveRead(owner, candidate);
50
+ } catch (error) {
51
+ if (error instanceof PathError && error.code === 'not-found') continue;
52
+ throw error;
53
+ }
54
+ const stat = await statIfExists(real);
55
+ if (!stat) continue;
56
+ if (stat.isDirectory()) {
57
+ const index = path.join(candidate, 'index.html');
58
+ try {
59
+ const indexReal = await paths.resolveRead(owner, index);
60
+ const indexStat = await statIfExists(indexReal);
61
+ if (indexStat?.isFile()) return { relPath: index, real: indexReal, stat: indexStat };
62
+ } catch {
63
+ continue;
64
+ }
65
+ continue;
66
+ }
67
+ return { relPath: candidate, real, stat };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Gate refusals for reads are 404, shaped like a missing document. Never 403:
73
+ // a 403 tells a stranger the document exists.
74
+ async function resolveForRead(actor, urlPath) {
75
+ const { owner, rest } = parseRealm(urlPath);
76
+ if (owner && !config.features?.tenants) return null;
77
+ const found = await locate(owner, rest);
78
+ if (!found) return null;
79
+
80
+ const node = identify(owner, found.relPath);
81
+ if (!can(actor, 'read', node, ctx)) return null;
82
+ return { owner, node, ...found };
83
+ }
84
+
85
+ // A row if there is one, a provisional node otherwise. Reconciling a
86
+ // document's self-declared identity used to happen here; a file is now
87
+ // recognised by (dev, ino) and by its digest, in the scanner.
88
+ function identify(owner, relPath) {
89
+ return nodes.get(owner, relPath) || nodes.provisional(owner, relPath);
90
+ }
91
+
92
+ function isolationFor(node) {
93
+ if (!isolation) return false;
94
+ return isolation.isolatedFor(node);
95
+ }
96
+
97
+ // Serve-time injection. The save token exists only in the bytes sent to the
98
+ // browser: it is ephemeral and stripped on the way back, and nothing durable
99
+ // is ever written into a document.
100
+ async function renderDocument({ actor, node, real }) {
101
+ const stored = await readFileText(real);
102
+ const etag = documentEtag(stored);
103
+
104
+ const isolated = isolationFor(node);
105
+ const injections = {};
106
+
107
+ const canWrite = can(actor, 'write', node, ctx);
108
+ const share = shareFor(actor, node, ['edit']);
109
+ if (saveTokens && canWrite && (isolated || share)) {
110
+ const token = await saveTokens.mintFor({
111
+ actor,
112
+ node,
113
+ shareHash: share?.tokenHash ?? null,
114
+ grantKind: share?.grantKind ?? 'edit',
115
+ });
116
+ if (token) {
117
+ // The spec's spelling, and the one clayjs prefers. htmlclay injects
118
+ // `htmlclaytoken` on its own host; this one does not need to speak both.
119
+ injections.savetoken = token;
120
+ }
121
+ }
122
+
123
+ let body = stored;
124
+ if (Object.keys(injections).length) body = setRootAttrs(stored, injections);
125
+ return { body, etag, stored, node, isolated, hasToken: !!injections.savetoken };
126
+ }
127
+
128
+ return {
129
+ ctx,
130
+ locate,
131
+ identify,
132
+ resolveForRead,
133
+ renderDocument,
134
+ isolationFor,
135
+ parseRealm,
136
+ };
137
+ }
@@ -0,0 +1,20 @@
1
+ import { documentEtag } from '../spec/wire.js';
2
+
3
+ // Whole-file drift detection (§11.4), layered UNDER If-Match.
4
+ //
5
+ // No shipping client sends If-Match, so `conditional` alone protects nobody
6
+ // today. This is htmlclay's protection instead: the host remembers the etag of
7
+ // the bytes it last wrote, and if the file on disk no longer matches, something
8
+ // else changed it. The save still lands (last-write-wins is the core rule), but
9
+ // the bytes about to be clobbered are published as a version FIRST and the
10
+ // response carries msgType:'warning', which clayjs already renders as a distinct
11
+ // toast. Zero client changes.
12
+
13
+ export const DRIFT_MESSAGE =
14
+ 'This file changed outside this tab. Your version was saved; the previous one is in Versions.';
15
+
16
+ export function detectDrift({ storedBytes, lastKnownEtag }) {
17
+ if (!lastKnownEtag) return 'unknown';
18
+ if (storedBytes == null) return 'unknown';
19
+ return documentEtag(storedBytes) === lastKnownEtag ? 'match' : 'drift';
20
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { createHost, HOST_DEFAULTS } from './create-host.js';
2
+ export { createSqliteStore } from './store/sqlite.js';
3
+ export { inspectInstallation } from './inspect.js';
4
+ export { none } from './auth/none.js';
5
+ export { can, ACTIONS, GUEST } from './auth/can.js';
6
+ export { createPaths, PathError, validateSegments, decodeOnce, isDocumentPath } from './paths.js';
7
+ export { documentEtag, ifMatchSatisfied, documentUrlHeader, isJsonContentType, resolveDocumentHref } from './spec/wire.js';
8
+ export { hostMeta, SPEC_VERSION } from './spec/meta.js';
9
+ export { HostError, CODES } from './spec/codes.js';
10
+ export { formatHtml, hasHtmlRoot } from './documents/format-html.js';
11
+ export { getRootAttr, injectRootAttrs, stripRootAttrs, scanRootTag } from './documents/root-attrs.js';
12
+ export { ulid, token } from './util/id.js';
package/src/inspect.js ADDED
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'upath';
3
+ import { createPaths } from './paths.js';
4
+ import { createSqliteStore } from './store/sqlite.js';
5
+ import { createRecovery } from './recovery.js';
6
+ import { statIfExists } from './util/fsx.js';
7
+
8
+ // Look at an installation without touching it.
9
+ //
10
+ // `makerclay doctor` is documented as read-only and used to boot a whole host to
11
+ // produce its report, which meant asking for a health check FINISHED interrupted
12
+ // operations, could rebuild the entire database from disk, reconciled metadata,
13
+ // reinstalled the shipped UI files, scanned every realm and left file watchers
14
+ // running. A report that repairs is not a report, and the one moment you most
15
+ // want to look before touching anything is the moment something has gone wrong.
16
+ //
17
+ // So this reads and reports, and the read-only promise is enforced rather than
18
+ // intended:
19
+ //
20
+ // - the database is opened with SQLite's own readOnly connection, which
21
+ // refuses writes at the driver. The normal open MIGRATES, which is a schema
22
+ // change no health check should make.
23
+ // - paths are built with `create: false`, so looking at a directory that is
24
+ // not an installation does not create one.
25
+ // - the lock file is never acquired, so this works against a RUNNING server.
26
+ // That is the case where booting a second host could not work at all.
27
+ //
28
+ // The verdict comes from `recovery.assess()`, the same function boot calls, so
29
+ // what this predicts and what boot would do cannot drift. assess() only reads;
30
+ // `rebuild()` is the half that writes, and is never called here.
31
+
32
+ async function countFiles(dir) {
33
+ let files = 0;
34
+ let directories = 0;
35
+ async function walk(current) {
36
+ let entries;
37
+ try {
38
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
39
+ } catch {
40
+ return;
41
+ }
42
+ for (const entry of entries) {
43
+ if (entry.isDirectory()) {
44
+ directories += 1;
45
+ await walk(path.join(current, entry.name));
46
+ } else {
47
+ files += 1;
48
+ }
49
+ }
50
+ }
51
+ await walk(dir);
52
+ return { files, directories };
53
+ }
54
+
55
+ export async function inspectInstallation({ data, layout = 'clean', logger = console } = {}) {
56
+ const dataDir = path.resolve(String(data));
57
+ const report = {
58
+ dataDir,
59
+ exists: false,
60
+ running: false,
61
+ files: 0,
62
+ directories: 0,
63
+ nodes: null,
64
+ trashed: null,
65
+ pendingOperations: [],
66
+ missing: [],
67
+ integrity: null,
68
+ state: null,
69
+ database: 'absent',
70
+ warnings: [],
71
+ };
72
+
73
+ if (!(await statIfExists(dataDir))) return report;
74
+ report.exists = true;
75
+
76
+ const paths = createPaths({ data: dataDir, layout, create: false });
77
+
78
+ // Presence of the lock file, not ownership of it. Taking the lock is what the
79
+ // old implementation did, and it is why doctor refused to run against a live
80
+ // server; the whole point of a read-only report is that it can.
81
+ const lock = await statIfExists(paths.files.lock);
82
+ report.running = !!lock;
83
+
84
+ const counts = await countFiles(paths.dirs.files);
85
+ report.files = counts.files;
86
+ report.directories = counts.directories;
87
+
88
+ const dbFile = await statIfExists(paths.files.db);
89
+ if (!dbFile) {
90
+ report.database = 'absent';
91
+ } else {
92
+ let store = null;
93
+ try {
94
+ store = createSqliteStore({ file: paths.files.db });
95
+ store.open({ paths, readOnly: true });
96
+ report.database = 'open';
97
+
98
+ const integrity = store.integrityCheck();
99
+ report.integrity = integrity.length === 1 && integrity[0] === 'ok' ? 'ok' : integrity.join('; ');
100
+
101
+ report.nodes = store.listAllNodes().length;
102
+ report.trashed = store.listTrash().length;
103
+ report.pendingOperations = store.listOperations();
104
+
105
+ // A row whose file is gone. Read with the store's own path resolution so a
106
+ // tenant realm is looked for where it actually lives.
107
+ for (const node of store.listAllNodes()) {
108
+ if (node.kind === 'dir') continue;
109
+ const real = path.join(paths.realmRoot(node.owner || ''), node.path);
110
+ if (!(await statIfExists(real))) report.missing.push({ path: node.path, owner: node.owner || '' });
111
+ }
112
+
113
+ // nodes and versions are unused by assess(); it reads the store, the
114
+ // durable records and two directories. Passing them would mean building
115
+ // machinery this must not run.
116
+ const recovery = createRecovery({
117
+ paths, store, nodes: null, versions: null, clock: { now: () => Date.now() }, logger,
118
+ });
119
+ report.state = await recovery.assess();
120
+ } catch (error) {
121
+ report.database = 'unreadable';
122
+ report.warnings.push(
123
+ `The database could not be read: ${error?.message || error}. `
124
+ + 'Nothing was changed. The files and their history are on disk and do not depend on it.',
125
+ );
126
+ } finally {
127
+ try {
128
+ store?.close();
129
+ } catch {
130
+ // Closing a connection that never opened is not a finding.
131
+ }
132
+ }
133
+ }
134
+
135
+ return report;
136
+ }
@@ -0,0 +1,59 @@
1
+ import { HostError, statusForCode } from './spec/codes.js';
2
+
3
+ // Invariant 6: every response on a save-lane route is a JSON object, on every
4
+ // status. clayjs calls res.json() unconditionally, so an HTML error page from
5
+ // the framework turns a clean 413 into "Save failed" with no explanation.
6
+ //
7
+ // One formatter covers two sources at once: thrown HostErrors, which now
8
+ // includes PathError, and body-parser failures (which arrive as errors with a
9
+ // `type`). PathError used to need a branch of its own here that reconstructed
10
+ // its code from its status, because it carried a status and usually no code.
11
+
12
+ export function sendJson(res, status, body) {
13
+ if (res.headersSent) return res;
14
+ return res.status(status).json(body);
15
+ }
16
+
17
+ export function jsonError(res, code, msg, extra = {}) {
18
+ return sendJson(res, statusForCode(code), { msg, msgType: 'error', code, ...extra });
19
+ }
20
+
21
+ const BODY_PARSER_CODES = {
22
+ 'entity.too.large': 'too-large',
23
+ 'entity.parse.failed': 'bad-request',
24
+ 'entity.verify.failed': 'bad-request',
25
+ 'request.aborted': 'bad-request',
26
+ 'request.size.invalid': 'bad-request',
27
+ 'encoding.unsupported': 'unsupported-media-type',
28
+ 'charset.unsupported': 'unsupported-media-type',
29
+ };
30
+
31
+ function errorToJson(error) {
32
+ if (error instanceof HostError) {
33
+ return { status: error.status, body: { msg: error.message, msgType: 'error', code: error.code } };
34
+ }
35
+ const parserCode = BODY_PARSER_CODES[error?.type];
36
+ if (parserCode) {
37
+ return {
38
+ status: statusForCode(parserCode),
39
+ body: { msg: error.message || 'Bad request', msgType: 'error', code: parserCode },
40
+ };
41
+ }
42
+ if (error?.status || error?.statusCode) {
43
+ const status = error.status || error.statusCode;
44
+ return { status, body: { msg: error.message || 'Request failed', msgType: 'error', code: 'bad-request' } };
45
+ }
46
+ return {
47
+ status: 500,
48
+ body: { msg: 'Internal server error', msgType: 'error', code: 'server-error' },
49
+ };
50
+ }
51
+
52
+ export function jsonErrorHandler({ logger = console } = {}) {
53
+ return function handleJsonError(error, req, res, next) {
54
+ if (res.headersSent) return next(error);
55
+ const { status, body } = errorToJson(error);
56
+ if (status >= 500) logger.error?.('[makerclay]', req.method, req.path, error);
57
+ return sendJson(res, status, body);
58
+ };
59
+ }
@@ -0,0 +1,75 @@
1
+ import pkg from 'livesync-hyperclay';
2
+
3
+ const { liveSync } = pkg;
4
+
5
+ // livesync-hyperclay wiring. Channel keys are namespaced
6
+ // `${hostId}:${owner}:${path}` from day one: hyperclay-local's bare-name keys
7
+ // cross-talk between roots, and two hosts in one process (which is how the test
8
+ // suite stays fast) would share every channel.
9
+
10
+ export function createLiveSync({ hostId, config, logger = console }) {
11
+ const keepaliveMs = config.limits?.sseKeepaliveMs ?? 30_000;
12
+ const open = new Set();
13
+
14
+ function channelKey(owner, relPath) {
15
+ return `${hostId}:${owner || ''}:${relPath}`;
16
+ }
17
+
18
+ function subscribe(owner, relPath, res, { lane = 'live' } = {}) {
19
+ const key = channelKey(owner, relPath);
20
+ res.setHeader('Content-Type', 'text/event-stream');
21
+ res.setHeader('Cache-Control', 'no-cache');
22
+ res.setHeader('Connection', 'keep-alive');
23
+ res.setHeader('X-Accel-Buffering', 'no');
24
+ res.flushHeaders?.();
25
+
26
+ liveSync.subscribe(key, res, { lane });
27
+ const keepAlive = setInterval(() => {
28
+ try {
29
+ res.write(': ping\n\n');
30
+ } catch {
31
+ clearInterval(keepAlive);
32
+ }
33
+ }, keepaliveMs);
34
+ keepAlive.unref?.();
35
+ const entry = { key, res, keepAlive };
36
+ open.add(entry);
37
+
38
+ const cleanup = () => {
39
+ clearInterval(keepAlive);
40
+ liveSync.unsubscribe(key, res);
41
+ open.delete(entry);
42
+ };
43
+ res.req?.on('close', cleanup);
44
+ res.write(': connected\n\n');
45
+ return cleanup;
46
+ }
47
+
48
+ function broadcast(owner, relPath, payload, options) {
49
+ return liveSync.broadcast(channelKey(owner, relPath), payload, options);
50
+ }
51
+
52
+ function notify(owner, relPath, payload, options) {
53
+ return liveSync.notify(channelKey(owner, relPath), payload, options);
54
+ }
55
+
56
+ // Revoking a share must close its open streams within one keepalive.
57
+ function closeChannel(owner, relPath) {
58
+ return liveSync.closeChannel(channelKey(owner, relPath));
59
+ }
60
+
61
+ function closeAll() {
62
+ for (const entry of [...open]) {
63
+ clearInterval(entry.keepAlive);
64
+ liveSync.unsubscribe(entry.key, entry.res);
65
+ try {
66
+ entry.res.end();
67
+ } catch {
68
+ logger.debug?.('[livesync] stream already closed');
69
+ }
70
+ open.delete(entry);
71
+ }
72
+ }
73
+
74
+ return { channelKey, subscribe, broadcast, notify, closeChannel, closeAll, stats: () => liveSync.getStats() };
75
+ }
@@ -0,0 +1,14 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
4
+
5
+ // A ULID-shaped identity derived from (owner, path). Used only when the host
6
+ // runs with `store: null`: there are no rows to hold a minted id, so version
7
+ // directories key off a stable hash of the path instead. Renames lose history in
8
+ // that configuration, which is exactly what "no metadata features" means.
9
+ export function stableId(owner, relPath) {
10
+ const digest = crypto.createHash('sha256').update(`${owner ?? ''}\0${relPath}`).digest();
11
+ let out = '';
12
+ for (let i = 0; i < 26; i++) out += ENCODING[digest[i] % 32];
13
+ return out;
14
+ }
@@ -0,0 +1,57 @@
1
+ import path from 'upath';
2
+ import { statIfExists } from '../util/fsx.js';
3
+
4
+ // What may be typed into a name box. Behavior port of hyperclay's
5
+ // is-valid-name.js + shared/validation.js, with the platform's slug rules
6
+ // dropped: a makerclay name is a real filename, not a subdomain label, so it
7
+ // keeps its extension, its case and its spaces.
8
+
9
+ // Reserved on Windows even with an extension, and a makerclay export is meant to
10
+ // unzip anywhere. Cheap to refuse at the door, expensive to discover later.
11
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
12
+
13
+ export function nameError(name) {
14
+ if (typeof name !== 'string' || name.length === 0) return 'A name is required.';
15
+ if (name.length > 255) return 'That name is too long.';
16
+ if (name === '.' || name === '..') return 'That name is reserved.';
17
+ if (name.startsWith('.')) return 'Names cannot start with a dot.';
18
+ if (/[/\\]/.test(name)) return 'Names cannot contain slashes.';
19
+ // eslint-disable-next-line no-control-regex
20
+ if (/[\0-\x1f\x7f]/.test(name)) return 'That name contains control characters.';
21
+ if (/[<>:"|?*]/.test(name)) return 'That name contains a character some filesystems refuse.';
22
+ if (name.endsWith(' ') || name.endsWith('.')) return 'Names cannot end with a space or a dot.';
23
+ if (WINDOWS_RESERVED.test(name)) return 'That name is reserved on some systems.';
24
+ return null;
25
+ }
26
+
27
+ export function splitName(name) {
28
+ const match = /^(.*?)(\.[A-Za-z0-9]{1,12}(?:\.[A-Za-z0-9]{1,12})?)$/.exec(name);
29
+ if (!match || !match[1]) return { stem: name, ext: '' };
30
+ return { stem: match[1], ext: match[2] };
31
+ }
32
+
33
+ // A free name in `dir`, derived from `name`. Uniqueness is decided by what is
34
+ // actually on disk under the full filename, which is the fix for the platform
35
+ // bug where a folder named `todo` blocked a document named `todo`: here the
36
+ // document is `todo.html` and the two coexist, exactly as the filesystem allows.
37
+ export async function uniqueName(dir, name, { skip = null } = {}) {
38
+ const { stem, ext } = splitName(name);
39
+ for (let n = 0; n < 1000; n++) {
40
+ const candidate = n === 0 ? name : `${stem}-${n + 1}${ext}`;
41
+ const full = path.join(dir, candidate);
42
+ if (full === skip) return candidate;
43
+ if (!(await statIfExists(full))) return candidate;
44
+ }
45
+ throw new Error(`No free name for ${name} in ${dir}`);
46
+ }
47
+
48
+ // The folder a document's uploads land in: `assets-<stem>/` beside the document.
49
+ // One convention, shared by the upload lane that creates it and the tree
50
+ // operations that have to carry it, because a document and its files are a pair
51
+ // and two spellings of the pairing rule would drift.
52
+ export function assetsDirFor(relPath) {
53
+ const dir = relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '';
54
+ const base = relPath.slice(relPath.lastIndexOf('/') + 1);
55
+ const folder = `assets-${base.replace(/\.[^.]*$/, '')}`;
56
+ return dir ? `${dir}/${folder}` : folder;
57
+ }