diff --git a/docs/adr/0001-bidirectional-cyd-archive-compatibility.md b/docs/adr/0001-bidirectional-cyd-archive-compatibility.md deleted file mode 100644 index e19b4d96..00000000 --- a/docs/adr/0001-bidirectional-cyd-archive-compatibility.md +++ /dev/null @@ -1,3 +0,0 @@ -# Bidirectional Cyd Bluesky archive compatibility begins with version 2 - -Cyd mobile and desktop exchange Cyd Bluesky archives beginning with the canonical Bluesky version 2 format. Bluesky archive versions are independent of the archive contracts and version histories for Cyd's other social platforms. The unversioned mobile prototype has no users and is unsupported by both clients; abandoning it avoids coupling the shared contract to Mobile's historical runtime schema. Each client clearly rejects Bluesky archive versions it does not support so evolving one application does not silently break portability with the other. diff --git a/docs/adr/0001-shared-cyd-bluesky-archive-format.md b/docs/adr/0001-shared-cyd-bluesky-archive-format.md new file mode 100644 index 00000000..07cc6352 --- /dev/null +++ b/docs/adr/0001-shared-cyd-bluesky-archive-format.md @@ -0,0 +1,7 @@ +# ADR 0001: Shared Cyd Bluesky archive format + +Cyd and Cyd Mobile share one Bluesky archive format for import, export, and +recovery. The normative contract is `docs/archive/bluesky/v2/` in this repo — +its README, SQLite schema, and canonical fixtures are the single source of +truth, mirrored into Cyd Mobile as a pinned contract bundle. Conformance is +enforced by tests in both repos, not by prose. diff --git a/docs/adr/0002-canonical-archive-interchange-database.md b/docs/adr/0002-canonical-archive-interchange-database.md deleted file mode 100644 index 1802b8a5..00000000 --- a/docs/adr/0002-canonical-archive-interchange-database.md +++ /dev/null @@ -1,3 +0,0 @@ -# Canonical Bluesky interchange database - -The `data.db` inside Bluesky version 2 and later Cyd Bluesky archives is a canonical Bluesky interchange database rather than a copy of either client's runtime database. Mobile and desktop translate between private storage and this versioned schema, allowing their implementations and migrations to evolve independently; neither client maintains an adapter for the unused unversioned mobile prototype. diff --git a/docs/adr/0002-protect-credentials-only.md b/docs/adr/0002-protect-credentials-only.md new file mode 100644 index 00000000..8c6bd8d1 --- /dev/null +++ b/docs/adr/0002-protect-credentials-only.md @@ -0,0 +1,14 @@ +# ADR 0002: Protect credentials only, with disclosure + +Credentials for every platform — Bluesky OAuth sessions, X and Facebook +browser sessions, and anything else that authorizes account control — are +stored outside every account database, media directory, and archive through +the desktop-wide credential facility, which uses OS-protected storage +wherever one is available. Where none is, it stores the credential in the +clear in the same owner-only vault and tells the user so, rather than +refusing to persist it. Saved content relies on operating-system account +permissions and disk encryption; credentials are the exception because they +authorize account control. Bluesky connects through OAuth rather than app +passwords, and Cyd holds one Bluesky OAuth session per DID, shared by every +account acting on that identity, deriving its holders from account state so +a session is revoked at the PDS only when the last holder disconnects. diff --git a/docs/adr/0003-bluesky-durable-jobs-and-content-addressed-storage.md b/docs/adr/0003-bluesky-durable-jobs-and-content-addressed-storage.md new file mode 100644 index 00000000..013d0f1e --- /dev/null +++ b/docs/adr/0003-bluesky-durable-jobs-and-content-addressed-storage.md @@ -0,0 +1,12 @@ +# ADR 0003: Bluesky durable jobs and content-addressed storage + +Bluesky save, delete, import, and export operations are durable, resumable, +and idempotent: they commit progress incrementally, recover after restart, +and leave existing account data untouched until final commit. Bluesky +storage keeps the latest observed record at its stable AT URI (not every +historical revision), and stores media content-addressably within each +Bluesky local account, with saved records referencing assets rather than +owning duplicate files. Disabling a save category affects future collection +only, never deleting previously saved records; local deletion is a separate +explicit action. Other platforms (X, Facebook) follow their own storage +models; this ADR is about Bluesky. diff --git a/docs/adr/0003-match-bluesky-accounts-by-did.md b/docs/adr/0003-match-bluesky-accounts-by-did.md deleted file mode 100644 index 9e59c733..00000000 --- a/docs/adr/0003-match-bluesky-accounts-by-did.md +++ /dev/null @@ -1,3 +0,0 @@ -# Match Bluesky accounts by DID - -Archive import matches a Bluesky account by DID, not by handle or Cyd UUID. Handles can change and separate installations can assign different UUIDs to the same Bluesky identity; UUIDs remain local-account identifiers and are preserved when possible, while DID matching prevents duplicate local accounts for one Bluesky identity. A matching DID keeps the existing local UUID; a new DID adopts the archive UUID when it is unused, or receives a newly generated UUID with a reported remapping when the archive UUID already belongs to another identity. diff --git a/docs/adr/0004-merge-archive-imports-idempotently.md b/docs/adr/0004-merge-archive-imports-idempotently.md deleted file mode 100644 index dd39cb5a..00000000 --- a/docs/adr/0004-merge-archive-imports-idempotently.md +++ /dev/null @@ -1,3 +0,0 @@ -# Merge archive imports idempotently - -A Bluesky archive import merges a Cyd Bluesky archive with the matching Bluesky local account rather than replacing Bluesky saved data. Bluesky saved records match on stable identifiers such as AT URIs, richer or newer representations can update older ones, Bluesky saved data absent from the Cyd Bluesky archive is retained, and importing the same archive repeatedly has no further effect; this supports safe cross-device transfer without sacrificing data collected independently on either device. Because Bluesky archive import is an explicit recovery action, it may restore records removed through Bluesky local deletion, and its preview reports that restoration rather than relying on permanent deletion tombstones that would suppress older backups. diff --git a/docs/adr/0005-exclude-credentials-from-archives.md b/docs/adr/0005-exclude-credentials-from-archives.md deleted file mode 100644 index d0a5a0bc..00000000 --- a/docs/adr/0005-exclude-credentials-from-archives.md +++ /dev/null @@ -1,3 +0,0 @@ -# Exclude credentials from Cyd archives - -Cyd Bluesky archives never contain OAuth tokens, private keys, session state, or other reusable credentials. Imported accounts are immediately available for offline browsing, but each installation must establish its own Bluesky connection before it can refresh data or perform account actions, preventing a portable archive from becoming an account-control credential. diff --git a/docs/adr/0006-offline-first-in-app-browsing.md b/docs/adr/0006-offline-first-in-app-browsing.md deleted file mode 100644 index 88c00307..00000000 --- a/docs/adr/0006-offline-first-in-app-browsing.md +++ /dev/null @@ -1,3 +0,0 @@ -# Make in-app browsing offline-first - -The Cyd browser reads the local saved dataset rather than depending on live Bluesky APIs or generated HTML. Posts, reposts, likes, bookmarks, and chats remain inspectable offline even after their source becomes unavailable; live Bluesky links are optional enhancements rather than dependencies. diff --git a/docs/adr/0007-save-complete-media-for-selected-records.md b/docs/adr/0007-save-complete-media-for-selected-records.md deleted file mode 100644 index 0460ee83..00000000 --- a/docs/adr/0007-save-complete-media-for-selected-records.md +++ /dev/null @@ -1,3 +0,0 @@ -# Save complete media for selected records - -When a Bluesky record is selected for saving, Cyd downloads every media asset needed to reproduce it offline, including full video. This applies consistently to the user's posts and to other enabled categories such as likes; users control storage through category selection rather than receiving an incomplete backup whose videos can disappear with the live Bluesky source. diff --git a/docs/adr/0008-own-the-archive-contract-in-cyd.md b/docs/adr/0008-own-the-archive-contract-in-cyd.md deleted file mode 100644 index 75985b34..00000000 --- a/docs/adr/0008-own-the-archive-contract-in-cyd.md +++ /dev/null @@ -1,3 +0,0 @@ -# Own the shared archive contract in cyd - -The normative Cyd Bluesky archive specification, Bluesky format-version history, and canonical compatibility fixtures live in the `cyd` repository. Other social platforms have independent archive contracts and version histories. Mobile and desktop may maintain implementation-specific types, but both must validate against this shared Bluesky contract so compatibility is not inferred from either application's current runtime schema. diff --git a/docs/adr/0009-bound-context-preservation.md b/docs/adr/0009-bound-context-preservation.md deleted file mode 100644 index 668cdb12..00000000 --- a/docs/adr/0009-bound-context-preservation.md +++ /dev/null @@ -1,3 +0,0 @@ -# Bound contextual data preservation - -A saved Bluesky record includes snapshots of the directly referenced data needed to render it faithfully, including its author, reply parent, quoted record, external embed, facets, and media. Cyd does not recursively crawl entire threads or social graphs; related records that are independently saved are linked through stable AT URIs, keeping backups complete for selected records without unbounded expansion. diff --git a/docs/adr/0010-make-each-archive-self-contained.md b/docs/adr/0010-make-each-archive-self-contained.md deleted file mode 100644 index d8873e8e..00000000 --- a/docs/adr/0010-make-each-archive-self-contained.md +++ /dev/null @@ -1,3 +0,0 @@ -# Make each Cyd Bluesky archive self-contained - -Every Cyd Bluesky archive is a complete snapshot of a Bluesky local account's Bluesky saved data at export time rather than an incremental delta that depends on earlier Cyd Bluesky archives. Individual archives may be larger, but any one of them remains sufficient for recovery or cross-device transfer through Bluesky archive import. diff --git a/docs/adr/0011-ship-v2-archives-without-encryption.md b/docs/adr/0011-ship-v2-archives-without-encryption.md deleted file mode 100644 index e96f325e..00000000 --- a/docs/adr/0011-ship-v2-archives-without-encryption.md +++ /dev/null @@ -1,3 +0,0 @@ -# Ship Bluesky version 2 archives without encryption - -Cyd Bluesky archives at version 2 are plaintext ZIP packages while the initial cross-client Bluesky contract is established. Passphrase encryption is deferred to a later Bluesky format version; clients must not imply that Bluesky version 2 archives protect sensitive Bluesky saved data such as chats, and exports should communicate that limitation clearly. diff --git a/docs/adr/0012-validate-v2-archive-integrity-before-import.md b/docs/adr/0012-validate-v2-archive-integrity-before-import.md deleted file mode 100644 index e70cf2a1..00000000 --- a/docs/adr/0012-validate-v2-archive-integrity-before-import.md +++ /dev/null @@ -1,3 +0,0 @@ -# Validate Bluesky version 2 archive integrity before import - -Bluesky version 2 archives include a manifest listing every payload file's path, byte size, and SHA-256 digest, and export captures a point-in-time-consistent database and media snapshot. Bluesky archive import validates the complete manifest before mutating Bluesky saved data and applies the merge transactionally, preventing corrupt or partial packages from producing partially imported Bluesky local accounts. diff --git a/docs/adr/0013-export-recoverable-incomplete-backups-honestly.md b/docs/adr/0013-export-recoverable-incomplete-backups-honestly.md deleted file mode 100644 index 0bf438ee..00000000 --- a/docs/adr/0013-export-recoverable-incomplete-backups-honestly.md +++ /dev/null @@ -1,3 +0,0 @@ -# Export recoverable incomplete backups honestly - -Cyd may export a valid archive when the local saved dataset already lacks expected assets, preserving everything recoverable rather than blocking export indefinitely. The interchange database records expected assets and their availability, export reports incompleteness, import retains those statuses, and browsing renders unavailable media explicitly; only archives containing every expected asset may be described as complete backups. diff --git a/docs/adr/0014-use-local-reminders-for-desktop-scheduling.md b/docs/adr/0014-use-local-reminders-for-desktop-scheduling.md deleted file mode 100644 index 448fc041..00000000 --- a/docs/adr/0014-use-local-reminders-for-desktop-scheduling.md +++ /dev/null @@ -1,3 +0,0 @@ -# Use local reminders for desktop scheduling - -Desktop Bluesky scheduling is reminder-driven: Cyd uses local operating-system notifications to prompt the user to review and start due work, without server push notifications or unattended deletion. Desktop notifications are a future capability tracked separately, so initial Bluesky support must not imply that scheduled actions run automatically while Cyd is closed. diff --git a/docs/adr/0015-share-contracts-not-runtime-code.md b/docs/adr/0015-share-contracts-not-runtime-code.md deleted file mode 100644 index 40a0dc37..00000000 --- a/docs/adr/0015-share-contracts-not-runtime-code.md +++ /dev/null @@ -1,3 +0,0 @@ -# Share contracts rather than a runtime package - -The first desktop Bluesky implementation does not introduce a runtime package shared with Cyd Mobile. Desktop ports platform-neutral behavior behind desktop-native interfaces, while the applications share the normative archive specification, canonical fixtures, and behavioral contract tests; this preserves semantic parity without coupling their runtimes and release cadence. diff --git a/docs/adr/0016-replace-the-unused-desktop-bluesky-model.md b/docs/adr/0016-replace-the-unused-desktop-bluesky-model.md deleted file mode 100644 index 15594f03..00000000 --- a/docs/adr/0016-replace-the-unused-desktop-bluesky-model.md +++ /dev/null @@ -1,3 +0,0 @@ -# Replace the unused desktop Bluesky model - -The dormant desktop `blueskyAccount` model is unused and is not a compatibility surface, so the new Bluesky implementation starts from a clean domain and persistence model rather than preserving its settings shape. A forward migration removes obsolete tables, links, and unreachable rows while retaining historical migration history; no adapter is required for the abandoned desktop model. diff --git a/docs/adr/0017-key-desktop-account-storage-by-uuid.md b/docs/adr/0017-key-desktop-account-storage-by-uuid.md deleted file mode 100644 index 25ef0acd..00000000 --- a/docs/adr/0017-key-desktop-account-storage-by-uuid.md +++ /dev/null @@ -1,3 +0,0 @@ -# Key desktop Bluesky storage by local UUID - -Each desktop Bluesky local account owns a private runtime database and media directory keyed by its Cyd UUID, with the Bluesky DID stored as the durable social identity and the mutable handle kept as account data. Handle changes therefore require no filesystem move, and each account remains an isolated unit for browsing and archive translation. diff --git a/docs/adr/0018-use-oauth-and-os-protected-credential-storage.md b/docs/adr/0018-use-oauth-and-os-protected-credential-storage.md deleted file mode 100644 index f82805af..00000000 --- a/docs/adr/0018-use-oauth-and-os-protected-credential-storage.md +++ /dev/null @@ -1,3 +0,0 @@ -# Use OAuth and OS-protected credential storage - -Desktop connects to Bluesky through OAuth rather than app passwords and stores session material outside the account database and media directory through Cyd's desktop-wide credential facility. That facility uses OS-protected storage on macOS and Windows and prefers libsecret or KWallet on Linux; when neither Linux backend is available, it may use Electron/Chromium's `basic_text` fallback only with clear disclosure. Disconnecting deletes the local connection while retaining saved data; deleting the local account removes both its connection and local data, and archive creation never traverses credential storage. Building and remediating the shared credential facility is tracked separately in issue #671 rather than as part of desktop Bluesky platform support. diff --git a/docs/adr/0019-store-the-latest-observed-record.md b/docs/adr/0019-store-the-latest-observed-record.md deleted file mode 100644 index 80893904..00000000 --- a/docs/adr/0019-store-the-latest-observed-record.md +++ /dev/null @@ -1,3 +0,0 @@ -# Store the latest observed record - -Bluesky version 2 represents the latest observation of each Bluesky record at its stable AT URI, including observation timestamps and deletion state, rather than retaining every historical CID revision. Import selects the newer observation and does not replace populated fields with absent data; complete revision history is outside the initial parity scope. diff --git a/docs/adr/0020-do-not-use-the-archive-filename-for-validation.md b/docs/adr/0020-do-not-use-the-archive-filename-for-validation.md deleted file mode 100644 index 1ddf26e0..00000000 --- a/docs/adr/0020-do-not-use-the-archive-filename-for-validation.md +++ /dev/null @@ -1,3 +0,0 @@ -# Do not use the archive filename for validation - -Bluesky version 2 archive filenames are friendly suggestions and may be changed by users without affecting import. Clients determine platform, identity, format support, and integrity from validated internal metadata, schema, and manifest content rather than a handle-bearing filename; the unused unversioned mobile prototype receives no filename-based compatibility exception. diff --git a/docs/adr/0021-use-at-protocol-apis-directly.md b/docs/adr/0021-use-at-protocol-apis-directly.md deleted file mode 100644 index bcb09d98..00000000 --- a/docs/adr/0021-use-at-protocol-apis-directly.md +++ /dev/null @@ -1,3 +0,0 @@ -# Use AT Protocol APIs directly - -Desktop Bluesky saving and deletion use authenticated AT Protocol APIs, matching Cyd Mobile's integration model. Browser or OAuth-window interaction is limited to authorization; Bluesky webview scraping, request interception, and MITM automation are outside the implementation boundary because they add X-specific fragility where supported APIs exist. diff --git a/docs/adr/0022-run-save-and-delete-as-durable-jobs.md b/docs/adr/0022-run-save-and-delete-as-durable-jobs.md deleted file mode 100644 index 22226652..00000000 --- a/docs/adr/0022-run-save-and-delete-as-durable-jobs.md +++ /dev/null @@ -1,3 +0,0 @@ -# Run save and delete as durable resumable jobs - -Desktop Bluesky save and delete operations are durable, resumable, and idempotent. They commit progress incrementally, expose rate-limit backoff, and recover after restart without silently duplicating work; destructive jobs require explicit review and confirmation before their first execution, while safe retries of that confirmed job do not require per-item confirmation. diff --git a/docs/adr/0023-stage-and-resume-archive-jobs.md b/docs/adr/0023-stage-and-resume-archive-jobs.md deleted file mode 100644 index db51569e..00000000 --- a/docs/adr/0023-stage-and-resume-archive-jobs.md +++ /dev/null @@ -1,3 +0,0 @@ -# Stage and resume archive jobs - -Import and export are checkpointed, resumable jobs capable of continuing validation, hashing, media copying, and merge preparation after interruption. Import staging remains isolated from the live local account until final commit; cancellation or unrecoverable failure leaves existing account data untouched and safely removes staged artifacts. diff --git a/docs/adr/0024-deduplicate-media-within-each-account.md b/docs/adr/0024-deduplicate-media-within-each-account.md deleted file mode 100644 index 8f7c2ebc..00000000 --- a/docs/adr/0024-deduplicate-media-within-each-account.md +++ /dev/null @@ -1,3 +0,0 @@ -# Deduplicate media within each Bluesky local account - -Desktop stores media content-addressably within each UUID-keyed Bluesky local account, and Bluesky saved records reference those assets rather than owning duplicate files. Bluesky version 2 archives likewise package each unique asset once and map record references through the Bluesky interchange database; cross-account deduplication is deferred so deleting or exporting one Bluesky local account does not depend on another Bluesky local account's storage. diff --git a/docs/adr/0025-never-treat-save-settings-as-retention-rules.md b/docs/adr/0025-never-treat-save-settings-as-retention-rules.md deleted file mode 100644 index 887ea6d4..00000000 --- a/docs/adr/0025-never-treat-save-settings-as-retention-rules.md +++ /dev/null @@ -1,3 +0,0 @@ -# Never treat save settings as retention rules - -Disabling a save category affects future collection but never deletes previously saved records or media. Source deletion retains the local backup, while local deletion requires a separate explicit action with a preview; media garbage collection removes only unreferenced assets created by that explicit local deletion. diff --git a/docs/adr/0026-export-portable-account-data-not-operational-state.md b/docs/adr/0026-export-portable-account-data-not-operational-state.md deleted file mode 100644 index fe962f95..00000000 --- a/docs/adr/0026-export-portable-account-data-not-operational-state.md +++ /dev/null @@ -1,3 +0,0 @@ -# Export portable Bluesky saved data, not operational state - -Bluesky version 2 archives include the Bluesky identity and captured profile, Bluesky saved records and media, Bluesky source-deletion state, asset-completeness state, and portable Bluesky account settings. They exclude credentials, schedules, pending or historical jobs, logs, analytics, error reports, temporary files, caches, and UI state because those are client-local operations whose transfer could duplicate work or trigger unintended behavior. diff --git a/docs/adr/0027-preserve-captured-author-context.md b/docs/adr/0027-preserve-captured-author-context.md deleted file mode 100644 index ec957a1b..00000000 --- a/docs/adr/0027-preserve-captured-author-context.md +++ /dev/null @@ -1,3 +0,0 @@ -# Preserve captured author context - -The local Bluesky account profile advances to the latest observed profile for its DID, while each saved record and context snapshot retains the author representation captured with that observation. Browsing may link a captured author DID to its current profile, but handle or display-name changes do not rewrite historical snapshots. diff --git a/docs/adr/0028-roll-out-archive-readers-before-writers.md b/docs/adr/0028-roll-out-archive-readers-before-writers.md deleted file mode 100644 index 33bb7421..00000000 --- a/docs/adr/0028-roll-out-archive-readers-before-writers.md +++ /dev/null @@ -1,3 +0,0 @@ -# Roll out archive readers before writers - -Bluesky archive format changes ship readers before writers: Mobile and Desktop first gain and verify Bluesky version 2 import, and Bluesky version 2 export is enabled only after compatible readers are released. Version 2 is the first supported Bluesky archive format; neither client imports or exports the unused unversioned mobile prototype. diff --git a/docs/adr/0029-minimize-bluesky-diagnostics.md b/docs/adr/0029-minimize-bluesky-diagnostics.md deleted file mode 100644 index 8b96d801..00000000 --- a/docs/adr/0029-minimize-bluesky-diagnostics.md +++ /dev/null @@ -1,3 +0,0 @@ -# Minimize Bluesky diagnostics - -Automatic Bluesky telemetry and error reports contain only operational metadata such as job type, stage, counts, error class, and application or archive-format version. They exclude record and chat content, media, handles, DIDs, credentials, and local paths; any diagnostic bundle containing user data requires an explicit preview and consent before submission. diff --git a/docs/adr/0030-encrypt-credentials-not-saved-content.md b/docs/adr/0030-encrypt-credentials-not-saved-content.md deleted file mode 100644 index a5704d82..00000000 --- a/docs/adr/0030-encrypt-credentials-not-saved-content.md +++ /dev/null @@ -1,3 +0,0 @@ -# Encrypt credentials, not saved content - -The initial desktop Bluesky release does not add application-level encryption for runtime databases, chats, or media; saved content relies on operating-system account permissions and disk encryption, and Cyd must not create broader file permissions. OAuth credentials remain the exception and require protected persistence as defined separately, because they authorize account control rather than merely exposing a local backup. diff --git a/docs/adr/0031-gate-releases-on-semantic-archive-contract-tests.md b/docs/adr/0031-gate-releases-on-semantic-archive-contract-tests.md deleted file mode 100644 index fa8ec074..00000000 --- a/docs/adr/0031-gate-releases-on-semantic-archive-contract-tests.md +++ /dev/null @@ -1,3 +0,0 @@ -# Gate releases on semantic archive contract tests - -Bluesky releases must pass canonical compatibility tests for bidirectional version 2 exchange, repeated and mixed-device merges, offline full-video playback, all record categories and direct context, integrity and adversarial failures, supported and unsupported version handling, DID/UUID collisions, and settings differences. The unused unversioned mobile prototype is outside the compatibility matrix. Tests compare normalized records, relationships, asset digests, completeness, and source-deletion state rather than byte-identical ZIP or SQLite layouts. diff --git a/docs/adr/0032-stream-and-defensively-validate-archive-imports.md b/docs/adr/0032-stream-and-defensively-validate-archive-imports.md deleted file mode 100644 index 12a8bcef..00000000 --- a/docs/adr/0032-stream-and-defensively-validate-archive-imports.md +++ /dev/null @@ -1,3 +0,0 @@ -# Stream and defensively validate archive imports - -Archive import streams into an isolated staging root and rejects path traversal, symlinks, unexpected file types, forged manifest entries, and unsafe entry counts or metadata. It verifies declared sizes and digests while writing and monitors total expansion against available storage; large legitimate video archives have no small absolute size cap, but crossing conservative resource thresholds requires explicit confirmation. diff --git a/docs/adr/0033-persist-credentials-with-disclosure.md b/docs/adr/0033-persist-credentials-with-disclosure.md deleted file mode 100644 index e9d4a239..00000000 --- a/docs/adr/0033-persist-credentials-with-disclosure.md +++ /dev/null @@ -1,3 +0,0 @@ -# Persist credentials with disclosure rather than refusing - -Cyd persists account-control credentials through one desktop-wide credential store, which keeps them outside every account database, media directory, and Cyd archive, and encrypts each value with the operating system's facility wherever one is available. Where none is, on a Linux desktop with no keyring, behind a password store Cyd does not recognize, or on a platform that offers nothing, Cyd stores the credential in the clear in the same owner-only vault and tells the user so, in a warning bar that names the password store and in the startup log, rather than refusing to persist it. Refusing was implemented first and was worse: Electron reports encryption as unavailable whenever Chromium falls back to `basic_text`, which is exactly the keyring-less case, so refusal made the Bluesky migration unusable there and destroyed working sessions on upgrade. Credentials Cyd already holds in plaintext are moved into the store when an account database opens, whether or not the operating system can protect them, because the vault is owner-only and outside the archives, exports, and error reports the config table was not. Cyd revokes before it discards wherever it holds a usable credential, so disconnecting or deleting an account invalidates the session at the authorization server rather than merely forgetting it locally. diff --git a/docs/adr/0034-derive-bluesky-session-holders-from-account-state.md b/docs/adr/0034-derive-bluesky-session-holders-from-account-state.md deleted file mode 100644 index 27bbce84..00000000 --- a/docs/adr/0034-derive-bluesky-session-holders-from-account-state.md +++ /dev/null @@ -1,3 +0,0 @@ -# Derive Bluesky session holders from account state - -Cyd holds one Bluesky OAuth session per DID, shared by every platform that acts on that identity, and decides when to revoke it by deriving its holders — an X account with its Bluesky migration connected, or a connected Bluesky local account bound to that DID — from account state on every question, rather than by maintaining a reference count beside the session. Releasing a hold means making the releasing account's own state say it is disconnected and then asking again who is left: while anyone is, the session is untouched and no other account is signed out; when nobody is, Cyd revokes at the PDS and destroys the local session and DPoP key material. A counter was rejected because a crash or a forced quit can leave it one too high, stranding a session nothing can revoke, or one too low after a partial delete, signing out an account that is still connected; account state cannot drift from itself, and a session left with zero holders by interrupted work is swept and revoked at the next startup. No account can force-revoke a session another account still uses, and no interface offers to. diff --git a/docs/archive/bluesky/v2/README.md b/docs/archive/bluesky/v2/README.md index 342e21b2..13f28c22 100644 --- a/docs/archive/bluesky/v2/README.md +++ b/docs/archive/bluesky/v2/README.md @@ -1,17 +1,14 @@ # Cyd Bluesky archive format version 2 -This directory is the normative contract for version 2 of the cross-client Cyd -Bluesky archive. It applies only to Bluesky data; other social platforms have -independent archive contracts and version histories. “Must”, “must not”, -“required”, “should”, and “may” are used in their RFC 2119 sense. +This directory is the normative contract for the cross-client Cyd Bluesky +archive, version 2. It applies only to Bluesky data. RFC 2119 terms apply. ## Package A Bluesky v2 archive is a plaintext, self-contained ZIP file. Its filename and -file extension are not authoritative. Readers identify its archive family, -platform, and platform-specific version from `metadata.json`. All ZIP entry -names are UTF-8, `/`-separated, normalized relative paths. An archive has -exactly this layout: +extension are not authoritative; readers identify the archive family, +platform, and version from `metadata.json`. All ZIP entry names are UTF-8, +`/`-separated, normalized relative paths. The layout is exactly: ```text metadata.json @@ -40,15 +37,12 @@ reserved and must be ignored): } ``` -`format`, `platform`, and `version` form the format discriminator. Version 2 -belongs to the Bluesky archive version namespace; it does not define version 2 -for X, Facebook, Mastodon, or any other platform. Readers must reject a -different platform rather than interpreting it with this schema. - -Times use RFC 3339 UTC with milliseconds. `completeness` is `complete` only -when every expected asset is available; otherwise it is `incomplete`. The -metadata format, platform, version, identity, UUID, creation time, and -completeness must equal the single row in `data.db.archive`. +`format`, `platform`, and `version` form the format discriminator. Readers +must reject a different platform rather than interpreting it with this +schema. Times use RFC 3339 UTC with milliseconds. `completeness` is +`complete` only when every expected asset is available, otherwise +`incomplete`. The metadata identity, UUID, creation time, and completeness +must equal the single row in `data.db.archive`. ## Integrity manifest @@ -63,117 +57,102 @@ completeness must equal the single row in `data.db.archive`. } ``` -It lists every regular payload except itself exactly once, sorted by path. -Each entry contains its normalized path, uncompressed byte length, and SHA-256 -of its bytes. Readers must validate the complete manifest before exposing a -Bluesky archive import preview or mutating Bluesky saved data. `metadata.json` -and `data.db` are payloads. -Media paths must end in their digest and their bytes must match that digest. +It lists every regular payload except itself exactly once, sorted by path, +each with its normalized path, uncompressed byte length, and SHA-256 of its +bytes. `metadata.json` and `data.db` are payloads. Media paths must end in +their digest and their bytes must match that digest. -Readers extract into an isolated staging directory while validating entry -type, path, declared and expanded sizes, entry counts, free space, and digest. -They must not follow links or write outside staging. There is no small fixed -archive-size limit; clients may require explicit confirmation at documented -resource thresholds. Failure or cancellation leaves the live Bluesky local -account unchanged. +Readers must validate the complete manifest before exposing an import +preview or mutating saved data. They extract into an isolated staging +directory while validating entry type, path, sizes, entry counts, free +space, and digest; they must not follow links or write outside staging. +There is no small fixed archive-size limit; clients may require explicit +confirmation at documented resource thresholds. Failure or cancellation +leaves the live Bluesky local account unchanged. ## Bluesky interchange database -`data.db` is SQLite 3 and must implement [schema.sql](schema.sql) exactly for -Bluesky v2. It is a Bluesky interchange model, never a copy of a client's -runtime database. Text is UTF-8. Booleans are integers constrained to `0` or -`1`. JSON columns contain canonical JSON values rather than client-specific -serialized objects. - -The model has these semantic groups: - -- `archive`, `identity`, and `profiles`: Bluesky local account UUID, durable - DID, current profile, and captured historical author profiles. -- `records`, `selections`, `record_subjects`, and `record_context`: the latest - observed form of posts/reposts/likes/bookmarks, the selected relationship's - target record, plus direct reply-parent, quote, external, and author context. - Context is bounded; it does not recursively capture threads. -- `conversations`, `conversation_members`, and `messages`: direct-message - context, membership, and messages. -- `relationships`: follows, blocks, and mutes with Bluesky source-deletion state. -- `assets` and `record_assets`: every expected image, preview, thumbnail, and +`data.db` is SQLite 3 and must implement [schema.sql](schema.sql) exactly. +It is an interchange model, never a copy of a client's runtime database. +Text is UTF-8. Booleans are integers constrained to `0` or `1`. JSON columns +contain canonical JSON values. + +Semantic groups: + +- `archive`, `identity`, `profiles`: local account UUID, durable DID, current + profile, and captured historical author profiles. +- `records`, `selections`, `record_subjects`, `record_context`: the latest + observed form of posts/reposts/likes/bookmarks, the selected + relationship's target record, plus direct reply-parent, quote, external, + and author context (bounded; not recursively captured threads). +- `conversations`, `conversation_members`, `messages`: direct messages. +- `relationships`: follows, blocks, and mutes with source-deletion state. +- `assets`, `record_assets`: every expected image, preview, thumbnail, and full video, including unavailable assets and their reason. - `portable_settings`: portable Bluesky account setting defaults. -Stable AT URIs identify records; DIDs identify Bluesky identities. `cid` is -the latest observed CID, not revision history. `observed_at`, -`source_deleted_at`, and captured profiles preserve observation and deletion -state. An unavailable asset has no digest or archive path, remains referenced, -and makes the archive incomplete. Every available asset has a unique digest, -byte count, media type, and content-addressed payload. - -All five selection categories—`posts`, `reposts`, `likes`, `bookmarks`, and -`chats`—must be representable. Category selection pulls its required direct -context and media with it. Bluesky archive import merges records by stable -identifiers, retains Bluesky saved data absent from the Cyd Bluesky archive, -prefers newer non-empty observations, and is idempotent. It may restore data -removed through Bluesky local deletion. - -## Completeness and consistency - -Export is a point-in-time-consistent snapshot of the selected Bluesky saved -data. Each Cyd Bluesky archive stands alone; Bluesky v2 has no delta or -predecessor mechanism. -A structurally valid archive may be incomplete. Missing expected media must be -represented as `unavailable` or `missing`, never silently omitted. The archive -and metadata completeness is `complete` if and only if all asset rows are -`available` and every referenced payload validates. - -## Privacy boundary - -Only portable Bluesky saved data belongs in Bluesky v2. Cyd Bluesky archives -must not contain OAuth or other credentials, private keys, sessions, schedules, -pending/running/history jobs, logs, analytics, diagnostics, error reports, -caches, temporary data, filesystem paths, or UI state. Portable Bluesky account +Stable AT URIs identify records; DIDs identify identities. `cid` is the +latest observed CID, not revision history. `observed_at`, +`source_deleted_at`, and captured profiles preserve observation and +deletion state. An unavailable asset has no digest or archive path, remains +referenced, and makes the archive incomplete. Every available asset has a +unique digest, byte count, media type, and content-addressed payload. + +All five selection categories — `posts`, `reposts`, `likes`, `bookmarks`, +and `chats` — must be representable; selection pulls its required direct +context and media with it. Import merges records by stable identifiers, +retains saved data absent from the archive, prefers newer non-empty +observations, and is idempotent. It may restore data removed through local +deletion. + +## Completeness and privacy + +Export is a point-in-time-consistent snapshot of the selected saved data. +Each archive stands alone; there is no delta or predecessor mechanism. A +structurally valid archive may be incomplete: missing expected media must be +represented as `unavailable` or `missing`, never silently omitted. An +archive is `complete` if and only if all asset rows are `available` and every +referenced payload validates. + +Archives must not contain OAuth or other credentials, private keys, +sessions, schedules, jobs, logs, analytics, diagnostics, error reports, +caches, temporary data, filesystem paths, or UI state. Portable account settings are defaults, not authority to schedule or execute work, and do not -overwrite an existing Bluesky local account unless the user explicitly chooses -to adopt them. +overwrite an existing local account unless the user explicitly chooses to +adopt them. ## Version behavior -Desktop imports and exports Bluesky v2 only. It rejects the historical -unversioned Bluesky mobile format explicitly as `unsupported legacy format`; -it never guesses a version from a filename. It rejects another platform as -`unsupported archive platform` and a valid internal Bluesky version greater -than 2 as `unsupported newer archive version`. It rejects invalid Bluesky v2 -content as an invalid or corrupt archive, which is distinct from platform and -version rejection. Desktop Bluesky writers must emit only Bluesky v2. Readers -must ship and pass the canonical semantic fixtures before Bluesky v2 writing +Readers and writers handle Bluesky v2 only. Readers reject the historical +unversioned Mobile format as `unsupported legacy format`, another platform as +`unsupported archive platform`, a valid internal version greater than 2 as +`unsupported newer archive version`, and invalid v2 content as an invalid or +corrupt archive. Version meaning must never be inferred from a filename or +applied to another social platform. Writers must emit only Bluesky v2. +Readers must ship and pass the canonical semantic fixtures before v2 writing is release-enabled. +| Version | Status | Meaning | +| ------------------ | ----------- | ------------------------------------ | +| Unversioned ("v1") | Unsupported | Historical Mobile-only format. | +| 2 | Current | First cross-client Bluesky contract. | + +Future Bluesky versions must append an entry here and define explicit reader +behavior. + ## Canonical fixtures `fixtures/complete.cyd` and `fixtures/incomplete.cyd` are canonical semantic examples generated by `scripts/generate.py`. They cover every category, captured/current profiles, reply/quote/external context, relationships, observations, deletion state, chats, settings, images, previews, video -thumbnails, and a playable full video payload. The PNG payloads have complete -chunk framing and valid CRCs; the MP4 is an FFmpeg-generated test pattern with -movie metadata and sample tables. The incomplete fixture differs by one -expected unavailable full-video asset. - -`fixtures/semantic-expectations.json` defines the normalized identity, profiles, -records, selections, record subjects, direct context, conversations, messages, -relationships, observation and deletion state, assets and their digests, -asset roles, portable Bluesky account settings, completeness, and version -behavior. A consumer must compare all of those semantics, not only row counts -or category names. - -Consumers compare normalized database meaning, asset digests, completeness, -and rejection outcomes—not ZIP bytes, entry order, or SQLite page layout. - -## Bluesky version history - -| Version | Status | Meaning | -| ------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| Unversioned (“v1”) | Unsupported | Historical Mobile-only Bluesky archive format; never a cross-client contract. | -| 2 | Current | First Cyd Bluesky archive contract supported for Desktop/Mobile interchange. Plaintext ZIP and canonical SQLite Bluesky interchange database. | - -Future Bluesky versions must append an entry here and define explicit reader -behavior. Version meaning must never be inferred from a filename or applied to -another social platform. +thumbnails, and a playable full video payload. The incomplete fixture +differs by one expected unavailable full-video asset. + +`fixtures/semantic-expectations.json` defines the normalized identity, +profiles, records, selections, record subjects, direct context, +conversations, messages, relationships, observation and deletion state, +assets and their digests, asset roles, portable settings, completeness, and +version behavior. Consumers compare normalized database meaning, asset +digests, completeness, and rejection outcomes — not row counts, ZIP bytes, +entry order, or SQLite page layout. diff --git a/src/account_bluesky/bluesky_account_controller.ts b/src/account_bluesky/bluesky_account_controller.ts index fe33a7d7..ce2beaf9 100644 --- a/src/account_bluesky/bluesky_account_controller.ts +++ b/src/account_bluesky/bluesky_account_controller.ts @@ -41,15 +41,8 @@ import { import { migrations } from "./controller/migrations"; import type { BlueskyJobRow, BlueskyMediaRow } from "./types"; -/** - * Owns one Bluesky local account's isolated local resources: its private - * runtime database, content-addressed media store, job state, and staging - * area. Everything it touches lives under one UUID-keyed directory, so two - * local accounts can never read or delete each other's data. - * - * Bluesky uses direct AT Protocol APIs, so this controller has no MITM - * interception or webview scraping. - */ +// Owns one Bluesky local account's isolated local resources, all under one +// UUID-keyed directory so accounts can never read or delete each other's data. export class BlueskyAccountController { public accountID: number; public accountUUID: string = ""; @@ -162,15 +155,8 @@ export class BlueskyAccountController { return Boolean(this.account?.connectedAt); } - /** - * Start a browser authorization for a handle. - * - * This goes through the one Bluesky OAuth implementation in Cyd, so an - * identity the X migration already authorized needs no second sign-in: the - * shared session is found by DID and reused. Authorization is OAuth and - * every call afterwards is a direct AT Protocol call — never an app - * password, never scraping, never interception. - */ + // Shared OAuth implementation: an identity authorized elsewhere (e.g. the X + // migration) is reused by DID without a second sign-in. async connect(handle: string): Promise { const start = await authorizeBlueskyIdentity(handle, { platform: "Bluesky", @@ -240,16 +226,8 @@ export class BlueskyAccountController { }); } - /** - * Remove this installation's authorization to act on the identity, keeping - * the local account and every byte of Bluesky saved data. - * - * Only this account's hold is released. An X account whose migration is - * connected to the same identity stays connected and is never asked to - * re-authorize; the session is revoked at the PDS only once nobody is left - * holding it. Recording the disconnection first is what makes the release - * honest: by then this account is no longer among the holders. - */ + // Keeps the local account and all saved data; only this account's hold is + // released, and the session is revoked only once nobody is left holding it. async disconnect(): Promise { const did = this.account?.did ?? null; if (!this.accountUUID) { @@ -263,10 +241,7 @@ export class BlueskyAccountController { } } - /** - * Update mutable profile data. A handle change never moves storage, because - * storage is keyed by the Cyd UUID. - */ + // A handle change never moves storage; storage is keyed by the Cyd UUID. updateProfile(profile: { handle?: string | null; displayName?: string | null; diff --git a/src/account_bluesky/controller/migrations.ts b/src/account_bluesky/controller/migrations.ts index 31e30f5d..cf92aeda 100644 --- a/src/account_bluesky/controller/migrations.ts +++ b/src/account_bluesky/controller/migrations.ts @@ -1,12 +1,7 @@ import type { Migration } from "../../database"; -/** - * Migrations for one Bluesky local account's private runtime database. - * - * This database belongs to a single account and is never a compatibility - * surface: the Cyd Bluesky archive interchange schema is what travels between - * clients. - */ +// This database belongs to a single account; the Cyd Bluesky archive +// interchange schema is what travels between clients. export const migrations: Migration[] = [ { name: "initial", diff --git a/src/account_bluesky/storage.ts b/src/account_bluesky/storage.ts index 4d5b6c61..6a5a0685 100644 --- a/src/account_bluesky/storage.ts +++ b/src/account_bluesky/storage.ts @@ -8,8 +8,7 @@ import type { BlueskySavedMedia, } from "../shared_types"; -// Every Bluesky local account owns one UUID-keyed directory. Handles are -// mutable profile data, so they never appear in a path: renaming a Bluesky +// Handles are mutable, so they never appear in a path: renaming a Bluesky // handle must never move a byte on disk. const ACCOUNT_TYPE_DIRECTORY = "Bluesky"; @@ -51,11 +50,8 @@ export const ensureOwnerOnlyFile = (filePath: string): string => { return filePath; }; -/** - * SQLite creates the database and its write-ahead log side by side with - * default permissions, and saved data lives in the log until it is - * checkpointed. All three files must be owner-only. - */ +// Saved data lives in the WAL until checkpointed, so the db, -wal, and -shm +// files must all be owner-only. export const ensureOwnerOnlyDatabase = (databasePath: string): string => { for (const suffix of ["", "-wal", "-shm"]) { ensureOwnerOnlyFile(`${databasePath}${suffix}`); @@ -63,10 +59,7 @@ export const ensureOwnerOnlyDatabase = (databasePath: string): string => { return databasePath; }; -/** - * Where one Bluesky local account keeps its local resources. This only - * computes paths; nothing is created. - */ +// Computes paths only; nothing is created. export const blueskyAccountPaths = ( accountUUID: string, ): BlueskyLocalAccountPaths => { @@ -91,10 +84,7 @@ export const ensureBlueskyAccountStorage = ( return paths; }; -/** - * Permanently remove one account's local resources. Only this account's - * UUID-keyed directory is touched, so sibling accounts are unaffected. - */ +// Only this account's UUID-keyed directory is touched; siblings unaffected. export const removeBlueskyAccountStorage = (accountUUID: string): void => { const paths = blueskyAccountPaths(accountUUID); fs.rmSync(paths.accountPath, { recursive: true, force: true }); @@ -103,11 +93,8 @@ export const removeBlueskyAccountStorage = (accountUUID: string): void => { const sha256Digest = (data: Buffer): string => crypto.createHash("sha256").update(data).digest("hex"); -/** - * Where an asset with this digest lives inside an account's media store. The - * layout matches the Cyd Bluesky archive v2 media layout so translation - * between runtime storage and an archive stays mechanical. - */ +// This media layout intentionally mirrors the Cyd Bluesky archive v2 media +// layout, so translation between runtime storage and an archive stays mechanical. export const blueskyMediaPath = (mediaPath: string, digest: string): string => { if (!/^[0-9a-f]{64}$/.test(digest)) { throw new Error(`Invalid media digest: ${digest}`); @@ -117,10 +104,7 @@ export const blueskyMediaPath = (mediaPath: string, digest: string): string => { export type StoredMediaFile = Omit; -/** - * Write bytes into an account's content-addressed media store. Storing the - * same bytes twice reuses the single stored copy. - */ +// Content-addressed: storing the same bytes twice reuses the single copy. export const storeBlueskyMediaFile = ( mediaPath: string, data: Buffer, @@ -150,10 +134,7 @@ export const storeBlueskyMediaFile = ( }; }; -/** - * Create an isolated scratch directory for one piece of in-progress work. - * Staging data is disposable: nothing outside it may depend on it. - */ +// Staging data is disposable: nothing outside it may depend on it. export const createBlueskyStagingArea = ( stagingPath: string, label: string, diff --git a/src/account_x/bluesky_holder.ts b/src/account_x/bluesky_holder.ts index 5f2e9142..9130570f 100644 --- a/src/account_x/bluesky_holder.ts +++ b/src/account_x/bluesky_holder.ts @@ -8,25 +8,14 @@ import { getAccounts } from "../database/account"; import { BLUESKY_DID_CONFIG_KEY } from "../credentials"; import { getAccountDataPath } from "../util"; -/** - * The X migration's half of deriving who still depends on a Bluesky OAuth - * session. - * - * An X account holds a Bluesky identity exactly when its account database - * records a connected migration, so the answer is read from that database - * rather than from a counter that a crash could leave stale. The read is - * read-only and applies no migrations: asking who holds a session must not - * have side effects on an account nobody opened. - */ +// An X account holds a Bluesky identity exactly when its account database +// records a connected migration. The read is read-only and applies no +// migrations: asking who holds a session must not have side effects. export const xAccountDatabasePath = (username: string): string => path.join(getAccountDataPath("X", username), "data.sqlite3"); -/** - * The Bluesky identity this X account's migration is connected to, or null - * when it has none. An account that was never set up, or whose database has - * not been created yet, simply holds nothing. - */ +// Null when this account was never set up or has no database yet. export const xBlueskyMigrationDID = (username: string): string | null => { const databasePath = xAccountDatabasePath(username); if (!fs.existsSync(databasePath)) { @@ -51,10 +40,9 @@ export const xBlueskyMigrationDID = (username: string): string | null => { .get(BLUESKY_DID_CONFIG_KEY) as { value: string | null } | undefined; return row?.value ? row.value : null; } catch (error) { - // A database Cyd cannot read cannot be shown to hold anything. Saying so - // out loud matters: an unreadable account here would otherwise look like - // a released hold and could take a live session down with it. Only the - // error's class is logged, because a SQLite message carries the path. + // An unreadable database here would otherwise look like a released hold + // and could take a live session down with it, so this throws. Only the + // error's class is logged (a SQLite message carries the path). log.error( "xBlueskyMigrationDID: could not read an X account's Bluesky migration state", error instanceof Error ? error.name : typeof error, @@ -65,11 +53,8 @@ export const xBlueskyMigrationDID = (username: string): string | null => { } }; -/** - * The Cyd account IDs of every X account whose migration is connected to this - * identity. Which accounts exist and how an X account records its migration - * both belong to X, so the shared OAuth module asks rather than looks. - */ +// Which accounts exist and how an X account records its migration both +// belong to X, so the shared OAuth module asks rather than looks. export const xBlueskyHolderAccountIDs = (did: string): number[] => { const accountIDs: number[] = []; for (const account of getAccounts()) { diff --git a/src/account_x/controller/bluesky/BlueskyService.ts b/src/account_x/controller/bluesky/BlueskyService.ts index c5d8dfe3..bc128f82 100644 --- a/src/account_x/controller/bluesky/BlueskyService.ts +++ b/src/account_x/controller/bluesky/BlueskyService.ts @@ -41,10 +41,7 @@ import { XTweetBlueskyMigrationRow, } from "../../types"; -/** - * Service class for handling Bluesky migration operations for X/Twitter accounts. - * This encapsulates all Bluesky-related functionality that was previously in XAccountController. - */ +// Bluesky migration operations for X/Twitter accounts. export class BlueskyService { constructor( private db: Database.Database, @@ -62,13 +59,7 @@ export class BlueskyService { private updateRateLimitInfo: (info: Partial) => void, ) {} - /** - * Whether this X account's migration is connected, and to which identity. - * - * The DID is a public identifier, not a credential; the credentials it - * points at live in the shared Bluesky OAuth store, where a Bluesky local - * account for the same identity can reach them too. - */ + // The DID is a public identifier; credentials live in the shared Bluesky OAuth store. private async connectedDID(): Promise { return this.getConfig(BLUESKY_DID_CONFIG_KEY); } @@ -90,13 +81,8 @@ export class BlueskyService { return getBlueskyProfile(did); } - /** - * Connect the migration to a Bluesky handle. - * - * An identity Cyd already holds a session for — because a Bluesky local - * account authorized it, or this account did before — is bound here without - * a second browser sign-in. Anything else starts one. - */ + // An identity Cyd already holds a session for binds here without a second + // browser sign-in; anything else starts one. async authorize(handle: string): Promise { const start = await authorizeBlueskyIdentity(handle, { platform: "X", @@ -106,11 +92,7 @@ export class BlueskyService { return start; } - // Reusing a session still has to bind the identity and confirm Cyd can - // read its profile, exactly as finishing a browser authorization does. - // Binding first is deliberate: an account that claims the identity is a - // holder of its session, and a profile call that fails must not leave the - // session with one fewer holder than it really has. + // Bind first: a profile failure must not leave the session under-counted. await this.setConfig(BLUESKY_DID_CONFIG_KEY, start.did); const profile = await getBlueskyProfile(start.did); if (!profile) { @@ -122,13 +104,7 @@ export class BlueskyService { return start; } - /** - * Finish an authorization this account started and bind the identity to it. - * - * Connecting an identity Cyd already holds a session for does not ask for a - * second browser authorization; the migration simply joins the Bluesky - * platform as another holder of the same session. - */ + // Finish an authorization this account started and bind the identity to it. async callback(queryString: string): Promise { const authorization = await completeBlueskyAuthorization(queryString); if (!authorization.ok) { @@ -144,15 +120,8 @@ export class BlueskyService { return true; } - /** - * Disconnect this account's migration. - * - * Only this account's hold is released. A Bluesky local account on the same - * identity keeps its connection and is never asked to re-authorize, and no - * path here can force a revocation on its behalf. Clearing the account's own - * state first is what makes the release honest: by the time the shared - * module counts who is left, this account is no longer among them. - */ + // Releases only this account's hold; a Bluesky local account on the same + // identity keeps its connection. async disconnect(): Promise { const did = await this.connectedDID(); @@ -160,9 +129,8 @@ export class BlueskyService { // claiming a connection before anything else happens. await this.deleteConfig(BLUESKY_DID_CONFIG_KEY); - // Older versions of Cyd kept the OAuth state and session in this account's - // config table and its own credential vault. Sweep both, in case a - // database predates the shared store. + // Older Cyd versions kept OAuth state/session in this account's config and + // credential vault; sweep both for legacy databases. await this.deleteConfigLike(`${BLUESKY_OAUTH_STATE_PREFIX}%`); await this.deleteConfigLike(`${BLUESKY_OAUTH_SESSION_PREFIX}%`); const legacyCredentials = accountCredentials(this.accountID); diff --git a/src/bluesky_oauth/client.ts b/src/bluesky_oauth/client.ts index 4458b916..a44da759 100644 --- a/src/bluesky_oauth/client.ts +++ b/src/bluesky_oauth/client.ts @@ -8,15 +8,9 @@ import { import { blueskyOAuthClientID } from "./constants"; import { blueskyOAuthSessionStore, blueskyOAuthStateStore } from "./store"; -/** - * The one `NodeOAuthClient` in Cyd. - * - * Client metadata resolution, the state store, and the session store are - * settled here and nowhere else, so a platform cannot accidentally authorize - * against different metadata or write a session somewhere the other platforms - * cannot read it. The client is cached because fetching client metadata is a - * network round trip, and every caller wants the same one. - */ +// The one `NodeOAuthClient` in Cyd: stores and metadata are settled here so +// every platform authorizes against the same one. Cached because fetching +// client metadata is a network round trip. let client: NodeOAuthClient | null = null; let pending: Promise | null = null; @@ -49,11 +43,7 @@ export const getBlueskyOAuthClient = async (): Promise => { return pending; }; -/** - * Drop the cached client so the next caller builds a fresh one. Cyd reaches - * for this when a client turns out to be unusable, and tests use it to keep - * one test's client out of the next. - */ +// Drop the cached client so the next caller builds a fresh one. export const resetBlueskyOAuthClient = (): void => { client = null; pending = null; diff --git a/src/bluesky_oauth/constants.ts b/src/bluesky_oauth/constants.ts index 21c41ff0..49369606 100644 --- a/src/bluesky_oauth/constants.ts +++ b/src/bluesky_oauth/constants.ts @@ -1,13 +1,6 @@ -/** - * The names the Bluesky OAuth round trip is spelled with. - * - * The authorization server redirects to a custom URL scheme that Cyd registers - * with the operating system. Three independent codebases have to agree on that - * scheme and path — Cyd, `cyd-server`, which publishes the client metadata, and - * `cyd.social`, which bounces the browser back — and spelling it out separately - * in each is what broke the callback in #699. Inside Cyd there is now exactly - * one spelling, here. - */ +// The names the Bluesky OAuth round trip is spelled with: Cyd, `cyd-server`, +// and `cyd.social` all have to agree on the scheme and path. Inside Cyd there +// is exactly one spelling, here. // Cyd registers the reverse-domain form of the API host as a protocol client, // so the production and development builds can be installed side by side @@ -15,30 +8,17 @@ const PROD_API_HOST = "api.cyd.social"; const DEV_API_HOST = "dev-api.cyd.social"; -/** - * The API host whose client metadata identifies Cyd to Bluesky. - * - * Only production talks to the production authorization server. Every other - * mode Cyd ships — dev, local, and open — uses the development one. - * - * The mode is passed in rather than read here, because the two callers read it - * from different places: the app reads `CYD_MODE` at runtime, and the packaging - * config reads `CYD_ENV` at build time. They still have to arrive at the same - * scheme, so they share the derivation instead of the lookup. - */ +// Only production talks to the production authorization server; every other +// mode uses the development one. The mode is passed in because the app reads +// `CYD_MODE` at runtime and the packaging config reads `CYD_ENV` at build time. export const blueskyOAuthAPIHostForMode = (mode: string | undefined): string => mode === "prod" ? PROD_API_HOST : DEV_API_HOST; export const blueskyOAuthAPIHost = (): string => blueskyOAuthAPIHostForMode(process.env.CYD_MODE); -/** - * The custom URL scheme the authorization callback arrives on. - * - * The packaging config registers this scheme with the operating system and the - * app matches callbacks against it, so both build it from here. #699 was those - * two spelling it separately. - */ +// The packaging config registers this scheme with the OS and the app matches +// callbacks against it; both build it from here. export const blueskyOAuthCallbackSchemeForMode = ( mode: string | undefined, ): string => blueskyOAuthAPIHostForMode(mode).split(".").reverse().join("."); @@ -63,14 +43,8 @@ export const BLUESKY_OAUTH_CALLBACK_PATH = "/atproto-oauth-callback/"; /** The shape `@atproto/oauth-client` recognizes a private-use redirect URI by. */ type PrivateUseURI = `${string}.${string}:/${string}`; -/** - * The redirect URI Cyd authorizes with: where the authorization server sends - * the browser once the person is done. - * - * A single slash follows the colon because a private-use URI scheme has no - * naming authority (RFC 8252 section 7.1). The cast asserts that shape, which - * cannot be proven from a scheme the compiler only knows as a string. - */ +// A single slash follows the colon because a private-use URI scheme has no +// naming authority (RFC 8252 section 7.1); the cast asserts that shape. export const blueskyOAuthCallbackURL = (): PrivateUseURI => `${blueskyOAuthCallbackScheme()}:${BLUESKY_OAUTH_CALLBACK_PATH}` as PrivateUseURI; diff --git a/src/bluesky_oauth/flows.ts b/src/bluesky_oauth/flows.ts index 54b52bb6..d6f01d51 100644 --- a/src/bluesky_oauth/flows.ts +++ b/src/bluesky_oauth/flows.ts @@ -9,15 +9,8 @@ import { storedBlueskyOAuthAppState } from "./store"; // callback against stored authorization state belongs to the main process. export * from "../shared_types/bluesky_oauth"; -/** - * Which flow an authorization callback belongs to. - * - * The callback URL carries the OAuth `state` parameter, which names the stored - * authorization state; that state holds the flow identifier. Reading it does - * not consume it, because the OAuth client still needs the entry to finish the - * exchange. A callback whose state Cyd does not hold — stale, forged, or - * already spent — resolves to null and is not dispatched anywhere. - */ +// Reads without consuming (the OAuth client still needs the entry); a +// callback whose state Cyd does not hold resolves to null and is not dispatched. export const resolveBlueskyOAuthFlow = ( queryString: string, ): BlueskyOAuthFlow | null => { diff --git a/src/bluesky_oauth/holders.ts b/src/bluesky_oauth/holders.ts index 20c59d42..f683ee1b 100644 --- a/src/bluesky_oauth/holders.ts +++ b/src/bluesky_oauth/holders.ts @@ -3,41 +3,17 @@ import { xBlueskyHolderAccountIDs } from "../account_x/bluesky_holder"; import type { BlueskyOAuthPlatform } from "../shared_types"; -/** - * Who still depends on one Bluesky identity's authorization. - * - * A single authorized DID can be reached from more than one place at once: an - * X account with the migration connected, and a Bluesky local account for the - * same identity. Each of those is a **holder**. Releasing one hold must leave - * the others signed in; releasing the last one must genuinely sign out. - * - * Holders are recomputed from account state every time they are asked for, - * never tracked in a counter beside the session. A counter can be left one too - * high by a crash, stranding a session nothing can ever revoke, or one too low - * by a partial delete, signing out an account that is still connected. Account - * state cannot drift from itself. - * - * A holder names the same pair as a flow does, but it is not one: a flow is an - * authorization in progress, and a holder is an account depending on one that - * finished. - */ +// Who still depends on one Bluesky identity's authorization: each account +// reaching a DID is a **holder**. Recomputed from account state every time — +// never tracked in a counter, which a crash or partial delete could desync. export type BlueskyHolder = { platform: BlueskyOAuthPlatform; accountID: number; }; -/** - * Every account that currently depends on this identity's session. - * - * Each platform is asked about its own accounts, because how an X account - * records a connected migration and how a Bluesky local account records a - * connection are each that platform's business. - * - * This reads account state and nothing else, so it is safe to ask at any - * moment. Callers release a hold by first making the holder's own state say it - * is disconnected — or by deleting the account outright — and only then asking - * again. - */ +// Reads account state only, so it is safe to ask at any moment. Callers +// release a hold by first making the holder's own state say it is +// disconnected, then asking again. export const blueskyHolders = (did: string): BlueskyHolder[] => [ ...xBlueskyHolderAccountIDs(did).map((accountID) => ({ platform: "X" as BlueskyOAuthPlatform, diff --git a/src/bluesky_oauth/session.ts b/src/bluesky_oauth/session.ts index c5dd49f1..ea69e8af 100644 --- a/src/bluesky_oauth/session.ts +++ b/src/bluesky_oauth/session.ts @@ -25,14 +25,9 @@ import { storedBlueskyOAuthStateKeys, } from "./store"; -/** - * Authorizing, restoring, and releasing a Bluesky identity. - * - * Every platform in Cyd reaches Bluesky through this file. Authorization is - * OAuth against the identity's own PDS and every call afterwards is a direct - * AT Protocol call: Cyd never asks for an app password, never scrapes a page, - * and never intercepts a request. - */ +// Authorizing, restoring, and releasing a Bluesky identity. Every platform +// in Cyd reaches Bluesky through this file: OAuth against the identity's own +// PDS, never an app password. /** The outcome of finishing a browser authorization. */ export type BlueskyAuthorization = @@ -42,12 +37,7 @@ export type BlueskyAuthorization = const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); -/** - * The live session for an identity, refreshed if it is close to expiring. - * - * Every platform restores through here, so a session refreshed for the X - * migration is the same one the Bluesky platform picks up a moment later. - */ +// Every platform restores through here, so all platforms share one live session. export const restoreBlueskySession = async ( did: string, ): Promise => { @@ -76,10 +66,7 @@ export const blueskySessionIsUsable = async (did: string): Promise => { const normalizeHandle = (handle: string): string => handle.startsWith("@") ? handle.slice(1) : handle; -/** - * The identity behind a handle. Handles change and DIDs do not, so every - * decision about a session is made on the DID. - */ +// Handles change and DIDs do not: every session decision is made on the DID. const resolveBlueskyIdentifier = async ( identifier: string, ): Promise => { @@ -88,9 +75,8 @@ const resolveBlueskyIdentifier = async ( const { did } = await client.identityResolver.resolve(identifier); return did; } catch (error) { - // Not being able to resolve a handle is not a failure to connect: it just - // means Cyd cannot shortcut, and the authorization request will report a - // bad handle properly. + // Resolution failure is not a connection failure; the authorization + // request will report a bad handle properly. log.info( "blueskyOAuth: could not resolve an identifier before authorizing", errorMessage(error), @@ -99,20 +85,10 @@ const resolveBlueskyIdentifier = async ( } }; -/** - * Connect a handle on behalf of one flow. - * - * When Cyd already holds a usable session for the identity behind the handle, - * nothing opens: the caller simply becomes another holder of the session that - * is already there. That is what keeps a person from authorizing the same - * identity twice, once through the X migration wizard and once as a Bluesky - * account. - * - * Otherwise a browser authorization starts, carrying the flow identifier as - * the OAuth request's application state so the answer finds its way back to - * whichever platform asked — even if Cyd is restarted in between, and even if - * another authorization is already in the air. - */ +// When Cyd already holds a usable session for the identity behind the handle, +// the caller becomes another holder of it without a second sign-in. Otherwise +// a browser authorization starts, carrying the flow identifier as OAuth state +// so the answer finds its way back to whichever platform asked. export const authorizeBlueskyIdentity = async ( handle: string, flow: BlueskyOAuthFlow, @@ -139,13 +115,8 @@ export const authorizeBlueskyIdentity = async ( } }; -/** - * Finish a browser authorization. - * - * The session it produces is written to the shared store under the DID it - * authorizes, which is what makes one authorization enough for every part of - * Cyd that wants that identity. - */ +// The session is written to the shared store under its DID, so one +// authorization serves every part of Cyd that wants that identity. export const completeBlueskyAuthorization = async ( queryString: string, ): Promise => { @@ -214,14 +185,8 @@ export const getBlueskyProfile = async ( }; }; -/** - * Sign an identity out at its PDS and destroy every local trace of it. - * - * Revoking upstream comes first, because once the refresh token is gone - * locally Cyd can no longer tell the authorization server to invalidate it. A - * server Cyd cannot reach must not strand the local material either, so the - * local delete runs whether or not the revocation succeeded. - */ +// Revoke upstream first (the refresh token is needed to do it), but delete +// local material regardless of whether the revocation succeeded. const revokeBlueskyIdentity = async (did: string): Promise => { try { const client = await getBlueskyOAuthClient(); @@ -238,18 +203,9 @@ const revokeBlueskyIdentity = async (did: string): Promise => { log.info("blueskyOAuth: revoked a session and deleted its local material"); }; -/** - * Release one account's hold on an identity. - * - * The caller has already made its own state say it is disconnected — cleared - * its DID, or deleted the account — so this only has to ask who is left. While - * anyone is, the session stays exactly as it is and nobody is signed out. When - * nobody is, the session is revoked at the PDS and every local trace of it is - * destroyed. - * - * No caller passes in which holder it was, and none can force a revocation: an - * account can only ever release its own hold. - */ +// The caller has already cleared its own state, so this only asks who is +// left: while anyone holds the identity the session stays; when nobody does +// it is revoked. No caller can force a revocation on another holder. export const releaseBlueskyHold = async (did: string): Promise => { if (!did) { return; @@ -263,14 +219,8 @@ export const releaseBlueskyHold = async (did: string): Promise => { await revokeBlueskyIdentity(did); }; -/** - * Revoke sessions nothing holds and discard authorizations nothing can finish. - * - * Interrupted work is the only way to reach either state: a quit between - * clearing an account's connection and revoking its session, or an account - * deleted while its browser authorization was still open. Run at startup, this - * makes both self-correcting rather than permanent. - */ +// Startup sweep: revoke sessions nothing holds, discard authorizations +// nothing can finish. Makes interrupted work self-correcting. export const sweepOrphanedBlueskyOAuth = async (): Promise => { let swept = 0; @@ -279,10 +229,8 @@ export const sweepOrphanedBlueskyOAuth = async (): Promise => { try { holderCount = blueskyHolders(did).length; } catch (error) { - // An account whose state cannot be read might still be holding this - // identity, so it is left alone. One unreadable account must not stop - // the sweep for every other identity, which is why this is caught per - // DID rather than around the loop. + // An unreadable account might still hold this identity, so leave it + // alone; catch per DID so one failure doesn't stop the sweep. log.error( "blueskyOAuth: could not decide whether an identity is still held, leaving it alone", errorMessage(error), @@ -299,10 +247,8 @@ export const sweepOrphanedBlueskyOAuth = async (): Promise => { for (const stateKey of storedBlueskyOAuthStateKeys()) { const flow = parseBlueskyOAuthFlowID(storedBlueskyOAuthAppState(stateKey)); - // An authorization whose state Cyd cannot read, or whose account is gone, - // can never be completed. An authorization for an account that still - // exists is left alone: the user may be finishing it in their browser - // right now, and on Linux and Windows that can outlive the app. + // Unreadable state or a gone account means it can never be completed; an + // account that still exists may be finishing it in the browser right now. if (!flow || getAccount(flow.accountID) === null) { deleteStoredBlueskyOAuthState(stateKey); swept += 1; diff --git a/src/bluesky_oauth/store.ts b/src/bluesky_oauth/store.ts index c7f8b128..7205ea80 100644 --- a/src/bluesky_oauth/store.ts +++ b/src/bluesky_oauth/store.ts @@ -16,19 +16,8 @@ import { type AccountCredentials, } from "../credentials"; -/** - * Where every Bluesky OAuth session lives, for every part of Cyd. - * - * Sessions are keyed by DID, which is the Bluesky identity itself, so one - * authorization serves every Cyd account that reaches for that identity: the X - * platform's migration and the Bluesky platform both find the same entry. They - * used to live in `account-${accountID}` vaults, which made a session - * authorized under one account invisible to another and turned "disconnect" - * into a prefix wipe of a whole vault. - * - * Nothing here deletes by prefix. A session belongs to a DID, and only that - * DID's entry is ever removed. - */ +// Shared OAuth store keyed by DID: one authorization serves every Cyd +// account reaching for that identity. Only a DID's own entry is ever removed. const BLUESKY_OAUTH_VAULT = "bluesky-oauth"; export const blueskyOAuthCredentials = (): AccountCredentials => @@ -39,11 +28,8 @@ const stateKey = (key: string): string => `${BLUESKY_OAUTH_STATE_PREFIX}${key}`; const sessionKey = (did: string): string => `${BLUESKY_OAUTH_SESSION_PREFIX}${did}`; -/** - * The OAuth authorization state, which carries the PKCE verifier and a private - * DPoP key. It is short-lived: the client deletes each entry once the matching - * authorization comes back. - */ +// Short-lived OAuth state (PKCE verifier, DPoP key); the client deletes each +// entry once the matching authorization comes back. export const blueskyOAuthStateStore = (): NodeSavedStateStore => { const credentials = blueskyOAuthCredentials(); return { @@ -60,10 +46,7 @@ export const blueskyOAuthStateStore = (): NodeSavedStateStore => { }; }; -/** - * The OAuth session, which carries access and refresh tokens and a private - * DPoP key, keyed by the DID it authorizes. - */ +// OAuth session (tokens + DPoP key), keyed by the DID it authorizes. export const blueskyOAuthSessionStore = (): NodeSavedSessionStore => { const credentials = blueskyOAuthCredentials(); return { @@ -91,13 +74,8 @@ export const storedBlueskyOAuthDIDs = (): string[] => export const hasStoredBlueskyOAuthSession = (did: string): boolean => blueskyOAuthCredentials().get(sessionKey(did)) !== null; -/** - * Remove one identity's session and DPoP key material, and nothing else. - * - * This is the local half of revocation. Callers reach it through - * `releaseBlueskyHold`, which revokes upstream first, because dropping the - * local copy on its own leaves a refresh token alive at the PDS. - */ +// The local half of revocation; callers go through `releaseBlueskyHold`, +// which revokes upstream first so no refresh token is left alive at the PDS. export const deleteStoredBlueskyOAuthSession = (did: string): void => { blueskyOAuthCredentials().delete(sessionKey(did)); }; @@ -109,13 +87,8 @@ export const storedBlueskyOAuthStateKeys = (): string[] => .filter((key) => key.startsWith(BLUESKY_OAUTH_STATE_PREFIX)) .map((key) => key.slice(BLUESKY_OAUTH_STATE_PREFIX.length)); -/** - * The application state an authorization was started with, read without - * consuming it: the OAuth client still needs the entry to finish the callback. - * - * Returns null when the state is unknown, which is what a stale, forged, or - * already-completed callback looks like. - */ +// Reads without consuming: the OAuth client still needs the entry to finish +// the callback. Null means a stale, forged, or already-completed callback. export const storedBlueskyOAuthAppState = (key: string): string | null => { const stored = blueskyOAuthCredentials().get(stateKey(key)); if (!stored) { @@ -137,16 +110,8 @@ export const deleteStoredBlueskyOAuthState = (key: string): void => { blueskyOAuthCredentials().delete(stateKey(key)); }; -/** - * Move an account's Bluesky OAuth credentials into the shared store. - * - * Before the store was shared, the X migration wrote its sessions into the - * account's own vault, where a Bluesky local account for the same identity - * could not see them. Carrying those sessions forward is what stops anyone - * being asked to authorize an identity Cyd already holds a live session for. - * It is idempotent, and it never overwrites a shared entry: whatever is - * already shared is at least as fresh. - */ +// Idempotent; never overwrites a shared entry (whatever is shared is at +// least as fresh). export const migrateAccountBlueskyOAuthCredentials = ( accountID: number, ): number => { @@ -173,23 +138,15 @@ export const migrateAccountBlueskyOAuthCredentials = ( from.delete(key); } - // The account is not named: an account ID points straight at a person, the - // same way a handle or a DID does. + // The account is not named: an account ID identifies a person. log.info( `blueskyOAuth: moved ${moved} of ${keys.length} Bluesky OAuth credentials into the shared store`, ); return moved; }; -/** - * Carry every account's Bluesky OAuth credentials into the shared store. - * - * Run at startup, before anything asks who holds a session. Doing this per - * account as its database opens is not enough: someone who upgrades and adds a - * Bluesky account before ever opening the X account that authorized the - * identity would be sent back to a browser for a session Cyd already had — - * which is the papercut the shared store exists to remove. - */ +// Run at startup, before anything asks who holds a session: migrating per +// account at database-open is not enough. export const migrateAllAccountBlueskyOAuthCredentials = (): number => { let moved = 0; for (const account of getAccounts()) { diff --git a/src/renderer/src/view_models/BlueskyViewModel/diagnostics.ts b/src/renderer/src/view_models/BlueskyViewModel/diagnostics.ts index 2909a208..77aa27aa 100644 --- a/src/renderer/src/view_models/BlueskyViewModel/diagnostics.ts +++ b/src/renderer/src/view_models/BlueskyViewModel/diagnostics.ts @@ -1,11 +1,4 @@ -/** - * Automatic Bluesky diagnostics carry operational metadata only: the state or - * job that failed and the class of error. They never carry record or chat - * content, media, handles, DIDs, credentials, or local paths, which rules out - * error messages because a message can quote any of those. - * - * See docs/adr/0029-minimize-bluesky-diagnostics.md. - */ +// Diagnostics carry operational metadata only (state, job, error class) — never content, handles, DIDs, credentials, or paths, so never include error messages. export type BlueskyDiagnostic = { /** The view model state that failed. */ state: string; diff --git a/src/renderer/src/view_models/BlueskyViewModel/view_model.ts b/src/renderer/src/view_models/BlueskyViewModel/view_model.ts index eb7d7b69..7e988bbc 100644 --- a/src/renderer/src/view_models/BlueskyViewModel/view_model.ts +++ b/src/renderer/src/view_models/BlueskyViewModel/view_model.ts @@ -17,15 +17,9 @@ import { import { blueskyDiagnostic } from "./diagnostics"; /** - * Renderer-side owner of one Bluesky local account's view state. - * - * Bluesky uses the AT Protocol directly, so this view model never drives an - * embedded browser: there is no login page to automate and no page to scrape. - * Every call into the account's local resources goes through the Bluesky IPC - * boundary, so the renderer never touches storage itself. - * - * The dashboard is reachable with no Bluesky connection and no saved data. - * Saving and browsing arrive in #676 and #677. + * Renderer-side owner of one Bluesky local account's view state. Bluesky uses + * the AT Protocol directly, so this view model never drives an embedded + * browser; all calls go through the Bluesky IPC boundary. */ export class BlueskyViewModel extends BaseViewModel { public progress: BlueskyProgress = emptyBlueskyProgress(); @@ -45,14 +39,7 @@ export class BlueskyViewModel extends BaseViewModel { this.state = State.BlueskyWizardDashboard; } - /** - * An automatic Bluesky error report names no account: a handle and a DID both - * identify a person, and neither belongs in one. Spelled out rather than left - * to the core's default, so that adding a label here is a deliberate choice - * against the ADR rather than an oversight. - * - * See docs/adr/0029-minimize-bluesky-diagnostics.md. - */ + // Error reports for Bluesky deliberately name no account (handle or DID). protected get errorReportAccountLabel(): string { return ""; } @@ -62,13 +49,7 @@ export class BlueskyViewModel extends BaseViewModel { return this.account.blueskyLocalAccount; } - /** - * Whether this installation is authorized to act on the identity right now. - * - * A Bluesky local account keeps its DID after disconnecting, because that - * DID is how an archive import recognizes the identity again. Being - * connected is the separate, reversible fact. - */ + // The DID persists after disconnecting; connectedness is the reversible fact. get isConnected(): boolean { return Boolean(this.localAccount?.connectedAt); } @@ -128,13 +109,9 @@ export class BlueskyViewModel extends BaseViewModel { } /** - * Start a browser authorization for a handle. - * - * Cyd shares one Bluesky OAuth implementation and one DID-keyed session - * store across platforms, so an identity already authorized through the X - * migration wizard connects here without a second browser sign-in. - * Authorization is OAuth against the identity's own PDS, never an app - * password. + * Start a browser authorization for a handle. OAuth against the identity's + * own PDS (never an app password); an identity already authorized through + * another Cyd wizard reuses its session without a second sign-in. */ async connect(handle: string): Promise { this.connectError = ""; @@ -194,14 +171,7 @@ export class BlueskyViewModel extends BaseViewModel { } } - /** - * Remove this installation's authorization, keeping the local account and - * all of its Bluesky saved data. - * - * Only this account's hold on the shared session is released. An X account - * migrating to the same identity stays connected, and nothing here can sign - * it out: there is no force-revoke to offer. - */ + // Removes only this account's hold on the shared session; saved data is kept. async disconnect(): Promise { try { await window.electron.Bluesky.disconnect(this.account.id); diff --git a/src/shared_types/account.ts b/src/shared_types/account.ts index 48940372..5a6de1d1 100644 --- a/src/shared_types/account.ts +++ b/src/shared_types/account.ts @@ -56,11 +56,7 @@ export type XAccount = { tombstoneLockAccount: boolean; }; -/** - * This installation's local representation of a Bluesky identity. It is - * identified by its Cyd UUID, which is also the account's UUID, so a handle - * change never moves its storage. - */ +// Keyed by Cyd UUID, so a handle change never moves its storage. export type BlueskyLocalAccount = { uuid: string; createdAt: Date; @@ -72,10 +68,7 @@ export type BlueskyLocalAccount = { handle: string | null; displayName: string | null; profileImageDataURI: string | null; - /** - * When this installation was last authorized to act on the identity, or null - * when it is not. The DID outlives a disconnection; this does not. - */ + // The DID outlives a disconnection; this does not. connectedAt: Date | null; }; diff --git a/src/shared_types/bluesky.ts b/src/shared_types/bluesky.ts index 312a7546..b4279453 100644 --- a/src/shared_types/bluesky.ts +++ b/src/shared_types/bluesky.ts @@ -7,13 +7,8 @@ import type { PlatformJob } from "./common"; // Bluesky DID is the durable social identity. Handles and display names are // mutable profile data and never appear in storage paths. -/** - * Current profile data for a Bluesky identity, as fetched from its PDS. - * - * The DID is the identity; the handle, display name, and avatar are mutable - * and are refreshed on every connection. None of them ever determines where - * anything is stored. - */ +// The DID is the identity; handle, display name, and avatar are mutable +// profile data and never determine where anything is stored. export type BlueskyIdentityProfile = { did: string; handle: string; @@ -54,10 +49,7 @@ export type BlueskyJob = PlatformJob & { scheduledAt: Date; }; -/** - * Permanently deleting a Bluesky local account is irreversible, so the caller - * must name the account UUID it means to destroy. - */ +// Deletion is irreversible, so the caller must name the account UUID. export type BlueskyDeleteConfirmation = { confirmedAccountUUID: string; }; diff --git a/src/shared_types/bluesky_migration.ts b/src/shared_types/bluesky_migration.ts index 563172b7..118bc41c 100644 --- a/src/shared_types/bluesky_migration.ts +++ b/src/shared_types/bluesky_migration.ts @@ -1,10 +1,7 @@ import type { BlueskyIdentityProfile } from "./bluesky"; -/** - * The X migration's view of a Bluesky identity. It is the same profile the - * Bluesky platform reads, because both go through the same shared OAuth - * session and the same AT Protocol call. - */ +// Same profile the Bluesky platform reads: both go through the same shared +// OAuth session and AT Protocol call. export type BlueskyMigrationProfile = BlueskyIdentityProfile; export interface BlueskyAPIError { diff --git a/src/shared_types/bluesky_oauth.ts b/src/shared_types/bluesky_oauth.ts index 4fef08eb..1929dd2a 100644 --- a/src/shared_types/bluesky_oauth.ts +++ b/src/shared_types/bluesky_oauth.ts @@ -1,16 +1,7 @@ -/** - * How a Bluesky authorization finds its way back to whoever started it. - * - * Cyd used to remember the account in a single global config key and dispatch - * the callback to `blueskyOAuthCallback-${accountID}`. That allowed exactly one - * authorization at a time and assumed the initiator was an X account. A flow - * identifier travels inside the OAuth request instead, so two authorizations - * can be in the air at once and each comes back to the platform that asked. - * - * These names are a contract between the main process, which routes the - * callback, and the renderer, which listens for it, so they live with the - * other shared types rather than inside either side. - */ +// How a Bluesky authorization finds its way back to whoever started it: a +// flow identifier travels inside the OAuth request, so two authorizations can +// be in the air at once and each returns to the platform that asked. These +// names are a contract between the main process and the renderer. export type BlueskyOAuthPlatform = "X" | "Bluesky"; @@ -21,11 +12,8 @@ export type BlueskyOAuthFlow = { const FLOW_ID_PATTERN = /^(X|Bluesky):(\d+)$/; -/** - * The flow identifier, carried as the OAuth request's application state. The - * authorization server echoes it back, so the identifier survives a browser - * round trip and an app restart without Cyd holding anything in memory. - */ +// Carried as OAuth application state, so it survives a browser round trip +// and an app restart without Cyd holding anything in memory. export const blueskyOAuthFlowID = (flow: BlueskyOAuthFlow): string => `${flow.platform}:${flow.accountID}`; @@ -46,13 +34,8 @@ export const parseBlueskyOAuthFlowID = ( export const blueskyOAuthCallbackEventName = (flow: BlueskyOAuthFlow): string => `blueskyOAuthCallback-${blueskyOAuthFlowID(flow)}`; -/** - * What happened when a platform asked to connect a handle. - * - * `reused` is the point of sharing one session store across platforms: the - * identity is already authorized, so the caller binds it and there is no - * browser round trip and no callback to wait for. - */ +// `reused`: the identity is already authorized via the shared session store, +// so the caller binds it with no browser round trip. export type BlueskyConnectStart = | { status: "reused"; did: string } | { status: "browser" } diff --git a/src/shared_types/credentials.ts b/src/shared_types/credentials.ts index dcbb76e9..bafefad2 100644 --- a/src/shared_types/credentials.ts +++ b/src/shared_types/credentials.ts @@ -1,13 +1,5 @@ -/** - * Which facility protects Cyd's persisted credentials at rest. - * - * These names describe what the operating system actually provides, not what - * Cyd would like it to provide. `basic_text` is Chromium's obfuscation-only - * fallback, `unknown` is a Linux password store Cyd cannot vouch for, and - * `unavailable` means the platform offered nothing at all. Cyd persists - * credentials either way; the last three mean it does so in the clear, and - * says so. - */ +// These names describe what the OS actually provides: `basic_text`, +// `unknown`, and `unavailable` mean credentials are persisted in the clear. export type CredentialBackend = | "macos_keychain" | "windows_dpapi"