@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,24 @@
1
+ // Everyone-is-owner. This is hyperclay-local's posture: a single-user process on
2
+ // loopback where every file on disk belongs to the person at the keyboard.
3
+ //
4
+ // The two client-readable cookies are set unconditionally, byte-for-byte what
5
+ // hyperclay-local does today, because clayjs reads `isAdminOfCurrentResource`
6
+ // (httpOnly:false) to decide whether the page is editable.
7
+
8
+ export function none() {
9
+ return {
10
+ name: 'none',
11
+
12
+ routes() {},
13
+
14
+ async identify() {
15
+ return { kind: 'owner' };
16
+ },
17
+
18
+ decorate(req, res) {
19
+ const options = { httpOnly: false, secure: false, sameSite: 'lax' };
20
+ res.cookie('isAdminOfCurrentResource', 'true', options);
21
+ res.cookie('isLoggedIn', 'true', options);
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,281 @@
1
+ import crypto from 'node:crypto';
2
+ import express from 'express';
3
+ import { createSessions } from './sessions.js';
4
+ import { hashPassword, verifyPassword } from './passwords.js';
5
+ import { createRateLimiter } from './rate-limit.js';
6
+ import { createCsrfGuard } from './csrf.js';
7
+ import { jsonError } from '../json-errors.js';
8
+ import { token as mintToken } from '../util/id.js';
9
+
10
+ // Single-owner password auth, WordPress-style first run. No email anywhere.
11
+ //
12
+ // The owner's hash lives in `kv`, not in a users table, and that is deliberate:
13
+ // the schema cannot express two owners. There is no invite flow to leave
14
+ // half-finished and no "admin" role to escalate into.
15
+ //
16
+ // First boot prints a one-time setup key to the console. Requiring it closes the
17
+ // race-to-claim hole: without it, an exposed fresh server belongs to whoever
18
+ // loads /_/setup first, which on a public IP is a scanner, not the owner.
19
+
20
+ const OWNER_HASH_KEY = 'owner_password';
21
+ const SETUP_TOKEN_KEY = 'setup_token';
22
+ const RESET_TOKEN_KEY = 'reset_token';
23
+ const RESET_EXPIRY_KEY = 'reset_token_expires';
24
+
25
+ const MIN_PASSWORD = 8;
26
+ const RESET_TTL_MS = 60 * 60 * 1000;
27
+
28
+ // The setup page needs the client to boot, and /_/health is how anyone finds
29
+ // out what is going on. Everything else waits for an owner to exist.
30
+ const OPEN_WHILE_UNCLAIMED = /^\/_\/(setup|health|clay[\w-]*\.js|sap\.js|all\.js|src\/)/;
31
+
32
+ function timingSafeEquals(a, b) {
33
+ const left = Buffer.from(String(a ?? ''), 'utf8');
34
+ const right = Buffer.from(String(b ?? ''), 'utf8');
35
+ if (left.length !== right.length || left.length === 0) return false;
36
+ return crypto.timingSafeEqual(left, right);
37
+ }
38
+
39
+ export function ownerPassword(options = {}) {
40
+ let ctx = null;
41
+ let sessions = null;
42
+ let loginLimiter = null;
43
+
44
+ function ownerHash() {
45
+ return ctx.store.getKv(OWNER_HASH_KEY);
46
+ }
47
+
48
+ function claimed() {
49
+ return !!ownerHash();
50
+ }
51
+
52
+ // Minted once, when the host starts with nobody in charge of it.
53
+ function setupToken() {
54
+ if (!ctx?.store || claimed()) return null;
55
+ let value = ctx.store.getKv(SETUP_TOKEN_KEY);
56
+ if (!value) {
57
+ value = mintToken(18);
58
+ ctx.store.setKv(SETUP_TOKEN_KEY, value);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ async function setPassword(raw) {
64
+ if (typeof raw !== 'string' || raw.length < MIN_PASSWORD) {
65
+ throw new Error(`The password must be at least ${MIN_PASSWORD} characters.`);
66
+ }
67
+ ctx.store.setKv(OWNER_HASH_KEY, await hashPassword(raw));
68
+ ctx.store.deleteKv(SETUP_TOKEN_KEY);
69
+ ctx.store.deleteKv(RESET_TOKEN_KEY);
70
+ ctx.store.deleteKv(RESET_EXPIRY_KEY);
71
+ return true;
72
+ }
73
+
74
+ // `makerclay reset-password` on the box. The recovery path is physical access
75
+ // to the machine, which is the only authority this host recognises above the
76
+ // owner's own password.
77
+ function createResetToken() {
78
+ const value = mintToken(18);
79
+ ctx.store.setKv(RESET_TOKEN_KEY, value);
80
+ ctx.store.setKv(RESET_EXPIRY_KEY, String(ctx.clock.now() + RESET_TTL_MS));
81
+ return { token: value, expiresAt: ctx.clock.now() + RESET_TTL_MS };
82
+ }
83
+
84
+ function resetTokenValid(candidate) {
85
+ const stored = ctx.store.getKv(RESET_TOKEN_KEY);
86
+ const expires = Number(ctx.store.getKv(RESET_EXPIRY_KEY) || 0);
87
+ if (!stored || !expires || expires <= ctx.clock.now()) return false;
88
+ return timingSafeEquals(candidate, stored);
89
+ }
90
+
91
+ function wantsJson(req) {
92
+ return req.accepts(['html', 'json']) === 'json' || req.is('application/json');
93
+ }
94
+
95
+ function finish(req, res, redirect, body = {}) {
96
+ if (wantsJson(req)) return res.json({ ok: true, redirect, ...body });
97
+ return res.redirect(303, redirect);
98
+ }
99
+
100
+ const provider = {
101
+ name: 'password',
102
+
103
+ routes(app, hostCtx) {
104
+ ctx = hostCtx;
105
+ if (!ctx.store) {
106
+ throw new Error('ownerPassword() needs a store: there is nowhere to keep the owner hash.');
107
+ }
108
+
109
+ sessions = createSessions({
110
+ store: ctx.store,
111
+ clock: ctx.clock,
112
+ ids: ctx.ids,
113
+ secure: String(ctx.config.publicUrl || '').startsWith('https://'),
114
+ ttlMs: options.sessionTtlMs,
115
+ });
116
+ loginLimiter = createRateLimiter({ clock: ctx.clock, max: options.loginAttempts ?? 10, windowMs: 15 * 60 * 1000 });
117
+ setupToken();
118
+
119
+ const router = express.Router();
120
+ // Scoped to these four paths, never to `/_` as a whole. A provider mounts
121
+ // ahead of the host's own router, so an unscoped `router.use` here would
122
+ // put a 64kb body limit on every save and re-run the CSRF guard without
123
+ // the host's exemptions.
124
+ const OWN_PATHS = ['/setup', '/login', '/logout', '/reset'];
125
+ router.use(
126
+ OWN_PATHS,
127
+ express.json({ limit: '64kb' }),
128
+ express.urlencoded({ extended: false, limit: '64kb' }),
129
+ createCsrfGuard({ publicUrl: ctx.config.publicUrl || null }),
130
+ );
131
+
132
+ // ---- setup ---------------------------------------------------------
133
+
134
+ router.get('/setup', async (req, res) => {
135
+ if (claimed()) return res.redirect(303, '/_/login');
136
+ return ctx.ui.send(res, 'setup', {
137
+ title: 'Set up makerclay',
138
+ key: typeof req.query.key === 'string' ? req.query.key : '',
139
+ mode: 'setup',
140
+ action: '/_/setup',
141
+ });
142
+ });
143
+
144
+ router.post('/setup', async (req, res) => {
145
+ if (claimed()) return jsonError(res, 'forbidden', 'This server already has an owner.');
146
+ const limit = loginLimiter.check(req);
147
+ if (!limit.ok) return jsonError(res, 'rate-limited', 'Too many attempts. Wait a minute.');
148
+
149
+ const { key, password } = req.body || {};
150
+ if (!timingSafeEquals(key, ctx.store.getKv(SETUP_TOKEN_KEY))) {
151
+ return jsonError(res, 'unauthorized', 'That setup key is not right. It was printed in the terminal at startup.');
152
+ }
153
+ if (typeof password !== 'string' || password.length < MIN_PASSWORD) {
154
+ return jsonError(res, 'bad-request', `Choose a password of at least ${MIN_PASSWORD} characters.`);
155
+ }
156
+
157
+ await setPassword(password);
158
+ loginLimiter.forgive(req);
159
+ sessions.issue(res, 'owner');
160
+ ctx.logger.info?.('[makerclay] owner password set. Setup is closed.');
161
+ return finish(req, res, '/_/');
162
+ });
163
+
164
+ // ---- login ---------------------------------------------------------
165
+
166
+ router.get('/login', async (req, res) => {
167
+ if (!claimed()) return res.redirect(303, '/_/setup');
168
+ // A tenant is signed in but has no dashboard, and `/_/` would send them
169
+ // straight back here. Their own document is the only landing page they
170
+ // have, so send them there rather than around the loop.
171
+ //
172
+ // `req.actor`, never the raw session: a tenant who is not approved, or
173
+ // who was disabled, still holds a `tenant:` cookie that `identify`
174
+ // refuses. Trusting the cookie would send them to a page that refuses
175
+ // them too, and the sign-in page would be unreachable for a month.
176
+ if (req.actor?.kind === 'tenant') return res.redirect(303, '/_/tenant/home');
177
+ if (req.actor?.kind === 'owner') return res.redirect(303, '/_/');
178
+ return ctx.ui.send(res, 'login', { title: 'Sign in', action: '/_/login', error: '' });
179
+ });
180
+
181
+ router.post('/login', async (req, res) => {
182
+ if (!claimed()) return jsonError(res, 'forbidden', 'This server has no owner yet.');
183
+ const limit = loginLimiter.check(req);
184
+ if (!limit.ok) {
185
+ return jsonError(res, 'rate-limited', `Too many attempts. Try again in ${limit.retryAfter} seconds.`);
186
+ }
187
+ const ok = await verifyPassword(req.body?.password, ownerHash());
188
+ if (!ok) return jsonError(res, 'unauthorized', 'That password is not right.');
189
+
190
+ // A correct password must not spend from the attacker budget, or a
191
+ // person who mistypes twice locks themselves out of their own box.
192
+ loginLimiter.forgive(req);
193
+ sessions.issue(res, 'owner');
194
+ return finish(req, res, typeof req.body?.next === 'string' && req.body.next.startsWith('/') ? req.body.next : '/_/');
195
+ });
196
+
197
+ router.post('/logout', (req, res) => {
198
+ sessions.destroy(req, res);
199
+ return finish(req, res, '/_/login');
200
+ });
201
+
202
+ // ---- reset ---------------------------------------------------------
203
+
204
+ router.get('/reset', async (req, res) => {
205
+ return ctx.ui.send(res, 'setup', {
206
+ title: 'Choose a new password',
207
+ key: typeof req.query.key === 'string' ? req.query.key : '',
208
+ mode: 'reset',
209
+ action: '/_/reset',
210
+ });
211
+ });
212
+
213
+ router.post('/reset', async (req, res) => {
214
+ const limit = loginLimiter.check(req);
215
+ if (!limit.ok) return jsonError(res, 'rate-limited', 'Too many attempts. Wait a minute.');
216
+ if (!resetTokenValid(req.body?.key)) {
217
+ return jsonError(res, 'unauthorized', 'That reset link is not valid or has expired. Run `makerclay reset-password` again.');
218
+ }
219
+ const { password } = req.body || {};
220
+ if (typeof password !== 'string' || password.length < MIN_PASSWORD) {
221
+ return jsonError(res, 'bad-request', `Choose a password of at least ${MIN_PASSWORD} characters.`);
222
+ }
223
+
224
+ await setPassword(password);
225
+ // Every other device is signed out. Without this, "reset the password"
226
+ // does not evict whoever prompted the reset.
227
+ sessions.destroyAllFor('owner');
228
+ loginLimiter.forgive(req);
229
+ sessions.issue(res, 'owner');
230
+ return finish(req, res, '/_/');
231
+ });
232
+
233
+ app.use('/_', router);
234
+
235
+ // ---- the unclaimed-server gate --------------------------------------
236
+
237
+ app.use((req, res, next) => {
238
+ if (claimed() || OPEN_WHILE_UNCLAIMED.test(req.path)) return next();
239
+ if (req.method === 'GET' && !wantsJson(req)) return res.redirect(303, '/_/setup');
240
+ return jsonError(res, 'unauthorized', 'This server has not been set up yet. Open /_/setup.');
241
+ });
242
+ },
243
+
244
+ async identify(req) {
245
+ if (!sessions) return null;
246
+ const session = sessions.read(req);
247
+ if (!session) return null;
248
+ if (session.principal === 'owner') return { kind: 'owner' };
249
+ if (session.principal.startsWith('tenant:')) {
250
+ const username = session.principal.slice('tenant:'.length);
251
+ const tenant = ctx.store.getTenant(username);
252
+ if (!tenant || tenant.disabledAt || !tenant.approvedAt) return null;
253
+ return { kind: 'tenant', username };
254
+ }
255
+ return null;
256
+ },
257
+
258
+ decorate(req, res, actor) {
259
+ // clayjs 0.2.0 reads this client-readable cookie to decide whether the
260
+ // page is editable. A share or token grant gets edit mode from the
261
+ // injected savetoken instead, so this only ever speaks for the owner.
262
+ // [wire-compat]
263
+ const isOwner = actor?.kind === 'owner';
264
+ res.cookie('isAdminOfCurrentResource', isOwner ? 'true' : 'false', {
265
+ httpOnly: false, sameSite: 'lax', path: '/',
266
+ });
267
+ res.cookie('isLoggedIn', actor && actor.kind !== 'guest' ? 'true' : 'false', {
268
+ httpOnly: false, sameSite: 'lax', path: '/',
269
+ });
270
+ },
271
+
272
+ // Used by the CLI and by tenants (M5), which share the session table.
273
+ get sessions() { return sessions; },
274
+ claimed: () => claimed(),
275
+ setupToken,
276
+ setPassword,
277
+ createResetToken,
278
+ };
279
+
280
+ return provider;
281
+ }
@@ -0,0 +1,79 @@
1
+ import crypto from 'node:crypto';
2
+ import { promisify } from 'node:util';
3
+
4
+ // Password hashing. scrypt from node:crypto, so there is no bcrypt, no native
5
+ // build step, and nothing to compile when someone runs `npx makerclay` on a
6
+ // machine without a toolchain.
7
+ //
8
+ // One format string holds the parameters, so raising the cost later does not
9
+ // invalidate anyone's existing password: verify reads N/r/p from the stored
10
+ // value, and only a re-hash on next login moves them up.
11
+
12
+ const scrypt = promisify(crypto.scrypt);
13
+
14
+ // Cost is a knob so the test suite can stop paying it. Production stays at the
15
+ // OWASP-ish default; the suite runs thousands of hashes and only cares that the
16
+ // format round-trips. Safe to vary because the parameters travel INSIDE the
17
+ // stored hash: verifyPassword reads N/r/p back off the value it is checking, so
18
+ // a password hashed at one cost still verifies after the cost changes.
19
+ const N = Number(process.env.MAKERCLAY_SCRYPT_N) || 16384;
20
+ const R = 8;
21
+ const P = 1;
22
+ const KEY_LEN = 64;
23
+ const MAXMEM = 64 * 1024 * 1024;
24
+
25
+ export async function hashPassword(password) {
26
+ if (typeof password !== 'string' || password.length === 0) {
27
+ throw new Error('a password is required');
28
+ }
29
+ const salt = crypto.randomBytes(16);
30
+ const key = await scrypt(password, salt, KEY_LEN, { N, r: R, p: P, maxmem: MAXMEM });
31
+ return ['scrypt', N, R, P, salt.toString('base64'), key.toString('base64')].join('$');
32
+ }
33
+
34
+ export async function verifyPassword(password, stored) {
35
+ if (typeof password !== 'string' || typeof stored !== 'string') return false;
36
+ const parts = stored.split('$');
37
+ if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
38
+
39
+ const [, n, r, p, saltB64, keyB64] = parts;
40
+ let derived;
41
+ try {
42
+ const salt = Buffer.from(saltB64, 'base64');
43
+ const expected = Buffer.from(keyB64, 'base64');
44
+ derived = await scrypt(password, salt, expected.length, {
45
+ N: Number(n), r: Number(r), p: Number(p), maxmem: MAXMEM,
46
+ });
47
+ return derived.length === expected.length && crypto.timingSafeEqual(derived, expected);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ // Recovery codes are shown once and never again, because there is no email on
54
+ // this server to send them to. Grouped in fives so they can be read aloud.
55
+ //
56
+ // Crockford base32 specifically: it omits I, L, O and U, and the three
57
+ // confusable ones fold back onto characters that ARE in the alphabet (I and L
58
+ // to 1, O to 0). An alphabet that merely omits 0 and 1 cannot do that, because
59
+ // a person who typed `0` has no valid character to be corrected to.
60
+ // 256 % 32 === 0, so the modulo below is unbiased.
61
+ const RECOVERY_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
62
+
63
+ export function generateRecoveryCode(length = 20) {
64
+ const bytes = crypto.randomBytes(length);
65
+ let out = '';
66
+ for (let i = 0; i < length; i++) {
67
+ if (i > 0 && i % 5 === 0) out += '-';
68
+ out += RECOVERY_ALPHABET[bytes[i] % RECOVERY_ALPHABET.length];
69
+ }
70
+ return out;
71
+ }
72
+
73
+ export function normalizeRecoveryCode(value) {
74
+ return String(value || '')
75
+ .toUpperCase()
76
+ .replace(/[^0-9A-Z]/g, '')
77
+ .replace(/[IL]/g, '1')
78
+ .replace(/O/g, '0');
79
+ }
@@ -0,0 +1,66 @@
1
+ // A sliding-window limiter, in memory, per host.
2
+ //
3
+ // In-memory is the correct scope here and not a shortcut: makerclay is a single
4
+ // writer process by invariant 10, so there is no second node whose counters
5
+ // would need sharing. It exists to make guessing an owner password or spraying
6
+ // signups expensive, not to survive a restart.
7
+
8
+ const DEFAULT_SWEEP_MS = 60_000;
9
+
10
+ export function parseRate(spec, fallback = { max: 30, windowMs: 60_000 }) {
11
+ if (typeof spec === 'number') return { max: spec, windowMs: fallback.windowMs };
12
+ const match = /^(\d+)\s*\/\s*(\d*)\s*(ms|s|sec|m|min|h|hour)$/i.exec(String(spec || '').trim());
13
+ if (!match) return fallback;
14
+ const unit = { ms: 1, s: 1000, sec: 1000, m: 60_000, min: 60_000, h: 3_600_000, hour: 3_600_000 }[match[3].toLowerCase()];
15
+ return { max: Number(match[1]), windowMs: (Number(match[2]) || 1) * unit };
16
+ }
17
+
18
+ function clientKey(req) {
19
+ // req.ip already honors trust proxy when the embedder sets it; the raw socket
20
+ // address is the fallback so a misconfigured proxy fails closed to per-socket
21
+ // limiting rather than to one shared bucket for the whole internet.
22
+ return req.ip || req.socket?.remoteAddress || 'unknown';
23
+ }
24
+
25
+ export function createRateLimiter({ clock = Date, max = 30, windowMs = 60_000, key = clientKey } = {}) {
26
+ const hits = new Map();
27
+ let lastSweep = 0;
28
+
29
+ function sweep(now) {
30
+ if (now - lastSweep < DEFAULT_SWEEP_MS) return;
31
+ lastSweep = now;
32
+ for (const [id, stamps] of hits) {
33
+ const live = stamps.filter((stamp) => now - stamp < windowMs);
34
+ if (live.length) hits.set(id, live);
35
+ else hits.delete(id);
36
+ }
37
+ }
38
+
39
+ function check(req) {
40
+ const now = clock.now ? clock.now() : Date.now();
41
+ sweep(now);
42
+ const id = key(req);
43
+ const stamps = (hits.get(id) || []).filter((stamp) => now - stamp < windowMs);
44
+ if (stamps.length >= max) {
45
+ hits.set(id, stamps);
46
+ return { ok: false, retryAfter: Math.ceil((windowMs - (now - stamps[0])) / 1000) };
47
+ }
48
+ stamps.push(now);
49
+ hits.set(id, stamps);
50
+ return { ok: true, remaining: max - stamps.length };
51
+ }
52
+
53
+ // Undo one hit, so a SUCCESSFUL login does not spend from the attacker budget.
54
+ // Without this, a busy legitimate user locks themselves out.
55
+ function forgive(req) {
56
+ const id = key(req);
57
+ const stamps = hits.get(id);
58
+ if (stamps?.length) stamps.pop();
59
+ }
60
+
61
+ function reset() {
62
+ hits.clear();
63
+ }
64
+
65
+ return { check, forgive, reset, size: () => hits.size };
66
+ }
@@ -0,0 +1,86 @@
1
+ import crypto from 'node:crypto';
2
+ import { token as mintToken } from '../util/id.js';
3
+
4
+ // Sessions, stored as hashes.
5
+ //
6
+ // The cookie holds the only copy of the raw token. The `sessions` table holds
7
+ // sha256 of it, so a stolen database read (a backup on a laptop, a support
8
+ // bundle, the delete-the-db test's own tarball) does not hand anyone a live
9
+ // login. sha256 rather than scrypt is right here and wrong for passwords: the
10
+ // input is 192 bits of our own randomness, so there is nothing to brute-force,
11
+ // and this runs on every single request.
12
+
13
+ const SESSION_COOKIE = 'mk_session';
14
+ const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
15
+ const TOUCH_AFTER_MS = 60 * 60 * 1000;
16
+
17
+ function hashSessionToken(raw) {
18
+ return crypto.createHash('sha256').update(String(raw)).digest('hex');
19
+ }
20
+
21
+ export function createSessions({
22
+ store, clock, ids = { token: mintToken }, ttlMs = DEFAULT_TTL_MS,
23
+ cookieName = SESSION_COOKIE, secure = false,
24
+ }) {
25
+ function cookieOptions(maxAgeMs) {
26
+ return {
27
+ httpOnly: true,
28
+ sameSite: 'lax',
29
+ secure,
30
+ path: '/',
31
+ maxAge: Math.round(maxAgeMs / 1000),
32
+ };
33
+ }
34
+
35
+ function issue(res, principal) {
36
+ if (!store) return null;
37
+ const raw = ids.token(24);
38
+ const now = clock.now();
39
+ store.insertSession({
40
+ tokenHash: hashSessionToken(raw),
41
+ principal,
42
+ createdAt: now,
43
+ expiresAt: now + ttlMs,
44
+ });
45
+ res.cookie(cookieName, raw, cookieOptions(ttlMs));
46
+ return raw;
47
+ }
48
+
49
+ function read(req) {
50
+ if (!store) return null;
51
+ const raw = req.cookies?.[cookieName];
52
+ if (!raw) return null;
53
+ const tokenHash = hashSessionToken(raw);
54
+ const session = store.getSession(tokenHash);
55
+ if (!session) return null;
56
+ const now = clock.now();
57
+ if (session.expiresAt <= now) {
58
+ store.deleteSession(tokenHash);
59
+ return null;
60
+ }
61
+ // One write an hour instead of one per request: last_seen is for the
62
+ // sessions list, not for accounting.
63
+ if (!session.lastSeenAt || now - session.lastSeenAt > TOUCH_AFTER_MS) {
64
+ store.touchSession(tokenHash, now);
65
+ }
66
+ return session;
67
+ }
68
+
69
+ function destroy(req, res) {
70
+ const raw = req.cookies?.[cookieName];
71
+ if (raw && store) store.deleteSession(hashSessionToken(raw));
72
+ res.clearCookie(cookieName, { path: '/' });
73
+ }
74
+
75
+ // Used by a password reset: every other device is logged out, which is the
76
+ // only thing that makes "reset the password" mean anything.
77
+ function destroyAllFor(principal) {
78
+ return store ? store.deleteSessionsFor(principal) : 0;
79
+ }
80
+
81
+ function purgeExpired() {
82
+ return store ? store.purgeExpiredSessions(clock.now()) : 0;
83
+ }
84
+
85
+ return { issue, read, destroy, destroyAllFor, purgeExpired, cookieName };
86
+ }
@@ -0,0 +1,72 @@
1
+ import { createCsrfGuard } from './csrf.js';
2
+
3
+ // The token lanes, and the two gates that both have to know about them.
4
+ //
5
+ // A lane whose credential is a save token in the URL is addressed by a sandboxed
6
+ // document, which has an opaque origin. That single fact has two consequences,
7
+ // and a lane that gets one of them and not the other is broken in a way that
8
+ // shows up as a browser-side CORS failure with a completely healthy server log:
9
+ //
10
+ // 1. §8's origin gate must let it through, because it has no origin to check.
11
+ // 2. It must answer `Access-Control-Allow-Origin: null`, or the response the
12
+ // host sent lands in a browser that will not let the document read it.
13
+ //
14
+ // One list, read by both, in one file, so adding a lane cannot satisfy half of
15
+ // that. `/sync` was the case that proved it: it was in the origin gate and not
16
+ // in the CORS headers, so a sandboxed document could save but never sync, which
17
+ // is the state this host shipped in until 2026-08-28.
18
+
19
+ const TOKEN_LANES = [
20
+ '/save/:token', '/meta/:token', '/versions/:token',
21
+ '/version/:token/:name', '/restore/:token/:name',
22
+ '/upload/:token',
23
+ ];
24
+
25
+ // The sync lane's credential is the same per-document save token, but it arrives
26
+ // as `?token=` rather than in the path, because §10 gives send and receive one
27
+ // address and a GET carries no body.
28
+ const TOKEN_QUERY_LANES = ['/sync', '/live-sync/save', '/live-sync/stream'];
29
+
30
+ const TOKEN_LANE_PATTERN = new RegExp(
31
+ `^(?:${TOKEN_LANES.map((lane) => lane.replace(/:[A-Za-z]+/g, '[^/]+')).join('|')})$`,
32
+ );
33
+ const TOKEN_QUERY_LANE_PATTERN = new RegExp(`^(?:${TOKEN_QUERY_LANES.join('|')})$`);
34
+
35
+ // X-Hyperclay-User-Driven is clayjs 0.2.0's spelling of Save-Trigger, and
36
+ // spec/wire.js already reads it. Leaving it out of the preflight is enough on
37
+ // its own to make every sandboxed save fail before it is sent. [wire-compat]
38
+ const CORS_HEADERS = 'Content-Type, Document-URL, Page-URL, If-Match, Save-Trigger, '
39
+ + 'X-Sender-Id, X-Hyperclay-User-Driven, Accept';
40
+
41
+ export function mountTokenLanes({ system, config }) {
42
+ // Spec §8 lives on the host's own prefix, which is where every state-changing
43
+ // route is. The collection submit lane is exempt because a form on the owner's
44
+ // blog posting into their makerclay is a feature (§15).
45
+ system.use(createCsrfGuard({
46
+ tokenLanes: TOKEN_LANE_PATTERN,
47
+ queryTokenLanes: TOKEN_QUERY_LANE_PATTERN,
48
+
49
+ // The submit lane only. Update, delete and restore stay behind the origin
50
+ // check, because they answer to the owner's cookie and the exemption exists
51
+ // for a stranger's form, not for a stranger's page acting as the owner.
52
+ exempt: [/^\/collection\/[^/]+\/records$/],
53
+ publicUrl: config.publicUrl || null,
54
+ }));
55
+
56
+ // §16.3. `null` rather than `*`: the token in the path is the entire
57
+ // credential, and a page with a real origin has no business reading one. Never
58
+ // Allow-Credentials, because `Origin: null` is forgeable and must never buy
59
+ // ambient authority.
60
+ system.use([...TOKEN_LANES, ...TOKEN_QUERY_LANES], (req, res, next) => {
61
+ res.setHeader('Access-Control-Allow-Origin', 'null');
62
+ res.setHeader('Vary', 'Origin');
63
+ next();
64
+ });
65
+
66
+ system.options([...TOKEN_LANES, ...TOKEN_QUERY_LANES], (req, res) => {
67
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
68
+ res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
69
+ res.setHeader('Access-Control-Max-Age', '600');
70
+ res.status(204).end();
71
+ });
72
+ }