Invidious answers a revoked session with 403, not 401, so the failure
reached recordServerOutcome as a plain HTTP error and fell through the
switch: an account whose session died server-side kept reporting
signedIn forever, and credentialRejected was unreachable on the
commonest backend. The roadmap's rule cuts the other way too — a raw
401/403 alone must never reject, because the failure may be about the
operation (someone else's playlist) rather than the login.
recordServerOutcome now treats .unauthorized and HTTP 401/403 alike as
suspicion, not verdict: after the existing basic-auth-proxy exclusion,
it hands the account to a new CredentialProbeService, which re-asks the
server with a harmless authenticated read — Invidious's auth
subscriptions endpoint with the same SID, Piped's subscriptions with
the same token. Only a probe-level 401/403 marks the account
credentialRejected; success means the original failure was
operation-specific and the account stays signedIn; an unreachable
probe records exactly that, and anything ambiguous leaves the status
alone.
The probe applies its verdict only if the account still exists and
still holds the credential it tested — a replacement or an identity
flip mid-flight drops the answer, and a dropped answer starts no
cooldown, so the login the user just fixed is examined without delay.
One probe per account may be in flight; an answered probe stands for a
cooldown before the next suspicion re-asks.
With rejection finally reachable, the existing machinery engages on
its own: the validators fall back to local data, server mutations
pause, and a successful call or re-sign-in lifts the mark.
The accounts.unresolved.* UserDefaults namespace was found on a real
device holding a stale, contradictory snapshot: a tombstoned account
listed live, context reset to local, every sync outbox still pending.
With iCloud Drive off the identity scope dips to unresolved on every
foreground activation, so that snapshot was loaded — and rewritten —
in an entirely ordinary configuration.
Close it off on four fronts:
- Gate the unprompted writers. reconcileLegacyAccounts() and
migrateLegacyContextIfNeeded() now no-op while the identity scope is
unresolved; reloadForCurrentIdentity() re-runs both once a scope
resolves, so nothing is lost, and nothing synthesizes accounts or
queues uploads under a namespace that answers to no one.
- Fence the reload gap. Between a scope change and this manager's
observer reloading, namespace already names the new identity while
memory still holds the old snapshot. loadedNamespace records what
memory belongs to; save(), the stored-context flag, and every
coordinator path that builds, enqueues, applies or confirms
account-category records refuses when it mismatches. Staged
downloads stay durable and re-apply after the reload.
- Tie confirmations and conflict retries to the identity that sent
them. Every outbound account-category record carries an
identityNamespace field; didSave honours only confirmations whose
record carries the current identity's stamp, conflict resolutions
inherit the local record's stamp and are dropped when foreign, and
a skipped confirmation re-registers the durable outbox so nothing
strands. The abandoning conflict branches re-register it too.
- Purge the stale blob. Once a scope resolves, the unresolved
namespace keys and the unresolved credential-fingerprint store are
removed; the activation-time validators no longer correct the
context or rewrite the legacy selection from unresolved state.
Hardened through four adversarial review rounds; the last found
nothing.
The two observer tokens carried an isolation annotation the compiler
reported as having no effect, while rejecting the plain `nonisolated` it
suggested in its place. Both follow from `@Observable`: the macro rewrites
stored properties into computed accessors, where `unsafe` means nothing
and `nonisolated` is not allowed at all.
They are notification tokens, written on the main actor and read once in
deinit. Nothing renders them, so nothing should be tracking them. Marking
them ignored lets the annotation apply to a real stored property again
and settles the warning by removing the reason it appeared.
Found on the iPad: after signing a second login into an instance, its
migrated account could no longer retire the unscoped legacy item when it
was deleted, and the instance was left still claiming a pre-revamp login.
Storing a credential retires the adoption marker, but it matched on
service, instance and identity only. An instance holds several logins, so
a sibling sign-in silently withdrew the migrated account's claim on a
secret it had nothing to do with. After that the legacy item outlived the
account that adopted it, and could be adopted a second time.
Erring that way costs nothing — an item that is not deleted is an item
nobody is logged out of — but the marker already carries the account it
was written for, and deletion authority already checks it. Retiring it
now matches on the same thing.
Review of the silent migration found two paths that defeat the point of
writing the adopted copy device-local.
The marker is written after the copy it describes, so a launch that ends
in between leaves an adopted credential nothing claims — and adoption
never retries, because the key is no longer empty. Enabling iCloud sync
then rewrites it as synchronizable. The resync now also recognizes the
value: a scoped credential identical to the unscoped item it could only
have come from is an adopted copy whether or not a marker says so.
The opposite failure is a marker that outlives what it described.
Signing in again replaces the credential with one the user proved they
hold, but the marker stayed, so enabling sync afterwards skipped the
account and left every other device showing a login it never receives.
Storing a credential now retires the marker.
Also corrects four comments that still describe adoption as something
the user is asked about, and states plainly what the legacy-deletion
conditions do and do not prove now that no one is asked.
The pre-revamp release reads its unscoped Keychain item with
kSecAttrSynchronizableAny and no identity in the key, so a credential
retained across an Apple Account change is already served to whoever
holds the device. Asking before reusing it prevents nothing that is not
already happening, and it cost every upgrading user a trip into the
instance editor to find a button they had no reason to look for.
What silent adoption could genuinely add is re-filing that secret as
synchronizable under the identity signed in now, which uploads a login
of unproven provenance into that person's iCloud Keychain and spreads it
to their devices. So the copy is written device-local instead, and the
migration runs where the status is computed. The real owner loses
nothing: the item it was copied from syncs, so each of their devices
finds it and adopts its own copy, and adoption still never deletes it.
Two things keep that closed. Turning iCloud sync on rewrites stored
credentials to follow the setting, which would undo the whole point, so
adopted ones are left out of it. And only the account the instance's
legacy login became may inherit the secret — the old item is keyed by
instance, so a second login the user added themselves would otherwise
match it.
Local data is always a valid context. The legacy validator predates
that: it treats local as unavailable without a Yattee Server, which is a
feed concern the feed reports itself — and since the correction now
moves the data context with it, that would have quietly put users on
somebody's backend account, overwriting a local choice made on another
device.
Stale feed responses no longer touch anything. Rejecting only the videos
still let a request from a login the cache had moved off overwrite the
new one's loading state, error state and pagination — replacement
requests are allowed to overlap, so both were in flight.
The unreachable retry timers are cancelled when the namespace reloads. A
migrated account reuses its instance's UUID, so the same ID can exist
under the next Apple account, and an old timer would declare that
account reachable on the strength of a verdict about someone else's.
Rewriting a credential's sync flag is skipped when it already matches
and restores the original if the re-add fails. Changing that flag means
delete-then-add, and the delete propagates to every device — turning
sync on should never be able to log the user out.
"Could not be reached" now lifts by mutating the status rather than by
having the getter compare the clock. Nothing re-reads a value because
time has passed, so the read fallback stayed in place and no request was
ever made to discover that the server had recovered.
The identity-scope notification names the scope it is about, and
everything namespaced by identity observes its own. A second scope
resolving would otherwise make every account list in the process reload
and drop its statuses — which is also what made the new test fail.
A disk load of the feed waits for any purge already asked for. The clear
runs in its own task, so a load starting afterwards could still reach
the file first and restore what the purge was about to delete.
The comment on orphaned accounts no longer promises a removal action
that does not exist yet — that screen is Phase 3 — and says what happens
until then.
Two devices editing an account in the same instant — or migrating one,
since every migrated account is stamped at the epoch — each kept its own
label and considered the matter closed. Equal timestamps are now broken
by the label, on the incoming side and in the conflict resolver alike,
the same rule the context already uses.
Cached feeds were thrown away on every ordinary launch: recomputing
statuses posts a notification, and the observer ran before the value it
compares against had been seeded. The seed now happens first.
Cache ownership is checked once at launch and compared against a
recorded owner afterwards. An unresolved identity at launch means the
owner cannot be established at all — a switch to an unseen Apple account
while offline looks exactly like that — so the caches are discarded;
mid-session it is a transient, and with iCloud Drive off it happens on
every activation, so it no longer clears anything.
Whether a Keychain item syncs is fixed when it is written, so a login
stored while sync was off stayed on this device while its account record
travelled, leaving every other device with an account whose credential
never arrives. Turning sync on rewrites them.
Reseeding a deleted zone marks the account records dirty even while the
category is off, so re-enabling it restores them instead of finding an
empty outbox.
A deletion can reach a device before the account it deletes does — the
tombstone and the account are separate records, and iCloud Keychain runs
on its own timeline — and the credential cleanup was skipped whenever
the account was unknown here. The scoped key needs only the account ID,
so both credential managers are now asked to drop it; the unscoped
legacy item still needs the instance and is left alone.
Cached data is invalidated on the data context rather than on the legacy
selection it maps to. Two logins on the same instance collapse onto the
same legacy value, so switching between them cleared nothing and the
subscriptions feed kept showing the previous account's videos. Home
feeds are keyed by instance alone and had the same hole across accounts
and across Apple accounts; they are dropped with it.
The third source-deletion path now cascades to accounts like the other
two, and the Invidious private-playlist lookup resolves its session
through the account — a login created after the revamp writes only an
account-scoped credential, so the instance-keyed lookup missed it and
fell through to the public endpoint.
Five findings against the accounts layer, all real.
A fresh login created a random-UUID account and then flipped
usesAccountLogin, which posts instancesDidChange and re-ran legacy
reconciliation immediately. That guard only looked for an account whose ID
equalled the instance ID, so it synthesized a second, credential-less
account alongside the real one. Signing in now marks the instance migrated,
and reconciliation treats any account on an instance as evidence its login
is already represented.
save() set hasStoredContext, so the save inside reconcileLegacyAccounts
marked the still-default .local context as explicitly chosen and the
legacy-context migration that ran immediately after did nothing. Upgrading
users with a selected server account silently lost it. Only setContext,
applySyncedContext and the migration itself record a choice now.
An Apple Account change re-resolved the credential scope but never told
AccountsManager to reload its identity-namespaced storage - the only reload
was a one-time launch task - so the previous identity's accounts, context
and outbox stayed live under the new one, as did cached per-account
subscription providers. The scope now posts when its resolved value
changes; the accounts store reloads and the provider cache is dropped.
Fourteen call sites still read the pre-revamp instance-keyed Keychain item,
which a new login no longer writes: reopening the source screen showed the
account as signed out, and feed, browse, home and import operations treated
it as unauthenticated. They now resolve the credential through the account
layer, preferring the selected context's account and otherwise any
signed-in account on that instance.
Signing in while the identity scope was still unresolved created the
account in a temporary namespace that reloadForCurrentIdentity later
discards, while credential storage silently refused the write and the UI
reported success. Login is gated on a resolved scope and verifies the
credential actually stored before reporting success, rolling back a
newly-created account if it did not.
Seventh review pass. No correctness findings this round; both are
availability.
The retry budget was only reset inside the tokenless branch of an identity
change, so an account that exhausted its four attempts left the next one
with none - its queued resolve() did no lookup at all and credentials
stayed unavailable until another foreground transition. The budget belongs
to the identity being resolved, so it now resets on every observed change.
ubiquityIdentityToken was archived with requiringSecureCoding: true, but
Foundation guarantees only NSCoding for it. Measured: a class adopting only
NSCoding does throw under secure archiving, and the `try?` there would have
silently produced no fingerprint - losing the scope cache, forcing a
CloudKit lookup every launch and leaving credentials unavailable offline.
This Mac's token happens to support secure coding, so nothing was failing
yet; the archive is only ever hashed, never decoded, so secure coding buys
nothing regardless. Also measured: the non-secure archive is deterministic,
which is what the fingerprint comparison depends on.
Note the digest differs between the two archiving modes, so any scope
cached before this change resolves once more against CloudKit and re-caches.
Sixth review pass, both findings in the tokenless path the fourth pass
introduced.
The staleness guard compared fingerprints, which is useless when there is
no ubiquity token: nil before the switch and nil after it, so a user-record
lookup started under account A was accepted as current after CKAccountChanged
had already announced B. The scope then read as resolved, the pending-resolve
replay was skipped because it only runs while unresolved, and B was left
pointed at A's credential namespace. Every async path now captures a
generation counter that every observed account change bumps, and refuses to
apply a result across it.
Foreground revalidation also kept serving the old tokenless scope across
both awaits, and indefinitely when the account status came back
undetermined - so a switch that happened while the app was suspended could
hand A's retained credentials to B. The scope is now dropped before the
confirmation rather than compared after it, which also let the bespoke
revalidation logic collapse into resolve(), where the signed-out,
signed-in and undetermined cases are already handled. A tokenless setup
briefly reports "can't tell yet" on each activation; the tri-state UI
covers that, and the alternative traded a real cross-account disclosure for
the convenience.
Fifth review pass, which prompted measuring whether SecItemDelete can be
conditioned on a value. It cannot: a delete issued with a deliberately
wrong kSecValueData still returns 0 and removes the item, so there is no
compare-and-delete and every check-then-delete is a race by construction.
That measurement decides both fixes here.
Adoption no longer rolls back its own write when the verification read
comes back empty. The rollback could only ever be a blind delete of
whatever occupied the key by then - including a credential synced from
another device in the gap - and it would propagate. Nothing is deleted; no
marker is written, so the account stays signed out and an ordinary sign-in
recovers it.
Legacy deletion re-reads immediately before deleting and aborts if the
value moved, shrinking the window to a single call gap and catching a
replacement that landed while the fingerprint was being computed. A
residual race remains and is documented at the call site: closing it is not
possible with these APIs, and never deleting would leave a credential the
user explicitly removed sitting in their iCloud Keychain.
The debug provenance report repeated the same nil-token ambiguity the scope
logic was fixed for, reporting "no Apple account signed in" when iCloud
Drive is merely off. It now reports the token as unavailable and shows
CloudKit's account status separately, so a re-run cannot draw a false
conclusion from it.
Fourth review pass, on a hole the previous commit opened.
Making a nil ubiquity token mean "unresolved" fixed the shared-local-scope
collision, but left a scope resolved from CloudKit alone with nothing local
to watch: with iCloud Drive off the token stays nil across a switch from
account A to B, so NSUbiquityIdentityDidChange never fires and the scope
kept pointing at A - and its retained credentials with it - until relaunch.
Two additions. CKAccountChanged is now observed alongside the ubiquity
notification, and a tokenless scope is dropped outright when either fires
because there is no fingerprint to re-derive it from. Foreground activation
also revalidates a tokenless scope against CloudKit, covering a switch
whose notification never arrived; it compares before replacing so the
common case never blanks out credentials, and an unreachable account leaves
the scope standing rather than flapping on every transient failure.
A token-derived scope is untouched by all of this - it changes only via the
notification, and a test pins that down.
Third review pass.
adoptCurrentIdentity() treated a nil ubiquityIdentityToken as sign-out and
scoped credentials to "local". But Foundation also returns nil when iCloud
Drive is unavailable or switched off, and Yattee declares only the CloudKit
service with no ubiquity container, so the token tracks iCloud Drive state
rather than the account. In that configuration two different signed-in
Apple accounts both resolved to "local" and shared one credential
namespace - since Keychain items survive an account switch, account B could
then read account A's retained credential. That is the precise failure this
whole change exists to prevent.
A nil token now means unresolved, and CloudKit's account status settles it
during resolution: noAccount scopes to local, available proceeds to the
user-record lookup, anything else stays unresolved and retries. A scope
resolved without a token is not cached, because there is no key that could
be trusted to mean this account.
Adoption markers are now namespaced by Keychain service. Both credential
managers share one marker store and an instance UUID can outlive a change
of backend, so adopting an Invidious credential could suppress the Piped
offer for the same instance and point deletion at the wrong secret.
Writing the account-status branch also surfaced a Swift trap: an
unqualified `break` inside the switch left only the switch and fell through
to the user-record lookup, resolving a scope on an undetermined account
status. The loop is labelled now, and the test that caught it is included.
Two more issues from a second review pass.
Adoption verified its write by reading the key back and deleted it on any
mismatch. But a mismatch can also mean iCloud Keychain delivered a real
credential for that key between the add and the read - and since the delete
spans both synchronizable forms, it would propagate and revoke that
credential on every device. A readable value that is not ours is now left
strictly alone; only an unreadable key (the add reported success but
produced nothing usable) is rolled back so the offer can be retried.
An identity switch arriving while a resolution was in flight was dropped:
the observer's resolve() returned early on the isResolving guard, and the
in-flight one then bailed on its fingerprint check without starting a
lookup for the new identity, leaving credentials disabled until a
foreground transition or relaunch. Requests that arrive mid-resolution are
now replayed once the running one finishes.
Adds a fingerprint provider and an injectable backoff so identity
resolution is testable without a real Apple account or real seconds, plus
tests for retry, foreground recovery, mid-flight switching, signed-out
scoping and the cached-scope fast path.
Four issues from an external review of the previous commit.
The scope was resolved once at startup, so signing out or switching Apple
account while the app kept running left it pinned to the old identity -
reads and writes would then reach the previous account's retained
credentials, which is the exact leak scoping exists to prevent.
CredentialIdentityScope now observes NSUbiquityIdentityDidChange and
re-resolves.
Legacy deletion authority checked only the instance and the identity, not
the account. On an instance with several accounts, deleting any of them
satisfied the marker lookup and could retire the legacy item another
account had adopted, logging out devices still on the previous release.
The marker's accountID is now required to match.
A first-time resolution that failed (launched offline, CloudKit
unreachable) was never retried, disabling credentials for the rest of the
process. resolve() now retries with backoff, and resolveIfNeeded() runs on
foreground activation.
Adoption re-checked nothing at execution time, so a scoped credential
delivered by iCloud Keychain between rendering the offer and tapping it
would be overwritten with the older legacy secret. Adoption now re-verifies
eligibility and writes with a non-replacing add, leaving a duplicate
strictly alone.
Adds regression tests for the sibling-account deletion and the adoption
race.
Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.
Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.
The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.
The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.
While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.
Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
Renames YatteeServerCredentialsManager → BasicAuthCredentialsManager so the
same Keychain-backed username/password storage can be reused for any instance
type that sits behind a reverse proxy requiring HTTP Basic Auth. Adds a
one-time migration that moves existing items from the legacy
'com.yattee.yatteeserver' Keychain service to 'com.yattee.basicauth',
preserving the iCloud-sync attribute. No behavior change for end users.