@makerclay/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- package/ui/versions.html +114 -0
package/src/auth/none.js
ADDED
|
@@ -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
|
+
}
|