@makerclay/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
@@ -0,0 +1,355 @@
1
+ import path from 'upath';
2
+ import { can } from '../auth/can.js';
3
+ import { HostError } from '../spec/codes.js';
4
+ import { hashPassword, verifyPassword, generateRecoveryCode, normalizeRecoveryCode } from '../auth/passwords.js';
5
+ import { readFileText, readdirIfExists } from '../util/fsx.js';
6
+
7
+ // Signups: someone who is not the owner gets their own copy of one of the
8
+ // owner's documents, in their own realm, under their own login.
9
+ //
10
+ // Two things make this safe enough to offer at all, and neither is optional:
11
+ //
12
+ // 1. Invariant 1. Every tenant document is served under an opaque origin, so
13
+ // a tenant's HTML cannot read the owner's cookies, cannot script the
14
+ // dashboard, and cannot reach another tenant's document. §16.1 spells out
15
+ // why nothing weaker works on a single origin.
16
+ // 2. The fork is a COPY, not a link. Alice's instance is her document from the
17
+ // first byte: the owner can read it and moderate it, and cannot edit it.
18
+ //
19
+ // There is no email on this server, so the only way back into a lost account is
20
+ // a recovery code shown once at signup, or the owner issuing a new one.
21
+
22
+ const USERNAME = /^[a-z0-9_-]{2,32}$/;
23
+
24
+ // Not a routing need: `~` keeps tenant paths out of the owner's namespace on its
25
+ // own. These are the names that would let someone look like the machine.
26
+ const RESERVED_USERNAMES = new Set(['_', 'admin', 'api', 'makerclay', 'root', 'owner', 'support']);
27
+
28
+ // §16.5 names both `features.tenants` and `tenants.enabled`. Two switches for
29
+ // one thing is a bug waiting to happen (and `serve.js` already gates realm
30
+ // resolution on the first), so `features.tenants` is THE switch and this object
31
+ // holds only the knobs.
32
+ export const TENANT_DEFAULTS = {
33
+ approve: true,
34
+ maxTenants: 100,
35
+ maxDocsPerTenant: 25,
36
+ maxBytesPerTenant: 100e6,
37
+ signupRateLimit: '10/hour',
38
+ };
39
+
40
+ export function usernameError(raw) {
41
+ const name = String(raw ?? '').trim().toLowerCase();
42
+ if (!name) return 'Pick a username.';
43
+ if (!USERNAME.test(name)) {
44
+ return 'Usernames are 2 to 32 characters, using a-z, 0-9, hyphen and underscore.';
45
+ }
46
+ if (RESERVED_USERNAMES.has(name)) return 'That username is reserved.';
47
+ return null;
48
+ }
49
+
50
+ export function createTenants({
51
+ store, paths, ops, config, clock, events,
52
+ sessions = () => null, saveTokens = null, logger = console,
53
+ }) {
54
+ const limits = { ...TENANT_DEFAULTS, ...(config.tenants || {}) };
55
+ const enabled = config.features?.tenants === true;
56
+
57
+ function assert(condition, code, message) {
58
+ if (!condition) throw new HostError(code, message);
59
+ }
60
+
61
+ function requireEnabled() {
62
+ assert(enabled && store, 'not-found', 'This host does not take signups.');
63
+ }
64
+
65
+ function principal(username) {
66
+ return `tenant:${username}`;
67
+ }
68
+
69
+ // Sessions and save tokens are the same authority in two shapes, so they are
70
+ // always revoked together. A suspension that ends only the session leaves the
71
+ // tenant writing through their save token for its full TTL.
72
+ function endAccess(username) {
73
+ sessions()?.destroyAllFor(principal(username));
74
+ saveTokens?.revokeForPrincipal(principal(username));
75
+ }
76
+
77
+ // Provisioning and removal are authorized by the OWNER and performed in the
78
+ // TENANT's realm, which is the only realm the capability matrix lets that work
79
+ // in: `can()` gives the owner no write on someone else's tree, deliberately
80
+ // (§16.5, there is no owner-edits-instance lane). Every call site here checks
81
+ // `admin` first and then acts as the person whose tree it is.
82
+ function asTenant(username) {
83
+ return { kind: 'tenant', username, grants: {} };
84
+ }
85
+
86
+ function shape(row) {
87
+ if (!row) return null;
88
+ return {
89
+ username: row.username,
90
+ createdAt: row.createdAt,
91
+ approvedAt: row.approvedAt ?? null,
92
+ disabledAt: row.disabledAt ?? null,
93
+ sourceId: row.sourceId ?? null,
94
+ bytesUsed: row.bytesUsed ?? 0,
95
+ state: row.disabledAt ? 'suspended' : (row.approvedAt ? 'active' : 'waiting'),
96
+ };
97
+ }
98
+
99
+ function get(username) {
100
+ if (!store) return null;
101
+ return shape(store.getTenant(String(username || '').toLowerCase()));
102
+ }
103
+
104
+ function list({ actor, ctx = {} }) {
105
+ assert(can(actor, 'admin', null, ctx), 'not-found', 'Not found');
106
+ if (!store) return [];
107
+ return store.listTenants().map((row) => ({ ...shape(row), usage: usage(row.username) }));
108
+ }
109
+
110
+ // ---- quotas ------------------------------------------------------------
111
+
112
+ function usage(username) {
113
+ if (!store) return { docs: 0, bytes: 0 };
114
+ const rows = store.listNodes({ owner: username }).filter((row) => row.kind !== 'dir');
115
+ return {
116
+ docs: rows.length,
117
+ bytes: rows.reduce((total, row) => total + (row.bytes || 0), 0),
118
+ };
119
+ }
120
+
121
+ // Called by the save lane before the kernel runs, because a quota that only
122
+ // notices afterwards is an audit log, not a limit.
123
+ function checkQuota(username, { relPath, bytes }) {
124
+ if (!store) return;
125
+ const row = store.getTenant(username);
126
+ if (!row) return;
127
+ const current = usage(username);
128
+ const existing = store.getNode(username, relPath);
129
+ const docs = existing ? current.docs : current.docs + 1;
130
+ const total = current.bytes - (existing?.bytes || 0) + bytes;
131
+
132
+ assert(docs <= limits.maxDocsPerTenant, 'too-large',
133
+ `That would be ${docs} documents, and this server allows ${limits.maxDocsPerTenant} each.`);
134
+ assert(total <= limits.maxBytesPerTenant, 'too-large',
135
+ 'That would put you over your storage limit on this server.');
136
+ }
137
+
138
+ function recordUsage(username) {
139
+ if (!store || !store.getTenant(username)) return;
140
+ store.updateTenant(username, { bytesUsed: usage(username).bytes });
141
+ }
142
+
143
+ // ---- signup ------------------------------------------------------------
144
+
145
+ // The document being signed up to has to be an ORIGINAL that its owner opened
146
+ // for signups. Each clause closes a different hole: a private app would leak
147
+ // through its own instances, and an instance-of-an-instance would let one
148
+ // tenant recruit tenants of their own inside someone else's tree.
149
+ function signupTarget(node) {
150
+ assert(node && !node.provisional, 'not-found', 'Not found');
151
+ assert(node.signups, 'not-found', 'Not found');
152
+ assert(!node.private, 'not-found', 'Not found');
153
+ assert((node.owner ?? '') === '', 'forbidden', 'An instance cannot take signups of its own.');
154
+ assert(!node.sourceId, 'forbidden', 'An instance cannot take signups of its own.');
155
+ return node;
156
+ }
157
+
158
+ async function signup({ appNode, username: raw, password, res = null }) {
159
+ requireEnabled();
160
+ const app = signupTarget(appNode);
161
+
162
+ const username = String(raw ?? '').trim().toLowerCase();
163
+ const problem = usernameError(username);
164
+ assert(!problem, 'bad-request', problem || '');
165
+ assert(typeof password === 'string' && password.length >= 8,
166
+ 'bad-request', 'Choose a password of at least 8 characters.');
167
+ assert(!store.getTenant(username), 'conflict', 'That username is taken.');
168
+ assert(store.listTenants().length < limits.maxTenants,
169
+ 'forbidden', 'This server is not taking any more signups.');
170
+
171
+ const recoveryCode = generateRecoveryCode();
172
+ store.insertTenant({
173
+ username,
174
+ passwordHash: await hashPassword(password),
175
+ recoveryHash: await hashPassword(normalizeRecoveryCode(recoveryCode)),
176
+ sourceId: app.id,
177
+ createdAt: clock.now(),
178
+ // Approval is the default because an open signup form on a box someone
179
+ // runs at home is a way to fill their disk.
180
+ approvedAt: limits.approve ? null : clock.now(),
181
+ });
182
+ events.emit('tenant-created', { username });
183
+
184
+ if (limits.approve) {
185
+ return { username, state: 'waiting', recoveryCode, instance: null };
186
+ }
187
+ const instance = await provision(username, app);
188
+ if (res) sessions()?.issue(res, principal(username));
189
+ return { username, state: 'active', recoveryCode, instance };
190
+ }
191
+
192
+ // The fork. A copy of the app's bytes, created through the mutation kernel
193
+ // like every other document (invariant 2), which is also what strips the
194
+ // ephemeral save token on the way in.
195
+ async function provision(username, app) {
196
+ const realm = paths.realmRoot(username);
197
+ const source = await paths.resolveRead(app.owner ?? '', app.path);
198
+ const bytes = await readFileText(source);
199
+
200
+ const created = await ops.createDocument({
201
+ actor: asTenant(username),
202
+ owner: username,
203
+ name: path.basename(app.path),
204
+ html: bytes,
205
+ ctx: {},
206
+ });
207
+ store.updateNode(created.node.id, { sourceId: app.id });
208
+ recordUsage(username);
209
+ logger.info?.(`[makerclay] provisioned /~${username}/${created.path}`);
210
+ return { path: created.path, url: `/~${username}/${created.path}`, realm };
211
+ }
212
+
213
+ // ---- owner-side management ---------------------------------------------
214
+
215
+ async function approve({ actor, username, ctx: gate = {} }) {
216
+ requireEnabled();
217
+ assert(can(actor, 'admin', null, gate), 'not-found', 'Not found');
218
+ const row = store.getTenant(String(username || '').toLowerCase());
219
+ assert(row, 'not-found', 'No such person.');
220
+ assert(!row.approvedAt, 'conflict', 'They are already approved.');
221
+
222
+ const app = store.getNodeById(row.sourceId);
223
+ assert(app, 'not-found', 'The document they signed up to is gone.');
224
+
225
+ store.updateTenant(row.username, { approvedAt: clock.now(), disabledAt: null });
226
+ const instance = await provision(row.username, app);
227
+ events.emit('tenant-approved', { username: row.username });
228
+ return { ...get(row.username), instance };
229
+ }
230
+
231
+ function suspend({ actor, username, ctx: gate = {}, suspended = true }) {
232
+ requireEnabled();
233
+ assert(can(actor, 'admin', null, gate), 'not-found', 'Not found');
234
+ const row = store.getTenant(String(username || '').toLowerCase());
235
+ assert(row, 'not-found', 'No such person.');
236
+
237
+ store.updateTenant(row.username, { disabledAt: suspended ? clock.now() : null });
238
+ // Suspension has to evict, not just refuse the next login: a live session is
239
+ // exactly what a suspension is trying to end.
240
+ if (suspended) endAccess(row.username);
241
+ events.emit('tenant-updated', { username: row.username });
242
+ return get(row.username);
243
+ }
244
+
245
+ // No email on this server, so a reset is a code the owner reads out. It also
246
+ // signs every device out, because a reset nobody can be evicted by is theatre.
247
+ async function issueRecoveryCode({ actor, username, ctx: gate = {} }) {
248
+ requireEnabled();
249
+ assert(can(actor, 'admin', null, gate), 'not-found', 'Not found');
250
+ const row = store.getTenant(String(username || '').toLowerCase());
251
+ assert(row, 'not-found', 'No such person.');
252
+
253
+ const code = generateRecoveryCode();
254
+ store.updateTenant(row.username, { recoveryHash: await hashPassword(normalizeRecoveryCode(code)) });
255
+ endAccess(row.username);
256
+ return { username: row.username, recoveryCode: code };
257
+ }
258
+
259
+ // Trashes the tree rather than deleting it, so an owner who suspends the wrong
260
+ // person has the same undo everything else on this host has.
261
+ async function remove({ actor, username, ctx: gate = {} }) {
262
+ requireEnabled();
263
+ assert(can(actor, 'admin', null, gate), 'not-found', 'Not found');
264
+ const row = store.getTenant(String(username || '').toLowerCase());
265
+ assert(row, 'not-found', 'No such person.');
266
+
267
+ endAccess(row.username);
268
+ const realm = paths.realmRoot(row.username);
269
+ const trashed = [];
270
+ for (const name of await readdirIfExists(realm)) {
271
+ if (name.startsWith('.')) continue;
272
+ try {
273
+ await ops.trash({ actor: asTenant(row.username), owner: row.username, relPath: name, ctx: gate });
274
+ trashed.push(name);
275
+ } catch (error) {
276
+ logger.warn?.(`[makerclay] could not trash /~${row.username}/${name}:`, error?.message || error);
277
+ }
278
+ }
279
+ store.deleteTenant(row.username);
280
+ events.emit('tenant-removed', { username: row.username, trashed });
281
+ return { ok: true, username: row.username, trashed };
282
+ }
283
+
284
+ // ---- the tenant's own session ------------------------------------------
285
+
286
+ async function login({ username: raw, password, res }) {
287
+ requireEnabled();
288
+ const username = String(raw ?? '').trim().toLowerCase();
289
+ const row = store.getTenant(username);
290
+ // One message for every failure: which of "no such person", "not approved
291
+ // yet" and "wrong password" it was is not a stranger's business.
292
+ const fail = () => { throw new HostError('unauthorized', 'That username and password do not match.'); };
293
+ if (!row || !row.approvedAt || row.disabledAt) fail();
294
+ if (!(await verifyPassword(password, row.passwordHash))) fail();
295
+
296
+ sessions()?.issue(res, principal(username));
297
+ return { username, home: await homeOf(username) };
298
+ }
299
+
300
+ function logout(req, res) {
301
+ sessions()?.destroy(req, res);
302
+ return { ok: true };
303
+ }
304
+
305
+ async function recover({ username: raw, code, password, res }) {
306
+ requireEnabled();
307
+ const username = String(raw ?? '').trim().toLowerCase();
308
+ const row = store.getTenant(username);
309
+ const fail = () => { throw new HostError('unauthorized', 'That recovery code is not right.'); };
310
+ if (!row || !row.recoveryHash || row.disabledAt) fail();
311
+ if (!(await verifyPassword(normalizeRecoveryCode(code), row.recoveryHash))) fail();
312
+ assert(typeof password === 'string' && password.length >= 8,
313
+ 'bad-request', 'Choose a password of at least 8 characters.');
314
+
315
+ // A recovery code is single use, and a fresh one is issued in its place, so
316
+ // nobody is ever one lost password away from having no way back in.
317
+ const next = generateRecoveryCode();
318
+ store.updateTenant(username, {
319
+ passwordHash: await hashPassword(password),
320
+ recoveryHash: await hashPassword(normalizeRecoveryCode(next)),
321
+ });
322
+ endAccess(username);
323
+ sessions()?.issue(res, principal(username));
324
+ return { username, recoveryCode: next, home: await homeOf(username) };
325
+ }
326
+
327
+ async function homeOf(username) {
328
+ const rows = store ? store.listNodes({ owner: username, kind: 'doc' }) : [];
329
+ if (rows.length) return `/~${username}/${rows[0].path}`;
330
+ const names = await readdirIfExists(paths.realmRoot(username));
331
+ const first = names.find((name) => /\.html?$/i.test(name));
332
+ return first ? `/~${username}/${first}` : `/~${username}/`;
333
+ }
334
+
335
+ return {
336
+ enabled,
337
+ limits,
338
+ usernameError,
339
+ get,
340
+ list,
341
+ usage,
342
+ checkQuota,
343
+ recordUsage,
344
+ signupTarget,
345
+ signup,
346
+ approve,
347
+ suspend,
348
+ issueRecoveryCode,
349
+ remove,
350
+ login,
351
+ logout,
352
+ recover,
353
+ homeOf,
354
+ };
355
+ }
@@ -0,0 +1,91 @@
1
+ // Which documents run under an opaque origin.
2
+ //
3
+ // off nothing in the owner's realm is sandboxed
4
+ // untrusted every tenant document, plus any document the owner never saved
5
+ // through this host (uploaded, rsynced, restored from a backup).
6
+ // Needs a store to answer that; with store: null it runs as `off`.
7
+ // all everything, the right posture for hosting files you did not write
8
+ //
9
+ // A per-node override in nodes.isolated wins over the config, in the owner's own
10
+ // realm only: a tenant document is sandboxed under every mode and every override.
11
+
12
+ const ISOLATION_MODES = ['off', 'untrusted', 'all'];
13
+
14
+ export const SANDBOX_HEADER =
15
+ 'sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads allow-top-navigation-by-user-activation';
16
+
17
+ // Isolation decides two things and they must not drift apart: whether a node is
18
+ // sandboxed, and what the response carrying its bytes says. Every lane that
19
+ // sends node bytes calls this, so a new one cannot forget the header the way the
20
+ // version-read lane did.
21
+ export function applySecurityHeaders(res, isolated) {
22
+ res.setHeader('X-Content-Type-Options', 'nosniff');
23
+ if (!isolated) return;
24
+ res.setHeader('Content-Security-Policy', SANDBOX_HEADER);
25
+ res.setHeader('Referrer-Policy', 'no-referrer');
26
+ }
27
+
28
+ export function createIsolation({ config, store = null, logger = console }) {
29
+ const requested = ISOLATION_MODES.includes(config.isolation) ? config.isolation : 'untrusted';
30
+
31
+ // `untrusted` asks a question only a store can answer: has the owner ever saved
32
+ // this document through this host? With no store there are no rows, so every
33
+ // node is provisional and every `etag` is null. Read literally that says "this
34
+ // host has never vouched for anything", and the mode sandboxes the entire tree.
35
+ //
36
+ // That is not a strict reading of `untrusted`, it is a different mode: it is
37
+ // `all` in everything but name, and it silently breaks saving. A sandboxed
38
+ // document sends `Origin: null`, which the CSRF guard only accepts when the
39
+ // request carries a save token, and minting a save token needs the store that
40
+ // is not there. So the advertised `store: null` configuration boots, serves,
41
+ // and cannot save a single document from a browser.
42
+ //
43
+ // "Cannot know" is not "untrusted". With no memory to consult, the honest
44
+ // answer is the one that matches the configuration's own promise: no store
45
+ // means no per-document state, so no per-document sandboxing either.
46
+ const downgraded = requested === 'untrusted' && !store;
47
+ const mode = downgraded ? 'off' : requested;
48
+
49
+ if (downgraded) {
50
+ logger.warn?.(
51
+ '[makerclay] isolation "untrusted" needs a store to know which documents this host has saved. '
52
+ + 'With store: null it would sandbox every document and, because a sandboxed save needs a save '
53
+ + 'token this host cannot mint, make saving impossible. Running as isolation "off". '
54
+ + 'Set isolation: "all" if you meant to sandbox everything, and expect saves to be refused.',
55
+ );
56
+ }
57
+ if (mode === 'all' && !store) {
58
+ logger.warn?.(
59
+ '[makerclay] isolation "all" with store: null sandboxes every document, and a sandboxed save '
60
+ + 'needs a save token this host cannot mint without a store. Documents will serve but no save '
61
+ + 'will succeed from a browser.',
62
+ );
63
+ }
64
+
65
+ function isolatedFor(node) {
66
+ if (!node) return mode === 'all';
67
+ // A tenant realm is isolated unconditionally, ahead of both the per-node
68
+ // override and `isolation: 'off'`. Those two exist for the owner's own files.
69
+ // Letting either reach a tenant document means the owner opens someone else's
70
+ // HTML at their own origin, carrying their own session, which is the one
71
+ // outcome invariant 1 exists to prevent. There is no legitimate reason to
72
+ // turn this off for one document, so it is not offered.
73
+ if ((node.owner ?? '') !== '') return true;
74
+ if (node.isolated === true) return true;
75
+ if (node.isolated === false) return false;
76
+ if (mode === 'off') return false;
77
+ if (mode === 'all') return true;
78
+ // `etag` is written only by the mutation kernel, so a null one means the owner
79
+ // has never saved this document through this host: it was uploaded, rsynced or
80
+ // dropped in by an editor, and nobody here has vouched for it.
81
+ //
82
+ // Deliberately a fact about the DOCUMENT, not about the bytes currently on
83
+ // disk. A document saved once stays trusted even if it is later replaced from
84
+ // outside, because the alternative sandboxes every file the owner has ever
85
+ // edited in a terminal, which on this host is nearly all of them. The residual
86
+ // risk is restoring a compromised backup over a live install.
87
+ return node.etag == null;
88
+ }
89
+
90
+ return { mode, isolatedFor };
91
+ }
@@ -0,0 +1,131 @@
1
+ import express from 'express';
2
+ import { can } from '../auth/can.js';
3
+ import { jsonError } from '../json-errors.js';
4
+ import { createRateLimiter, parseRate } from '../auth/rate-limit.js';
5
+
6
+ // The signup surface. Everything here is origin-checked by the host's CSRF guard
7
+ // (no exemptions: unlike a collection submission, a signup has no reason to come
8
+ // from someone else's site) and the public half is rate-limited by IP.
9
+
10
+ export function mountTenantRoutes(ctx) {
11
+ const { router, config, tenants, resolveTarget, gateCtx, wrap, clock, ui } = ctx;
12
+ const json = express.json({ limit: '64kb' });
13
+ const form = express.urlencoded({ extended: false, limit: '64kb' });
14
+ const rate = parseRate(tenants.limits.signupRateLimit, { max: 10, windowMs: 3_600_000 });
15
+ const limiter = createRateLimiter({ clock, ...rate });
16
+
17
+ function requireOwner(req, res, next) {
18
+ if (!can(req.actor, 'admin', null, gateCtx)) return jsonError(res, 'not-found', 'Not found');
19
+ return next();
20
+ }
21
+
22
+ function wantsJson(req) {
23
+ return req.accepts(['html', 'json']) === 'json' || req.is('application/json');
24
+ }
25
+
26
+ function finish(req, res, body, redirect) {
27
+ if (wantsJson(req) || !redirect) return res.json({ ok: true, ...body });
28
+ return res.redirect(303, redirect);
29
+ }
30
+
31
+ function throttle(req, res) {
32
+ const verdict = limiter.check(req);
33
+ if (verdict.ok) return true;
34
+ res.setHeader('Retry-After', String(verdict.retryAfter));
35
+ jsonError(res, 'rate-limited', 'Too many attempts. Try again later.');
36
+ return false;
37
+ }
38
+
39
+ // ---- the public half ---------------------------------------------------
40
+
41
+ router.post('/tenant/signup', json, form, wrap(async (req, res) => {
42
+ if (!throttle(req, res)) return undefined;
43
+ const { app: appHref, username, password } = req.body || {};
44
+ if (typeof appHref !== 'string' || !appHref) {
45
+ return jsonError(res, 'bad-request', 'Which document are you signing up to?');
46
+ }
47
+
48
+ // Resolved through the normal read gate, so a private app is a 404 here for
49
+ // exactly the same reason it is a 404 everywhere else.
50
+ const found = await resolveTarget(req.actor, appHref, 'read');
51
+ if (!found) return jsonError(res, 'not-found', 'Not found');
52
+
53
+ // No `forgive` here, unlike login: the limit exists to stop a spray of
54
+ // SUCCESSFUL signups filling someone's disk, so a success has to count.
55
+ const result = await tenants.signup({
56
+ appNode: found.node, username, password, ctx: gateCtx, res,
57
+ });
58
+
59
+ return finish(req, res, {
60
+ username: result.username,
61
+ state: result.state,
62
+ // Shown once. There is no email on this server, so this is the only way
63
+ // back in, and the caller has to put it in front of the person NOW.
64
+ recoveryCode: result.recoveryCode,
65
+ home: result.instance?.url ?? null,
66
+ msg: result.state === 'waiting'
67
+ ? 'Your account is waiting for the owner to approve it.'
68
+ : 'Your copy is ready.',
69
+ }, result.instance?.url ?? null);
70
+ }));
71
+
72
+ router.post('/tenant/login', json, form, wrap(async (req, res) => {
73
+ if (!throttle(req, res)) return undefined;
74
+ const { username, password } = req.body || {};
75
+ const result = await tenants.login({ username, password, res });
76
+ limiter.forgive(req);
77
+ return finish(req, res, result, result.home);
78
+ }));
79
+
80
+ router.post('/tenant/logout', wrap(async (req, res) => {
81
+ return finish(req, res, tenants.logout(req, res), '/');
82
+ }));
83
+
84
+ router.post('/tenant/recover', json, form, wrap(async (req, res) => {
85
+ if (!throttle(req, res)) return undefined;
86
+ const { username, code, password } = req.body || {};
87
+ const result = await tenants.recover({ username, code, password, res });
88
+ limiter.forgive(req);
89
+ return finish(req, res, result, null);
90
+ }));
91
+
92
+ // Where a tenant lands with no idea what their document is called.
93
+ router.get('/tenant/home', wrap(async (req, res) => {
94
+ if (req.actor?.kind !== 'tenant') return jsonError(res, 'unauthorized', 'Sign in first.');
95
+ return res.redirect(303, await tenants.homeOf(req.actor.username));
96
+ }));
97
+
98
+ // ---- the owner's Tenants view -------------------------------------------
99
+
100
+ router.get('/tenants', requireOwner, wrap(async (req, res) => {
101
+ res.json({ ok: true, tenants: tenants.list({ actor: req.actor, ctx: gateCtx }), limits: tenants.limits });
102
+ }));
103
+
104
+ router.post('/tenants/:username/approve', requireOwner, wrap(async (req, res) => {
105
+ res.json({ ok: true, tenant: await tenants.approve({ actor: req.actor, username: req.params.username, ctx: gateCtx }) });
106
+ }));
107
+
108
+ for (const [route, suspended] of [['suspend', true], ['resume', false]]) {
109
+ router.post(`/tenants/:username/${route}`, requireOwner, wrap(async (req, res) => {
110
+ res.json({
111
+ ok: true,
112
+ tenant: tenants.suspend({ actor: req.actor, username: req.params.username, ctx: gateCtx, suspended }),
113
+ });
114
+ }));
115
+ }
116
+
117
+ router.post('/tenants/:username/reset', requireOwner, wrap(async (req, res) => {
118
+ const result = await tenants.issueRecoveryCode({ actor: req.actor, username: req.params.username, ctx: gateCtx });
119
+ // Read this out to them. It is single use and it signs their devices out.
120
+ res.json({ ok: true, ...result });
121
+ }));
122
+
123
+ router.post('/tenants/:username/delete', requireOwner, wrap(async (req, res) => {
124
+ res.json(await tenants.remove({ actor: req.actor, username: req.params.username, ctx: gateCtx }));
125
+ }));
126
+
127
+ router.get('/tenants.html', requireOwner, wrap(async (req, res) => {
128
+ if (config.features?.dashboard === false) return jsonError(res, 'not-found', 'Not found');
129
+ return ui.send(res, 'tenants', { title: 'People on this server' });
130
+ }));
131
+ }
package/src/ui.js ADDED
@@ -0,0 +1,95 @@
1
+ import path from 'upath';
2
+ import { fileURLToPath } from 'node:url';
3
+ import {
4
+ readdirIfExists, readFileTextIfExists, statIfExists, atomicWrite, mkdirp,
5
+ } from './util/fsx.js';
6
+
7
+ // Two copies of the shipped UI, and a third the owner can make (§17.2).
8
+ //
9
+ // packages/core/ui/ read-only, what the release contains
10
+ // <data>/ui/ copied on first run, and what actually serves
11
+ // files/_ui/ `makerclay eject`: ordinary, editable, VERSIONED
12
+ // malleable documents, preferred when present
13
+ //
14
+ // The canonical UI stays boring and unbrickable; `makerclay repair --ui`
15
+ // restores it. The ejected copy is the fork button that makes "you own this" a
16
+ // command rather than a slogan.
17
+
18
+ const PACKAGE_UI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui');
19
+ const EJECT_DIR = '_ui';
20
+
21
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
22
+
23
+ function escapeHtml(value) {
24
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ESCAPES[char]);
25
+ }
26
+
27
+ // {{name}} is escaped, {{{name}}} is raw. A missing key renders empty rather
28
+ // than leaving the braces on screen.
29
+ export function renderTemplate(source, vars = {}) {
30
+ return source
31
+ .replace(/\{\{\{\s*([\w.]+)\s*\}\}\}/g, (_, key) => String(vars[key] ?? ''))
32
+ .replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => escapeHtml(vars[key] ?? ''));
33
+ }
34
+
35
+ export function createUi({ paths, logger = console }) {
36
+ async function packagePages() {
37
+ return (await readdirIfExists(PACKAGE_UI))
38
+ .filter((name) => name.endsWith('.html') || name.endsWith('.css'));
39
+ }
40
+
41
+ // Copy the shipped pages into the data directory on first run. Never
42
+ // overwrites: an owner who edited <data>/ui/ keeps their edits, and
43
+ // `repair --ui` is the explicit way back.
44
+ async function ensure({ force = false } = {}) {
45
+ await mkdirp(paths.dirs.ui);
46
+ let copied = 0;
47
+ for (const name of await packagePages()) {
48
+ const target = path.join(paths.dirs.ui, name);
49
+ if (!force && (await statIfExists(target))) continue;
50
+ const source = await readFileTextIfExists(path.join(PACKAGE_UI, name));
51
+ if (source == null) continue;
52
+ await atomicWrite(target, source);
53
+ copied += 1;
54
+ }
55
+ if (copied) logger.info?.(`[makerclay] installed ${copied} UI page(s) into ${paths.dirs.ui}`);
56
+ return { copied };
57
+ }
58
+
59
+ // Ejected copy first, then the data directory, then the package. One chain,
60
+ // so an owner who ejects gets their version of every page at once.
61
+ async function resolveFile(filename) {
62
+ const safe = String(filename).replace(/[^a-z0-9.-]/gi, '');
63
+ if (!safe || safe.includes('..')) return null;
64
+ for (const dir of [path.join(paths.dirs.files, EJECT_DIR), paths.dirs.ui, PACKAGE_UI]) {
65
+ const candidate = path.join(dir, safe);
66
+ if (await statIfExists(candidate)) return candidate;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ async function resolve(name) {
72
+ return resolveFile(`${String(name).replace(/[^a-z0-9-]/gi, '')}.html`);
73
+ }
74
+
75
+ async function render(name, vars = {}) {
76
+ const file = await resolve(name);
77
+ if (!file) return null;
78
+ const source = await readFileTextIfExists(file);
79
+ if (source == null) return null;
80
+ return renderTemplate(source, vars);
81
+ }
82
+
83
+ async function send(res, name, vars = {}, status = 200) {
84
+ const body = await render(name, vars);
85
+ if (body == null) {
86
+ return res.status(500).type('text/html')
87
+ .send('<!DOCTYPE html><title>Missing page</title><h1>This page is missing</h1><p>Run <code>makerclay repair --ui</code>.</p>');
88
+ }
89
+ res.status(status).type('text/html; charset=utf-8');
90
+ res.setHeader('Cache-Control', 'no-store');
91
+ return res.send(body);
92
+ }
93
+
94
+ return { ensure, resolve, resolveFile, render, send, packageDir: PACKAGE_UI, ejectDir: EJECT_DIR, packagePages };
95
+ }