A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: expired atproto refresh token

+195 -97
+175 -94
src/components/output/raw/atproto/element.js
··· 1 - import { Client, ok } from "@atcute/client"; 1 + import { Client, ClientResponseError, ok } from "@atcute/client"; 2 2 import { BroadcastableDiffuseElement } from "@common/element.js"; 3 3 import { computed, signal } from "@common/signal.js"; 4 4 import { outputManager } from "../../common.js"; 5 - import { login, logout, OAuthUserAgent, restoreOrFinalize } from "./oauth.js"; 5 + import { 6 + clearStoredSession, 7 + login, 8 + logout, 9 + OAuthUserAgent, 10 + restoreOrFinalize, 11 + TokenRefreshError, 12 + } from "./oauth.js"; 6 13 7 14 /** 8 15 * @import {Signal} from "@common/signal.d.ts" ··· 38 45 this.#manager = outputManager({ 39 46 facets: { 40 47 empty: () => [], 41 - get: () => this.#listRecords("sh.diffuse.output.facet"), 48 + get: () => this.listRecords("sh.diffuse.output.facet"), 42 49 put: (data) => this.#putRecords("sh.diffuse.output.facet", data), 43 50 }, 44 51 playlistItems: { 45 52 empty: () => [], 46 - get: () => this.#listRecords("sh.diffuse.output.playlistItem"), 53 + get: () => this.listRecords("sh.diffuse.output.playlistItem"), 47 54 put: (data) => this.#putRecords("sh.diffuse.output.playlistItem", data), 48 55 }, 49 56 themes: { 50 57 empty: () => [], 51 - get: () => this.#listRecords("sh.diffuse.output.theme"), 58 + get: () => this.listRecords("sh.diffuse.output.theme"), 52 59 put: (data) => this.#putRecords("sh.diffuse.output.theme", data), 53 60 }, 54 61 tracks: { 55 62 empty: () => [], 56 - get: () => this.#listRecords("sh.diffuse.output.track"), 63 + get: () => this.listRecords("sh.diffuse.output.track"), 57 64 put: (data) => this.#putRecords("sh.diffuse.output.track", data), 58 65 }, 59 66 }); ··· 97 104 98 105 // AUTH 99 106 100 - async #tryRestore() { 101 - await this.whenConnected(); 102 - 103 - const session = await restoreOrFinalize(); 104 - 105 - if (session) { 106 - this.#setSession(session); 107 - } 108 - } 109 - 110 - /** 111 - * @param {import("@atcute/oauth-browser-client").Session} session 112 - */ 113 - #setSession(session) { 114 - this.#agent = new OAuthUserAgent(session); 115 - this.#rpc = new Client({ handler: this.#agent }); 116 - this.#did.value = session.info.sub; 117 - this.#authenticated.resolve(); 118 - } 119 - 120 107 /** 121 108 * Initiate the OAuth flow. 122 109 * Navigates the browser to the authorization server. ··· 140 127 } 141 128 } 142 129 130 + /** 131 + * Clear session state without contacting the server. 132 + * Used when the session has already been revoked. 133 + */ 134 + #clearSession() { 135 + this.#agent = null; 136 + this.#authenticated = Promise.withResolvers(); 137 + this.#did.value = null; 138 + this.#rpc = null; 139 + clearStoredSession(); 140 + } 141 + 142 + /** 143 + * @param {unknown} err 144 + * @returns {boolean} 145 + */ 146 + #isSessionError(err) { 147 + if (err instanceof TokenRefreshError) return true; 148 + // OAuthUserAgent.handle() swallows TokenRefreshError and returns the 149 + // original 401 response, which ok() wraps as a ClientResponseError. 150 + if (err instanceof ClientResponseError && err.status === 401) return true; 151 + if (err && typeof err === "object" && "cause" in err) { 152 + return this.#isSessionError(/** @type {any} */ (err).cause); 153 + } 154 + return false; 155 + } 156 + 157 + async #tryRestore() { 158 + await this.whenConnected(); 159 + 160 + try { 161 + const session = await restoreOrFinalize(); 162 + 163 + if (session) { 164 + this.#setSession(session); 165 + } 166 + } catch (err) { 167 + if (this.#isSessionError(err)) { 168 + this.#clearSession(); 169 + } else { 170 + throw err; 171 + } 172 + } 173 + } 174 + 175 + /** 176 + * @param {import("@atcute/oauth-browser-client").Session} session 177 + */ 178 + #setSession(session) { 179 + const agent = new OAuthUserAgent(session); 180 + 181 + // Intercept token refresh to detect session revocation proactively. 182 + // OAuthUserAgent.handle() swallows TokenRefreshError silently, 183 + // so we hook into getSession to clear state as soon as refresh fails. 184 + const originalGetSession = agent.getSession.bind(agent); 185 + agent.getSession = /** @param {any[]} args */ (...args) => { 186 + const promise = originalGetSession(...args); 187 + 188 + promise.catch((err) => { 189 + if (err instanceof TokenRefreshError) { 190 + this.#clearSession(); 191 + } 192 + }); 193 + 194 + return promise; 195 + }; 196 + 197 + this.#agent = agent; 198 + this.#rpc = new Client({ handler: agent }); 199 + this.#did.value = session.info.sub; 200 + this.#authenticated.resolve(); 201 + } 202 + 143 203 // RECORDS 144 204 145 205 /** 146 206 * @template T 147 207 * @param {string} collection 208 + * @param {string} [did] 148 209 * @returns {Promise<T[]>} 149 210 */ 150 - async #listRecords(collection) { 151 - if (!this.#rpc || !this.#did.value) return []; 211 + async listRecords(collection, did) { 212 + did ??= this.#did.value ?? undefined; 213 + 214 + if (!this.#rpc || !did) return []; 215 + 216 + try { 217 + const records = []; 218 + let cursor; 152 219 153 - const records = []; 154 - let cursor; 220 + do { 221 + /** @type {any} */ 222 + const page = await ok(this.#rpc.get( 223 + "com.atproto.repo.listRecords", 224 + { params: { repo: did, collection, limit: 100, cursor } }, 225 + )); 226 + 227 + for (const record of page.records) { 228 + records.push(record.value); 229 + } 155 230 156 - do { 157 - /** @type {any} */ 158 - const page = await ok(this.#rpc.get( 159 - "com.atproto.repo.listRecords", 160 - { params: { repo: this.#did.value, collection, limit: 100, cursor } }, 161 - )); 231 + cursor = page.cursor; 232 + } while (cursor); 162 233 163 - for (const record of page.records) { 164 - records.push(record.value); 234 + return records; 235 + } catch (err) { 236 + if (this.#isSessionError(err)) { 237 + this.#clearSession(); 238 + return []; 165 239 } 166 240 167 - cursor = page.cursor; 168 - } while (cursor); 169 - 170 - return records; 241 + throw err; 242 + } 171 243 } 172 244 173 245 /** ··· 177 249 async #putRecordsSync(collection, data) { 178 250 if (!this.#rpc || !this.#did.value) return; 179 251 180 - // 1. Fetch current state 181 - /** @type {Map<string, { rkey: string, value: unknown }>} */ 182 - const existing = new Map(); 183 - let cursor; 252 + try { 253 + // 1. Fetch current state 254 + /** @type {Map<string, { rkey: string, value: unknown }>} */ 255 + const existing = new Map(); 256 + let cursor; 184 257 185 - do { 186 - /** @type {any} */ 187 - const page = await ok(this.#rpc.get( 188 - "com.atproto.repo.listRecords", 189 - { params: { repo: this.#did.value, collection, limit: 100, cursor } }, 190 - )); 258 + do { 259 + /** @type {any} */ 260 + const page = await ok(this.#rpc.get( 261 + "com.atproto.repo.listRecords", 262 + { params: { repo: this.#did.value, collection, limit: 100, cursor } }, 263 + )); 191 264 192 - for (const record of page.records) { 193 - const rkey = record.uri.split("/").pop(); 194 - existing.set(record.value.id, { rkey, value: record.value }); 195 - } 265 + for (const record of page.records) { 266 + const rkey = record.uri.split("/").pop(); 267 + existing.set(record.value.id, { rkey, value: record.value }); 268 + } 196 269 197 - cursor = page.cursor; 198 - } while (cursor); 270 + cursor = page.cursor; 271 + } while (cursor); 199 272 200 - // 2. Build desired state 201 - const desired = new Map( 202 - data.map((record) => [record.id, { $type: collection, ...record }]), 203 - ); 273 + // 2. Build desired state 274 + const desired = new Map( 275 + data.map((record) => [record.id, { $type: collection, ...record }]), 276 + ); 204 277 205 - // 3. Compute diff 206 - /** @type {unknown[]} */ 207 - const writes = []; 278 + // 3. Compute diff 279 + /** @type {unknown[]} */ 280 + const writes = []; 208 281 209 - for (const [id, { rkey }] of existing) { 210 - if (!desired.has(id)) { 211 - writes.push({ 212 - $type: "com.atproto.repo.applyWrites#delete", 213 - collection, 214 - rkey, 215 - }); 282 + for (const [id, { rkey }] of existing) { 283 + if (!desired.has(id)) { 284 + writes.push({ 285 + $type: "com.atproto.repo.applyWrites#delete", 286 + collection, 287 + rkey, 288 + }); 289 + } 216 290 } 217 - } 218 291 219 - for (const [id, record] of desired) { 220 - const entry = existing.get(id); 292 + for (const [id, record] of desired) { 293 + const entry = existing.get(id); 294 + 295 + if (!entry) { 296 + writes.push({ 297 + $type: "com.atproto.repo.applyWrites#create", 298 + collection, 299 + rkey: id, 300 + value: record, 301 + }); 302 + } else if (JSON.stringify(entry.value) !== JSON.stringify(record)) { 303 + writes.push({ 304 + $type: "com.atproto.repo.applyWrites#update", 305 + collection, 306 + rkey: entry.rkey, 307 + value: record, 308 + }); 309 + } 310 + } 221 311 222 - if (!entry) { 223 - writes.push({ 224 - $type: "com.atproto.repo.applyWrites#create", 225 - collection, 226 - rkey: id, 227 - value: record, 312 + // 4. Apply 313 + if (writes.length > 0) { 314 + await this.#rpc.post("com.atproto.repo.applyWrites", { 315 + input: { repo: this.#did.value, writes }, 228 316 }); 229 - } else if (JSON.stringify(entry.value) !== JSON.stringify(record)) { 230 - writes.push({ 231 - $type: "com.atproto.repo.applyWrites#update", 232 - collection, 233 - rkey: entry.rkey, 234 - value: record, 235 - }); 317 + } 318 + } catch (err) { 319 + if (this.#isSessionError(err)) { 320 + this.#clearSession(); 321 + return; 236 322 } 237 - } 238 323 239 - // 4. Apply 240 - if (writes.length > 0) { 241 - await this.#rpc.post("com.atproto.repo.applyWrites", { 242 - input: { repo: this.#did.value, writes }, 243 - }); 324 + throw err; 244 325 } 245 326 } 246 327
+20 -3
src/components/output/raw/atproto/oauth.js
··· 18 18 finalizeAuthorization, 19 19 getSession, 20 20 OAuthUserAgent, 21 + TokenRefreshError, 21 22 } from "@atcute/oauth-browser-client"; 22 23 23 - export { OAuthUserAgent }; 24 + export { OAuthUserAgent, TokenRefreshError }; 24 25 25 26 /** 26 27 * @import {Session} from "@atcute/oauth-browser-client" ··· 139 140 try { 140 141 return await getSession( 141 142 /** @type {`did:${string}:${string}`} */ (did), 142 - { allowStale: true }, 143 143 ); 144 144 } catch (err) { 145 145 console.warn(err); 146 - localStorage.removeItem(STORAGE_KEY); 146 + clearStoredSession(); 147 147 return null; 148 148 } 149 149 } 150 150 151 151 return null; 152 + } 153 + 154 + // CLEAR SESSION 155 + // ============= 156 + 157 + /** 158 + * Remove stored session data without contacting the server. 159 + * Used when the session has already been revoked. 160 + */ 161 + export function clearStoredSession() { 162 + const did = localStorage.getItem(STORAGE_KEY); 163 + 164 + if (did) { 165 + deleteStoredSession(/** @type {`did:${string}:${string}`} */ (did)); 166 + } 167 + 168 + localStorage.removeItem(STORAGE_KEY); 152 169 } 153 170 154 171 // LOGOUT