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
14
fork

Configure Feed

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

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

+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 + }