admin-only access

Changed files
+42 -13
atproto-notifications
src
server
+20 -7
atproto-notifications/src/App.tsx
··· 14 14 </div> 15 15 ); 16 16 17 - function requestPermission(host, setAsking) { 17 + function requestPermission(host, setAsking, setPermissionError) { 18 18 return async () => { 19 19 setAsking(true); 20 20 let err; ··· 27 27 body: JSON.stringify({ sub }), 28 28 credentials: 'include', 29 29 }); 30 - if (!res.ok) throw res; 30 + if (!res.ok) { 31 + let content; 32 + try { 33 + content = (await res.json()).reason; 34 + } catch (_) { 35 + content = await res.text(); 36 + } 37 + throw content; 38 + } 31 39 } catch (e) { 40 + setPermissionError(e); 32 41 err = e; 33 42 } 34 43 setAsking(false); ··· 68 77 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 69 78 const [verif, setVerif] = useState(null); 70 79 const [asking, setAsking] = useState(false); 80 + const [permissionError, setPermissionError] = useState(null); 71 81 72 82 const onIdentify = useCallback(async details => { 73 83 setVerif('verifying'); ··· 102 112 content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>; 103 113 } 104 114 } 105 - } else if (notifPerm !== 'granted') { 115 + } else if (permissionError !== null || notifPerm !== 'granted') { 106 116 content = ( 107 117 <> 108 118 <h3>Step 2: Allow notifications</h3> 109 119 <p>To show notifications we need permission:</p> 110 120 <p> 111 121 <button 112 - onClick={requestPermission(host, setAsking)} 122 + onClick={requestPermission(host, setAsking, setPermissionError)} 113 123 disabled={asking} 114 124 > 115 125 {asking ? <>Requesting&hellip;</> : <>Request permission</>} ··· 117 127 </p> 118 128 {notifPerm === 'denied' ? ( 119 129 <p className="detail">Notification permission was denied. You may need to clear the browser setting to try again.</p> 120 - ) : ( 121 - <p className="detail">You can revoke this any time</p> 122 - )} 130 + ) : permissionError ? ( 131 + <p className="detail">Sorry, something went wrong: {permissionError}</p> 132 + ) : ( 133 + <p className="detail">You can revoke this any time</p> 134 + ) 135 + } 123 136 </> 124 137 ); 125 138 } else {
+5 -2
server/db.js
··· 37 37 38 38 this.#stmt_insert_account = db.prepare( 39 39 `insert into accounts (did) 40 - values (?)`); 40 + values (?) 41 + on conflict do nothing`); 41 42 42 43 this.#stmt_get_account = db.prepare( 43 44 `select a.first_seen, ··· 53 54 54 55 this.#stmt_insert_push_sub = db.prepare( 55 56 `insert into push_subs (account_did, session, subscription) 56 - values (?, ?, ?)`); 57 + values (?, ?, ?) 58 + on conflict do update 59 + set subscription = excluded.subscription`); 57 60 58 61 this.#stmt_get_all_sub_dids = db.prepare( 59 62 `select distinct account_did
+17 -4
server/index.js
··· 225 225 return res.writeHead(200).end('okayyyy'); 226 226 }; 227 227 228 - const handleSubscribe = async (db, req, res, appSecret) => { 228 + const handleSubscribe = async (db, req, res, appSecret, adminDid) => { 229 229 let info = getAccountCookie(req, res, appSecret); 230 230 if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 231 231 const [did, session] = info; 232 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 + 233 243 const body = await getRequesBody(req); 234 244 const { sub } = JSON.parse(body); 235 245 // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) ··· 240 250 res.end('{"oh": "hi"}'); 241 251 }; 242 252 243 - const requestListener = (secrets, jwks, db) => (req, res) => { 253 + const requestListener = (secrets, jwks, db, adminDid) => (req, res) => { 244 254 if (req.method === 'GET' && req.url === '/') { 245 255 return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 246 256 } ··· 263 273 } 264 274 if (req.method === 'POST' && req.url === '/subscribe') { 265 275 res.setHeaders(new Headers(CORS_PERMISSIVE(req))); 266 - return handleSubscribe(db, req, res, secrets.appSecret); 276 + return handleSubscribe(db, req, res, secrets.appSecret, adminDid); 267 277 } 268 278 269 279 res.writeHead(200); ··· 271 281 } 272 282 273 283 const main = env => { 284 + if (!env.ADMIN_DID) throw new Error('ADMIN_DID is required to run'); 285 + const adminDid = env.ADMIN_DID; 286 + 274 287 if (!env.SECRETS_FILE) throw new Error('SECRETS_FILE is required to run'); 275 288 const secrets = getOrCreateSecrets(env.SECRETS_FILE); 276 289 webpush.setVapidDetails( ··· 294 307 const port = parseInt(env.PORT ?? 8000, 10); 295 308 296 309 http 297 - .createServer(requestListener(secrets, jwks, db)) 310 + .createServer(requestListener(secrets, jwks, db, adminDid)) 298 311 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 299 312 }; 300 313