@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
package/src/paths.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { realpath, statIfExists } from './util/fsx.js';
|
|
4
|
+
import { HostError } from './spec/codes.js';
|
|
5
|
+
|
|
6
|
+
// Every disk path in the core resolves through this module and nowhere else.
|
|
7
|
+
// It is the entire difference between the `clean` layout (makerclay: state in
|
|
8
|
+
// its own data dir) and the future `inline` layout (hyperclay-local: a user
|
|
9
|
+
// folder with state nested inside it), so v2 convergence is a paths change
|
|
10
|
+
// rather than a migration.
|
|
11
|
+
|
|
12
|
+
// One error vocabulary. This used to be its own class with its own constructor,
|
|
13
|
+
// `PathError(status, message, code)`, sitting beside `HostError(code, message,
|
|
14
|
+
// status)` — the same three values in the opposite order, so passing a status
|
|
15
|
+
// where a code belonged was a silent mistake with a plausible-looking result.
|
|
16
|
+
//
|
|
17
|
+
// It stays a distinct subclass rather than disappearing into HostError because
|
|
18
|
+
// two callers genuinely need to know that path resolution is what refused,
|
|
19
|
+
// rather than something deeper in the same call: `serve.locate` continues to the
|
|
20
|
+
// next candidate on a miss, and `requests.resolveTarget` turns a miss into null
|
|
21
|
+
// while a HostError from the gate must still reach the client.
|
|
22
|
+
export class PathError extends HostError {
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(code, message);
|
|
25
|
+
this.name = 'PathError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const notFound = (message = 'File not found') => new PathError('not-found', message);
|
|
30
|
+
const invalidPath = (message = 'Invalid path') => new PathError('bad-request', message);
|
|
31
|
+
|
|
32
|
+
const OWNER_NAME = /^[a-z0-9_-]{2,32}$/;
|
|
33
|
+
|
|
34
|
+
// Decode exactly once. Express never decodes req.path, so without this a file
|
|
35
|
+
// with a space or any non-ASCII name is unreachable; decoding twice would let
|
|
36
|
+
// `%252e%252e` walk out of the tree.
|
|
37
|
+
export function decodeOnce(urlPath) {
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(urlPath);
|
|
40
|
+
} catch {
|
|
41
|
+
throw invalidPath('Malformed URL encoding');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reject traversal, NUL and backslash outright; hide dotfiles behind a 404 so
|
|
46
|
+
// their existence never leaks. A leading-dot refusal is also what makes an
|
|
47
|
+
// `inline` state directory structurally unservable later.
|
|
48
|
+
export function validateSegments(relPath, { maxDepth = 5 } = {}) {
|
|
49
|
+
if (typeof relPath !== 'string' || relPath.length === 0) {
|
|
50
|
+
throw invalidPath();
|
|
51
|
+
}
|
|
52
|
+
if (relPath.includes('\0') || relPath.includes('\\')) {
|
|
53
|
+
throw invalidPath();
|
|
54
|
+
}
|
|
55
|
+
if (path.isAbsolute(relPath)) {
|
|
56
|
+
throw invalidPath();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const segments = relPath.split('/').filter((segment) => segment.length > 0);
|
|
60
|
+
if (segments.length === 0) throw invalidPath();
|
|
61
|
+
if (segments.length > maxDepth + 1) throw invalidPath('Path too deep');
|
|
62
|
+
|
|
63
|
+
for (const segment of segments) {
|
|
64
|
+
if (segment === '.' || segment === '..') throw invalidPath();
|
|
65
|
+
if (segment.startsWith('.')) throw notFound();
|
|
66
|
+
if (segment.length > 255) throw invalidPath();
|
|
67
|
+
}
|
|
68
|
+
return segments;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isDocumentPath(relPath) {
|
|
72
|
+
return /\.(html|htmlclay)$/i.test(relPath);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A node row whose path begins with a dot segment is a metadata anchor, not a
|
|
76
|
+
// file in the served tree (`.records/<slug>/<key>.json` is the only one in v1).
|
|
77
|
+
// validateSegments 404s the path, the scanner's walk skips it, and no listing
|
|
78
|
+
// shows it, so this is the one predicate that keeps those three honest.
|
|
79
|
+
export function isReservedNodePath(relPath) {
|
|
80
|
+
return String(relPath || '').startsWith('.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isContained(base, target) {
|
|
84
|
+
if (target === base) return true;
|
|
85
|
+
return target.startsWith(base.endsWith('/') ? base : `${base}/`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Walk up until realpath succeeds, then re-append the not-yet-existing tail.
|
|
89
|
+
// This is what makes write resolution work for a file about to be created.
|
|
90
|
+
async function realpathNearestParent(dir) {
|
|
91
|
+
const missing = [];
|
|
92
|
+
let current = dir;
|
|
93
|
+
for (;;) {
|
|
94
|
+
try {
|
|
95
|
+
const real = await realpath(current);
|
|
96
|
+
return missing.length ? path.join(real, ...missing.reverse()) : real;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
|
|
99
|
+
throw new PathError('forbidden', 'Access denied');
|
|
100
|
+
}
|
|
101
|
+
const parent = path.dirname(current);
|
|
102
|
+
if (parent === current) throw new PathError('forbidden', 'Access denied');
|
|
103
|
+
missing.push(path.basename(current));
|
|
104
|
+
current = parent;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Synchronous on purpose: this runs once at boot, before a single request, and
|
|
110
|
+
// keeping it sync is what lets createHost() stay a plain constructor.
|
|
111
|
+
// `create: false` is for inspection, which must not bring an installation into
|
|
112
|
+
// existence just by looking at it. Everything else here is pure path arithmetic;
|
|
113
|
+
// this one mkdir was the only write, and a caller that passes false is promising
|
|
114
|
+
// the directory already exists.
|
|
115
|
+
export function createPaths({ data, layout = 'clean', maxFolderDepth = 5, create = true }) {
|
|
116
|
+
if (layout !== 'clean') {
|
|
117
|
+
throw new Error(`Unsupported layout: ${layout}. Only 'clean' ships in v1.`);
|
|
118
|
+
}
|
|
119
|
+
if (!data || typeof data !== 'string') {
|
|
120
|
+
throw new Error('createHost requires a data directory');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const root = path.resolve(data);
|
|
124
|
+
if (create) fs.mkdirSync(path.join(root, 'files'), { recursive: true });
|
|
125
|
+
const dataReal = path.resolve(fs.realpathSync(root));
|
|
126
|
+
|
|
127
|
+
const dirs = {
|
|
128
|
+
data: dataReal,
|
|
129
|
+
files: path.join(dataReal, 'files'),
|
|
130
|
+
tenants: path.join(dataReal, 'tenants'),
|
|
131
|
+
versions: path.join(dataReal, 'versions'),
|
|
132
|
+
trash: path.join(dataReal, 'trash'),
|
|
133
|
+
derived: path.join(dataReal, 'derived'),
|
|
134
|
+
ui: path.join(dataReal, 'ui'),
|
|
135
|
+
records: path.join(dataReal, 'records'),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const files = {
|
|
139
|
+
db: path.join(dataReal, 'makerclay.db'),
|
|
140
|
+
lock: path.join(dataReal, 'makerclay.lock'),
|
|
141
|
+
config: path.join(dataReal, 'config.json'),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function realmRoot(owner = '') {
|
|
145
|
+
if (!owner) return dirs.files;
|
|
146
|
+
if (!OWNER_NAME.test(owner)) throw notFound();
|
|
147
|
+
return path.join(dirs.tenants, owner);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reject any symlink inside a managed tree. A network server must not follow
|
|
151
|
+
// a user symlink into versions/, the db, or the host filesystem; the desktop
|
|
152
|
+
// consent registry hyperclay-local uses does not come along.
|
|
153
|
+
function assertNoSymlinkTraversal(base, lexical, real) {
|
|
154
|
+
if (real !== lexical || !isContained(base, real)) {
|
|
155
|
+
throw notFound();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Canonical read resolution. Returns the real absolute path of an existing
|
|
160
|
+
// entry, or throws a PathError when it is absent, a symlink, or outside the
|
|
161
|
+
// realm.
|
|
162
|
+
async function resolveRead(owner, relPath) {
|
|
163
|
+
const base = realmRoot(owner);
|
|
164
|
+
validateSegments(relPath, { maxDepth: maxFolderDepth });
|
|
165
|
+
const lexical = path.resolve(path.join(base, relPath));
|
|
166
|
+
if (!isContained(base, lexical)) throw notFound();
|
|
167
|
+
|
|
168
|
+
let real;
|
|
169
|
+
try {
|
|
170
|
+
real = await realpath(lexical);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
173
|
+
throw notFound();
|
|
174
|
+
}
|
|
175
|
+
throw new PathError('forbidden', 'Access denied');
|
|
176
|
+
}
|
|
177
|
+
assertNoSymlinkTraversal(base, lexical, real);
|
|
178
|
+
return real;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// What a reader almost always wants: null instead of a throw. Every caller of
|
|
182
|
+
// `resolveRead` outside a writer was wrapping it in a try/catch that tested
|
|
183
|
+
// `instanceof PathError` and returned null, which put the decision about which
|
|
184
|
+
// failures are ordinary in the caller, where it has to be re-derived and can
|
|
185
|
+
// silently widen. It belongs here, where "the path does not resolve" is known
|
|
186
|
+
// precisely.
|
|
187
|
+
//
|
|
188
|
+
// Anything that is not a PathError still throws: a permissions failure or a
|
|
189
|
+
// full disk is not a missing file, and answering 404 to one is how a real
|
|
190
|
+
// fault gets mistaken for a typo.
|
|
191
|
+
async function tryResolveRead(owner, relPath) {
|
|
192
|
+
try {
|
|
193
|
+
return await resolveRead(owner, relPath);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error instanceof PathError) return null;
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Canonical create/write resolution. The returned path is both the file to
|
|
201
|
+
// write and the lock key; every writer uses exactly this.
|
|
202
|
+
async function resolveWrite(owner, relPath) {
|
|
203
|
+
const base = realmRoot(owner);
|
|
204
|
+
validateSegments(relPath, { maxDepth: maxFolderDepth });
|
|
205
|
+
const lexical = path.resolve(path.join(base, relPath));
|
|
206
|
+
if (!isContained(base, lexical)) throw notFound();
|
|
207
|
+
|
|
208
|
+
const existing = await statIfExists(lexical);
|
|
209
|
+
if (existing) {
|
|
210
|
+
const real = await realpath(lexical);
|
|
211
|
+
assertNoSymlinkTraversal(base, lexical, real);
|
|
212
|
+
return real;
|
|
213
|
+
}
|
|
214
|
+
const parentReal = await realpathNearestParent(path.dirname(lexical));
|
|
215
|
+
const real = path.join(parentReal, path.basename(lexical));
|
|
216
|
+
assertNoSymlinkTraversal(base, lexical, real);
|
|
217
|
+
return real;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// versions/<slug>--<id8>/ — keyed by identity, named for humans. The slug is
|
|
221
|
+
// the current basename (display only, renamed in place on file rename); id8
|
|
222
|
+
// is the real key.
|
|
223
|
+
//
|
|
224
|
+
// id8 is the LAST eight characters of the ULID, not the first. The first ten
|
|
225
|
+
// are the mint TIMESTAMP, so an eight-character prefix has no randomness in it
|
|
226
|
+
// at all and every pair of documents created in the same second collides. That
|
|
227
|
+
// is not a rare race: a signup forks a document milliseconds after the one it
|
|
228
|
+
// copied, and the two then shared a history folder, which let the owner list
|
|
229
|
+
// and restore a tenant's bytes. The tail is 40 bits of randomness.
|
|
230
|
+
function idKey(nodeId) {
|
|
231
|
+
return String(nodeId).slice(-8);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function versionsDir(nodeId, slug) {
|
|
235
|
+
const safeSlug = String(slug || 'document')
|
|
236
|
+
.replace(/\.(html|htmlclay)$/i, '')
|
|
237
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
238
|
+
.replace(/^[.-]+/, '')
|
|
239
|
+
.slice(0, 64) || 'document';
|
|
240
|
+
return path.join(dirs.versions, `${safeSlug}--${idKey(nodeId)}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function versionsDirPrefix(nodeId) {
|
|
244
|
+
return `--${idKey(nodeId)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function trashPath(nodeId, originalName) {
|
|
248
|
+
const safe = String(originalName).replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 96);
|
|
249
|
+
return path.join(dirs.trash, `${nodeId}__${safe}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Collection records live OUTSIDE files/, and that placement is the whole
|
|
253
|
+
// privacy model rather than a filing preference. A record is a stranger's
|
|
254
|
+
// submission; inside the served tree it would be one un-set `private` flag
|
|
255
|
+
// away from public, and a lost database would set it back to public. Out
|
|
256
|
+
// here, the collection API is the only door, and it is the only thing that
|
|
257
|
+
// checks record codes.
|
|
258
|
+
//
|
|
259
|
+
// The record's node row (which carries its identity, its record_auth and its
|
|
260
|
+
// tombstone) uses the reserved `.records/` path prefix. No real file can
|
|
261
|
+
// collide with it: validateSegments 404s every dot-leading segment.
|
|
262
|
+
function recordsDir(slug) {
|
|
263
|
+
return path.join(dirs.records, String(slug).replace(/[^a-z0-9_-]/gi, ''));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function recordPath(slug, filename) {
|
|
267
|
+
return path.join(recordsDir(slug), `${String(filename).replace(/[^a-z0-9._@-]/gi, '')}.json`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function recordNodePath(slug, filename) {
|
|
271
|
+
return `.records/${String(slug).replace(/[^a-z0-9_-]/gi, '')}/${String(filename).replace(/[^a-z0-9._@-]/gi, '')}.json`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function derivedApiPath(owner, relPath) {
|
|
275
|
+
const realm = owner ? path.join('tenants', owner) : 'files';
|
|
276
|
+
return path.join(dirs.derived, 'api', realm, `${relPath}.json`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function derivedTailwindPath(owner, name) {
|
|
280
|
+
const realm = owner ? path.join('tenants', owner) : 'files';
|
|
281
|
+
return path.join(dirs.derived, 'tailwind', realm, `${name}.css`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
layout,
|
|
286
|
+
maxFolderDepth,
|
|
287
|
+
dirs,
|
|
288
|
+
files,
|
|
289
|
+
realmRoot,
|
|
290
|
+
resolveRead,
|
|
291
|
+
tryResolveRead,
|
|
292
|
+
resolveWrite,
|
|
293
|
+
versionsDir,
|
|
294
|
+
versionsDirPrefix,
|
|
295
|
+
trashPath,
|
|
296
|
+
recordsDir,
|
|
297
|
+
recordPath,
|
|
298
|
+
recordNodePath,
|
|
299
|
+
derivedApiPath,
|
|
300
|
+
derivedTailwindPath,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { exists, readdirIfExists, readFileTextIfExists, statIfExists } from '../util/fsx.js';
|
|
3
|
+
import { token as mintToken } from '../util/id.js';
|
|
4
|
+
|
|
5
|
+
// Deciding whether the store can be trusted, and rebuilding it when it cannot.
|
|
6
|
+
//
|
|
7
|
+
// The store is an OVERLAY. The durable records under versions/<slug>--<id8>/
|
|
8
|
+
// are the source of truth, so losing the database must not lose privacy or
|
|
9
|
+
// tenancy (§6.3). With the db gone and the records present, the overlay is
|
|
10
|
+
// rebuilt from them before the first request. With neither, but with evidence
|
|
11
|
+
// of prior state on disk, the host refuses to serve a freshly public tree and
|
|
12
|
+
// asks for recovery mode instead.
|
|
13
|
+
//
|
|
14
|
+
// Never guess. A clear degraded mode beats a half-working dashboard sitting on
|
|
15
|
+
// top of uncertain privacy metadata.
|
|
16
|
+
|
|
17
|
+
export function createOverlay({ paths, store, nodes, clock, logger = console }) {
|
|
18
|
+
async function hasAny(dir) {
|
|
19
|
+
const names = (await readdirIfExists(dir)).filter((name) => !name.startsWith('.'));
|
|
20
|
+
return names.length > 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One pass over versions/, which is the same walk the attic will need. The
|
|
24
|
+
// directory name carries only the last eight characters of the id, so the
|
|
25
|
+
// record inside is what is authoritative, never the folder name.
|
|
26
|
+
async function readAllMeta() {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const name of await readdirIfExists(paths.dirs.versions)) {
|
|
29
|
+
const raw = await readFileTextIfExists(path.join(paths.dirs.versions, name, 'meta.json'));
|
|
30
|
+
if (!raw) continue;
|
|
31
|
+
try {
|
|
32
|
+
const record = JSON.parse(raw);
|
|
33
|
+
if (record?.id && typeof record.path === 'string') found.push(record);
|
|
34
|
+
} catch {
|
|
35
|
+
logger.warn?.(`[makerclay] unreadable durable record in versions/${name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Written once, at the end of the first boot that reaches a normal state. It
|
|
42
|
+
// is what tells "this database is ours" apart from "this database is new",
|
|
43
|
+
// and row COUNT cannot: a host whose only document was purged has zero rows
|
|
44
|
+
// and a perfectly healthy db, which would otherwise read as catastrophic loss.
|
|
45
|
+
const INSTALLED = 'installed';
|
|
46
|
+
|
|
47
|
+
function markInstalled() {
|
|
48
|
+
if (store && !store.getKv(INSTALLED)) store.setKv(INSTALLED, String(clock.now()));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A database that opens and answers is not the same as a database that is
|
|
52
|
+
// sound: SQLite will read `kv` happily out of a file whose other pages are
|
|
53
|
+
// corrupt. §18.3 names a failed integrity check as a recovery trigger, and it
|
|
54
|
+
// has to be checked BEFORE the installed key, because the installed key is one
|
|
55
|
+
// of the things a corrupt file can hand back.
|
|
56
|
+
function integrity() {
|
|
57
|
+
if (!store.integrityCheck) return 'ok';
|
|
58
|
+
try {
|
|
59
|
+
const rows = store.integrityCheck();
|
|
60
|
+
return rows.length === 1 && rows[0] === 'ok' ? 'ok' : rows.join('; ');
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return error?.message || String(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `normal` | `rebuild` | `recover`
|
|
67
|
+
async function assess() {
|
|
68
|
+
if (!store) return { state: 'normal', reason: 'no-store' };
|
|
69
|
+
|
|
70
|
+
const sound = integrity();
|
|
71
|
+
if (sound !== 'ok') return { state: 'recover', reason: 'integrity-check-failed', error: sound };
|
|
72
|
+
|
|
73
|
+
if (store.getKv(INSTALLED)) return { state: 'normal', reason: 'installed' };
|
|
74
|
+
|
|
75
|
+
const rows = store.listAllNodes({ includeDeleted: true });
|
|
76
|
+
if (rows.length > 0) return { state: 'normal', reason: 'store-populated' };
|
|
77
|
+
|
|
78
|
+
// Durable records are consulted FIRST, because they are the evidence. Asking
|
|
79
|
+
// about versions/ or tenants/ first gets it backwards: a document the owner
|
|
80
|
+
// marked private but never saved has a record and no versions, so a
|
|
81
|
+
// versions-first check reads "fresh install" and serves it to the world.
|
|
82
|
+
const records = await readAllMeta();
|
|
83
|
+
if (records.length > 0) return { state: 'rebuild', reason: 'records-present', records: records.length };
|
|
84
|
+
|
|
85
|
+
// No rows, no records. Anything else on disk means there WAS state here and
|
|
86
|
+
// nothing left records who could read what; serving now would publish
|
|
87
|
+
// whatever used to be private.
|
|
88
|
+
const priorState = (await hasAny(paths.dirs.versions)) || (await hasAny(paths.dirs.tenants));
|
|
89
|
+
if (priorState) return { state: 'recover', reason: 'state-without-metadata' };
|
|
90
|
+
|
|
91
|
+
return { state: 'normal', reason: 'fresh' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Runs on EVERY boot, not only into an empty database. The likeliest real
|
|
95
|
+
// disaster is not a lost database, it is a RESTORED one: an old backup dropped
|
|
96
|
+
// onto today's files passes every health check, has rows, and says a document
|
|
97
|
+
// is public that was made private after the backup was taken. Neither the
|
|
98
|
+
// journal nor a rowless-file rule ever covered that. This does.
|
|
99
|
+
//
|
|
100
|
+
// Inserting a row for a record that has none is deliberate: it means the serve
|
|
101
|
+
// lane needs no special case at all, because by the time the first request is
|
|
102
|
+
// answered every previously-flagged document already has a correct row. The
|
|
103
|
+
// rows cost nothing new, since a record only exists for a document that was
|
|
104
|
+
// saved or flagged, which is exactly the set that already had rows.
|
|
105
|
+
//
|
|
106
|
+
// Where the two disagree, THE MORE RESTRICTIVE VALUE WINS. Not "the newer one":
|
|
107
|
+
// there is no reliable way to tell which is newer (a restored versions/ can be
|
|
108
|
+
// older than the database just as easily as the reverse), and guessing wrong in
|
|
109
|
+
// the permissive direction publishes something. Guessing wrong in the
|
|
110
|
+
// restrictive direction costs the owner one click.
|
|
111
|
+
async function reconcileMeta() {
|
|
112
|
+
if (!store) return { records: 0, inserted: 0, tightened: 0, skipped: 0 };
|
|
113
|
+
const records = await readAllMeta();
|
|
114
|
+
let inserted = 0;
|
|
115
|
+
let tightened = 0;
|
|
116
|
+
let skipped = 0;
|
|
117
|
+
|
|
118
|
+
for (const record of records) {
|
|
119
|
+
const row = store.getNodeById(record.id);
|
|
120
|
+
// Read before the transaction, because a transaction body has to be
|
|
121
|
+
// synchronous and this is the one question only the disk can answer. Asked
|
|
122
|
+
// only when a trash marker is actually in play, so an ordinary boot still
|
|
123
|
+
// costs nothing per record.
|
|
124
|
+
const fileThere = record.trashed && row && !row.deletedAt
|
|
125
|
+
? await exists(path.join(paths.realmRoot(row.owner || ''), row.path))
|
|
126
|
+
: false;
|
|
127
|
+
|
|
128
|
+
if (!row) {
|
|
129
|
+
// Two records can name the same path: rename a document, create a new
|
|
130
|
+
// one at the old name, then restore a database from before the rename.
|
|
131
|
+
// The live row is the one whose file is actually there, so the loser is
|
|
132
|
+
// not inserted at all. It keeps its history and becomes an attic entry,
|
|
133
|
+
// which is exactly what the attic is for. Inserting it anyway is the one
|
|
134
|
+
// option that is neither open nor closed: SQLite refuses on the live
|
|
135
|
+
// path index, the exception escapes boot(), and the host never serves
|
|
136
|
+
// again.
|
|
137
|
+
const holder = store.getNode(record.owner || '', record.path);
|
|
138
|
+
if (holder && holder.id !== record.id) {
|
|
139
|
+
skipped += 1;
|
|
140
|
+
logger.warn?.(
|
|
141
|
+
`[makerclay] ${record.path} is already held by another document, so the ` +
|
|
142
|
+
'older record was left for the attic rather than restored.',
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// One transaction per record, never one around the whole loop: a single
|
|
149
|
+
// unplaceable record must not discard the privacy tightening every other
|
|
150
|
+
// record just earned.
|
|
151
|
+
try {
|
|
152
|
+
const verdict = store.tx(() => {
|
|
153
|
+
if (!row) {
|
|
154
|
+
const now = clock.now();
|
|
155
|
+
store.insertNode({
|
|
156
|
+
id: record.id,
|
|
157
|
+
owner: record.owner || '',
|
|
158
|
+
path: record.path,
|
|
159
|
+
kind: record.kind || nodes.kindFor(record.path),
|
|
160
|
+
private: !!record.private,
|
|
161
|
+
isolated: record.isolated === undefined ? null : record.isolated,
|
|
162
|
+
signups: !!record.signups,
|
|
163
|
+
// Restoring the digest is also what restores the TRUST marker:
|
|
164
|
+
// `isolatedFor` reads `etag == null` to mean "the owner has never
|
|
165
|
+
// saved these bytes through this host, so sandbox it". A record only
|
|
166
|
+
// exists for a document that was saved or flagged, so a record with
|
|
167
|
+
// a digest is proof of a save and the row should say so. A document
|
|
168
|
+
// that was only ever flagged has no digest in its record either, and
|
|
169
|
+
// correctly stays untrusted.
|
|
170
|
+
etag: record.etag ?? null,
|
|
171
|
+
bytes: record.bytes ?? null,
|
|
172
|
+
deletedAt: record.trashed ? now : null,
|
|
173
|
+
seq: store.nextSeq(),
|
|
174
|
+
createdAt: now,
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
});
|
|
177
|
+
return 'inserted';
|
|
178
|
+
}
|
|
179
|
+
const patch = {};
|
|
180
|
+
// private: true is the restrictive side.
|
|
181
|
+
if (record.private && !row.private) patch.private = true;
|
|
182
|
+
// signups: FALSE is the restrictive side. Losing "signups on" costs the
|
|
183
|
+
// owner a click; losing "signups off" lets strangers open accounts.
|
|
184
|
+
if (record.signups === false && row.signups) patch.signups = false;
|
|
185
|
+
// isolated is tri-state, most restrictive first: true, then null
|
|
186
|
+
// (inherit the host default), then an explicit false.
|
|
187
|
+
const rank = (value) => (value === true ? 2 : value === false ? 0 : 1);
|
|
188
|
+
if (rank(record.isolated) > rank(row.isolated)) patch.isolated = record.isolated;
|
|
189
|
+
// Only when nothing is at the row's path. A trashed document's bytes
|
|
190
|
+
// live under trash/, so tombstoning a row whose file is sitting right
|
|
191
|
+
// where it says it is would leave the row saying trashed with the file
|
|
192
|
+
// outside trash/, and restore would then go looking for bytes that are
|
|
193
|
+
// not there.
|
|
194
|
+
if (record.trashed && !row.deletedAt && !fileThere) patch.deletedAt = clock.now();
|
|
195
|
+
if (!Object.keys(patch).length) return 'unchanged';
|
|
196
|
+
store.updateNode(row.id, { ...patch, updatedAt: clock.now(), seq: store.nextSeq() });
|
|
197
|
+
return 'tightened';
|
|
198
|
+
});
|
|
199
|
+
if (verdict === 'inserted') inserted += 1;
|
|
200
|
+
else if (verdict === 'tightened') tightened += 1;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
skipped += 1;
|
|
203
|
+
logger.warn?.(
|
|
204
|
+
`[makerclay] could not reconcile the record for ${record.path}: ${error?.message || error}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (inserted || tightened || skipped) {
|
|
210
|
+
logger.warn?.(
|
|
211
|
+
`[makerclay] reconciled the overlay against ${records.length} durable record(s): ` +
|
|
212
|
+
`${inserted} row(s) restored, ${tightened} tightened, ${skipped} skipped.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { records: records.length, inserted, tightened, skipped };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Deliberately partial, and honest about it. Sessions, share links, tenant
|
|
219
|
+
// passwords, save tokens and record codes are NOT recoverable and are not
|
|
220
|
+
// faked. What comes back is the state whose loss would fail open, plus enough
|
|
221
|
+
// to reach the bytes again.
|
|
222
|
+
async function rebuild() {
|
|
223
|
+
if (!store) return { nodes: 0, tenants: 0, collections: 0, trashed: 0 };
|
|
224
|
+
const reconciled = await reconcileMeta();
|
|
225
|
+
|
|
226
|
+
// Trashed bytes live at trash/<nodeId>__<name> and the name is
|
|
227
|
+
// self-describing, so a document trashed before its record was written is
|
|
228
|
+
// still reachable. Without this the bytes sit there with no row, the trash
|
|
229
|
+
// view is empty, and restore cannot be reached.
|
|
230
|
+
let trashed = 0;
|
|
231
|
+
const byId = new Map((await readAllMeta()).map((record) => [record.id, record]));
|
|
232
|
+
for (const name of await readdirIfExists(paths.dirs.trash)) {
|
|
233
|
+
const split = name.indexOf('__');
|
|
234
|
+
if (split <= 0) continue;
|
|
235
|
+
const id = name.slice(0, split);
|
|
236
|
+
if (store.getNodeById(id)) continue;
|
|
237
|
+
// trash/ is one flat directory carrying a basename and no realm, so the
|
|
238
|
+
// name alone puts every tenant's document back in the OWNER's trash,
|
|
239
|
+
// restorable into the owner's realm. That is the crossing attic.putBack
|
|
240
|
+
// refuses by hand. The record is the only thing that knows which realm
|
|
241
|
+
// these bytes came out of.
|
|
242
|
+
//
|
|
243
|
+
// Reaching it with a record in hand is rare, and deliberately so:
|
|
244
|
+
// reconcileMeta ran first and inserted a row for every record it could
|
|
245
|
+
// place, so an entry that still has none is one whose record it skipped on
|
|
246
|
+
// a path collision. Rare is not never, and the alternative is a realm
|
|
247
|
+
// crossing, so the lookup stays.
|
|
248
|
+
const record = byId.get(id) || null;
|
|
249
|
+
const owner = (record?.owner ?? '') || '';
|
|
250
|
+
const relPath = record?.path || name.slice(split + 2);
|
|
251
|
+
if (!record) {
|
|
252
|
+
// No record, so the realm is genuinely unknowable. Today's answer is the
|
|
253
|
+
// owner's trash under a bare name, and saying so beats pretending.
|
|
254
|
+
logger.warn?.(
|
|
255
|
+
`[makerclay] trash entry ${name} has no durable record, so it comes back in ` +
|
|
256
|
+
"the owner's trash under its own name.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const now = clock.now();
|
|
260
|
+
// Ask the filesystem, not the filename. A directory has no history folder,
|
|
261
|
+
// so trashing one writes no record at all, and typing it by name brings a
|
|
262
|
+
// folder called `notes` back as a file. `move` then skips rewriting its
|
|
263
|
+
// descendants, their rows keep the old path, and the bytes at the new path
|
|
264
|
+
// resolve to a provisional node that reads as public.
|
|
265
|
+
const entry = await statIfExists(path.join(paths.dirs.trash, name));
|
|
266
|
+
store.insertNode({
|
|
267
|
+
id,
|
|
268
|
+
owner,
|
|
269
|
+
path: relPath,
|
|
270
|
+
kind: record?.kind || nodes.kindFor(relPath, !!entry?.isDirectory()),
|
|
271
|
+
private: true,
|
|
272
|
+
deletedAt: now,
|
|
273
|
+
seq: store.nextSeq(),
|
|
274
|
+
createdAt: now,
|
|
275
|
+
updatedAt: now,
|
|
276
|
+
});
|
|
277
|
+
trashed += 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A collection's submissions are a folder of JSON on disk and the binding
|
|
281
|
+
// from slug to document exists nowhere else, so it comes from the record.
|
|
282
|
+
// The submit token does NOT: it is a credential, so a fresh one is minted
|
|
283
|
+
// and every form pointing here needs the new one, the same deal share links
|
|
284
|
+
// get.
|
|
285
|
+
let collections = 0;
|
|
286
|
+
for (const record of await readAllMeta()) {
|
|
287
|
+
if (!record.slug || !store.getNodeById(record.id)) continue;
|
|
288
|
+
if (store.getCollection(record.id) || store.getCollectionBySlug(record.slug)) continue;
|
|
289
|
+
store.insertCollection({
|
|
290
|
+
nodeId: record.id,
|
|
291
|
+
slug: record.slug,
|
|
292
|
+
submitToken: mintToken(18),
|
|
293
|
+
accepting: true,
|
|
294
|
+
createdAt: clock.now(),
|
|
295
|
+
});
|
|
296
|
+
collections += 1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// A realm directory is proof a tenant existed. The password hash is gone
|
|
300
|
+
// with the database and is not invented: the tenant comes back DISABLED so
|
|
301
|
+
// their tree keeps its owner and stays unreadable until the box owner resets
|
|
302
|
+
// it by hand.
|
|
303
|
+
let tenants = 0;
|
|
304
|
+
for (const name of await readdirIfExists(paths.dirs.tenants)) {
|
|
305
|
+
if (name.startsWith('.') || store.getTenant(name)) continue;
|
|
306
|
+
const now = clock.now();
|
|
307
|
+
store.insertTenant({
|
|
308
|
+
username: name,
|
|
309
|
+
passwordHash: 'disabled:rebuilt-from-disk',
|
|
310
|
+
createdAt: now,
|
|
311
|
+
approvedAt: now,
|
|
312
|
+
});
|
|
313
|
+
store.updateTenant(name, { disabledAt: now });
|
|
314
|
+
tenants += 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const count = store.listAllNodes({ includeDeleted: true }).length;
|
|
318
|
+
logger.warn?.(
|
|
319
|
+
`[makerclay] rebuilt the overlay from disk: ${count} node(s), ${trashed} from trash, ` +
|
|
320
|
+
`${tenants} tenant(s) restored disabled, ${collections} collection(s) with new submit tokens. ` +
|
|
321
|
+
'Share links, sessions, tenant passwords and record codes were not recoverable.',
|
|
322
|
+
);
|
|
323
|
+
return { nodes: count, tenants, collections, trashed, ...reconciled };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { assess, rebuild, reconcileMeta, readAllMeta, markInstalled };
|
|
327
|
+
}
|