@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/boot.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { jsonError } from './json-errors.js';
|
|
2
|
+
import { wrap } from './util/express.js';
|
|
3
|
+
import { mountRecoveryUi } from './recovery-ui.js';
|
|
4
|
+
|
|
5
|
+
// Starting up, and refusing to serve until the host knows it can.
|
|
6
|
+
//
|
|
7
|
+
// Boot work that genuinely cannot be synchronous: finish any interrupted
|
|
8
|
+
// operation, decide whether the overlay is trustworthy, rebuild it if the db is
|
|
9
|
+
// gone, then scan and watch. Every request waits on it, because serving a
|
|
10
|
+
// document before the overlay is reconciled is how a private file gets served
|
|
11
|
+
// public exactly once.
|
|
12
|
+
|
|
13
|
+
// Recovery mode (§18.3). A half-started server sitting on uncertain privacy
|
|
14
|
+
// metadata is worse than a clearly refused one, so this is a wall, not a warning.
|
|
15
|
+
const RECOVERY_MESSAGE =
|
|
16
|
+
'makerclay started in recovery mode and is not serving documents. ' +
|
|
17
|
+
'Run `makerclay doctor` for the repair plan.';
|
|
18
|
+
|
|
19
|
+
export function mountBoot({
|
|
20
|
+
app, config, paths, store, storeError, recovery, scanner, ui, logger,
|
|
21
|
+
}) {
|
|
22
|
+
let state = { state: 'starting' };
|
|
23
|
+
|
|
24
|
+
function tenantRealms() {
|
|
25
|
+
if (!store) return [];
|
|
26
|
+
try {
|
|
27
|
+
return store.listTenants().map((tenant) => tenant.username);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
logger.warn?.('[makerclay] could not list tenants to scan:', error?.message || error);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function boot() {
|
|
35
|
+
if (storeError) {
|
|
36
|
+
state = {
|
|
37
|
+
state: 'recover',
|
|
38
|
+
reason: 'store-open-failed',
|
|
39
|
+
error: storeError.message || String(storeError),
|
|
40
|
+
};
|
|
41
|
+
logger.error?.(`[makerclay] ${RECOVERY_MESSAGE} (${state.reason}): ${state.error}`);
|
|
42
|
+
return state;
|
|
43
|
+
}
|
|
44
|
+
const interrupted = await recovery.recoverOperations();
|
|
45
|
+
let assessment = await recovery.assess();
|
|
46
|
+
|
|
47
|
+
if (assessment.state === 'rebuild') {
|
|
48
|
+
const rebuilt = await recovery.rebuild();
|
|
49
|
+
assessment = { state: 'normal', reason: 'rebuilt-from-disk', ...rebuilt };
|
|
50
|
+
}
|
|
51
|
+
if (interrupted.ambiguous.length) {
|
|
52
|
+
assessment = {
|
|
53
|
+
state: 'recover',
|
|
54
|
+
reason: 'ambiguous-operations',
|
|
55
|
+
ambiguous: interrupted.ambiguous,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
state = { ...assessment, interrupted };
|
|
59
|
+
|
|
60
|
+
if (state.state === 'recover') {
|
|
61
|
+
logger.error?.(`[makerclay] ${RECOVERY_MESSAGE} (${state.reason})`);
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
// Before the scan, always: a record whose path is stale points at nothing,
|
|
65
|
+
// and the scan is what relocates its row to wherever the bytes actually are,
|
|
66
|
+
// carrying the flags with it.
|
|
67
|
+
state.reconciled = await recovery.reconcileMeta();
|
|
68
|
+
recovery.markInstalled();
|
|
69
|
+
await ui.ensure();
|
|
70
|
+
if (config.scan.boot !== false) state.scan = await scanner.scan({ owner: '' });
|
|
71
|
+
if (config.scan.watch !== false) scanner.watch({ owner: '' });
|
|
72
|
+
// A tenant realm is a realm. Scanning only the owner's tree meant no tenant
|
|
73
|
+
// document ever had identity maintenance: a tenant rename was rebound
|
|
74
|
+
// neither online nor at boot, and the tenant then saved the new path as a
|
|
75
|
+
// new identity while the history stayed on the missing one.
|
|
76
|
+
for (const realm of tenantRealms()) {
|
|
77
|
+
if (config.scan.boot !== false) {
|
|
78
|
+
// One realm is not the whole host. A tenant directory that was moved or
|
|
79
|
+
// deleted by hand must cost that realm its scan, never the boot.
|
|
80
|
+
try {
|
|
81
|
+
await scanner.scan({ owner: realm });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.warn?.(`[makerclay] could not scan the realm of ${realm}:`, error?.message || error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (config.scan.watch !== false) scanner.watch({ owner: realm });
|
|
87
|
+
}
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const ready = boot().catch((error) => {
|
|
92
|
+
logger.error?.('[makerclay] boot failed:', error?.message || error);
|
|
93
|
+
state = { state: 'recover', reason: 'boot-failed', error: error?.message || String(error) };
|
|
94
|
+
return state;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Mounted BEFORE the wall, and inert unless the wall is up. Loopback only:
|
|
98
|
+
// the operator is at the machine. (§18.3)
|
|
99
|
+
app.use('/_/recover', wrap(async (req, res, next) => { await ready; return next(); }));
|
|
100
|
+
const recoveryUi = mountRecoveryUi({ app, paths, store, recovery, stateOf: () => state, logger });
|
|
101
|
+
|
|
102
|
+
// The key is printed here and nowhere else. That is the whole mechanism: a
|
|
103
|
+
// proxy on the same box makes every request look like loopback, so reading
|
|
104
|
+
// this console is what proves the reader is at the machine.
|
|
105
|
+
ready.then((settled) => {
|
|
106
|
+
if (settled?.state !== 'recover') return;
|
|
107
|
+
const where = config.port ? `http://127.0.0.1:${config.port}` : '';
|
|
108
|
+
logger.error?.(`[makerclay] recovery UI (this machine only): ${where}${recoveryUi.url}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
app.use(wrap(async (req, res, next) => {
|
|
112
|
+
await ready;
|
|
113
|
+
if (state.state === 'recover' && req.path !== '/_/health') {
|
|
114
|
+
return jsonError(res, 'unavailable', RECOVERY_MESSAGE, { reason: state.reason });
|
|
115
|
+
}
|
|
116
|
+
return next();
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return { ready, recoveryUi, get state() { return state; } };
|
|
120
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { statIfExists } from './util/fsx.js';
|
|
4
|
+
import { jsonError } from './json-errors.js';
|
|
5
|
+
|
|
6
|
+
// The entry scripts clayjs publishes: `clay.js` and its `clay-*` satellites,
|
|
7
|
+
// plus `sap.js` and `all.js`. Matched by shape rather than listed by name, and
|
|
8
|
+
// shared with the preflight below so the two cannot disagree about which names
|
|
9
|
+
// exist.
|
|
10
|
+
const SATELLITE_PATTERN = /^\/(clay[\w-]*\.js|sap\.js|all\.js)$/;
|
|
11
|
+
|
|
12
|
+
// The self-hosted client (§17.4).
|
|
13
|
+
//
|
|
14
|
+
// Everything points at /_/clay.js, never at a CDN: an offline or firewalled box
|
|
15
|
+
// works, the client version is pinned to the server version so a conformance
|
|
16
|
+
// claim names a known pair, and loading a page is not reported to a third party.
|
|
17
|
+
//
|
|
18
|
+
// clay.js derives its module base from its own <script src>, then dynamically
|
|
19
|
+
// imports `${base}/src/loader.js` and hands it the query string. Served at
|
|
20
|
+
// /_/clay.js, that resolves to /_/src/loader.js and `?plugins=` passes through
|
|
21
|
+
// untouched, so no rewriting is needed at all.
|
|
22
|
+
//
|
|
23
|
+
// The ACAO header on these two routes is load-bearing rather than decorative: a
|
|
24
|
+
// sandboxed document has an OPAQUE origin, and a dynamic import() from an
|
|
25
|
+
// opaque origin is CORS-subject. Without it, clayjs silently fails to boot
|
|
26
|
+
// inside every isolated instance (§16.3). It is safe precisely because these
|
|
27
|
+
// bytes are a public library and carry nothing user-specific.
|
|
28
|
+
|
|
29
|
+
function locateClay() {
|
|
30
|
+
try {
|
|
31
|
+
const require_ = createRequire(import.meta.url);
|
|
32
|
+
// The package root, not the entry file's directory. clayjs keeps its public scripts
|
|
33
|
+
// in entries/ and src/ is their sibling at the package root, so resolving clay.js and
|
|
34
|
+
// taking its dirname would send every /src/* lookup inside entries/ and 404 the whole
|
|
35
|
+
// module graph. package.json is the one file guaranteed to sit at the root.
|
|
36
|
+
return path.dirname(require_.resolve('@panphora/clayjs/package.json'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function mountClientRoutes({ router, logger = console }) {
|
|
43
|
+
const root = locateClay();
|
|
44
|
+
if (!root) {
|
|
45
|
+
logger.warn?.('[makerclay] @panphora/clayjs is not installed; /_/clay.js will 404');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scriptHeaders(res) {
|
|
49
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
50
|
+
res.setHeader('Vary', 'Origin');
|
|
51
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
52
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
53
|
+
// Pinned to the installed version, but an upgrade must not leave a browser
|
|
54
|
+
// holding stale client code, so revalidate rather than cache blindly.
|
|
55
|
+
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sendScript(res, file) {
|
|
59
|
+
scriptHeaders(res);
|
|
60
|
+
return res.sendFile(file);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The installed package is the list. A hardcoded set of names has to track
|
|
64
|
+
// another package's contents, and it did not: clayjs grew `clay-internals.js`
|
|
65
|
+
// and this host answered 404 for it while its preflight said yes. The shape is
|
|
66
|
+
// still constrained, and the name can hold no slash, so nothing outside the
|
|
67
|
+
// package's own entry scripts is reachable.
|
|
68
|
+
router.get(SATELLITE_PATTERN, async (req, res) => {
|
|
69
|
+
if (!root) return jsonError(res, 'not-found', 'This host has no bundled client.');
|
|
70
|
+
const name = req.params[0];
|
|
71
|
+
// entries/ since clayjs 0.8.0, the package root before it. Try both rather than
|
|
72
|
+
// sniffing a version, so either installed layout serves.
|
|
73
|
+
for (const file of [path.join(root, 'entries', name), path.join(root, name)]) {
|
|
74
|
+
if (await statIfExists(file)) return sendScript(res, file);
|
|
75
|
+
}
|
|
76
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
router.get(/^\/src\/(.+)$/, async (req, res) => {
|
|
80
|
+
if (!root) return jsonError(res, 'not-found', 'This host has no bundled client.');
|
|
81
|
+
const rest = req.params[0];
|
|
82
|
+
if (rest.includes('\0') || rest.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
83
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
84
|
+
}
|
|
85
|
+
const base = path.join(root, 'src');
|
|
86
|
+
const file = path.resolve(path.join(base, rest));
|
|
87
|
+
if (file !== base && !file.startsWith(`${base}/`)) return jsonError(res, 'not-found', 'Not found');
|
|
88
|
+
if (!(await statIfExists(file))) return jsonError(res, 'not-found', 'Not found');
|
|
89
|
+
return sendScript(res, file);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
router.options(/^\/(src\/.+|clay[\w-]*\.js|sap\.js|all\.js)$/, (req, res) => {
|
|
93
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
94
|
+
res.setHeader('Vary', 'Origin');
|
|
95
|
+
res.status(204).end();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { root };
|
|
99
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { can } from '../auth/can.js';
|
|
3
|
+
import { HostError } from '../spec/codes.js';
|
|
4
|
+
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
|
5
|
+
import { token as mintToken } from '../util/id.js';
|
|
6
|
+
import {
|
|
7
|
+
atomicWrite, readFileTextIfExists, readdirIfExists, mkdirp,
|
|
8
|
+
} from '../util/fsx.js';
|
|
9
|
+
|
|
10
|
+
// Collections: a document plus the submissions people send it.
|
|
11
|
+
//
|
|
12
|
+
// The mechanics are the platform's, with the state moved out of columns on the
|
|
13
|
+
// node and into the `collections` table, so unmarking is one DELETE and the
|
|
14
|
+
// files and records survive it untouched.
|
|
15
|
+
//
|
|
16
|
+
// Three lessons from the platform that the port must not lose:
|
|
17
|
+
//
|
|
18
|
+
// 1. ANTI-SQUAT. A stranger submitting without a write code always gets a
|
|
19
|
+
// server-generated opaque id, never the key they asked for. Otherwise the
|
|
20
|
+
// first person to POST `key: "david"` owns that record forever.
|
|
21
|
+
// 2. THE PHONE RULE. A key made only of digits and phone punctuation collapses
|
|
22
|
+
// to its digits, so `(415) 555-0100` finds `4155550100`.
|
|
23
|
+
// 3. RECORD AUTH SOFT-DELETES WITH ITS RECORD. The platform hard-deletes the
|
|
24
|
+
// auth row on trash, so a restored keyed record comes back PUBLIC with no
|
|
25
|
+
// code. Here both go down and come back together.
|
|
26
|
+
|
|
27
|
+
const MAX_FAILURES = 10;
|
|
28
|
+
const LOCKOUT_MS = 15 * 60 * 1000;
|
|
29
|
+
const CODE_COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
30
|
+
const SAFE_FILENAME = /^[a-z0-9._@-]{1,80}$/;
|
|
31
|
+
|
|
32
|
+
export function normalizeKey(raw) {
|
|
33
|
+
const value = String(raw ?? '').trim();
|
|
34
|
+
if (!value) return null;
|
|
35
|
+
// Lesson 2. Only when it is unambiguously a number: a key of "2024" stays a
|
|
36
|
+
// key, but it has no punctuation to lose either way.
|
|
37
|
+
if (/^[0-9()+.\-\s]+$/.test(value) && /\d/.test(value)) {
|
|
38
|
+
const digits = value.replace(/\D/g, '');
|
|
39
|
+
if (digits.length >= 4) return digits;
|
|
40
|
+
}
|
|
41
|
+
return value.toLowerCase().replace(/\s+/g, ' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Byte lengths first, exactly as auth/password.js does it. Padding to a fixed
|
|
45
|
+
// width equalises CHARACTERS, not bytes, so one multi-byte character makes the
|
|
46
|
+
// two buffers different lengths and timingSafeEqual THROWS. On the CSRF-exempt,
|
|
47
|
+
// CORS-open submit lane that turns an unauthorized answer into a 500 for anyone
|
|
48
|
+
// who knows a slug.
|
|
49
|
+
function timingSafeEquals(a, b) {
|
|
50
|
+
const left = Buffer.from(String(a ?? ''), 'utf8');
|
|
51
|
+
const right = Buffer.from(String(b ?? ''), 'utf8');
|
|
52
|
+
if (left.length !== right.length || left.length === 0) return false;
|
|
53
|
+
return crypto.timingSafeEqual(left, right);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A filename for a key that may be an email, a phone number or a sentence. Safe
|
|
57
|
+
// keys keep their own name so the records folder stays readable; everything else
|
|
58
|
+
// gets a stable hash, and the real key is inside the file either way.
|
|
59
|
+
export function keyFilename(normalizedKey) {
|
|
60
|
+
if (SAFE_FILENAME.test(normalizedKey)) return normalizedKey;
|
|
61
|
+
return `k-${crypto.createHash('sha256').update(normalizedKey).digest('hex').slice(0, 24)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createCollections({
|
|
65
|
+
store, nodes, paths, versions, clock, ids = { token: mintToken }, events, config = {}, logger = console,
|
|
66
|
+
}) {
|
|
67
|
+
// Defaults live in HOST_DEFAULTS and are deep-merged before this runs, so
|
|
68
|
+
// there is no second copy of them here to drift.
|
|
69
|
+
const limits = config.collections;
|
|
70
|
+
|
|
71
|
+
function assert(condition, code, message) {
|
|
72
|
+
if (!condition) throw new HostError(code, message);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireStore() {
|
|
76
|
+
assert(store, 'not-found', 'This host keeps no collections.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- marking -----------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
async function mark({ actor, owner = '', relPath, ctx = {}, roleMap = null }) {
|
|
82
|
+
requireStore();
|
|
83
|
+
const node = nodes.materialize(nodes.resolve(owner, relPath));
|
|
84
|
+
assert(can(actor, 'admin', node, ctx), 'forbidden', 'Only the owner can make a collection.');
|
|
85
|
+
|
|
86
|
+
const existing = store.getCollection(node.id);
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
|
|
89
|
+
// Numeric ids never resolve, on purpose: a collection address should not be
|
|
90
|
+
// guessable by counting.
|
|
91
|
+
const slug = `col_${ids.token(6).replace(/[^a-z0-9]/gi, '').slice(0, 10).toLowerCase()}`;
|
|
92
|
+
// The slug is the address of a folder of real submissions on disk, and the
|
|
93
|
+
// slug-to-document binding exists nowhere else. Losing it strands them.
|
|
94
|
+
await versions.patchMeta(node, { slug }, { create: true });
|
|
95
|
+
const record = store.insertCollection({
|
|
96
|
+
nodeId: node.id,
|
|
97
|
+
slug,
|
|
98
|
+
submitToken: ids.token(18),
|
|
99
|
+
accepting: true,
|
|
100
|
+
roleMap,
|
|
101
|
+
createdAt: clock.now(),
|
|
102
|
+
});
|
|
103
|
+
await mkdirp(paths.recordsDir(slug));
|
|
104
|
+
events.emit('node-updated', { node, flag: 'collection' });
|
|
105
|
+
return record;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One DELETE. The document keeps its bytes, the records keep theirs, and
|
|
109
|
+
// marking it again later gives a new slug over the same folder of records
|
|
110
|
+
// only if the caller passes the old one.
|
|
111
|
+
async function unmark({ actor, owner = '', relPath, ctx = {} }) {
|
|
112
|
+
requireStore();
|
|
113
|
+
const node = nodes.resolve(owner, relPath);
|
|
114
|
+
assert(can(actor, 'admin', node, ctx), 'forbidden', 'Only the owner can do that.');
|
|
115
|
+
const collection = store.getCollection(node.id);
|
|
116
|
+
assert(collection, 'not-found', 'That is not a collection.');
|
|
117
|
+
store.deleteCollection(node.id);
|
|
118
|
+
await versions.patchMeta(node, { slug: null });
|
|
119
|
+
events.emit('node-updated', { node, flag: 'collection' });
|
|
120
|
+
return { ok: true, slug: collection.slug, recordsKept: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function bySlug(slug) {
|
|
124
|
+
requireStore();
|
|
125
|
+
const collection = store.getCollectionBySlug(String(slug || ''));
|
|
126
|
+
assert(collection, 'not-found', 'No such collection.');
|
|
127
|
+
return collection;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isOwnerOf(actor, collection, ctx) {
|
|
131
|
+
const node = store.getNodeById(collection.nodeId);
|
|
132
|
+
return can(actor, 'admin', node, ctx);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- record storage ----------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function nodeFor(collection, filename, { create = false } = {}) {
|
|
138
|
+
const nodePath = paths.recordNodePath(collection.slug, filename);
|
|
139
|
+
const existing = store.getNode('', nodePath) || store.getNodeAnyState('', nodePath);
|
|
140
|
+
if (existing || !create) return existing;
|
|
141
|
+
return nodes.materialize({ ...nodes.provisional('', nodePath, 'file'), kind: 'file' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readRaw(collection, filename) {
|
|
145
|
+
const raw = await readFileTextIfExists(paths.recordPath(collection.slug, filename));
|
|
146
|
+
if (raw == null) return null;
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
logger.warn?.(`[makerclay] record ${collection.slug}/${filename} is not valid JSON`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function writeRaw(collection, filename, record) {
|
|
156
|
+
await atomicWrite(paths.recordPath(collection.slug, filename), `${JSON.stringify(record, null, 2)}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function countRecords(collection) {
|
|
160
|
+
const names = await readdirIfExists(paths.recordsDir(collection.slug));
|
|
161
|
+
return names.filter((name) => name.endsWith('.json')).length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- codes -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async function checkCode(collection, filename, supplied, { res = null, record = null } = {}) {
|
|
167
|
+
const row = nodeFor(collection, filename);
|
|
168
|
+
const auth = row ? store.getRecordAuth(row.id) : null;
|
|
169
|
+
|
|
170
|
+
// Store-as-overlay, applied to record codes: the FILE remembers that a
|
|
171
|
+
// record is protected, the database only holds the hash that proves the
|
|
172
|
+
// code. Losing the database must therefore fail CLOSED. The alternative is
|
|
173
|
+
// that deleting makerclay.db republishes every keyed submission, which is
|
|
174
|
+
// the same bug as lesson 3 arriving through a different door.
|
|
175
|
+
if (record?.protected && (!auth || auth.deletedAt)) {
|
|
176
|
+
throw new HostError(
|
|
177
|
+
'unauthorized',
|
|
178
|
+
'This record is protected but its code is missing from the database. The owner can reset it.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No code was ever set, or the record is gone: nothing to check.
|
|
183
|
+
if (!auth || auth.deletedAt) return { ok: true, protected: false };
|
|
184
|
+
|
|
185
|
+
const now = clock.now();
|
|
186
|
+
if (auth.lockedUntil && auth.lockedUntil > now) {
|
|
187
|
+
throw new HostError('rate-limited', 'Too many wrong codes. Try again in a few minutes.');
|
|
188
|
+
}
|
|
189
|
+
if (typeof supplied !== 'string' || supplied.length === 0) {
|
|
190
|
+
return { ok: false, protected: true };
|
|
191
|
+
}
|
|
192
|
+
if (await verifyPassword(supplied, auth.codeHash)) {
|
|
193
|
+
if (auth.failures) store.updateRecordAuth(row.id, { failures: 0, lockedUntil: null });
|
|
194
|
+
// So nobody has to retype a code they have already proved they know.
|
|
195
|
+
if (res) {
|
|
196
|
+
res.cookie(`mk_record_${collection.slug}_${filename}`, supplied, {
|
|
197
|
+
httpOnly: true, sameSite: 'lax', path: '/', maxAge: CODE_COOKIE_MAX_AGE,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, protected: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const failures = (auth.failures || 0) + 1;
|
|
204
|
+
store.updateRecordAuth(row.id, {
|
|
205
|
+
failures,
|
|
206
|
+
lockedUntil: failures >= MAX_FAILURES ? now + LOCKOUT_MS : null,
|
|
207
|
+
});
|
|
208
|
+
return { ok: false, protected: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cookieCode(req, collection, filename) {
|
|
212
|
+
return req?.cookies?.[`mk_record_${collection.slug}_${filename}`] ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function setCode(collection, filename, code) {
|
|
216
|
+
const row = nodeFor(collection, filename, { create: true });
|
|
217
|
+
store.upsertRecordAuth({ nodeId: row.id, codeHash: await hashPassword(code) });
|
|
218
|
+
// The file has to remember that it is protected wherever a code is set, not
|
|
219
|
+
// only on the submit path, or one caller reintroduces the fail-open.
|
|
220
|
+
const record = await readRaw(collection, filename);
|
|
221
|
+
if (record && !record.protected) await writeRaw(collection, filename, { ...record, protected: true });
|
|
222
|
+
return row;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- the lanes ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
// The public submit lane. The submit token in the BODY is the entire
|
|
228
|
+
// credential, which is what lets a form on the owner's blog post in here.
|
|
229
|
+
async function submit({ collection, actor, ctx = {}, key = null, data = {}, code = null, submitToken = null }) {
|
|
230
|
+
requireStore();
|
|
231
|
+
assert(collection.accepting, 'forbidden', 'This collection is not accepting submissions.');
|
|
232
|
+
|
|
233
|
+
// The token is required of EVERYONE, owner included, and that is what makes
|
|
234
|
+
// the CSRF exemption safe. Being the owner is ambient (a cookie any page in
|
|
235
|
+
// that browser can spend); holding the token is deliberate. Identity still
|
|
236
|
+
// decides which keys you may claim, just below.
|
|
237
|
+
assert(
|
|
238
|
+
typeof submitToken === 'string' && timingSafeEquals(submitToken, collection.submitToken),
|
|
239
|
+
'unauthorized', 'That submit token is not right.',
|
|
240
|
+
);
|
|
241
|
+
const owner = isOwnerOf(actor, collection, ctx);
|
|
242
|
+
|
|
243
|
+
const total = await countRecords(collection);
|
|
244
|
+
assert(total < limits.maxRecords, 'too-large', 'This collection is full.');
|
|
245
|
+
|
|
246
|
+
// LESSON 1, the anti-squat rule. A requested key is only honored for the
|
|
247
|
+
// owner, or for someone who set a code on it and can still prove it.
|
|
248
|
+
let normalized = key ? normalizeKey(key) : null;
|
|
249
|
+
let filename = normalized ? keyFilename(normalized) : null;
|
|
250
|
+
|
|
251
|
+
if (normalized && !owner) {
|
|
252
|
+
const existing = await readRaw(collection, filename);
|
|
253
|
+
if (existing) {
|
|
254
|
+
const verdict = await checkCode(collection, filename, code, { record: existing });
|
|
255
|
+
if (!verdict.ok) {
|
|
256
|
+
throw new HostError('unauthorized', 'That name is taken. Send its code to update it.');
|
|
257
|
+
}
|
|
258
|
+
} else if (!code) {
|
|
259
|
+
// Asking for a name without claiming it gets you an opaque id instead.
|
|
260
|
+
normalized = null;
|
|
261
|
+
filename = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!filename) {
|
|
266
|
+
normalized = ids.token(12).toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 16);
|
|
267
|
+
filename = normalized;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const now = clock.now();
|
|
271
|
+
const previous = await readRaw(collection, filename);
|
|
272
|
+
const record = {
|
|
273
|
+
key: normalized,
|
|
274
|
+
opaque: !key || normalized !== normalizeKey(key),
|
|
275
|
+
protected: previous?.protected || !!(code && !previous),
|
|
276
|
+
createdAt: previous?.createdAt ?? now,
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
data,
|
|
279
|
+
};
|
|
280
|
+
await writeRaw(collection, filename, record);
|
|
281
|
+
nodeFor(collection, filename, { create: true });
|
|
282
|
+
if (code && !previous) await setCode(collection, filename, code);
|
|
283
|
+
|
|
284
|
+
events.emit('record-submitted', { slug: collection.slug, key: normalized });
|
|
285
|
+
return { key: normalized, filename, record, created: !previous };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function read({ collection, actor, ctx = {}, key, code = null, req = null, res = null }) {
|
|
289
|
+
requireStore();
|
|
290
|
+
const normalized = normalizeKey(key);
|
|
291
|
+
assert(normalized, 'bad-request', 'Which record?');
|
|
292
|
+
const filename = keyFilename(normalized);
|
|
293
|
+
|
|
294
|
+
const row = nodeFor(collection, filename);
|
|
295
|
+
if (row?.deletedAt) return null;
|
|
296
|
+
|
|
297
|
+
const record = await readRaw(collection, filename);
|
|
298
|
+
if (!record) return null;
|
|
299
|
+
if (!isOwnerOf(actor, collection, ctx)) {
|
|
300
|
+
const verdict = await checkCode(
|
|
301
|
+
collection, filename, code ?? cookieCode(req, collection, filename), { res, record },
|
|
302
|
+
);
|
|
303
|
+
if (!verdict.ok) throw new HostError('unauthorized', 'That record needs its code.');
|
|
304
|
+
}
|
|
305
|
+
return record;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function list({ collection, actor, ctx = {} }) {
|
|
309
|
+
requireStore();
|
|
310
|
+
assert(isOwnerOf(actor, collection, ctx), 'not-found', 'Not found');
|
|
311
|
+
const names = await readdirIfExists(paths.recordsDir(collection.slug));
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const name of names.filter((entry) => entry.endsWith('.json'))) {
|
|
314
|
+
const filename = name.replace(/\.json$/, '');
|
|
315
|
+
const row = nodeFor(collection, filename);
|
|
316
|
+
if (row?.deletedAt) continue;
|
|
317
|
+
const record = await readRaw(collection, filename);
|
|
318
|
+
if (!record) continue;
|
|
319
|
+
const auth = row ? store.getRecordAuth(row.id) : null;
|
|
320
|
+
out.push({
|
|
321
|
+
...record,
|
|
322
|
+
filename,
|
|
323
|
+
hasCode: !!auth,
|
|
324
|
+
// Protected by the file but unprovable by the database: the owner needs
|
|
325
|
+
// to see that this record is locked out until they reset or delete it.
|
|
326
|
+
codeMissing: !!record.protected && !auth,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function update({ collection, actor, ctx = {}, key, data, code = null, req = null, res = null }) {
|
|
334
|
+
requireStore();
|
|
335
|
+
const normalized = normalizeKey(key);
|
|
336
|
+
assert(normalized, 'bad-request', 'Which record?');
|
|
337
|
+
const filename = keyFilename(normalized);
|
|
338
|
+
const existing = await readRaw(collection, filename);
|
|
339
|
+
assert(existing, 'not-found', 'No such record.');
|
|
340
|
+
|
|
341
|
+
if (!isOwnerOf(actor, collection, ctx)) {
|
|
342
|
+
const verdict = await checkCode(
|
|
343
|
+
collection, filename, code ?? cookieCode(req, collection, filename), { res, record: existing },
|
|
344
|
+
);
|
|
345
|
+
if (!verdict.ok) throw new HostError('unauthorized', 'That record needs its code.');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const record = { ...existing, data, updatedAt: clock.now() };
|
|
349
|
+
await writeRaw(collection, filename, record);
|
|
350
|
+
events.emit('record-updated', { slug: collection.slug, key: normalized });
|
|
351
|
+
return record;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// LESSON 3. The record and its auth go down together and come back together,
|
|
355
|
+
// so a restored keyed record is never briefly public with no code.
|
|
356
|
+
async function remove({ collection, actor, ctx = {}, key }) {
|
|
357
|
+
requireStore();
|
|
358
|
+
assert(isOwnerOf(actor, collection, ctx), 'not-found', 'Not found');
|
|
359
|
+
const normalized = normalizeKey(key);
|
|
360
|
+
const filename = keyFilename(normalized);
|
|
361
|
+
const row = nodeFor(collection, filename);
|
|
362
|
+
assert(row, 'not-found', 'No such record.');
|
|
363
|
+
|
|
364
|
+
const at = clock.now();
|
|
365
|
+
store.tx(() => {
|
|
366
|
+
store.tombstone(row.id, at);
|
|
367
|
+
if (store.getRecordAuth(row.id)) store.updateRecordAuth(row.id, { deletedAt: at });
|
|
368
|
+
});
|
|
369
|
+
return { ok: true };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function restore({ collection, actor, ctx = {}, key }) {
|
|
373
|
+
requireStore();
|
|
374
|
+
assert(isOwnerOf(actor, collection, ctx), 'not-found', 'Not found');
|
|
375
|
+
const filename = keyFilename(normalizeKey(key));
|
|
376
|
+
const row = nodeFor(collection, filename);
|
|
377
|
+
assert(row?.deletedAt, 'not-found', 'That record is not deleted.');
|
|
378
|
+
|
|
379
|
+
store.tx(() => {
|
|
380
|
+
store.updateNode(row.id, { deletedAt: null, updatedAt: clock.now() });
|
|
381
|
+
const auth = store.getRecordAuth(row.id);
|
|
382
|
+
if (auth?.deletedAt) store.updateRecordAuth(row.id, { deletedAt: null });
|
|
383
|
+
});
|
|
384
|
+
return { ok: true };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function setAccepting({ actor, collection, ctx = {}, accepting }) {
|
|
388
|
+
requireStore();
|
|
389
|
+
assert(isOwnerOf(actor, collection, ctx), 'forbidden', 'Only the owner can do that.');
|
|
390
|
+
return store.updateCollection(collection.nodeId, { accepting: !!accepting });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
mark, unmark, bySlug, submit, read, list, update, remove, restore,
|
|
395
|
+
setAccepting, setCode, isOwnerOf, normalizeKey, keyFilename, limits,
|
|
396
|
+
};
|
|
397
|
+
}
|