@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.
- package/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- 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(/</g, '<')
|
|
112
|
+
.replace(/>/g, '>')
|
|
113
|
+
.replace(/"/g, '"')
|
|
114
|
+
.replace(/'/g, "'")
|
|
115
|
+
.replace(/&/g, '&');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function escapeAttr(value) {
|
|
119
|
+
return String(value)
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|
package/src/livesync.js
ADDED
|
@@ -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
|
+
}
|