Skip to content

fix(feeds): keep private subscriptions working across devices - #428

Merged
chhoumann merged 11 commits into
masterfrom
fix/private-feed-devices
Oct 13, 2026
Merged

chhoumann merged 11 commits into
masterfrom
fix/private-feed-devices

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #391.

Setting up a synced private feed on another device keeps its existing UUID. Use Set feed address on the saved Settings row, or import a same-title private feed through OPML. A public OPML namesake leaves the private subscription intact. Settings export/import now preserves the valid feed reference too. The orphan sweep retains IDs referenced anywhere in data.json, including unknown fields and object keys.

A publisher title change no longer creates a duplicate subscription: Settings matches the URL across saved feeds and Remove uses the original saved key. The missing-address notice says what is missing on this device. Enter submits Set feed address without reopening the dialog. Public addresses are refused before fetching. The Button change is cleanup only: Obsidian already replaces its callback; the test mock now does the same, and the mock-only regression test was removed.

The persistence guarantee is no private feed address in data.json, including the first write, all episode containers and preserved unknown fields. Settings exports use the same boundary with this device's known private addresses, so an episode description cannot leak the feed address into the exported vault file. Protection uses the current credential-bearing feed URL detector and locally known private addresses. Independent signed streamUrl, artworkUrl, chaptersUrl and episode links remain unchanged, so playback resume keeps working. Path-only detection remains research only; encoded textual representations are outside this change.

Verification

Current delta report and replay scripts, snapshots, timings and logs.

The npm run size:data stress library with one private feed encodes in 5.83 ms, down from 31.65 ms, in Obsidian's renderer (master: 2.18 ms). This is the median of nine alternating rounds. The remaining scan covers arbitrary text and keys. Ranges, the no-private-address case, write-guard cost, and an inclusive file-write probe are in the report.

Node v22.23.2, commands run through fnm exec --using 22 --:

Command Result
npm run lint Pass
npm run format:check Pass
npm run typecheck Pass
npm run build Pass
npm run test Pass: Svelte checks and 1,773 tests in 99 files
npm run test:tooling Pass: 10 tests
npm run docs:build Pass: 18 pages, internal links and 112 legacy anchors
npm run test:e2e Pass: 80 tests in real Obsidian, 13 files
npm run size Pass: 290,887 raw bytes, 92,571 gzip bytes at level 9

Size delta from the prior branch head 2898cc1: +976 raw bytes, +378 gzip bytes, including master's +175 / +25. The fixes alone add +801 / +353. Limits are 330,000 and 100,000 bytes. Master edb9447 was merged without conflicts. Gates, all E2E tests, and live export/storage-failure/restart checks passed on 9fd33c35784a659823e9468f87cd06e63740c8c0. Performance codecs are byte-identical across the merge.

Real Obsidian 1.14.4 on Linux x64 under Xvfb, two worktree-owned isolated vaults with separate SecretStorage profiles and one process at a time. The synthetic fixture feed is http://podnotes.test:41627/PRIV2_PATH_SENTINEL/feed.xml?auth=PRIV2_QUERY_SENTINEL; the runner maps its host to loopback.

  • Settings export sentinel: absent from the actual exported vault file, with the independent signed media URL preserved.
  • Enter recovery: real keyboard events close the dialog for invalid, public and private input. The negative control with the old modal reopens it. Public input makes no feed request.
  • SecretStorage write failure: one Notice, volume 0.73 to 0.37 persisted, private feed still fetches and plays during the session, and a full restart shows Set feed address under the same reference. Exact saved bytes contain no sentinel.
  • Settings recovery, OPML revival, and settings-transfer recovery each passed the full A/B sequence: recover B, sync both ways, gracefully restart each, and play on both with the same UUID.
  • Reproduced the original title-change duplicate, first-write leak and OPML replacement bugs before the fix. Tests failed before implementation and pass after it.
  • Captured the exact first disk bytes: SHA-256 8f0eda38a8767de3be413da3f2f61b34355bfba89290de5dc5629a09851012a9; rg --quiet PRIV2_SUBSCRIBE_SENTINEL after-first-write.json exits 1. Every captured write omits the sentinel.
  • Actual 2.20.0 and 2.21.1 encoder fixtures load byte-for-byte unchanged, keep their references and play successfully. One adversarial review pass used Claude and GPT; accepted findings have regression tests.

Original two-vault verification report · Replay scripts and evidence · Missing-address notice · Title-change before / after

Recovery after a failed storage write and restart

Set feed address on the saved row

Release impact

No schema or ID migration. Existing UUID references shipped in #295 / 2.20.0 and remain stable. New settings exports carry the non-secret feed UUID; older exports that already discarded it cannot recreate it. SecretStorage remains device-local. If a raw private feed address cannot be stored, PodNotes saves the placeholder and other changes, retains the address for this session, and shows one Notice. The feed needs Set feed address after restart; key-redaction collisions also refuse the write instead of losing entries. Public API and URI formats are unchanged.

Path-secret proposal: recommend verified host patterns plus an explicit privacy action, without implementing either here. The reviewed host docs did not establish that a major host reuses the same durable feed token in enclosure URLs. Signed-resource persistence and re-resolving media on restore remain a separate unit.

