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 console.log('ts');
162 // TODO: succeed early if they're already in?
163 const body = await getRequesBody(req);
164 const { secret_password } = JSON.parse(body);
165 const { did } = user;
166 const role = 'early';
167 const updated = db.setRole({ did, role, secret_password });
168 if (updated) {
169 return okBye(res);
170 } else {
171 return forbidden(res);
172 }
173};
174
175const handleListSecrets = async (db, res) => {
176 const secrets = db.getSecrets();
177 return ok(res, secrets);
178};
179
180const handleAddSecret = async (db, req, res) => {
181 const body = await getRequesBody(req);
182 const { secret_password } = JSON.parse(body);
183 try {
184 db.addTopSecret(secret_password);
185 } catch (e) {
186 if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) {
187 return conflict(res);
188 }
189 throw e;
190 }
191 return gotIt(res);
192};
193
194const handleExpireSecret = async (db, req, res) => {
195 const body = await getRequesBody(req);
196 const { secret_password } = JSON.parse(body);
197 if (db.expireTopSecret(secret_password)) {
198 return gotIt(res);
199 } else {
200 return notModified(res);
201 }
202};
203
204const handleTopSecretAccounts = async (db, req, res, searchParams) => {
205 const accounts = db.getSecretAccounts(searchParams.get('secret_password'));
206 return ok(res, accounts);
207};
208
209
210/////// end handlers
211
212const attempt = listener => async (req, res) => {
213 console.log(`-> ${req.method} ${req.url}`);
214 try {
215 await listener(req, res);
216 console.log(` <-${req.method} ${req.url} (${res.statusCode})`);
217 } catch (e) {
218 console.error('listener errored:', e);
219 return serverError(res);
220 }
221};
222
223const withCors = (allowedOrigin, listener) => {
224 const corsHeaders = new Headers({
225 'Access-Control-Allow-Origin': allowedOrigin,
226 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST',
227 'Access-Control-Allow-Headers': 'Content-Type',
228 'Access-Control-Allow-Credentials': 'true',
229 });
230 return (req, res) => {
231 res.setHeaders(corsHeaders);
232 if (req.method === 'OPTIONS') {
233 return okBye(res);
234 }
235 return listener(req, res);
236 }
237}
238
239export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => {
240 const handler = (req, res) => {
241 // don't love this but whatever
242 const { pathname, searchParams } = new URL(`http://localhost${req.url}`);
243 const { method } = req;
244
245 // public (we're doing fall-through auth, what could go wrong)
246 if (method === 'GET' && pathname === '/') {
247 return handleIndex(req, res, {});
248 }
249 if (method === 'POST' && pathname === '/verify') {
250 return handleVerify(db, req, res, secrets, jwks, adminDid);
251 }
252
253 // semi-public
254 const user = getUser(req, res, db, secrets.appSecret, adminDid);
255 if (method === 'GET' && pathname === '/hello') {
256 return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost);
257 }
258
259 // login required
260 if (method === 'POST' && pathname === '/logout') {
261 if (!user) return unauthorized(res);
262 return handleLogout(db, user, req, res, secrets.appSecret, updateSubs);
263 }
264 if (method === 'POST' && pathname === '/super-top-secret-access') {
265 if (!user) return unauthorized(res);
266 return handleTopSecret(db, user, req, res);
267 }
268
269 // non-public access required
270 if (method === 'POST' && pathname === '/subscribe') {
271 if (!user || user.role === 'public') return forbidden(res);
272 return handleSubscribe(db, user, req, res, updateSubs);
273 }
274 if (method === 'POST' && pathname === '/push-test') {
275 if (!user || user.role === 'public') return forbidden(res);
276 return handlePushTest(db, user, res, push);
277 }
278
279 // admin required (just 404 for non-admin)
280 if (user?.role === 'admin') {
281 if (method === 'GET' && pathname === '/top-secrets') {
282 return handleListSecrets(db, res);
283 }
284 if (method === 'POST' && pathname === '/top-secret') {
285 return handleAddSecret(db, req, res);
286 }
287 if (method === 'POST' && pathname === '/expire-top-secret') {
288 return handleExpireSecret(db, req, res);
289 }
290 if (method === 'GET' && pathname === '/top-secret-accounts') {
291 return handleTopSecretAccounts(db, req, res, searchParams);
292 }
293 }
294
295 // sigh
296 return notFound(res);
297 };
298
299 return http.createServer(attempt(withCors(allowedOrigin, handler)));
300}