sketch more of the user verif flow

blehhhhhhhhhhhh

+300 -23
atproto-notifications/public/icons/app.bsky.png

This is a binary file and will not be displayed.

+9
atproto-notifications/public/service-worker.js
···
··· 1 + self.addEventListener('push', handlePush); 2 + 3 + function handlePush(event) { 4 + const { title, body } = event.data.json(); 5 + // const icon = '/images/icon.png'; 6 + // const tag = 'simple-push-demo-notification-tag'; 7 + event.waitUntil(self.registration.showNotification(title, { body })); 8 + // TODO: resubscribe to notifs to try to stay alive 9 + }
-1
atproto-notifications/public/vite.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
···
+122 -10
atproto-notifications/src/App.tsx
··· 1 import { useLocalStorage } from "@uidotdev/usehooks"; 2 import { HostContext } from './context' 3 import { WhoAmI } from './components/WhoAmI'; 4 import './App.css' 5 6 function App() { 7 const [host, setHost] = useLocalStorage('spacedust-notif-host', 'http://localhost:8000'); 8 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 9 10 return ( 11 <HostContext.Provider value={host}> 12 <h1>🎇 atproto notifications demo</h1> 13 14 - {user === null 15 - ? ( 16 - <WhoAmI onIdentify={setUser} /> 17 - ) 18 - : ( 19 - <> 20 - <p>hi {user.handle}</p> 21 - <button onClick={() => setUser(null)}>clear</button> 22 - </> 23 - )} 24 </HostContext.Provider> 25 ) 26 } 27 28 export default App
··· 1 + import { useCallback, useState } from 'react'; 2 import { useLocalStorage } from "@uidotdev/usehooks"; 3 import { HostContext } from './context' 4 import { WhoAmI } from './components/WhoAmI'; 5 + import { urlBase64ToUint8Array } from './utils'; 6 import './App.css' 7 8 + const Problem = ({ children }) => ( 9 + <div className="problem"> 10 + <p>Sorry, {children}</p> 11 + </div> 12 + ); 13 + 14 + function requestPermission(setAsking) { 15 + return async () => { 16 + setAsking(true); 17 + let err; 18 + try { 19 + await Notification.requestPermission(); 20 + await subscribeToPush(); 21 + } catch (e) { 22 + err = e; 23 + } 24 + setAsking(false); 25 + if (err) throw err; 26 + 27 + } 28 + } 29 + 30 + async function subscribeToPush() { 31 + const registration = await navigator.serviceWorker.register('/service-worker.js'); 32 + const subscribeOptions = { 33 + userVisibleOnly: true, 34 + applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_PUSH_PUBKEY), 35 + }; 36 + const pushSubscription = await registration.pushManager.subscribe(subscribeOptions); 37 + console.log({ pushSubscription }); 38 + } 39 + 40 + async function verifyUser(host, token) { 41 + let res = await fetch(`${host}/verify`, { 42 + method: 'POST', 43 + headers: {'Content-Type': 'applicaiton/json'}, 44 + body: JSON.stringify({ token }), 45 + }); 46 + if (!res.ok) throw res; 47 + } 48 + 49 function App() { 50 const [host, setHost] = useLocalStorage('spacedust-notif-host', 'http://localhost:8000'); 51 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 52 + const [verif, setVerif] = useState(null); 53 + const [asking, setAsking] = useState(false); 54 + 55 + const onIdentify = useCallback(async details => { 56 + setVerif('verifying'); 57 + try { 58 + await verifyUser(host, details.token) 59 + setVerif('verified'); 60 + setUser(details); 61 + } catch (e) { 62 + console.error(e); 63 + setVerif('failed'); 64 + } 65 + // setTimeout(() => { 66 + // setVerif('verified'); 67 + // setUser(details); 68 + // }, 400); 69 + }, [host]); 70 + 71 + let hasSW = 'serviceWorker' in navigator; 72 + let hasPush = 'PushManager' in window; 73 + let notifPerm = Notification?.permission ?? 'default'; 74 + 75 + let content; 76 + if (!hasSW) { 77 + content = <Problem>your browser does not support the background task needd to deliver notifications</Problem>; 78 + } else if (!hasPush) { 79 + content = <Problem>your browser does not support registering push notifications.</Problem> 80 + } else if (!user) { 81 + if (verif === 'verifying') content = <p><em>verifying&hellip;</em></p>; 82 + else { 83 + content = <WhoAmI onIdentify={onIdentify} />; 84 + if (verif === 'failed') { 85 + content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>; 86 + } 87 + } 88 + } else if (notifPerm !== 'granted') { 89 + content = ( 90 + <> 91 + <h3>Step 2: Notification permission</h3> 92 + <p>To show atproto notifications we need permission:</p> 93 + <p> 94 + <button 95 + onClick={requestPermission(setAsking)} 96 + disabled={asking} 97 + > 98 + {asking ? <>Requesting&hellip;</> : <>Request permission</>} 99 + </button> 100 + </p> 101 + {notifPerm === 'denied' ? ( 102 + <p><em>Notification permission was denied. You may need to clear the browser setting to try again.</em></p> 103 + ) : ( 104 + <p><em>You can revoke this any time</em></p> 105 + )} 106 + </> 107 + ); 108 + } else { 109 + content = ( 110 + <> 111 + <p> 112 + @{user.handle} 113 + <button onClick={() => setUser(null)}>&times;</button> 114 + </p> 115 + </> 116 + ); 117 + } 118 119 return ( 120 <HostContext.Provider value={host}> 121 <h1>🎇 atproto notifications demo</h1> 122 123 + <p>Get browser push notifications from any app</p> 124 + 125 + {content} 126 </HostContext.Provider> 127 ) 128 } 129 130 export default App 131 + 132 + 133 + // {user === null ? ( 134 + 135 + // ) : ( 136 + // <> 137 + // <p>hi {user.handle}</p> 138 + // <button onClick={() => setUser(null)}>clear</button> 139 + // </> 140 + // )}
+67
atproto-notifications/src/components/Fetch.tsx
···
··· 1 + import { useContext, useEffect, useState } from 'react'; 2 + import { HostContext } from '../context' 3 + 4 + const loadingDefault = () => ( 5 + <em>Loading&hellip;</em> 6 + ); 7 + 8 + const errorDefault = err => ( 9 + <span className="error"> 10 + <strong>Error</strong>:<br/>{`${err}`} 11 + </span> 12 + ); 13 + 14 + export function Fetch({ using, args, ok, loading, error }) { 15 + const [asyncData, setAsyncData] = useState({ state: null }); 16 + 17 + useEffect(() => { 18 + let ignore = false; 19 + setAsyncData({ state: 'loading' }); 20 + (async () => { 21 + try { 22 + const data = await using(...args); 23 + !ignore && setAsyncData({ state: 'done', data }); 24 + } catch (err) { 25 + !ignore && setAsyncData({ state: 'error', err }); 26 + } 27 + })(); 28 + return () => { ignore = true; } 29 + }, args); 30 + 31 + if (asyncData.state === 'loading') { 32 + return (loading || loadingDefault)(...args); 33 + } else if (asyncData.state === 'error') { 34 + return (error || errorDefault)(asyncData.err); 35 + } else if (asyncData.state === null) { 36 + return <span>wat, request has not started (bug?)</span>; 37 + } else { 38 + if (asyncData.state !== 'done') { console.warn(`unexpected async data state: ${asyncData.state}`); } 39 + return ok(asyncData.data); 40 + } 41 + } 42 + 43 + ///// 44 + 45 + async function getJson(url) { 46 + const res = await fetch(url); 47 + if (!res.ok) { 48 + const m = await res.text(); 49 + throw new Error(`Failed to fetch: ${m}`); 50 + } 51 + return await res.json(); 52 + } 53 + 54 + export function GetJson({ endpoint, params, ...forFetch }) { 55 + const host = useContext(HostContext); 56 + const url = new URL(endpoint, host); 57 + for (let [key, val] of Object.entries(params ?? {})) { 58 + url.searchParams.append(key, val); 59 + } 60 + return ( 61 + <Fetch 62 + using={getJson} 63 + args={[url.toString()]} 64 + {...forFetch} 65 + /> 66 + ); 67 + }
+1
atproto-notifications/src/components/WhoAmI.tsx
··· 25 border: 'none', 26 display: 'block', 27 colorScheme: 'none', 28 }} 29 > 30 Ooops, failed to load the login helper
··· 25 border: 'none', 26 display: 'block', 27 colorScheme: 'none', 28 + margin: '0 auto', 29 }} 30 > 31 Ooops, failed to load the login helper
+2 -4
atproto-notifications/src/index.css
··· 4 font-weight: 400; 5 6 color-scheme: light dark; 7 - color: rgba(255, 255, 255, 0.87); 8 - background-color: #242424; 9 10 font-synthesis: none; 11 text-rendering: optimizeLegibility; ··· 24 25 body { 26 margin: 0; 27 - display: flex; 28 - place-items: center; 29 min-width: 320px; 30 min-height: 100vh; 31 }
··· 4 font-weight: 400; 5 6 color-scheme: light dark; 7 + color: #d6dade; 8 + background-color: #161a1e; 9 10 font-synthesis: none; 11 text-rendering: optimizeLegibility; ··· 24 25 body { 26 margin: 0; 27 min-width: 320px; 28 min-height: 100vh; 29 }
+12
atproto-notifications/src/utils.ts
···
··· 1 + export function urlBase64ToUint8Array(base64String) { 2 + var padding = '='.repeat((4 - base64String.length % 4) % 4); 3 + var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); 4 + 5 + var rawData = window.atob(base64); 6 + var outputArray = new Uint8Array(rawData.length); 7 + 8 + for (var i = 0; i < rawData.length; ++i) { 9 + outputArray[i] = rawData.charCodeAt(i); 10 + } 11 + return outputArray; 12 + }
+54 -8
server/index.js
··· 1 #!/usr/bin/env node 2 "use strict"; 3 4 - const webpush = require('web-push'); 5 const fs = require('node:fs'); 6 const http = require('http'); 7 8 const DUMMY_DID = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; 9 10 let spacedust; 11 let spacedustEverStarted = false; ··· 157 const handleIndex = handleFile('index.html', 'text/html'); 158 const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 159 160 const handleSubscribe = async (req, res) => { 161 const body = await getRequesBody(req); 162 const { did, sub } = JSON.parse(body); ··· 164 res.setHeader('Content-Type', 'application/json'); 165 res.writeHead(201); 166 res.end('{"oh": "hi"}'); 167 - } 168 169 - const requestListener = pubkey => (req, res) => { 170 - if (req.method === 'GET' && req.url === '/') 171 return handleIndex(req, res, { PUBKEY: pubkey }); 172 173 - if (req.method === 'GET' && req.url === '/service-worker.js') 174 - return handleServiceWorker(req, res, { PUBKEY: pubkey }); 175 176 - if (req.method === 'POST' && req.url === '/subscribe') 177 return handleSubscribe(req, res); 178 179 res.writeHead(200); 180 res.end('sup'); ··· 189 keys.privateKey, 190 ); 191 192 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 193 connectSpacedust(spacedustHost); 194 ··· 196 const port = parseInt(env.PORT ?? 8000, 10); 197 198 http 199 - .createServer(requestListener(keys.publicKey)) 200 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 201 }; 202
··· 1 #!/usr/bin/env node 2 "use strict"; 3 4 const fs = require('node:fs'); 5 const http = require('http'); 6 + const jose = require('jose'); 7 + const cookie = require('cookie'); 8 + const cookieSig = require('cookie-signature'); 9 + const webpush = require('web-push'); 10 11 const DUMMY_DID = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; 12 + 13 + const CORS_PERMISSIVE = { 14 + 'Access-Control-Allow-Origin': '*', 15 + 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 16 + 'Access-Control-Allow-Headers': 'Content-Type', 17 + }; 18 19 let spacedust; 20 let spacedustEverStarted = false; ··· 166 const handleIndex = handleFile('index.html', 'text/html'); 167 const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 168 169 + const handleVerify = async (req, res, jwks, app_secret) => { 170 + const body = await getRequesBody(req); 171 + const { token } = JSON.parse(body); 172 + let did; 173 + try { 174 + const verified = await jose.jwtVerify(token, jwks); 175 + did = verified.payload.sub; 176 + } catch (e) { 177 + res.setHeader('Set-Cookie', cookie.serialize('verified-did', '', { expires: new Date(0) })); 178 + return res.writeHead(400).end(JSON.stringify({ reason: 'verification failed' })); 179 + } 180 + const signed = cookieSig.sign(did, app_secret); 181 + res.setHeader('Set-Cookie', cookie.serialize('verified-did', signed, { 182 + httpOnly: true, 183 + secure: true, 184 + maxAge: 90 * 86_400, 185 + })) 186 + return res.writeHead(200).end('okayyyy'); 187 + }; 188 + 189 const handleSubscribe = async (req, res) => { 190 const body = await getRequesBody(req); 191 const { did, sub } = JSON.parse(body); ··· 193 res.setHeader('Content-Type', 'application/json'); 194 res.writeHead(201); 195 res.end('{"oh": "hi"}'); 196 + }; 197 198 + const requestListener = (pubkey, jwks, app_secret) => (req, res) => { 199 + if (req.method === 'GET' && req.url === '/') { 200 return handleIndex(req, res, { PUBKEY: pubkey }); 201 + } 202 + if (req.method === 'GET' && req.url === '/service-worker.js') { 203 + return handleServiceWorker(req, res, { PUBKEY: pubkey }); 204 + } 205 206 + if (req.method === 'OPTIONS' && req.url === '/verify') { 207 + // TODO: probably restrict the origin 208 + return res.writeHead(204, CORS_PERMISSIVE).end(); 209 + } 210 + if (req.method === 'POST' && req.url === '/verify') { 211 + res.setHeaders(new Headers(CORS_PERMISSIVE)); 212 + return handleVerify(req, res, jwks, app_secret); 213 + } 214 215 + if (req.method === 'POST' && req.url === '/subscribe') { 216 return handleSubscribe(req, res); 217 + } 218 219 res.writeHead(200); 220 res.end('sup'); ··· 229 keys.privateKey, 230 ); 231 232 + if (!env.APP_SECRET) throw new Error('APP_SECRET is required to run'); 233 + const app_secret = env.APP_SECRET; 234 + 235 + const whoamiHost = env.WHOAMI_HOST ?? 'https://who-am-i.microcosm.blue'; 236 + const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`)); 237 + 238 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 239 connectSpacedust(spacedustHost); 240 ··· 242 const port = parseInt(env.PORT ?? 8000, 10); 243 244 http 245 + .createServer(requestListener(keys.publicKey, jwks, app_secret)) 246 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 247 }; 248
+30
server/package-lock.json
··· 5 "packages": { 6 "": { 7 "dependencies": { 8 "web-push": "^3.6.7" 9 } 10 }, ··· 41 "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 42 "license": "BSD-3-Clause" 43 }, 44 "node_modules/debug": { 45 "version": "4.4.1", 46 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", ··· 94 "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 95 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 96 "license": "ISC" 97 }, 98 "node_modules/jwa": { 99 "version": "2.0.1",
··· 5 "packages": { 6 "": { 7 "dependencies": { 8 + "cookie": "^1.0.2", 9 + "cookie-signature": "^1.2.2", 10 + "jose": "^6.0.11", 11 "web-push": "^3.6.7" 12 } 13 }, ··· 44 "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 45 "license": "BSD-3-Clause" 46 }, 47 + "node_modules/cookie": { 48 + "version": "1.0.2", 49 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", 50 + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", 51 + "license": "MIT", 52 + "engines": { 53 + "node": ">=18" 54 + } 55 + }, 56 + "node_modules/cookie-signature": { 57 + "version": "1.2.2", 58 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", 59 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", 60 + "license": "MIT", 61 + "engines": { 62 + "node": ">=6.6.0" 63 + } 64 + }, 65 "node_modules/debug": { 66 "version": "4.4.1", 67 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", ··· 115 "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 116 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 117 "license": "ISC" 118 + }, 119 + "node_modules/jose": { 120 + "version": "6.0.11", 121 + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", 122 + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", 123 + "license": "MIT", 124 + "funding": { 125 + "url": "https://github.com/sponsors/panva" 126 + } 127 }, 128 "node_modules/jwa": { 129 "version": "2.0.1",
+3
server/package.json
··· 1 { 2 "dependencies": { 3 "web-push": "^3.6.7" 4 } 5 }
··· 1 { 2 "dependencies": { 3 + "cookie": "^1.0.2", 4 + "cookie-signature": "^1.2.2", 5 + "jose": "^6.0.11", 6 "web-push": "^3.6.7" 7 } 8 }