Docs at release

  • sync.md: replace the private-feed one-device limitation with stable-ID setup on each device; explain that the address itself stays local.
  • faq.md and mobile.md: replace remove/re-add advice with Set feed address and the exact missing-device notice.
  • settings.md: document the new control, UUID preservation through settings transfer, and the session-only fallback when a raw private address cannot be stored.
  • import_export.md: document private OPML revival under the existing ID, public namesake behavior and the limitation of older settings exports that lack the ID.
  • sync.md and faq.md: document that a startup sweep can remove this device's secret after a stale sync drops the subscription; removing and re-adding creates a new UUID; Set feed address does not verify show ownership.
  • Privacy wording: state the narrower data.json guarantee and the separate treatment of signed resource URLs and path-only detection.

Note

Fix private feed subscriptions syncing across devices with redacted URLs

  • Private feed addresses (credential-bearing URLs) now never reach data.json. redactPrivateFeedAddresses in privateFeedUrl.ts rewrites matching strings and object keys to placeholders during persistence, settings export, and sync merges.
  • FeedUrlRepository gains attach and retainForSession: a second device can populate an existing private-feed reference, and a failed SecretStorage write leaves the address resolvable in memory for the current session only.
  • A new Set feed address modal (FeedAddressModal.ts, PodcastQueryGrid.svelte) lets users recover a synced private feed whose address is unavailable locally; invalid, public, or unloadable URLs leave the saved entry unchanged.
  • Settings import no longer aborts when an address cannot be stored; other settings persist and the feed keeps its placeholder reference. OPML import attaches private URLs only to the original saved reference and never replaces a private feed with a same-title public feed.
  • Behavior change: internPrivateFeed now throws on SecretStorage failure instead of returning the raw feed; import summaries use new singular/plural wording; the unavailable-address notice is one sentence with no remove/re-add instruction. Risk: privateFeedUrl.ts contains a stray + in the redactPrivateFeedAddresses callback that is a TypeScript syntax error and must be removed before build.

Macroscope summarized 9fd33c3.

… losses

Set feed address must keep the synced reference, OPML must reuse a saved
private feed's reference and never let a public namesake replace it, the
orphan sweep must keep every ID data.json references, and released 2.20.0
and 2.21.1 libraries must load unchanged.
Button registered its click listener on every update, so a press after any
prop change dispatched once per update. Register it once when the button is
created.
…he first

A private feed's synced reference now stays its identity. Settings offers
Set feed address on a saved private feed whose URL is not on this device,
and OPML import sets a listed private URL the same way, both under the
existing reference, so every device keeps resolving its own copy. A public
namesake never replaces the feed. The orphan sweep keeps every ID data.json
still references, also in records this version drops or fields it does not
know.

Pasting a feed under a title it no longer has offers Remove for its saved
key instead of adding a duplicate. No private feed address reaches data.json:
the encoder redacts it in every field, from the first write, keeping signed
media and an episode's own link. A URL SecretStorage cannot keep is never
saved in its place; startup then leaves data.json as it is and refuses
saves. The missing-address notice no longer advises removing the feed.

Closes #391
Comment thread src/utility/privateFeedUrl.ts
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9fd33c3
Status: ✅  Deploy successful!
Preview URL: https://f3289267.podnotes.pages.dev
Branch Preview URL: https://fix-private-feed-devices.podnotes.pages.dev

View logs

Comment thread src/main.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

try {

If SecretStorage cannot retain a private feed URL during startup, loadSettings still writes the temporary retainForSession reference to data.json, losing the subscription URL after restart. protectPrivateFeeds reports these failures in feedProtection.failed, but this migration writes unconditionally; abort the write when that list is nonempty so the original file remains intact.

Also found in 1 other location(s)

src/ui/settings/PodNotesSettingsTab.ts:938

Removing the protection.failed guard lets a raw private-feed settings import report success even when SecretStorage cannot retain its address. protectPrivateFeeds catches that failure and substitutes retainForSession's temporary UUID; strict persistence accepts the placeholder, but the address exists only in FeedUrlRepository.sessionUrls. After restart the imported subscription cannot load, despite the successful import notice. Keep the failure check (or explicitly report a non-durable import) rather than silently accepting temporary references.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/main.ts around line 479:

If SecretStorage cannot retain a private feed URL during startup, `loadSettings` still writes the temporary `retainForSession` reference to `data.json`, losing the subscription URL after restart. `protectPrivateFeeds` reports these failures in `feedProtection.failed`, but this migration writes unconditionally; abort the write when that list is nonempty so the original file remains intact.

Also found in 1 other location(s):
- src/ui/settings/PodNotesSettingsTab.ts:938 -- Removing the `protection.failed` guard lets a raw private-feed settings import report success even when SecretStorage cannot retain its address. `protectPrivateFeeds` catches that failure and substitutes `retainForSession`'s temporary UUID; strict persistence accepts the placeholder, but the address exists only in `FeedUrlRepository.sessionUrls`. After restart the imported subscription cannot load, despite the successful import notice. Keep the failure check (or explicitly report a non-durable import) rather than silently accepting temporary references.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Protected private feeds lose access when re-added on another synced device

1 participant