demos for spacedust

dev/prod shenanigans

+85 -50
+2 -2
atproto-notifications/package.json
··· 4 4 "version": "0.0.0", 5 5 "type": "module", 6 6 "scripts": { 7 - "dev": "vite --host 127.0.0.1", 7 + "dev": "VITE_NOTIFICATIONS_HOST=http://127.0.0.1:8000 vite --host 127.0.0.1", 8 8 "build": "tsc -b && vite build", 9 - "just-build": "VITE_PUSH_PUBKEY=BDZZicf6KaHoH6EI9OETLO3G9M4e6mKQDRJDcj_XUUxvdsnz08ne-URkk_ToqpwWgrRqXIBd0LJ_w8bP-R5xMKA vite build", 9 + "just-build": "vite build", 10 10 "lint": "eslint .", 11 11 "preview": "vite preview" 12 12 },
+30 -18
atproto-notifications/src/App.tsx
··· 1 - import { useCallback, useState } from 'react'; 1 + import { useCallback, useState, useEffect } from 'react'; 2 2 import { useLocalStorage } from "@uidotdev/usehooks"; 3 - import { HostContext } from './context' 3 + import { GetJson } from './components/fetch'; 4 4 import { WhoAmI } from './components/WhoAmI'; 5 5 import { Feed } from './components/Feed'; 6 6 import { urlBase64ToUint8Array } from './utils'; ··· 14 14 </div> 15 15 ); 16 16 17 - function requestPermission(host, setAsking, setPermissionError) { 17 + function requestPermission(pushServerHost, pushServerPubkey, setAsking, setPermissionError) { 18 18 return async () => { 19 19 setAsking(true); 20 20 let err; 21 21 try { 22 22 await Notification.requestPermission(); 23 - const sub = await subscribeToPush(); 24 - const res = await fetch(`${host}/subscribe`, { 23 + const sub = await subscribeToPush(pushServerPubkey); 24 + const res = await fetch(`${pushServerHost}/subscribe`, { 25 25 method: 'POST', 26 26 headers: {'Content-Type': 'application/json'}, 27 27 body: JSON.stringify({ sub }), ··· 46 46 } 47 47 48 48 let autoreg; 49 - async function subscribeToPush() { 49 + async function subscribeToPush(pushServerPubkey) { 50 50 const registration = await navigator.serviceWorker.register('/service-worker.js'); 51 51 52 - // auto-update in case they keep it open in a tab for a long time 52 + // auto-update the service worker in case they keep it open in a tab for a long time 53 53 clearInterval(autoreg); 54 54 autoreg = setInterval(() => registration.update(), 4 * 60 * 60 * 1000); // every 4h 55 55 56 - const subscribeOptions = { 56 + return await registration.pushManager.subscribe({ 57 + pushServerPubkey: urlBase64ToUint8Array(pushServerPubkey), 57 58 userVisibleOnly: true, 58 - applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_PUSH_PUBKEY), 59 - }; 60 - const pushSubscription = await registration.pushManager.subscribe(subscribeOptions); 61 - console.log({ pushSubscription }); 62 - return pushSubscription; 59 + }); 63 60 } 64 61 65 62 async function verifyUser(host, token) { ··· 73 70 } 74 71 75 72 function App() { 76 - const [host, setHost] = useLocalStorage('spacedust-notif-host', 'http://localhost:8000'); 73 + const host = import.meta.env.VITE_NOTIFICATIONS_HOST; 74 + const [pushPubkey, setPushPubkey] = useState(null); 75 + const [whoamiHost, setWhoamiHost] = useState(null); 76 + 77 + const [role, setRole] = useLocalStorage('spacedust-notif-role', 'anonymous'); 77 78 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 78 79 const [verif, setVerif] = useState(null); 79 80 const [asking, setAsking] = useState(false); ··· 99 100 let hasPush = 'PushManager' in window; 100 101 let notifPerm = Notification?.permission ?? 'default'; 101 102 103 + function Blah({ info }) { 104 + useEffect(() => { 105 + setPushPubkey(info.webPushPublicKey); 106 + setWhoamiHost(info.whoamiHost); 107 + setRole(info.role); 108 + }); 109 + return <>Got server hello, updating app state&hellip;</>; 110 + } 111 + 102 112 let content; 103 113 if (!hasSW) { 104 114 content = <Problem>your browser does not support the background task needd to deliver notifications</Problem>; 105 115 } else if (!hasPush) { 106 116 content = <Problem>your browser does not support registering push notifications.</Problem> 117 + } else if (!whoamiHost) { 118 + content = <GetJson endpoint='/hello' ok={info => <Blah info={info} />} /> 107 119 } else if (!user) { 108 120 if (verif === 'verifying') content = <p><em>verifying&hellip;</em></p>; 109 121 else { 110 - content = <WhoAmI onIdentify={onIdentify} />; 122 + content = <WhoAmI onIdentify={onIdentify} origin={whoamiHost} />; 111 123 if (verif === 'failed') { 112 124 content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>; 113 125 } ··· 119 131 <p>To show notifications we need permission:</p> 120 132 <p> 121 133 <button 122 - onClick={requestPermission(host, setAsking, setPermissionError)} 134 + onClick={requestPermission(host, pushPubkey, setAsking, setPermissionError)} 123 135 disabled={asking} 124 136 > 125 137 {asking ? <>Requesting&hellip;</> : <>Request permission</>} ··· 140 152 } 141 153 142 154 return ( 143 - <HostContext.Provider value={host}> 155 + <> 144 156 <header id="app-header"> 145 157 <h1>spacedust notifications <span className="demo">demo!</span></h1> 146 158 {user && ( ··· 200 212 </label> 201 213 </p> 202 214 </div> 203 - </HostContext.Provider> 215 + </> 204 216 ) 205 217 } 206 218
+1 -2
atproto-notifications/src/components/Fetch.tsx
··· 1 1 import { useContext, useEffect, useState } from 'react'; 2 - import { HostContext } from '../context' 3 2 4 3 const loadingDefault = () => ( 5 4 <em>Loading&hellip;</em> ··· 52 51 } 53 52 54 53 export function GetJson({ endpoint, params, ...forFetch }) { 55 - const host = useContext(HostContext); 54 + const host = import.meta.env.VITE_NOTIFICATIONS_HOST; 56 55 const url = new URL(endpoint, host); 57 56 for (let [key, val] of Object.entries(params ?? {})) { 58 57 url.searchParams.append(key, val);
+1 -1
atproto-notifications/src/components/WhoAmI.tsx
··· 1 1 import { useRef, useEffect } from 'react'; 2 2 3 - export function WhoAmI({ onIdentify, origin = 'http://127.0.0.1:9997' }) { 3 + export function WhoAmI({ onIdentify, origin }) { 4 4 const frameRef = useRef(null); 5 5 6 6 useEffect(() => {
-5
atproto-notifications/src/context.ts
··· 1 - import { createContext } from 'react'; 2 - 3 - const HostContext = createContext(null); 4 - 5 - export { HostContext };
+1
gh-pages.sh
··· 6 6 git merge --no-ff main -m 'merge main' 7 7 8 8 cd atproto-notifications 9 + export VITE_NOTIFICATIONS_HOST=https://notifications-demo-api.microcosm.blue 9 10 npm run just-build 10 11 cd .. 11 12
+50 -22
server/index.js
··· 169 169 '', 170 170 { ...COOKIE_BASE, expires: new Date(0) }, 171 171 )); 172 - const getAccountCookie = (req, res, appSecret) => { 172 + const getAccountCookie = (req, res, appSecret, adminDid) => { 173 173 const cookies = cookie.parse(req.headers.cookie ?? ''); 174 174 const untrusted = cookies['verified-account'] ?? ''; 175 175 const json = cookieSig.unsign(untrusted, appSecret); ··· 177 177 clearAccountCookie(res); 178 178 return null; 179 179 } 180 + let did, session; 180 181 try { 181 - const [did, session] = JSON.parse(json); 182 - return [did, session]; 182 + [did, session] = JSON.parse(json); 183 183 } catch (e) { 184 184 console.warn('validated account cookie but failed to parse json', e); 185 185 clearAccountCookie(res); 186 186 return null; 187 187 } 188 + 189 + // not yet public!! 190 + if (!did || did !== adminDid) { 191 + res.setHeader('Content-Type', 'application/json'); 192 + res.writeHead(403); 193 + clearAccountCookie(res).end(JSON.stringify({ 194 + reason: 'the spacedust notifications demo isn\'t public yet!', 195 + })); 196 + throw new Error('unauthorized'); 197 + } 198 + 199 + return [did, session, did && (did === adminDid)]; 188 200 }; 189 201 190 202 // never EVER allow user-controllable input into fname (or just fix the path joining) ··· 209 221 const handleIndex = handleFile('index.html', 'text/html'); 210 222 const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 211 223 212 - const handleVerify = async (db, req, res, jwks, appSecret) => { 224 + const handleHello = async (db, req, res, secrets, whoamiHost, adminDid) => { 225 + const resBase = { webPushPublicKey: secrets.pushKeys.publicKey, whoamiHost }; 226 + res.setHeader('Content-Type', 'application/json'); 227 + let info = getAccountCookie(req, res, secrets.appSecret, adminDid); 228 + if (info) { 229 + const [did, _session, isAdmin] = info; 230 + const role = isAdmin ? 'admin' : 'public'; 231 + res 232 + .setHeader('Content-Type', 'application/json') 233 + .writeHead(200) 234 + .end(JSON.stringify({ ...resBase, role, did })); 235 + } else { 236 + res 237 + .setHeader('Content-Type', 'application/json') 238 + .writeHead(200) 239 + .end(JSON.stringify({ ...resBase, role: 'anonymous' })); 240 + } 241 + }; 242 + 243 + const handleVerify = async (db, req, res, whoamiHost, appSecret) => { 244 + const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`)); 213 245 const body = await getRequesBody(req); 214 246 const { token } = JSON.parse(body); 215 247 let did; ··· 226 258 }; 227 259 228 260 const handleSubscribe = async (db, req, res, appSecret, adminDid) => { 229 - let info = getAccountCookie(req, res, appSecret); 261 + let info = getAccountCookie(req, res, appSecret, adminDid); 230 262 if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 231 - const [did, session] = info; 232 - 233 - // not yet public!! 234 - if (did !== adminDid) { 235 - res.setHeader('Content-Type', 'application/json'); 236 - res.writeHead(403); 237 - 238 - return clearAccountCookie(res).end(JSON.stringify({ 239 - reason: 'the spacedust notifications demo isn\'t public yet!', 240 - })); 241 - } 242 - 263 + const [did, session, _isAdmin] = info; 243 264 const body = await getRequesBody(req); 244 265 const { sub } = JSON.parse(body); 245 266 // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) ··· 247 268 updateSubs(db); 248 269 res.setHeader('Content-Type', 'application/json'); 249 270 res.writeHead(201); 250 - res.end('{"oh": "hi"}'); 271 + res.end(JSON.stringify({ sup: 'hi' })); 251 272 }; 252 273 253 - const requestListener = (secrets, jwks, db, adminDid) => (req, res) => { 274 + const requestListener = (secrets, whoamiHost, db, adminDid) => (req, res) => { 254 275 if (req.method === 'GET' && req.url === '/') { 255 276 return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 256 277 } ··· 258 279 return handleServiceWorker(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 259 280 } 260 281 282 + if (req.method === 'OPTIONS' && req.url === '/hello') { 283 + return res.writeHead(204, CORS_PERMISSIVE(req)).end(); 284 + } 285 + if (req.method === 'GET' && req.url === '/hello') { 286 + res.setHeaders(new Headers(CORS_PERMISSIVE(req))); 287 + return handleHello(db, req, res, secrets, whoamiHost, adminDid); 288 + } 289 + 261 290 if (req.method === 'OPTIONS' && req.url === '/verify') { 262 291 // TODO: probably restrict the origin 263 292 return res.writeHead(204, CORS_PERMISSIVE(req)).end(); 264 293 } 265 294 if (req.method === 'POST' && req.url === '/verify') { 266 295 res.setHeaders(new Headers(CORS_PERMISSIVE(req))); 267 - return handleVerify(db, req, res, jwks, secrets.appSecret); 296 + return handleVerify(db, req, res, whoamiHost, secrets.appSecret); 268 297 } 269 298 270 299 if (req.method === 'OPTIONS' && req.url === '/subscribe') { ··· 293 322 ); 294 323 295 324 const whoamiHost = env.WHOAMI_HOST ?? 'https://who-am-i.microcosm.blue'; 296 - const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`)); 297 325 298 326 const dbFilename = env.DB_FILE ?? './db.sqlite3'; 299 327 const initDb = process.argv.includes('--init-db'); ··· 307 335 const port = parseInt(env.PORT ?? 8000, 10); 308 336 309 337 http 310 - .createServer(requestListener(secrets, jwks, db, adminDid)) 338 + .createServer(requestListener(secrets, whoamiHost, db, adminDid)) 311 339 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 312 340 }; 313 341