demos for spacedust
1import fs from 'node:fs';
2import http from 'http';
3import { jwtVerify } from 'jose';
4import cookie from 'cookie';
5import cookieSig from 'cookie-signature';
6import { v4 as uuidv4 } from 'uuid';
7
8const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code);
9const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason }));
10
11const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data));
12const gotIt = res => res.writeHead(201).end();
13const okBye = res => res.writeHead(204).end();
14const notModified = res => res.writeHead(304).end();
15const badRequest = (res, reason) => errJson(400, reason)(res);
16const forbidden = errJson(401, 'forbidden');
17const unauthorized = errJson(403, 'unauthorized');
18const notFound = errJson(404, 'not found');
19const conflict = errJson(409, 'conflict');
20const serverError = errJson(500, 'internal server error');
21
22const getRequesBody = async req => new Promise((resolve, reject) => {
23 let body = '';
24 req.on('data', chunk => body += chunk);
25 req.on('end', () => resolve(body));
26 req.on('error', err => reject(err));
27});
28
29const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' };
30const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize(
31 'verified-account',
32 cookieSig.sign(JSON.stringify([did, session]), appSecret),
33 { ...COOKIE_BASE, maxAge: 90 * 86_400 },
34));
35const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize(
36 'verified-account',
37 '',
38 { ...COOKIE_BASE, expires: new Date(0) },
39));
40
41const getUser = (req, res, db, appSecret, adminDid) => {
42 const cookies = cookie.parse(req.headers.cookie ?? '');
43 const untrusted = cookies['verified-account'] ?? '';
44 const json = cookieSig.unsign(untrusted, appSecret);
45 if (!json) {
46 clearAccountCookie(res);
47 return null;
48 }
49 let did, session;
50 try {
51 [did, session] = JSON.parse(json);
52 } catch (e) {
53 console.warn('validated account cookie but failed to parse json', e);
54 clearAccountCookie(res);
55 return null;
56 }
57 let role;
58 if (did === adminDid) {
59 role = 'admin';
60 } else {
61 const account = db.getAccount(did);
62 if (!account) {
63 console.warn('valid account cookie but could not find in db');
64 clearAccountCookie(res);
65 return null;
66 }
67 role = account.role ?? 'public';
68 }
69 return { did, session, role };
70};
71
72/////// handlers
73
74// never EVER allow user-controllable input into fname (or just fix the path joining)
75const handleFile = (fname, ftype) => async (req, res, replace = {}) => {
76 let content
77 try {
78 content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER
79 content = content.toString();
80 } catch (err) {
81 console.error(err);
82 return serverError(res);
83 }
84 res.setHeader('Content-Type', ftype);
85 res.writeHead(200);
86 for (let k in replace) {
87 content = content.replace(k, JSON.stringify(replace[k]));
88 }
89 res.end(content);
90}
91const handleIndex = handleFile('index.html', 'text/html');
92
93const handleVerify = async (db, req, res, secrets, jwks, adminDid) => {
94 const body = await getRequesBody(req);
95 const { token } = JSON.parse(body);
96 let did;
97 try {
98 const verified = await jwtVerify(token, jwks);
99 did = verified.payload.sub;
100 } catch (e) {
101 console.warn('jwks verification failed', e);
102 return badRequest(res, 'token verification failed');
103 }
104 const isAdmin = did && did === adminDid;
105 db.addAccount(did);
106 const session = uuidv4();
107 setAccountCookie(res, did, session, secrets.appSecret);
108 return ok(res, {
109 webPushPublicKey: secrets.pushKeys.publicKey,
110 role: isAdmin ? 'admin' : 'public',
111 did,
112 });
113};
114
115const handleHello = async (user, req, res, webPushPublicKey, whoamiHost) =>
116 ok(res, {
117 whoamiHost,
118 webPushPublicKey,
119 role: user?.role ?? 'anonymous',
120 did: user?.did,
121 });
122
123const handleSubscribe = async (db, user, req, res, updateSubs) => {
124 const body = await getRequesBody(req);
125 const { sub } = JSON.parse(body);
126 try {
127 db.addPushSub(user.did, user.session, JSON.stringify(sub));
128 } catch (e) {
129 console.warn('failed to add sub', e);
130 return serverError(res);
131 }
132 updateSubs(db);
133 return gotIt(res);
134};
135
136const handlePushTest = async (db, user, res, push) => {
137 const subscription = db.getSubBySession(user.session);
138 const payload = JSON.stringify({
139 subject: user.did,
140 source: 'blue.microcosm.test.notification:hello',
141 source_record: `at://${user.did}/blue.microcosm.test.notification/test`,
142 timestamp: +new Date(),
143 });
144 await push(db, subscription, payload);
145 return okBye(res);
146};
147
148const handleLogout = async (db, user, req, res, appSecret, updateSubs) => {
149 try {
150 db.deleteSub(user.session);
151 } catch (e) {
152 console.warn('failed to remove sub', e);
153 return serverError(res);
154 }
155 updateSubs(db);
156 clearAccountCookie(res);
157 return okBye(res);
158};
159
160const handleTopSecret = async (db, user, req, res) => {
161 // TODO: succeed early if they're already in?
162 const body = await getRequesBody(req);
163 const { secret_password } = JSON.parse(body);
164 const { did } = user;
165 const role = 'early';
166 const updated = db.setRole({ did, role, secret_password });
167 if (updated) {
168 return okBye(res);
169 } else {
170 return forbidden(res);
171 }
172};
173
174const handleGetGlobalNotifySettings = async (db, user, res) => {
175 const settings = db.getNotifyAccountGlobals(user.did);
176 return ok(res, settings);
177};
178
179const handleSetGlobalNotifySettings = async (db, user, req, res) => {
180 const body = await getRequesBody(req);
181 const { notify_enabled, notify_self } = JSON.parse(body);
182 db.setNotifyAccountGlobals(user.did, { notify_enabled, notify_self });
183 return gotIt(res);
184};
185
186const handleGetNotificationFilter = async (db, user, searchParams, res) => {
187 const selector = searchParams.get('selector');
188 if (!selector) return badRequest(res, '"selector" required in search query');
189
190 const selection = searchParams.get('selection');
191 if (!selection) return badRequest(res, '"selection" required in search query');
192
193 const { did } = user;
194
195 const notify = db.getNotificationFilter(did, selector, selection) ?? null;
196 return ok(res, { notify });
197};
198
199const handleSetNotificationFilter = async (db, user, req, res) => {
200 const body = await getRequesBody(req);
201 const { selector, selection, notify } = JSON.parse(body);
202 const { did } = user;
203 db.setNotificationFilter(did, selector, selection, notify);
204 return ok(res, { notify });
205};
206
207/// admin stuff
208
209const handleListSecrets = async (db, res) => {
210 const secrets = db.getSecrets();
211 return ok(res, secrets);
212};
213
214const handleAddSecret = async (db, req, res) => {
215 const body = await getRequesBody(req);
216 const { secret_password } = JSON.parse(body);
217 try {
218 db.addTopSecret(secret_password);
219 } catch (e) {
220 if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) {
221 return conflict(res);
222 }
223 throw e;
224 }
225 return gotIt(res);
226};
227
228const handleExpireSecret = async (db, req, res) => {
229 const body = await getRequesBody(req);
230 const { secret_password } = JSON.parse(body);
231 if (db.expireTopSecret(secret_password)) {
232 return gotIt(res);
233 } else {
234 return notModified(res);
235 }
236};
237
238const handleTopSecretAccounts = async (db, req, res, searchParams) => {
239 const secret = searchParams.get('secret_password');
240 const accounts = secret ? db.getSecretAccounts(secret) : db.getNonSecretAccounts();
241 return ok(res, accounts);
242};
243
244
245/////// end handlers
246
247const attempt = listener => async (req, res) => {
248 console.log(`-> ${req.method} ${req.url}`);
249 try {
250 await listener(req, res);
251 console.log(` <-${req.method} ${req.url} (${res.statusCode})`);
252 } catch (e) {
253 console.error('listener errored:', e);
254 return serverError(res);
255 }
256};
257
258const withCors = (allowedOrigin, listener) => {
259 const corsHeaders = new Headers({
260 'Access-Control-Allow-Origin': allowedOrigin,
261 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST',
262 'Access-Control-Allow-Headers': 'Content-Type',
263 'Access-Control-Allow-Credentials': 'true',
264 });
265 return (req, res) => {
266 res.setHeaders(corsHeaders);
267 if (req.method === 'OPTIONS') {
268 return okBye(res);
269 }
270 return listener(req, res);
271 }
272}
273
274export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => {
275 const handler = (req, res) => {
276 // don't love this but whatever
277 const { pathname, searchParams } = new URL(`http://localhost${req.url}`);
278 const { method } = req;
279
280 // public (we're doing fall-through auth, what could go wrong)
281 if (method === 'GET' && pathname === '/') {
282 return handleIndex(req, res, {});
283 }
284 if (method === 'POST' && pathname === '/verify') {
285 return handleVerify(db, req, res, secrets, jwks, adminDid);
286 }
287
288 // semi-public
289 const user = getUser(req, res, db, secrets.appSecret, adminDid);
290 if (method === 'GET' && pathname === '/hello') {
291 return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost);
292 }
293
294 // login required
295 if (method === 'POST' && pathname === '/logout') {
296 if (!user) return unauthorized(res);
297 return handleLogout(db, user, req, res, secrets.appSecret, updateSubs);
298 }
299 if (method === 'POST' && pathname === '/super-top-secret-access') {
300 if (!user) return unauthorized(res);
301 return handleTopSecret(db, user, req, res);
302 }
303 if (method === 'GET' && pathname === '/global-notify') {
304 if (!user) return unauthorized(res);
305 return handleGetGlobalNotifySettings(db, user, res);
306 }
307 if (method === 'POST' && pathname === '/global-notify') {
308 if (!user) return unauthorized(res);
309 return handleSetGlobalNotifySettings(db, user, req, res);
310 }
311 if (method === 'GET' && pathname === '/notification-filter') {
312 if (!user) return unauthorized(res);
313 return handleGetNotificationFilter(db, user, searchParams, res);
314 }
315 if (method === 'POST' && pathname === '/notification-filter') {
316 if (!user) return unauthorized(res);
317 return handleSetNotificationFilter(db, user, req, res);
318 }
319
320 // non-public access required
321 if (method === 'POST' && pathname === '/subscribe') {
322 if (!user || user.role === 'public') return forbidden(res);
323 return handleSubscribe(db, user, req, res, updateSubs);
324 }
325 if (method === 'POST' && pathname === '/push-test') {
326 if (!user || user.role === 'public') return forbidden(res);
327 return handlePushTest(db, user, res, push);
328 }
329
330 // admin required (just 404 for non-admin)
331 if (user?.role === 'admin') {
332 if (method === 'GET' && pathname === '/top-secrets') {
333 return handleListSecrets(db, res);
334 }
335 if (method === 'POST' && pathname === '/top-secret') {
336 return handleAddSecret(db, req, res);
337 }
338 if (method === 'POST' && pathname === '/expire-top-secret') {
339 return handleExpireSecret(db, req, res);
340 }
341 if (method === 'GET' && pathname === '/top-secret-accounts') {
342 return handleTopSecretAccounts(db, req, res, searchParams);
343 }
344 }
345
346 // sigh
347 return notFound(res);
348 };
349
350 return http.createServer(attempt(withCors(allowedOrigin, handler)));
351}