@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,540 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'upath';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// `node:sqlite` is only listed in module.builtinModules under its prefixed name,
|
|
6
|
+
// so a plain `import` sends bundlers and test runners hunting for a package
|
|
7
|
+
// called "sqlite" and failing. process.getBuiltinModule reaches the real module
|
|
8
|
+
// without going through any resolver at all.
|
|
9
|
+
const { DatabaseSync } = process.getBuiltinModule('node:sqlite');
|
|
10
|
+
|
|
11
|
+
const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'migrations');
|
|
12
|
+
|
|
13
|
+
function readMigrations() {
|
|
14
|
+
return fs.readdirSync(MIGRATIONS_DIR)
|
|
15
|
+
.filter((name) => name.endsWith('.sql'))
|
|
16
|
+
.sort()
|
|
17
|
+
.map((name) => ({ name, sql: fs.readFileSync(path.join(MIGRATIONS_DIR, name), 'utf8') }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NODE_COLUMNS = [
|
|
21
|
+
'id', 'owner', 'path', 'kind',
|
|
22
|
+
'etag', 'observed_etag', 'bytes', 'mtime_ms', 'dev', 'ino', 'seq',
|
|
23
|
+
'created_at', 'updated_at', 'deleted_at', 'private', 'isolated', 'signups', 'source_id',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function toNode(row) {
|
|
27
|
+
if (!row) return null;
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
owner: row.owner ?? '',
|
|
31
|
+
path: row.path,
|
|
32
|
+
kind: row.kind,
|
|
33
|
+
etag: row.etag ?? null,
|
|
34
|
+
observedEtag: row.observed_etag ?? null,
|
|
35
|
+
bytes: row.bytes ?? null,
|
|
36
|
+
mtimeMs: row.mtime_ms ?? null,
|
|
37
|
+
dev: row.dev ?? null,
|
|
38
|
+
ino: row.ino ?? null,
|
|
39
|
+
seq: row.seq,
|
|
40
|
+
createdAt: row.created_at,
|
|
41
|
+
updatedAt: row.updated_at,
|
|
42
|
+
deletedAt: row.deleted_at ?? null,
|
|
43
|
+
private: !!row.private,
|
|
44
|
+
isolated: row.isolated === null || row.isolated === undefined ? null : !!row.isolated,
|
|
45
|
+
signups: !!row.signups,
|
|
46
|
+
sourceId: row.source_id ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CAMEL_TO_COLUMN = {
|
|
51
|
+
observedEtag: 'observed_etag',
|
|
52
|
+
mtimeMs: 'mtime_ms',
|
|
53
|
+
createdAt: 'created_at',
|
|
54
|
+
updatedAt: 'updated_at',
|
|
55
|
+
deletedAt: 'deleted_at',
|
|
56
|
+
sourceId: 'source_id',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function columnFor(key) {
|
|
60
|
+
const column = CAMEL_TO_COLUMN[key] || key;
|
|
61
|
+
if (!NODE_COLUMNS.includes(column)) {
|
|
62
|
+
throw new Error(`updateNode: unknown column "${key}"`);
|
|
63
|
+
}
|
|
64
|
+
return column;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function likeEscape(value) {
|
|
68
|
+
return String(value).replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toBind(value) {
|
|
72
|
+
if (value === undefined || value === null) return null;
|
|
73
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createSqliteStore({ file } = {}) {
|
|
78
|
+
let db = null;
|
|
79
|
+
let inTx = 0;
|
|
80
|
+
|
|
81
|
+
function run(sql, ...params) {
|
|
82
|
+
return db.prepare(sql).run(...params.map(toBind));
|
|
83
|
+
}
|
|
84
|
+
function get(sql, ...params) {
|
|
85
|
+
return db.prepare(sql).get(...params.map(toBind)) ?? null;
|
|
86
|
+
}
|
|
87
|
+
function all(sql, ...params) {
|
|
88
|
+
return db.prepare(sql).all(...params.map(toBind));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const store = {
|
|
92
|
+
name: 'sqlite',
|
|
93
|
+
|
|
94
|
+
// `readOnly` is for inspection (`makerclay doctor`), and the guarantee is
|
|
95
|
+
// SQLite's rather than this module's: the connection itself refuses every
|
|
96
|
+
// write, so a reader cannot migrate, checkpoint or repair by accident. That
|
|
97
|
+
// matters because the normal open MIGRATES, which is a schema change nobody
|
|
98
|
+
// asked a health check to make.
|
|
99
|
+
//
|
|
100
|
+
// No journal_mode pragma on this path: setting WAL is itself a write, and it
|
|
101
|
+
// is not needed. A read-only connection reads a WAL database fine, including
|
|
102
|
+
// one another process is holding open, and including one left mid-crash with
|
|
103
|
+
// a -wal and -shm still on disk, which is exactly when a report is wanted.
|
|
104
|
+
open({ paths, readOnly = false } = {}) {
|
|
105
|
+
const target = file || paths?.files?.db;
|
|
106
|
+
if (!target) throw new Error('sqlite store needs a file path');
|
|
107
|
+
if (readOnly) {
|
|
108
|
+
db = new DatabaseSync(target, { readOnly: true });
|
|
109
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
110
|
+
return store;
|
|
111
|
+
}
|
|
112
|
+
db = new DatabaseSync(target);
|
|
113
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
114
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
115
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
116
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
117
|
+
|
|
118
|
+
const current = db.prepare('PRAGMA user_version').get().user_version;
|
|
119
|
+
const migrations = readMigrations();
|
|
120
|
+
if (current < migrations.length) {
|
|
121
|
+
db.exec('BEGIN');
|
|
122
|
+
try {
|
|
123
|
+
for (let i = current; i < migrations.length; i++) db.exec(migrations[i].sql);
|
|
124
|
+
db.exec(`PRAGMA user_version = ${migrations.length}`);
|
|
125
|
+
db.exec('COMMIT');
|
|
126
|
+
} catch (error) {
|
|
127
|
+
db.exec('ROLLBACK');
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return store;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
close() {
|
|
135
|
+
if (db) {
|
|
136
|
+
db.close();
|
|
137
|
+
db = null;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
integrityCheck() {
|
|
142
|
+
return all('PRAGMA integrity_check').map((row) => row.integrity_check);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Nested calls join the outer transaction rather than opening a second one:
|
|
146
|
+
// SQLite has no nested BEGIN, and the kernel calls tx() from inside node ops.
|
|
147
|
+
tx(fn) {
|
|
148
|
+
if (inTx > 0) {
|
|
149
|
+
inTx += 1;
|
|
150
|
+
try {
|
|
151
|
+
return fn(store);
|
|
152
|
+
} finally {
|
|
153
|
+
inTx -= 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
db.exec('BEGIN');
|
|
157
|
+
inTx = 1;
|
|
158
|
+
try {
|
|
159
|
+
const result = fn(store);
|
|
160
|
+
db.exec('COMMIT');
|
|
161
|
+
return result;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
db.exec('ROLLBACK');
|
|
164
|
+
throw error;
|
|
165
|
+
} finally {
|
|
166
|
+
inTx = 0;
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
nextSeq() {
|
|
171
|
+
const row = get('SELECT v FROM kv WHERE k = ?', 'seq');
|
|
172
|
+
const next = (row ? Number(row.v) : 0) + 1;
|
|
173
|
+
run('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', 'seq', String(next));
|
|
174
|
+
return next;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
getNode(owner, nodePath) {
|
|
178
|
+
return toNode(get(
|
|
179
|
+
'SELECT * FROM nodes WHERE owner = ? AND path = ? AND deleted_at IS NULL',
|
|
180
|
+
owner ?? '', nodePath,
|
|
181
|
+
));
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
getNodeAnyState(owner, nodePath) {
|
|
185
|
+
return toNode(get('SELECT * FROM nodes WHERE owner = ? AND path = ?', owner ?? '', nodePath));
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
getNodeById(id) {
|
|
189
|
+
return toNode(get('SELECT * FROM nodes WHERE id = ?', id));
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
listNodes({ owner = '', prefix = null, includeDeleted = false, sinceSeq = null, kind = null } = {}) {
|
|
193
|
+
const where = ['owner = ?'];
|
|
194
|
+
const params = [owner];
|
|
195
|
+
if (!includeDeleted) where.push('deleted_at IS NULL');
|
|
196
|
+
// `_` is a LIKE wildcard and an extremely common filename character, so an
|
|
197
|
+
// unescaped prefix makes `my_notes/` match `myXnotes/` and a folder move
|
|
198
|
+
// rewrite rows belonging to a different folder.
|
|
199
|
+
if (prefix !== null) { where.push("path LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(prefix)}%`); }
|
|
200
|
+
if (sinceSeq !== null) { where.push('seq > ?'); params.push(sinceSeq); }
|
|
201
|
+
if (kind !== null) { where.push('kind = ?'); params.push(kind); }
|
|
202
|
+
return all(`SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY path`, ...params).map(toNode);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
listAllNodes({ includeDeleted = false } = {}) {
|
|
206
|
+
const where = includeDeleted ? '' : 'WHERE deleted_at IS NULL';
|
|
207
|
+
return all(`SELECT * FROM nodes ${where} ORDER BY owner, path`).map(toNode);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
listTrash() {
|
|
211
|
+
return all('SELECT * FROM nodes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').map(toNode);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
insertNode(node) {
|
|
215
|
+
const now = node.createdAt ?? Date.now();
|
|
216
|
+
run(
|
|
217
|
+
`INSERT INTO nodes (${NODE_COLUMNS.join(', ')})
|
|
218
|
+
VALUES (${NODE_COLUMNS.map(() => '?').join(', ')})`,
|
|
219
|
+
node.id,
|
|
220
|
+
node.owner ?? '',
|
|
221
|
+
node.path,
|
|
222
|
+
node.kind,
|
|
223
|
+
node.etag ?? null,
|
|
224
|
+
node.observedEtag ?? null,
|
|
225
|
+
node.bytes ?? null,
|
|
226
|
+
node.mtimeMs ?? null,
|
|
227
|
+
node.dev ?? null,
|
|
228
|
+
node.ino ?? null,
|
|
229
|
+
node.seq ?? store.nextSeq(),
|
|
230
|
+
now,
|
|
231
|
+
node.updatedAt ?? now,
|
|
232
|
+
node.deletedAt ?? null,
|
|
233
|
+
node.private ? 1 : 0,
|
|
234
|
+
node.isolated === null || node.isolated === undefined ? null : (node.isolated ? 1 : 0),
|
|
235
|
+
node.signups ? 1 : 0,
|
|
236
|
+
node.sourceId ?? null,
|
|
237
|
+
);
|
|
238
|
+
return store.getNodeById(node.id);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
updateNode(id, patch) {
|
|
242
|
+
const keys = Object.keys(patch).filter((key) => key !== 'id');
|
|
243
|
+
if (!keys.length) return store.getNodeById(id);
|
|
244
|
+
const assignments = keys.map((key) => `${columnFor(key)} = ?`).join(', ');
|
|
245
|
+
run(`UPDATE nodes SET ${assignments} WHERE id = ?`, ...keys.map((key) => toBind(patch[key])), id);
|
|
246
|
+
return store.getNodeById(id);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
tombstone(id, at) {
|
|
250
|
+
run('UPDATE nodes SET deleted_at = ? WHERE id = ?', at, id);
|
|
251
|
+
return store.getNodeById(id);
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
deleteNode(id) {
|
|
255
|
+
return run('DELETE FROM nodes WHERE id = ?', id).changes > 0;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
getKv(key) {
|
|
259
|
+
const row = get('SELECT v FROM kv WHERE k = ?', key);
|
|
260
|
+
return row ? row.v : null;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
setKv(key, value) {
|
|
264
|
+
run('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', key, value);
|
|
265
|
+
return value;
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
deleteKv(key) {
|
|
269
|
+
run('DELETE FROM kv WHERE k = ?', key);
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// shares
|
|
273
|
+
insertShare(share) {
|
|
274
|
+
run(
|
|
275
|
+
`INSERT INTO shares (token_hash, node_id, grant_kind, label, created_at, expires_at, revoked_at, last_used_at)
|
|
276
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)`,
|
|
277
|
+
share.tokenHash, share.nodeId, share.grantKind, share.label ?? null,
|
|
278
|
+
share.createdAt, share.expiresAt ?? null,
|
|
279
|
+
);
|
|
280
|
+
return store.getShare(share.tokenHash);
|
|
281
|
+
},
|
|
282
|
+
getShare(tokenHash) {
|
|
283
|
+
const row = get('SELECT * FROM shares WHERE token_hash = ?', tokenHash);
|
|
284
|
+
if (!row) return null;
|
|
285
|
+
return {
|
|
286
|
+
tokenHash: row.token_hash,
|
|
287
|
+
nodeId: row.node_id,
|
|
288
|
+
grantKind: row.grant_kind,
|
|
289
|
+
label: row.label ?? null,
|
|
290
|
+
createdAt: row.created_at,
|
|
291
|
+
expiresAt: row.expires_at ?? null,
|
|
292
|
+
revokedAt: row.revoked_at ?? null,
|
|
293
|
+
lastUsedAt: row.last_used_at ?? null,
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
listShares(nodeId) {
|
|
297
|
+
return all('SELECT token_hash FROM shares WHERE node_id = ? ORDER BY created_at DESC', nodeId)
|
|
298
|
+
.map((row) => store.getShare(row.token_hash));
|
|
299
|
+
},
|
|
300
|
+
touchShare(tokenHash, at) {
|
|
301
|
+
run('UPDATE shares SET last_used_at = ? WHERE token_hash = ?', at, tokenHash);
|
|
302
|
+
},
|
|
303
|
+
revokeShare(tokenHash, at) {
|
|
304
|
+
return run('UPDATE shares SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', at, tokenHash).changes > 0;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
// sessions
|
|
308
|
+
insertSession(session) {
|
|
309
|
+
run(
|
|
310
|
+
'INSERT INTO sessions (token_hash, principal, created_at, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)',
|
|
311
|
+
session.tokenHash, session.principal, session.createdAt, session.expiresAt, session.createdAt,
|
|
312
|
+
);
|
|
313
|
+
return session;
|
|
314
|
+
},
|
|
315
|
+
getSession(tokenHash) {
|
|
316
|
+
const row = get('SELECT * FROM sessions WHERE token_hash = ?', tokenHash);
|
|
317
|
+
if (!row) return null;
|
|
318
|
+
return {
|
|
319
|
+
tokenHash: row.token_hash,
|
|
320
|
+
principal: row.principal,
|
|
321
|
+
createdAt: row.created_at,
|
|
322
|
+
expiresAt: row.expires_at,
|
|
323
|
+
lastSeenAt: row.last_seen_at ?? null,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
touchSession(tokenHash, at) {
|
|
327
|
+
run('UPDATE sessions SET last_seen_at = ? WHERE token_hash = ?', at, tokenHash);
|
|
328
|
+
},
|
|
329
|
+
deleteSession(tokenHash) {
|
|
330
|
+
return run('DELETE FROM sessions WHERE token_hash = ?', tokenHash).changes > 0;
|
|
331
|
+
},
|
|
332
|
+
deleteSessionsFor(principal) {
|
|
333
|
+
return run('DELETE FROM sessions WHERE principal = ?', principal).changes;
|
|
334
|
+
},
|
|
335
|
+
purgeExpiredSessions(now) {
|
|
336
|
+
return run('DELETE FROM sessions WHERE expires_at < ?', now).changes;
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
// save tokens
|
|
340
|
+
insertSaveToken(record) {
|
|
341
|
+
run(
|
|
342
|
+
`INSERT INTO save_tokens (token, node_id, principal, share_hash, grant_kind, expires_at)
|
|
343
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
344
|
+
record.token, record.nodeId, record.principal ?? null,
|
|
345
|
+
record.shareHash ?? null, record.grantKind, record.expiresAt,
|
|
346
|
+
);
|
|
347
|
+
return record;
|
|
348
|
+
},
|
|
349
|
+
getSaveToken(token) {
|
|
350
|
+
const row = get('SELECT * FROM save_tokens WHERE token = ?', token);
|
|
351
|
+
if (!row) return null;
|
|
352
|
+
return {
|
|
353
|
+
token: row.token,
|
|
354
|
+
nodeId: row.node_id,
|
|
355
|
+
principal: row.principal ?? null,
|
|
356
|
+
shareHash: row.share_hash ?? null,
|
|
357
|
+
grantKind: row.grant_kind,
|
|
358
|
+
expiresAt: row.expires_at,
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
findSaveToken({ nodeId, principal, shareHash, now }) {
|
|
362
|
+
const row = get(
|
|
363
|
+
`SELECT * FROM save_tokens
|
|
364
|
+
WHERE node_id = ? AND expires_at > ?
|
|
365
|
+
AND principal IS ? AND share_hash IS ?
|
|
366
|
+
ORDER BY expires_at DESC LIMIT 1`,
|
|
367
|
+
nodeId, now, principal ?? null, shareHash ?? null,
|
|
368
|
+
);
|
|
369
|
+
return row ? store.getSaveToken(row.token) : null;
|
|
370
|
+
},
|
|
371
|
+
deleteSaveTokensForNode(nodeId) {
|
|
372
|
+
return run('DELETE FROM save_tokens WHERE node_id = ?', nodeId).changes;
|
|
373
|
+
},
|
|
374
|
+
deleteSaveTokensForPrincipal(principal) {
|
|
375
|
+
return run('DELETE FROM save_tokens WHERE principal = ?', String(principal)).changes;
|
|
376
|
+
},
|
|
377
|
+
purgeExpiredSaveTokens(now) {
|
|
378
|
+
return run('DELETE FROM save_tokens WHERE expires_at < ?', now).changes;
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
// tenants
|
|
382
|
+
insertTenant(tenant) {
|
|
383
|
+
run(
|
|
384
|
+
`INSERT INTO tenants (username, password_hash, recovery_hash, source_id, created_at, approved_at, disabled_at, bytes_used)
|
|
385
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, 0)`,
|
|
386
|
+
tenant.username, tenant.passwordHash, tenant.recoveryHash ?? null,
|
|
387
|
+
tenant.sourceId ?? null, tenant.createdAt, tenant.approvedAt ?? null,
|
|
388
|
+
);
|
|
389
|
+
return store.getTenant(tenant.username);
|
|
390
|
+
},
|
|
391
|
+
getTenant(username) {
|
|
392
|
+
const row = get('SELECT * FROM tenants WHERE username = ?', username);
|
|
393
|
+
if (!row) return null;
|
|
394
|
+
return {
|
|
395
|
+
username: row.username,
|
|
396
|
+
passwordHash: row.password_hash,
|
|
397
|
+
recoveryHash: row.recovery_hash ?? null,
|
|
398
|
+
sourceId: row.source_id ?? null,
|
|
399
|
+
createdAt: row.created_at,
|
|
400
|
+
approvedAt: row.approved_at ?? null,
|
|
401
|
+
disabledAt: row.disabled_at ?? null,
|
|
402
|
+
bytesUsed: row.bytes_used ?? 0,
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
listTenants() {
|
|
406
|
+
return all('SELECT username FROM tenants ORDER BY created_at').map((row) => store.getTenant(row.username));
|
|
407
|
+
},
|
|
408
|
+
updateTenant(username, patch) {
|
|
409
|
+
const map = {
|
|
410
|
+
passwordHash: 'password_hash', recoveryHash: 'recovery_hash', sourceId: 'source_id',
|
|
411
|
+
approvedAt: 'approved_at', disabledAt: 'disabled_at', bytesUsed: 'bytes_used',
|
|
412
|
+
};
|
|
413
|
+
const keys = Object.keys(patch).filter((key) => map[key]);
|
|
414
|
+
if (!keys.length) return store.getTenant(username);
|
|
415
|
+
run(
|
|
416
|
+
`UPDATE tenants SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE username = ?`,
|
|
417
|
+
...keys.map((key) => toBind(patch[key])), username,
|
|
418
|
+
);
|
|
419
|
+
return store.getTenant(username);
|
|
420
|
+
},
|
|
421
|
+
deleteTenant(username) {
|
|
422
|
+
return run('DELETE FROM tenants WHERE username = ?', username).changes > 0;
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
// collections
|
|
426
|
+
insertCollection(record) {
|
|
427
|
+
run(
|
|
428
|
+
`INSERT INTO collections (node_id, slug, submit_token, accepting, role_map, created_at)
|
|
429
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
430
|
+
record.nodeId, record.slug, record.submitToken,
|
|
431
|
+
record.accepting === false ? 0 : 1,
|
|
432
|
+
record.roleMap ? JSON.stringify(record.roleMap) : null,
|
|
433
|
+
record.createdAt,
|
|
434
|
+
);
|
|
435
|
+
return store.getCollection(record.nodeId);
|
|
436
|
+
},
|
|
437
|
+
getCollection(nodeId) {
|
|
438
|
+
const row = get('SELECT * FROM collections WHERE node_id = ?', nodeId);
|
|
439
|
+
return row ? toCollection(row) : null;
|
|
440
|
+
},
|
|
441
|
+
getCollectionBySlug(slug) {
|
|
442
|
+
const row = get('SELECT * FROM collections WHERE slug = ?', slug);
|
|
443
|
+
return row ? toCollection(row) : null;
|
|
444
|
+
},
|
|
445
|
+
listCollections() {
|
|
446
|
+
return all('SELECT * FROM collections ORDER BY created_at DESC').map(toCollection);
|
|
447
|
+
},
|
|
448
|
+
updateCollection(nodeId, patch) {
|
|
449
|
+
const map = { slug: 'slug', submitToken: 'submit_token', accepting: 'accepting', roleMap: 'role_map' };
|
|
450
|
+
const keys = Object.keys(patch).filter((key) => map[key]);
|
|
451
|
+
if (!keys.length) return store.getCollection(nodeId);
|
|
452
|
+
run(
|
|
453
|
+
`UPDATE collections SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE node_id = ?`,
|
|
454
|
+
...keys.map((key) => (key === 'roleMap'
|
|
455
|
+
? (patch[key] ? JSON.stringify(patch[key]) : null)
|
|
456
|
+
: toBind(patch[key]))),
|
|
457
|
+
nodeId,
|
|
458
|
+
);
|
|
459
|
+
return store.getCollection(nodeId);
|
|
460
|
+
},
|
|
461
|
+
deleteCollection(nodeId) {
|
|
462
|
+
return run('DELETE FROM collections WHERE node_id = ?', nodeId).changes > 0;
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
// record auth
|
|
466
|
+
upsertRecordAuth(record) {
|
|
467
|
+
run(
|
|
468
|
+
`INSERT INTO record_auth (node_id, code_hash, failures, locked_until, deleted_at)
|
|
469
|
+
VALUES (?, ?, 0, NULL, NULL)
|
|
470
|
+
ON CONFLICT(node_id) DO UPDATE SET code_hash = excluded.code_hash, failures = 0, locked_until = NULL, deleted_at = NULL`,
|
|
471
|
+
record.nodeId, record.codeHash,
|
|
472
|
+
);
|
|
473
|
+
return store.getRecordAuth(record.nodeId);
|
|
474
|
+
},
|
|
475
|
+
getRecordAuth(nodeId) {
|
|
476
|
+
const row = get('SELECT * FROM record_auth WHERE node_id = ?', nodeId);
|
|
477
|
+
if (!row) return null;
|
|
478
|
+
return {
|
|
479
|
+
nodeId: row.node_id,
|
|
480
|
+
codeHash: row.code_hash,
|
|
481
|
+
failures: row.failures,
|
|
482
|
+
lockedUntil: row.locked_until ?? null,
|
|
483
|
+
deletedAt: row.deleted_at ?? null,
|
|
484
|
+
};
|
|
485
|
+
},
|
|
486
|
+
updateRecordAuth(nodeId, patch) {
|
|
487
|
+
const map = { codeHash: 'code_hash', failures: 'failures', lockedUntil: 'locked_until', deletedAt: 'deleted_at' };
|
|
488
|
+
const keys = Object.keys(patch).filter((key) => map[key]);
|
|
489
|
+
if (!keys.length) return store.getRecordAuth(nodeId);
|
|
490
|
+
run(
|
|
491
|
+
`UPDATE record_auth SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE node_id = ?`,
|
|
492
|
+
...keys.map((key) => toBind(patch[key])), nodeId,
|
|
493
|
+
);
|
|
494
|
+
return store.getRecordAuth(nodeId);
|
|
495
|
+
},
|
|
496
|
+
deleteRecordAuth(nodeId) {
|
|
497
|
+
return run('DELETE FROM record_auth WHERE node_id = ?', nodeId).changes > 0;
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
// operations journal
|
|
501
|
+
insertOperation(op) {
|
|
502
|
+
run(
|
|
503
|
+
'INSERT INTO operations (id, kind, node_id, phase, payload_json, started_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
504
|
+
op.id, op.kind, op.nodeId ?? null, op.phase, JSON.stringify(op.payload ?? {}), op.startedAt,
|
|
505
|
+
);
|
|
506
|
+
return op;
|
|
507
|
+
},
|
|
508
|
+
setOperationPhase(id, phase) {
|
|
509
|
+
run('UPDATE operations SET phase = ? WHERE id = ?', phase, id);
|
|
510
|
+
},
|
|
511
|
+
deleteOperation(id) {
|
|
512
|
+
return run('DELETE FROM operations WHERE id = ?', id).changes > 0;
|
|
513
|
+
},
|
|
514
|
+
listOperations() {
|
|
515
|
+
return all('SELECT * FROM operations ORDER BY started_at').map((row) => ({
|
|
516
|
+
id: row.id,
|
|
517
|
+
kind: row.kind,
|
|
518
|
+
nodeId: row.node_id ?? null,
|
|
519
|
+
phase: row.phase,
|
|
520
|
+
payload: JSON.parse(row.payload_json),
|
|
521
|
+
startedAt: row.started_at,
|
|
522
|
+
}));
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
function toCollection(row) {
|
|
527
|
+
let roleMap = null;
|
|
528
|
+
if (row.role_map) { try { roleMap = JSON.parse(row.role_map); } catch { roleMap = null; } }
|
|
529
|
+
return {
|
|
530
|
+
nodeId: row.node_id,
|
|
531
|
+
slug: row.slug,
|
|
532
|
+
submitToken: row.submit_token,
|
|
533
|
+
accepting: !!row.accepting,
|
|
534
|
+
roleMap,
|
|
535
|
+
createdAt: row.created_at,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return store;
|
|
540
|
+
}
|
package/src/templates.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { readdirIfExists, readFileTextIfExists, statIfExists } from './util/fsx.js';
|
|
4
|
+
|
|
5
|
+
// Starter documents. They ship as ordinary files in the package rather than as
|
|
6
|
+
// strings in a module, because they are meant to be read, copied and pillaged:
|
|
7
|
+
// a starter you cannot open in an editor teaches nothing.
|
|
8
|
+
|
|
9
|
+
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'templates');
|
|
10
|
+
|
|
11
|
+
const BLANK = `<!DOCTYPE html>
|
|
12
|
+
<html>
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
+
<title>Untitled</title>
|
|
17
|
+
<script src="/_/clay.js"></script>
|
|
18
|
+
<style>
|
|
19
|
+
body { font: 16px/1.6 system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; }
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<h1 contenteditable>Untitled</h1>
|
|
24
|
+
<p contenteditable>Click Edit, change anything, then save. The file you are reading is the database.</p>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export function createTemplates({ logger = console } = {}) {
|
|
30
|
+
async function list() {
|
|
31
|
+
const names = await readdirIfExists(TEMPLATES_DIR);
|
|
32
|
+
const found = names
|
|
33
|
+
.filter((name) => name.endsWith('.html'))
|
|
34
|
+
.map((name) => name.replace(/\.html$/, ''));
|
|
35
|
+
return found.includes('blank') ? found : ['blank', ...found];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function read(name = 'blank') {
|
|
39
|
+
const safe = String(name || 'blank').replace(/[^a-z0-9-]/gi, '');
|
|
40
|
+
if (!safe) return BLANK;
|
|
41
|
+
const file = path.join(TEMPLATES_DIR, `${safe}.html`);
|
|
42
|
+
if (!(await statIfExists(file))) {
|
|
43
|
+
if (safe !== 'blank') logger.warn?.(`[makerclay] unknown template "${name}", using blank`);
|
|
44
|
+
return BLANK;
|
|
45
|
+
}
|
|
46
|
+
return (await readFileTextIfExists(file)) || BLANK;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { list, read, dir: TEMPLATES_DIR };
|
|
50
|
+
}
|