bit of api refactor

Changed files
+76 -90
server
+76 -90
server/api.js
··· 5 import cookieSig from 'cookie-signature'; 6 import { v4 as uuidv4 } from 'uuid'; 7 8 const getRequesBody = async req => new Promise((resolve, reject) => { 9 let body = ''; 10 req.on('data', chunk => body += chunk); ··· 24 { ...COOKIE_BASE, expires: new Date(0) }, 25 )); 26 27 - const getAccountCookie = (req, res, appSecret, adminDid, noDidCheck = false) => { 28 const cookies = cookie.parse(req.headers.cookie ?? ''); 29 const untrusted = cookies['verified-account'] ?? ''; 30 const json = cookieSig.unsign(untrusted, appSecret); ··· 40 clearAccountCookie(res); 41 return null; 42 } 43 - 44 - // not yet public!! 45 - if (!did || (did !== adminDid && !noDidCheck)) { 46 - console.log('no, clearing you', did, did === adminDid, noDidCheck); 47 - clearAccountCookie(res) 48 - .setHeader('Content-Type', 'application/json') 49 - .writeHead(403) 50 - .end(JSON.stringify({ 51 - reason: 'the spacedust notifications demo isn\'t public yet!', 52 - })); 53 - throw new Error('unauthorized'); 54 } 55 56 - return [did, session, did && (did === adminDid)]; 57 - }; 58 59 // never EVER allow user-controllable input into fname (or just fix the path joining) 60 const handleFile = (fname, ftype) => async (req, res, replace = {}) => { ··· 64 content = content.toString(); 65 } catch (err) { 66 console.error(err); 67 - res.writeHead(500); 68 - res.end('Internal server error'); 69 - return; 70 } 71 res.setHeader('Content-Type', ftype); 72 res.writeHead(200); ··· 76 res.end(content); 77 } 78 const handleIndex = handleFile('index.html', 'text/html'); 79 - const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 80 81 - const handleHello = async (db, req, res, secrets, whoamiHost, adminDid) => { 82 - const resBase = { webPushPublicKey: secrets.pushKeys.publicKey, whoamiHost }; 83 - res.setHeader('Content-Type', 'application/json'); 84 - let info = getAccountCookie(req, res, secrets.appSecret, adminDid, true); 85 - if (info) { 86 - const [did, _session, isAdmin] = info; 87 - let role = db.getAccount(did)?.role; 88 - role = isAdmin ? 'admin' : (role ?? 'public'); 89 - res 90 - .setHeader('Content-Type', 'application/json') 91 - .writeHead(200) 92 - .end(JSON.stringify({ ...resBase, role, did })); 93 - } else { 94 - res 95 - .setHeader('Content-Type', 'application/json') 96 - .writeHead(200) 97 - .end(JSON.stringify({ ...resBase, role: 'anonymous' })); 98 - } 99 - }; 100 - 101 - const handleVerify = async (db, req, res, secrets, whoamiHost, adminDid, jwks) => { 102 const body = await getRequesBody(req); 103 const { token } = JSON.parse(body); 104 let did; ··· 107 did = verified.payload.sub; 108 } catch (e) { 109 console.warn('jwks verification failed', e); 110 - return clearAccountCookie(res).writeHead(400).end(JSON.stringify({ reason: 'verification failed' })); 111 } 112 const isAdmin = did && did === adminDid; 113 db.addAccount(did); 114 const session = uuidv4(); 115 setAccountCookie(res, did, session, secrets.appSecret); 116 - return res 117 - .setHeader('Content-Type', 'application/json') 118 - .writeHead(200) 119 - .end(JSON.stringify({ 120 - did, 121 role: isAdmin ? 'admin' : 'public', 122 - webPushPublicKey: secrets.pushKeys.publicKey, 123 - })); 124 }; 125 126 - const handleSubscribe = async (db, req, res, appSecret, updateSubs, adminDid) => { 127 - let info = getAccountCookie(req, res, appSecret, adminDid, true); 128 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 129 - const [did, session, _isAdmin] = info; 130 const body = await getRequesBody(req); 131 const { sub } = JSON.parse(body); 132 - // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) 133 try { 134 - db.addPushSub(did, session, JSON.stringify(sub)); 135 } catch (e) { 136 console.warn('failed to add sub', e); 137 - return res 138 - .setHeader('Content-Type', 'application/json') 139 - .writeHead(500) 140 - .end(JSON.stringify({ reason: 'failed to register subscription' })); 141 } 142 updateSubs(db); 143 - res.setHeader('Content-Type', 'application/json'); 144 - res.writeHead(201); 145 - res.end(JSON.stringify({ sup: 'hi' })); 146 }; 147 148 - const handleLogout = async (db, req, res, appSecret, updateSubs) => { 149 - let info = getAccountCookie(req, res, appSecret, null, true); 150 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 151 - const [_did, session, _isAdmin] = info; 152 try { 153 - db.deleteSub(session); 154 } catch (e) { 155 console.warn('failed to remove sub', e); 156 - return res 157 - .setHeader('Content-Type', 'application/json') 158 - .writeHead(500) 159 - .end(JSON.stringify({ reason: 'failed to register subscription' })); 160 } 161 updateSubs(db); 162 clearAccountCookie(res); 163 - res.setHeader('Content-Type', 'application/json'); 164 - res.writeHead(201); 165 - res.end(JSON.stringify({ sup: 'bye' })); 166 } 167 168 - const handleTopSecret = async (db, req, res, appSecret) => { 169 - let info = getAccountCookie(req, res, appSecret, null, true); 170 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 171 - const [did, _session, _isAdmin] = info; 172 const body = await getRequesBody(req); 173 const { secret_password } = JSON.parse(body); 174 console.log({ secret_password }); 175 const role = 'early'; 176 - db.setRole(did, role, secret_password); 177 - res.setHeader('Content-Type', 'application/json') 178 - .writeHead(200) 179 - .end('"heyyy"'); 180 } 181 182 const attempt = listener => async (req, res) => { ··· 185 return await listener(req, res); 186 } catch (e) { 187 console.error('listener errored:', e); 188 } 189 }; 190 ··· 198 return (req, res) => { 199 res.setHeaders(corsHeaders); 200 if (req.method === 'OPTIONS') { 201 - return res.writeHead(204).end(); 202 } 203 return listener(req, res); 204 } ··· 206 207 export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid) => { 208 const handler = (req, res) => { 209 if (req.method === 'GET' && req.url === '/') { 210 - return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 211 - } 212 - if (req.method === 'GET' && req.url === '/hello') { 213 - return handleHello(db, req, res, secrets, whoamiHost, adminDid); 214 } 215 if (req.method === 'POST' && req.url === '/verify') { 216 - return handleVerify(db, req, res, secrets, whoamiHost, adminDid, jwks); 217 } 218 - if (req.method === 'POST' && req.url === '/subscribe') { 219 - return handleSubscribe(db, req, res, secrets.appSecret, updateSubs, adminDid); 220 } 221 if (req.method === 'POST' && req.url === '/logout') { 222 - return handleLogout(db, req, res, secrets.appSecret, updateSubs); 223 } 224 if (req.method === 'POST' && req.url === '/super-top-secret-access') { 225 return handleTopSecret(db, req, res, secrets.appSecret); 226 } 227 228 - res.writeHead(404).end('not found (sorry)'); 229 }; 230 231 return http.createServer(attempt(withCors(allowedOrigin, handler)));
··· 5 import cookieSig from 'cookie-signature'; 6 import { v4 as uuidv4 } from 'uuid'; 7 8 + const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code); 9 + const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason })); 10 + 11 + const badRequest = (res, reason) => errJson(400, reason)(res); 12 + const forbidden = errJson(401, 'forbidden'); 13 + const unauthorized = errJson(403, 'unauthorized'); 14 + const notFound = errJson(404, 'not found'); 15 + const serverError = errJson(500, 'internal server error'); 16 + const okBye = res => res.writeHead(204).end(); 17 + const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data)); 18 + 19 const getRequesBody = async req => new Promise((resolve, reject) => { 20 let body = ''; 21 req.on('data', chunk => body += chunk); ··· 35 { ...COOKIE_BASE, expires: new Date(0) }, 36 )); 37 38 + const getUser = (req, res, db, appSecret, adminDid) => { 39 const cookies = cookie.parse(req.headers.cookie ?? ''); 40 const untrusted = cookies['verified-account'] ?? ''; 41 const json = cookieSig.unsign(untrusted, appSecret); ··· 51 clearAccountCookie(res); 52 return null; 53 } 54 + let role; 55 + if (did === adminDid) { 56 + role = 'admin'; 57 + } else { 58 + const account = db.getAccount(did); 59 + if (!account) { 60 + console.warn('valid account cookie but could not find in db'); 61 + clearAccountCookie(res); 62 + return null; 63 + } 64 + role = account.role ?? 'public'; 65 } 66 + return { did, session, role }; 67 + }; 68 69 70 // never EVER allow user-controllable input into fname (or just fix the path joining) 71 const handleFile = (fname, ftype) => async (req, res, replace = {}) => { ··· 75 content = content.toString(); 76 } catch (err) { 77 console.error(err); 78 + return serverError(res); 79 } 80 res.setHeader('Content-Type', ftype); 81 res.writeHead(200); ··· 85 res.end(content); 86 } 87 const handleIndex = handleFile('index.html', 'text/html'); 88 89 + const handleVerify = async (db, req, res, secrets, jwks, adminDid) => { 90 const body = await getRequesBody(req); 91 const { token } = JSON.parse(body); 92 let did; ··· 95 did = verified.payload.sub; 96 } catch (e) { 97 console.warn('jwks verification failed', e); 98 + return badRequest(res, 'token verification failed'); 99 } 100 const isAdmin = did && did === adminDid; 101 db.addAccount(did); 102 const session = uuidv4(); 103 setAccountCookie(res, did, session, secrets.appSecret); 104 + return ok(res, { 105 + webPushPublicKey: secrets.pushKeys.publicKey, 106 role: isAdmin ? 'admin' : 'public', 107 + did, 108 + }); 109 }; 110 111 + const handleHello = async (user, req, res, webPushPublicKey, whoamiHost) => 112 + ok(res, { 113 + whoamiHost, 114 + webPushPublicKey, 115 + role: user?.role ?? 'anonymous', 116 + did: user?.did, 117 + }); 118 + 119 + const handleSubscribe = async (db, user, req, res, updateSubs) => { 120 const body = await getRequesBody(req); 121 const { sub } = JSON.parse(body); 122 try { 123 + db.addPushSub(user.did, user.session, JSON.stringify(sub)); 124 } catch (e) { 125 console.warn('failed to add sub', e); 126 + return serverError(res); 127 } 128 updateSubs(db); 129 + return okBye(res); 130 }; 131 132 + const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { 133 try { 134 + db.deleteSub(user.session); 135 } catch (e) { 136 console.warn('failed to remove sub', e); 137 + return serverError(res); 138 } 139 updateSubs(db); 140 clearAccountCookie(res); 141 + return okBye(res); 142 } 143 144 + const handleTopSecret = async (db, user, req, res) => { 145 const body = await getRequesBody(req); 146 const { secret_password } = JSON.parse(body); 147 console.log({ secret_password }); 148 const role = 'early'; 149 + db.setRole(user.did, role, secret_password); 150 + return okBye(res); 151 } 152 153 const attempt = listener => async (req, res) => { ··· 156 return await listener(req, res); 157 } catch (e) { 158 console.error('listener errored:', e); 159 + return serverError(res); 160 } 161 }; 162 ··· 170 return (req, res) => { 171 res.setHeaders(corsHeaders); 172 if (req.method === 'OPTIONS') { 173 + return okBye(res); 174 } 175 return listener(req, res); 176 } ··· 178 179 export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid) => { 180 const handler = (req, res) => { 181 + // public (we're doing fall-through auth, what could go wrong) 182 if (req.method === 'GET' && req.url === '/') { 183 + return handleIndex(req, res, {}); 184 } 185 if (req.method === 'POST' && req.url === '/verify') { 186 + return handleVerify(db, req, res, secrets, jwks, adminDid); 187 } 188 + 189 + // semi-public 190 + const user = getUser(req, res, db, secrets.appSecret, adminDid); 191 + if (req.method === 'GET' && req.url === '/hello') { 192 + return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost); 193 } 194 + 195 + // login required 196 if (req.method === 'POST' && req.url === '/logout') { 197 + if (!user) return unauthorized(res); 198 + return handleLogout(db, user, req, res, secrets.appSecret, updateSubs); 199 } 200 if (req.method === 'POST' && req.url === '/super-top-secret-access') { 201 + if (!user) return unauthorized(res); 202 return handleTopSecret(db, req, res, secrets.appSecret); 203 } 204 205 + // non-public access required 206 + if (req.method === 'POST' && req.url === '/subscribe') { 207 + if (!user || user.role === 'public') return forbidden(res); 208 + return handleSubscribe(db, user, req, res, updateSubs); 209 + } 210 + 211 + // admin required 212 + 213 + // sigh 214 + return notFound(res); 215 }; 216 217 return http.createServer(attempt(withCors(allowedOrigin, handler)));