Skip to content

feat(settings): the Integrations surface — and three live identity defects underneath it (G33) - #93

Merged
Adron merged 1 commit into
devfrom
feat/integrations-g33
Sep 17, 2026
Merged

Adron merged 1 commit into
devfrom
feat/integrations-g33

Conversation

@Adron

@Adron Adron commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Addresses #47 (G33). Ships the Integrations pane and — more importantly — three live defects the probe found underneath it. The remaining four pieces are deferred on evidence, not effort, and each is named below.

This issue is sized L and anticipated a split. The probe changed which half is urgent: Mastodon turned out to be a defect, not a modelling preference, so it leads.

The probe

GET /api/user/identities, live, 2026-09-15:

{"identities":[
  {"provider":"bluesky","providerUsername":"interlinedlist.bsky.social", …},
  {"provider":"mastodon:techhub.social","providerUsername":"interlinedlist_crew@techhub.social", …},
  {"provider":"twitter","providerUsername":"interlinedlist", …},
  {"provider":"github","providerUsername":"InterlinedListMessenger", …}]}

1. Every Mastodon identity rendered as an unknown provider

IdentityProvider.init(wireToken:) matched "mastodon" exactly, so "mastodon:techhub.social" fell to .other — an unknown provider, with none of its actions offered — and the instance host was discarded entirely.

The doc comment claimed the host was "carried on the LinkedIdentity". There was no such field.

LinkedIdentity.instance now carries it, and providerWireToken reassembles it — because a bare "mastodon" on an account with two instances is ambiguous enough that the server could disconnect the wrong one. There is a test for exactly that.

2. X was missing from the provider enum

The issue flagged this; the consequence is sharper than a missing row. A linked X identity decoded as .other("twitter") — so the one provider the account could already cross-post to was the one the client could not recognise.

3. GET /api/user/identities accepts Bearer

Contrary to its .session annotation. Not a hard failure — the kit lazily establishes a cookie session — but a redundant credentialed round-trip on every load of the pane. Same class of error as the send-verification-email correction in PR #83, in the opposite direction.

The pane

One row per connection, with Verify and Disconnect, per-row status, and per-row errors.

It is data-driven rather than a switch statement per provider — what a row offers comes from supportsMultipleInstances / requiresInstanceHost / isVerifiable / isDisconnectable on the provider itself. That is what the issue asked for, and it is what makes Mastodon work: it is the one provider with several connections, so it renders one row per instance and stays in the "connect an account" list when the others disappear.

The disconnect confirmation names what stops working, per provider — losing GitHub takes GitHub-backed lists with it; losing a cross-post provider silently stops those posts. A generic "are you sure" tells the user nothing they did not already know.

Two smaller calls:

  • Disconnect is not optimistic. It stops cross-posting, and a row that vanishes and comes back because the write failed is worse than one that waits.
  • A failed verify reports the request error, not "not responding". A failed ask is not a failed connection, and saying otherwise would be a lie about the provider.

Deferred, and why

Deferred Blocked on
LinkedIn posting-target editing GET /api/linkedin/posting-targets → {"targets":[],"orgScopeMissing":false}. No populated payload has been seen. Building an editor against an empty array is how the G21 and G25 silent-decode defects happened. It is also #79's supply side. (orgScopeMissing is an undocumented field this issue did not mention.)
githubDefaultRepo write · Reconnect for GitHub Issues GET /api/github/repos → []. No accessible repositories, exactly as #51 records, so a repo picker cannot be exercised end to end. The scope-upgrade path shares the OAuth flow with #51.
Integration reconnect reminders toggle The issue says "confirm which stored field backs it". It is on neither GET /api/user nor /api/user/notification-preferences (an event catalogue with push/inApp channels and no reconnect key). Backing field unidentified — nothing is wired to a guess.
Linking itself Stays in Linked accounts, which owns the ASWebAuthenticationSession and the Mastodon instance prompt. Two copies of an OAuth handshake is worse than two panes.

Verification

  • xcodebuild build → ** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 979 tests, with 0 failures · ** TEST SUCCEEDED ** (was 968)
  • swift test InterlinedDomain → Executed 1021 tests, with 0 failures (was 1012)
  • swift test InterlinedKit --skip ContractTests → Executed 480 tests, with 0 failures (was 477) — live ContractTests skipped because the account is rate-limited from this session's recon; unmodified by this PR.
  • swift test InterlinedPersistence → Executed 140 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

