Malachite is a tool to import your Last.fm and Spotify listening history to the AT Protocol network using the fm.teal.alpha.feed.play lexicon.
malachite scrobbles importer atproto music

feat(web): add ATProto OAuth sign-in alongside app passwords

Adds BrowserOAuthClient-based OAuth as the default auth method on the
import wizard, with app passwords kept as a fallback tab.

- Add @atproto/oauth-client-browser and publish client-metadata.json
- New oauth.ts wraps BrowserOAuthClient.load() as a singleton; uses the
loopback client ID convention (http://localhost?redirect_uri=...&scope=...)
in dev so OAuth works without a deployed origin
- AuthStep gains an OAuth/App password tab switcher; OAuth is default
- +page.svelte restores mode and step from sessionStorage on load to
eliminate FOUC and survive the OAuth redirect round-trip
- onMount calls initOAuth(), constructs Agent from the returned session,
and advances the wizard past the auth step automatically
- Replace all AtpAgent refs with the base Agent type across auth.ts,
import.ts, publisher.ts, and sync.ts
- Replace agent.session?.did with agent.did throughout sync.ts and
publisher.ts — .session is AtpAgent-only; OAuth agents expose DID
via the base Agent.did getter
- Update OptionsStep copy for deduplicate mode (heading, dry-run
description, and button label)
- Bump version to 0.2.0

ewancroft.uk 1beffb8d e0d4ea43

verified
+482 -89
+2 -1
.gitignore
··· 3 3 .DS_Store 4 4 .env 5 5 *.csv 6 - dist/ 6 + dist/ 7 + diff.txt
+2 -1
web/package.json
··· 1 1 { 2 2 "name": "web", 3 3 "private": true, 4 - "version": "0.1.1", 4 + "version": "0.2.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", ··· 16 16 "dependencies": { 17 17 "@atproto/api": "^0.18.13", 18 18 "@atproto/common-web": "^0.4.12", 19 + "@atproto/oauth-client-browser": "^0.3.41", 19 20 "@lucide/svelte": "^0.575.0" 20 21 }, 21 22 "devDependencies": {
+149 -1
web/pnpm-lock.yaml
··· 14 14 '@atproto/common-web': 15 15 specifier: ^0.4.12 16 16 version: 0.4.17 17 + '@atproto/oauth-client-browser': 18 + specifier: ^0.3.41 19 + version: 0.3.41 17 20 '@lucide/svelte': 18 21 specifier: ^0.575.0 19 22 version: 0.575.0(svelte@5.53.5) ··· 57 60 58 61 packages: 59 62 63 + '@atproto-labs/did-resolver@0.2.6': 64 + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} 65 + 66 + '@atproto-labs/fetch@0.2.3': 67 + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} 68 + 69 + '@atproto-labs/handle-resolver@0.3.6': 70 + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} 71 + 72 + '@atproto-labs/identity-resolver@0.3.6': 73 + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} 74 + 75 + '@atproto-labs/pipe@0.1.1': 76 + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} 77 + 78 + '@atproto-labs/simple-store-memory@0.1.4': 79 + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} 80 + 81 + '@atproto-labs/simple-store@0.3.0': 82 + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 83 + 60 84 '@atproto/api@0.18.21': 61 85 resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==} 62 86 63 87 '@atproto/common-web@0.4.17': 64 88 resolution: {integrity: sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==} 65 89 90 + '@atproto/did@0.3.0': 91 + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} 92 + 93 + '@atproto/jwk-jose@0.1.11': 94 + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} 95 + 96 + '@atproto/jwk-webcrypto@0.2.0': 97 + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} 98 + 99 + '@atproto/jwk@0.6.0': 100 + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 101 + 66 102 '@atproto/lex-data@0.0.12': 67 103 resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==} 68 104 ··· 71 107 72 108 '@atproto/lexicon@0.6.1': 73 109 resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} 110 + 111 + '@atproto/oauth-client-browser@0.3.41': 112 + resolution: {integrity: sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g==} 113 + 114 + '@atproto/oauth-client@0.6.0': 115 + resolution: {integrity: sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==} 116 + 117 + '@atproto/oauth-types@0.6.3': 118 + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} 74 119 75 120 '@atproto/syntax@0.4.3': 76 121 resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} ··· 769 814 resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 770 815 engines: {node: '>= 0.6'} 771 816 817 + core-js@3.48.0: 818 + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} 819 + 772 820 debug@4.4.3: 773 821 resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} 774 822 engines: {node: '>=6.0'} ··· 849 897 jiti@2.6.1: 850 898 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 851 899 hasBin: true 900 + 901 + jose@5.10.0: 902 + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 852 903 853 904 kleur@4.1.5: 854 905 resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} ··· 927 978 locate-character@3.0.0: 928 979 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 929 980 981 + lru-cache@10.4.3: 982 + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 983 + 930 984 lru-cache@11.2.6: 931 985 resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} 932 986 engines: {node: 20 || >=22} ··· 969 1023 resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} 970 1024 engines: {node: 4.x || >=6.0.0} 971 1025 peerDependencies: 972 - encoding: ^0.1.1 1026 + encoding: ^0.1.0 973 1027 peerDependenciesMeta: 974 1028 encoding: 975 1029 optional: true ··· 1218 1272 1219 1273 snapshots: 1220 1274 1275 + '@atproto-labs/did-resolver@0.2.6': 1276 + dependencies: 1277 + '@atproto-labs/fetch': 0.2.3 1278 + '@atproto-labs/pipe': 0.1.1 1279 + '@atproto-labs/simple-store': 0.3.0 1280 + '@atproto-labs/simple-store-memory': 0.1.4 1281 + '@atproto/did': 0.3.0 1282 + zod: 3.25.76 1283 + 1284 + '@atproto-labs/fetch@0.2.3': 1285 + dependencies: 1286 + '@atproto-labs/pipe': 0.1.1 1287 + 1288 + '@atproto-labs/handle-resolver@0.3.6': 1289 + dependencies: 1290 + '@atproto-labs/simple-store': 0.3.0 1291 + '@atproto-labs/simple-store-memory': 0.1.4 1292 + '@atproto/did': 0.3.0 1293 + zod: 3.25.76 1294 + 1295 + '@atproto-labs/identity-resolver@0.3.6': 1296 + dependencies: 1297 + '@atproto-labs/did-resolver': 0.2.6 1298 + '@atproto-labs/handle-resolver': 0.3.6 1299 + 1300 + '@atproto-labs/pipe@0.1.1': {} 1301 + 1302 + '@atproto-labs/simple-store-memory@0.1.4': 1303 + dependencies: 1304 + '@atproto-labs/simple-store': 0.3.0 1305 + lru-cache: 10.4.3 1306 + 1307 + '@atproto-labs/simple-store@0.3.0': {} 1308 + 1221 1309 '@atproto/api@0.18.21': 1222 1310 dependencies: 1223 1311 '@atproto/common-web': 0.4.17 ··· 1234 1322 '@atproto/lex-data': 0.0.12 1235 1323 '@atproto/lex-json': 0.0.12 1236 1324 '@atproto/syntax': 0.4.3 1325 + zod: 3.25.76 1326 + 1327 + '@atproto/did@0.3.0': 1328 + dependencies: 1329 + zod: 3.25.76 1330 + 1331 + '@atproto/jwk-jose@0.1.11': 1332 + dependencies: 1333 + '@atproto/jwk': 0.6.0 1334 + jose: 5.10.0 1335 + 1336 + '@atproto/jwk-webcrypto@0.2.0': 1337 + dependencies: 1338 + '@atproto/jwk': 0.6.0 1339 + '@atproto/jwk-jose': 0.1.11 1340 + zod: 3.25.76 1341 + 1342 + '@atproto/jwk@0.6.0': 1343 + dependencies: 1344 + multiformats: 9.9.0 1237 1345 zod: 3.25.76 1238 1346 1239 1347 '@atproto/lex-data@0.0.12': ··· 1254 1362 '@atproto/syntax': 0.4.3 1255 1363 iso-datestring-validator: 2.2.2 1256 1364 multiformats: 9.9.0 1365 + zod: 3.25.76 1366 + 1367 + '@atproto/oauth-client-browser@0.3.41': 1368 + dependencies: 1369 + '@atproto-labs/did-resolver': 0.2.6 1370 + '@atproto-labs/handle-resolver': 0.3.6 1371 + '@atproto-labs/simple-store': 0.3.0 1372 + '@atproto/did': 0.3.0 1373 + '@atproto/jwk': 0.6.0 1374 + '@atproto/jwk-webcrypto': 0.2.0 1375 + '@atproto/oauth-client': 0.6.0 1376 + '@atproto/oauth-types': 0.6.3 1377 + core-js: 3.48.0 1378 + 1379 + '@atproto/oauth-client@0.6.0': 1380 + dependencies: 1381 + '@atproto-labs/did-resolver': 0.2.6 1382 + '@atproto-labs/fetch': 0.2.3 1383 + '@atproto-labs/handle-resolver': 0.3.6 1384 + '@atproto-labs/identity-resolver': 0.3.6 1385 + '@atproto-labs/simple-store': 0.3.0 1386 + '@atproto-labs/simple-store-memory': 0.1.4 1387 + '@atproto/did': 0.3.0 1388 + '@atproto/jwk': 0.6.0 1389 + '@atproto/oauth-types': 0.6.3 1390 + '@atproto/xrpc': 0.7.7 1391 + core-js: 3.48.0 1392 + multiformats: 9.9.0 1393 + zod: 3.25.76 1394 + 1395 + '@atproto/oauth-types@0.6.3': 1396 + dependencies: 1397 + '@atproto/did': 0.3.0 1398 + '@atproto/jwk': 0.6.0 1257 1399 zod: 3.25.76 1258 1400 1259 1401 '@atproto/syntax@0.4.3': ··· 1732 1874 1733 1875 cookie@0.6.0: {} 1734 1876 1877 + core-js@3.48.0: {} 1878 + 1735 1879 debug@4.4.3: 1736 1880 dependencies: 1737 1881 ms: 2.1.3 ··· 1845 1989 1846 1990 jiti@2.6.1: {} 1847 1991 1992 + jose@5.10.0: {} 1993 + 1848 1994 kleur@4.1.5: {} 1849 1995 1850 1996 lightningcss-android-arm64@1.31.1: ··· 1897 2043 lightningcss-win32-x64-msvc: 1.31.1 1898 2044 1899 2045 locate-character@3.0.0: {} 2046 + 2047 + lru-cache@10.4.3: {} 1900 2048 1901 2049 lru-cache@11.2.6: {} 1902 2050
+1
web/pnpm-workspace.yaml
··· 1 1 onlyBuiltDependencies: 2 + - core-js 2 3 - esbuild
+178 -61
web/src/lib/components/steps/AuthStep.svelte
··· 1 1 <script lang="ts"> 2 2 import { Eye, EyeOff } from '@lucide/svelte'; 3 3 import { login } from '$lib/core/auth.js'; 4 - import type { AtpAgent } from '@atproto/api'; 4 + import { signInWithOAuth } from '$lib/core/oauth.js'; 5 + import type { Agent } from '@atproto/api'; 5 6 6 7 let { 7 8 onauth, 8 9 onback, 9 10 }: { 10 - onauth: (agent: AtpAgent) => void; 11 + onauth: (agent: Agent) => void; 11 12 onback: () => void; 12 13 } = $props(); 13 14 15 + // ─── tab ───────────────────────────────────────────────────────────────────── 16 + 17 + let tab = $state<'oauth' | 'password'>('oauth'); 18 + 19 + // ─── OAuth state ───────────────────────────────────────────────────────────── 20 + 21 + let oauthHandle = $state(''); 22 + let oauthLoading = $state(false); 23 + let oauthError = $state<string | null>(null); 24 + 25 + async function doOAuth() { 26 + oauthError = null; 27 + oauthLoading = true; 28 + try { 29 + await signInWithOAuth(oauthHandle.trim()); 30 + // Never reached — signInWithOAuth redirects away. 31 + } catch (err: any) { 32 + oauthError = err.message ?? 'OAuth sign-in failed'; 33 + oauthLoading = false; 34 + } 35 + } 36 + 37 + // ─── App-password state ─────────────────────────────────────────────────────── 38 + 14 39 let handle = $state(''); 15 40 let password = $state(''); 16 41 let pdsOverride = $state(''); ··· 36 61 <section class="card-section"> 37 62 <button class="back-btn" onclick={onback}>← Back</button> 38 63 <h2 class="section-title">Sign in to ATProto</h2> 39 - <p class="section-sub"> 40 - Use your Bluesky handle and an 41 - <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener">app password</a>. 42 - </p> 43 64 44 - <div class="form"> 45 - <label class="field"> 46 - <span class="field-label">Handle</span> 47 - <input 48 - type="text" 49 - bind:value={handle} 50 - placeholder="you.bsky.social" 51 - autocomplete="username" 52 - spellcheck="false" 53 - /> 54 - </label> 65 + <div class="tabs"> 66 + <button class:active={tab === 'oauth'} onclick={() => (tab = 'oauth')}>OAuth <span class="badge-tab">Recommended</span></button> 67 + <button class:active={tab === 'password'} onclick={() => (tab = 'password')}>App password</button> 68 + </div> 69 + 70 + {#if tab === 'oauth'} 71 + <p class="section-sub"> 72 + Sign in securely through your PDS — no password is ever shared with Malachite. 73 + </p> 55 74 56 - <label class="field"> 57 - <span class="field-label">App password</span> 58 - <div class="password-wrap"> 75 + <div class="form"> 76 + <label class="field"> 77 + <span class="field-label">Handle</span> 59 78 <input 60 - type={showPassword ? 'text' : 'password'} 61 - bind:value={password} 62 - placeholder="xxxx-xxxx-xxxx-xxxx" 63 - autocomplete="current-password" 79 + type="text" 80 + bind:value={oauthHandle} 81 + placeholder="you.bsky.social" 82 + autocomplete="username" 83 + spellcheck="false" 84 + onkeydown={(e) => e.key === 'Enter' && !oauthLoading && oauthHandle && doOAuth()} 64 85 /> 65 - <button 66 - class="pw-toggle" 67 - onclick={() => (showPassword = !showPassword)} 68 - type="button" 69 - aria-label={showPassword ? 'Hide password' : 'Show password'} 70 - > 71 - {#if showPassword}<EyeOff size={14} />{:else}<Eye size={14} />{/if} 72 - </button> 73 - </div> 74 - </label> 86 + </label> 75 87 76 - <button 77 - class="expand-btn" 78 - onclick={() => (showAdvanced = !showAdvanced)} 79 - type="button" 80 - > 81 - {showAdvanced ? '▾' : '▸'} Advanced options 82 - </button> 88 + {#if oauthError} 89 + <div class="alert alert-error">{oauthError}</div> 90 + {/if} 83 91 84 - {#if showAdvanced} 92 + <button 93 + class="btn-primary" 94 + onclick={doOAuth} 95 + disabled={oauthLoading || !oauthHandle} 96 + > 97 + {#if oauthLoading}<span class="spinner"></span> Redirecting…{:else}Continue with ATProto →{/if} 98 + </button> 99 + 100 + <p class="oauth-note"> 101 + You'll be sent to your PDS to approve access, then returned here automatically. 102 + </p> 103 + </div> 104 + 105 + {:else} 106 + <p class="section-sub"> 107 + Use your Bluesky handle and an 108 + <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener">app password</a>. 109 + </p> 110 + 111 + <div class="form"> 85 112 <label class="field"> 86 - <span class="field-label">PDS URL override <span class="badge">optional</span></span> 113 + <span class="field-label">Handle</span> 87 114 <input 88 - type="url" 89 - bind:value={pdsOverride} 90 - placeholder="https://your.pds.example" 115 + type="text" 116 + bind:value={handle} 117 + placeholder="you.bsky.social" 118 + autocomplete="username" 91 119 spellcheck="false" 92 120 /> 93 - <span class="field-hint"> 94 - Skip Slingshot identity resolution and connect directly to your PDS. 95 - </span> 96 121 </label> 97 - {/if} 98 122 99 - {#if authError} 100 - <div class="alert alert-error">{authError}</div> 101 - {/if} 123 + <label class="field"> 124 + <span class="field-label">App password</span> 125 + <div class="password-wrap"> 126 + <input 127 + type={showPassword ? 'text' : 'password'} 128 + bind:value={password} 129 + placeholder="xxxx-xxxx-xxxx-xxxx" 130 + autocomplete="current-password" 131 + /> 132 + <button 133 + class="pw-toggle" 134 + onclick={() => (showPassword = !showPassword)} 135 + type="button" 136 + aria-label={showPassword ? 'Hide password' : 'Show password'} 137 + > 138 + {#if showPassword}<EyeOff size={14} />{:else}<Eye size={14} />{/if} 139 + </button> 140 + </div> 141 + </label> 102 142 103 - <button 104 - class="btn-primary" 105 - onclick={doAuth} 106 - disabled={authLoading || !handle || !password} 107 - > 108 - {#if authLoading}<span class="spinner"></span> Signing in…{:else}Sign in →{/if} 109 - </button> 110 - </div> 143 + <button 144 + class="expand-btn" 145 + onclick={() => (showAdvanced = !showAdvanced)} 146 + type="button" 147 + > 148 + {showAdvanced ? '▾' : '▸'} Advanced options 149 + </button> 150 + 151 + {#if showAdvanced} 152 + <label class="field"> 153 + <span class="field-label">PDS URL override <span class="badge">optional</span></span> 154 + <input 155 + type="url" 156 + bind:value={pdsOverride} 157 + placeholder="https://your.pds.example" 158 + spellcheck="false" 159 + /> 160 + <span class="field-hint"> 161 + Skip Slingshot identity resolution and connect directly to your PDS. 162 + </span> 163 + </label> 164 + {/if} 165 + 166 + {#if authError} 167 + <div class="alert alert-error">{authError}</div> 168 + {/if} 169 + 170 + <button 171 + class="btn-primary" 172 + onclick={doAuth} 173 + disabled={authLoading || !handle || !password} 174 + > 175 + {#if authLoading}<span class="spinner"></span> Signing in…{:else}Sign in →{/if} 176 + </button> 177 + </div> 178 + {/if} 111 179 </section> 112 180 113 181 <style> 182 + /* ── Tabs ─────────────────────────────────────────────────────────────────── */ 183 + .tabs { 184 + display: flex; 185 + gap: 0.5rem; 186 + margin-bottom: 1.5rem; 187 + } 188 + 189 + .tabs button { 190 + flex: 1; 191 + display: flex; 192 + align-items: center; 193 + justify-content: center; 194 + gap: 0.4rem; 195 + padding: 0.55rem 0.75rem; 196 + border-radius: 6px; 197 + border: 1px solid var(--border); 198 + background: var(--surface); 199 + color: var(--muted); 200 + cursor: pointer; 201 + font-size: 0.85rem; 202 + transition: border-color 0.15s, color 0.15s; 203 + } 204 + 205 + .tabs button.active { 206 + border-color: var(--accent); 207 + color: var(--text); 208 + } 209 + 210 + .badge-tab { 211 + font-size: 0.65rem; 212 + font-family: 'JetBrains Mono', monospace; 213 + color: var(--accent); 214 + background: color-mix(in srgb, var(--accent) 12%, transparent); 215 + padding: 0.1rem 0.35rem; 216 + border-radius: 3px; 217 + text-transform: uppercase; 218 + letter-spacing: 0.04em; 219 + } 220 + 221 + /* ── OAuth note ───────────────────────────────────────────────────────────── */ 222 + .oauth-note { 223 + font-size: 0.775rem; 224 + color: var(--muted); 225 + text-align: center; 226 + margin: 0; 227 + line-height: 1.5; 228 + } 229 + 230 + /* ── Password field ───────────────────────────────────────────────────────── */ 114 231 .pw-toggle { 115 232 position: absolute; 116 233 right: 0.75rem;
+7 -3
web/src/lib/components/steps/OptionsStep.svelte
··· 20 20 21 21 <section class="card-section"> 22 22 <button class="back-btn" onclick={onback}>← Back</button> 23 - <h2 class="section-title">Import options</h2> 23 + <h2 class="section-title">{mode === 'deduplicate' ? 'Deduplication options' : 'Import options'}</h2> 24 24 25 25 <div class="options"> 26 26 <div class="option-row"> 27 27 <div class="option-info"> 28 28 <span class="option-name">Dry run</span> 29 - <span class="option-desc">Preview what would be imported without making changes</span> 29 + <span class="option-desc">{mode === 'deduplicate' ? 'Preview duplicates that would be removed without making changes' : 'Preview what would be imported without making changes'}</span> 30 30 </div> 31 31 <button 32 32 class="toggle" ··· 82 82 {/if} 83 83 84 84 <button class="btn-primary" onclick={onstartimport}> 85 - {dryRun ? 'Preview import →' : 'Start import →'} 85 + {#if mode === 'deduplicate'} 86 + {dryRun ? 'Preview duplicates →' : 'Start deduplication →'} 87 + {:else} 88 + {dryRun ? 'Preview import →' : 'Start import →'} 89 + {/if} 86 90 </button> 87 91 </section> 88 92
+2 -2
web/src/lib/core/auth.ts
··· 3 3 * No CLI prompts — credentials come from the web form. 4 4 */ 5 5 6 - import { AtpAgent } from '@atproto/api'; 6 + import { Agent, AtpAgent } from '@atproto/api'; 7 7 import { SLINGSHOT_RESOLVER } from '../config.js'; 8 8 9 9 interface ResolvedIdentity { ··· 29 29 identifier: string, 30 30 password: string, 31 31 pdsOverride?: string 32 - ): Promise<AtpAgent> { 32 + ): Promise<Agent> { 33 33 if (pdsOverride) { 34 34 const agent = new AtpAgent({ service: pdsOverride }); 35 35 await agent.login({ identifier, password });
+2 -2
web/src/lib/core/import.ts
··· 3 3 * Handles all five ImportMode flows with progress + cancellation callbacks. 4 4 */ 5 5 6 - import type { AtpAgent } from '@atproto/api'; 6 + import type { Agent } from '@atproto/api'; 7 7 import type { ImportMode, LogEntry, PlayRecord } from '../types.js'; 8 8 import { parseLastFmFile, convertToPlayRecord } from './csv.js'; 9 9 import { parseSpotifyFiles, convertSpotifyToPlayRecord } from './spotify.js'; ··· 38 38 } 39 39 40 40 export async function runImport( 41 - agent: AtpAgent, 41 + agent: Agent, 42 42 mode: ImportMode, 43 43 lastfmFiles: File[], 44 44 spotifyFiles: File[],
+66
web/src/lib/core/oauth.ts
··· 1 + /** 2 + * ATProto OAuth client — browser-only. 3 + * Wraps @atproto/oauth-client-browser for use across the app. 4 + */ 5 + 6 + import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 7 + import { Agent } from '@atproto/api'; 8 + 9 + // The loopback redirect_uri must use 127.0.0.1, not localhost — RFC 8252 10 + // explicitly disallows the localhost hostname in loopback redirect URIs. 11 + // 12 + // In dev, BrowserOAuthClient.load() calls atprotoLoopbackClientMetadata() 13 + // with our constructed client_id to generate virtual client metadata. 14 + // The client_id must be http://localhost with no path (query params are fine). 15 + // 16 + // In production, load() fetches the metadata from the https:// URL. 17 + const CLIENT_ID = import.meta.env.DEV 18 + ? `http://localhost?${new URLSearchParams([ 19 + ['redirect_uri', 'http://127.0.0.1:5173/import'], 20 + ['scope', 'atproto transition:generic'], 21 + ])}` 22 + : 'https://malachite.ewancroft.uk/client-metadata.json'; 23 + 24 + // Singleton promise — BrowserOAuthClient.load() is async. 25 + let _client: Promise<BrowserOAuthClient> | null = null; 26 + 27 + function getClient(): Promise<BrowserOAuthClient> { 28 + if (!_client) { 29 + // load() accepts clientId and dispatches correctly: 30 + // http: → atprotoLoopbackClientMetadata(clientId) for dev 31 + // https: → fetches the metadata document for production 32 + _client = BrowserOAuthClient.load({ 33 + clientId: CLIENT_ID, 34 + handleResolver: 'https://bsky.social', 35 + }); 36 + } 37 + return _client; 38 + } 39 + 40 + /** 41 + * Call once on mount on the /import page. 42 + * Processes any OAuth callback params in the URL and restores stored sessions. 43 + * Returns `{ session, agent }` if a session is active, or `null` if the user 44 + * still needs to sign in. 45 + */ 46 + /** 47 + * Call once on mount on the /import page. 48 + * Returns an Agent if a session was restored or a callback was processed, 49 + * or null if the user still needs to sign in. 50 + */ 51 + export async function initOAuth(): Promise<Agent | null> { 52 + const client = await getClient(); 53 + const result = await client.init(); 54 + if (!result) return null; 55 + return new Agent(result.session); 56 + } 57 + 58 + /** 59 + * Kicks off the OAuth sign-in flow for the given handle. 60 + * Redirects the browser away — this never resolves normally. 61 + */ 62 + export async function signInWithOAuth(handle: string): Promise<never> { 63 + const client = await getClient(); 64 + await client.signIn(handle, { scope: 'atproto transition:generic' }); 65 + throw new Error('redirect should have occurred'); 66 + }
+3 -3
web/src/lib/core/publisher.ts
··· 4 4 * Uses in-memory rate limiting and progress callbacks instead of console.log. 5 5 */ 6 6 7 - import type { AtpAgent } from '@atproto/api'; 7 + import type { Agent } from '@atproto/api'; 8 8 import type { PlayRecord } from '../types.js'; 9 9 import { RECORD_TYPE, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from '../config.js'; 10 10 import { BrowserRateLimiter } from './rate-limiter.js'; ··· 53 53 } 54 54 55 55 export async function publishRecords( 56 - agent: AtpAgent, 56 + agent: Agent, 57 57 records: PlayRecord[], 58 58 dryRun: boolean, 59 59 callbacks: PublisherCallbacks ··· 127 127 128 128 try { 129 129 const response = await agent.com.atproto.repo.applyWrites( 130 - { repo: agent.session?.did ?? '', writes: writes as any }, 130 + { repo: agent.did ?? '', writes: writes as any }, 131 131 { signal: ac.signal } 132 132 ); 133 133
+7 -7
web/src/lib/core/sync.ts
··· 3 3 * Fetches existing records from ATProto and filters for new ones. 4 4 */ 5 5 6 - import type { AtpAgent } from '@atproto/api'; 6 + import type { Agent } from '@atproto/api'; 7 7 import type { PlayRecord } from '../types.js'; 8 8 import { RECORD_TYPE } from '../config.js'; 9 9 ··· 22 22 const sessionCache = new Map<string, Map<string, ExistingRecord>>(); 23 23 24 24 export async function fetchExistingRecords( 25 - agent: AtpAgent, 25 + agent: Agent, 26 26 onProgress?: (fetched: number) => void, 27 27 forceRefresh = false, 28 28 signal?: AbortSignal 29 29 ): Promise<Map<string, ExistingRecord>> { 30 - const did = agent.session?.did; 30 + const did = agent.did; 31 31 if (!did) throw new Error('No authenticated session'); 32 32 33 33 if (!forceRefresh && sessionCache.has(did)) { ··· 73 73 } 74 74 75 75 export async function fetchAllRecordsForDedup( 76 - agent: AtpAgent, 76 + agent: Agent, 77 77 onProgress?: (fetched: number) => void, 78 78 signal?: AbortSignal 79 79 ): Promise<ExistingRecord[]> { 80 - const did = agent.session?.did; 80 + const did = agent.did; 81 81 if (!did) throw new Error('No authenticated session'); 82 82 83 83 const all: ExistingRecord[] = []; ··· 127 127 } 128 128 129 129 export async function removeDuplicateRecords( 130 - agent: AtpAgent, 130 + agent: Agent, 131 131 groups: DedupGroup[], 132 132 onProgress?: (removed: number) => void, 133 133 signal?: AbortSignal ··· 138 138 signal?.throwIfAborted(); 139 139 try { 140 140 await agent.com.atproto.repo.deleteRecord( 141 - { repo: agent.session?.did ?? '', collection: RECORD_TYPE, rkey: rec.uri.split('/').pop()! }, 141 + { repo: agent.did ?? '', collection: RECORD_TYPE, rkey: rec.uri.split('/').pop()! }, 142 142 { signal } 143 143 ); 144 144 removed++;
+48 -8
web/src/routes/import/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 2 3 import { fly } from 'svelte/transition'; 3 4 import { cubicOut } from 'svelte/easing'; 4 - import type { AtpAgent } from '@atproto/api'; 5 + import type { Agent } from '@atproto/api'; 5 6 7 + import { initOAuth } from '$lib/core/oauth.js'; 6 8 import { modeNeeds, stepLabelsFor } from '$lib/modes.js'; 7 9 import { runImport, type PublishProgress } from '$lib/core/import.js'; 8 10 import type { ImportMode, LogEntry } from '$lib/types.js'; ··· 14 16 import OptionsStep from '$lib/components/steps/OptionsStep.svelte'; 15 17 import RunStep from '$lib/components/steps/RunStep.svelte'; 16 18 19 + // ─── persistence keys ──────────────────────────────────────────────────────── 20 + 21 + const KEY_MODE = 'malachite:mode'; 22 + const KEY_STEP = 'malachite:step'; 23 + 17 24 // ─── wizard state ──────────────────────────────────────────────────────────── 25 + // Read synchronously from sessionStorage — safe because ssr = false. 26 + // This eliminates FOUC: the component renders immediately into the right step. 18 27 19 - let step = $state(0); 20 - let prevStep = $state(0); 21 - let mode = $state<ImportMode | null>(null); 28 + const _initMode = sessionStorage.getItem(KEY_MODE) as ImportMode | null; 29 + const _initStep = Number(sessionStorage.getItem(KEY_STEP)) || 0; 22 30 23 - let agent = $state<AtpAgent | null>(null); 31 + let step = $state(_initStep); 32 + let prevStep = $state(_initStep); 33 + let mode = $state<ImportMode | null>(_initMode); 34 + 35 + let agent = $state<Agent | null>(null); 24 36 let lastfmFiles = $state<File[]>([]); 25 37 let spotifyFiles = $state<File[]>([]); 26 38 ··· 48 60 49 61 // ─── navigation ────────────────────────────────────────────────────────────── 50 62 51 - function goTo(n: number) { prevStep = step; step = n; } 63 + function goTo(n: number) { 64 + prevStep = step; 65 + step = n; 66 + sessionStorage.setItem(KEY_STEP, String(n)); 67 + } 52 68 53 - function handleSelectMode(m: ImportMode) { mode = m; goTo(1); } 69 + function handleSelectMode(m: ImportMode) { 70 + mode = m; 71 + sessionStorage.setItem(KEY_MODE, m); 72 + goTo(1); 73 + } 54 74 55 75 function handleBack() { 56 76 if (step === 3 && mode === 'deduplicate') { goTo(1); return; } 57 77 goTo(Math.max(0, step - 1)); 58 78 } 59 79 60 - function handleAuth(a: AtpAgent) { 80 + function handleAuth(a: Agent) { 61 81 agent = a; 62 82 goTo(needs.files ? 2 : 3); 63 83 } ··· 107 127 } 108 128 } 109 129 130 + // ─── OAuth callback ──────────────────────────────────────────────────────── 131 + 132 + onMount(async () => { 133 + // If the URL contains an OAuth callback (?code=…&state=…), init() processes 134 + // it and returns the new session. If a session was already stored from a 135 + // previous visit it also comes back here. 136 + try { 137 + const oauthAgent = await initOAuth(); 138 + if (oauthAgent) { 139 + agent = oauthAgent; 140 + // mode is already restored synchronously from sessionStorage above. 141 + goTo(modeNeeds(mode).files ? 2 : 3); 142 + } 143 + } catch (err: any) { 144 + console.error('OAuth init error:', err); 145 + } 146 + }); 147 + 110 148 function handleReset() { 149 + sessionStorage.removeItem(KEY_MODE); 150 + sessionStorage.removeItem(KEY_STEP); 111 151 prevStep = step; step = 0; mode = null; agent = null; 112 152 lastfmFiles = []; spotifyFiles = []; 113 153 dryRun = false; reverseOrder = false; fresh = false;
+15
web/static/client-metadata.json
··· 1 + { 2 + "client_id": "https://malachite.ewancroft.uk/client-metadata.json", 3 + "client_name": "Malachite", 4 + "client_uri": "https://malachite.ewancroft.uk", 5 + "logo_uri": "https://malachite.ewancroft.uk/favicon.png", 6 + "tos_uri": "https://malachite.ewancroft.uk/about", 7 + "policy_uri": "https://malachite.ewancroft.uk/about", 8 + "redirect_uris": ["https://malachite.ewancroft.uk/import"], 9 + "grant_types": ["authorization_code", "refresh_token"], 10 + "response_types": ["code"], 11 + "scope": "atproto transition:generic", 12 + "application_type": "web", 13 + "token_endpoint_auth_method": "none", 14 + "dpop_bound_access_tokens": true 15 + }