New tests: IdentityProviderTokenTests (10, against the captured identity list), IntegrationsViewModelTests (11), plus corrected/extended UserEndpointTests.

Two existing tests changed, both deliberately: linkableProviders now includes X, and the identities builder now asserts .bearer. Both are the point of the change, not collateral.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9

…underneath it

The Linked accounts pane could only link. Once an account was connected there was
no way to check it, reconfigure it, or remove it from the Mac. Probing that
surface turned up three live defects, and they matter more than the pane.

Mastodon arrives instance-qualified. The wire sends
`"mastodon:techhub.social"`, and IdentityProvider matched `"mastodon"` exactly —
so every Mastodon identity fell to `.other` and rendered as an unknown provider
with none of its actions, while the instance host was discarded. The doc comment
claimed the host was "carried on the LinkedIdentity"; there was no such field.
It is split out now, and `providerWireToken` reassembles it for unlink and
verify — a bare "mastodon" on an account with two instances is ambiguous enough
that the server could disconnect the wrong one.

X was missing from the provider enum. The consequence is sharper than a missing
row: a linked X identity decoded as `.other("twitter")`, so the one provider the
account could already cross-post to was the one the client could not recognise.

`GET /api/user/identities` accepts Bearer, contrary to its `.session`
annotation. Not a hard failure — the kit lazily establishes a cookie session —
but a redundant credentialed round-trip on every load, and the same class of
error as the send-verification-email correction in PR #83, in the other
direction.

The pane itself is data-driven rather than a switch statement per provider:
supportsMultipleInstances, requiresInstanceHost, isVerifiable and
isDisconnectable live on the provider, so a row is a projection of the model
instead of a parallel copy of the same rules. That is also what makes Mastodon
work — it is the one provider with several connections, and it renders one row
per instance.

Two smaller judgement calls. Disconnect is not optimistic: it stops cross-posting,
and a row that vanishes and returns because the write failed is worse than one
that waits. And a failed verify reports the request error rather than "not
responding" — a failed ask is not a failed connection, and saying otherwise
would be a lie about the provider.

Four pieces are deferred on evidence, not effort: LinkedIn posting-target editing
(the read is empty on this account, and it is #79's supply side), the
githubDefaultRepo write and Reconnect for Issues (no accessible repos — see #51),
and the reconnect-reminders toggle (its backing field is on neither /api/user nor
the notification-preferences catalogue, so nothing is wired to a guess).

Refs #47

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
@Adron
Adron merged commit 74ce4a7 into dev Sep 17, 2026
8 checks passed
@Adron
Adron deleted the feat/integrations-g33 branch September 17, 2026 03:24
Adron added a commit that referenced this pull request Sep 17, 2026
Four files conflicted. Three were textual overlaps where both sides add distinct
members; the fourth needed more than a mechanical resolution.

AppTests/Support/StubUserService.swift was the only file git could not merge
itself. Both sides add members to the same type — this branch's profile-settings
stubs and #93's identity stubs — so the resolution is to keep both. Rebuilt from
dev's version with this branch's four additions reapplied, rather than by
concatenating the conflict hunks: the hunk boundaries crossed a closing brace, so
a naive keep-both dropped the `}` of `enqueueRequestPasswordReset(failure:)` and
nested the rest of the type inside it. It compiled far enough to look plausible
and failed with "Attribute 'private' can only be used in a non-local scope" 300
lines later.

UserDTO.swift, UserService.swift and SettingsRootView.swift auto-merged, and the
first two are genuinely fine — both sides only added. SettingsRootView's
auto-merge was textually clean and semantically wrong, which is the interesting
part of this merge.

PR #94 introduced `SettingsTab` and converted the pane into a
`TabView(selection:)`, tagging every tab it knew about. It was written against a
dev that predated both the Integrations tab (#93) and the Profile tab (this
branch), so neither got a tag. Git had no reason to flag that — no line
conflicts — but under `TabView(selection:)` an untagged tab cannot be selected
programmatically at all. Both are tagged here, and the enum gains the two cases.

The same gap had a second consequence: the sidebar's Integrations row set
`settingsTab = .linkedAccounts`, which was the honest best available when no
Integrations tab existed and is simply wrong now that one does. A row labelled
Integrations opening Linked accounts is the kind of thing that survives review
because it compiles. Retargeted.

The tab-count assertion becomes a full set. A count was already there and did not
catch this — the count was right, the tabs were unreachable — so it now pins the
identities, which at least fails loudly when a pane arrives without one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant